File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/src.tar
Auth/Basic.php 0000644 00000004755 15153252210 0007206 0 ustar 00 <?php
/**
* Basic Authentication provider
*
* @package Requests\Authentication
*/
namespace WpOrg\Requests\Auth;
use WpOrg\Requests\Auth;
use WpOrg\Requests\Exception\ArgumentCount;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Hooks;
/**
* Basic Authentication provider
*
* Provides a handler for Basic HTTP authentication via the Authorization
* header.
*
* @package Requests\Authentication
*/
class Basic implements Auth {
/**
* Username
*
* @var string
*/
public $user;
/**
* Password
*
* @var string
*/
public $pass;
/**
* Constructor
*
* @since 2.0 Throws an `InvalidArgument` exception.
* @since 2.0 Throws an `ArgumentCount` exception instead of the Requests base `Exception.
*
* @param array|null $args Array of user and password. Must have exactly two elements
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not an array or null.
* @throws \WpOrg\Requests\Exception\ArgumentCount On incorrect number of array elements (`authbasicbadargs`).
*/
public function __construct($args = null) {
if (is_array($args)) {
if (count($args) !== 2) {
throw ArgumentCount::create('an array with exactly two elements', count($args), 'authbasicbadargs');
}
list($this->user, $this->pass) = $args;
return;
}
if ($args !== null) {
throw InvalidArgument::create(1, '$args', 'array|null', gettype($args));
}
}
/**
* Register the necessary callbacks
*
* @see \WpOrg\Requests\Auth\Basic::curl_before_send()
* @see \WpOrg\Requests\Auth\Basic::fsockopen_header()
* @param \WpOrg\Requests\Hooks $hooks Hook system
*/
public function register(Hooks $hooks) {
$hooks->register('curl.before_send', [$this, 'curl_before_send']);
$hooks->register('fsockopen.after_headers', [$this, 'fsockopen_header']);
}
/**
* Set cURL parameters before the data is sent
*
* @param resource|\CurlHandle $handle cURL handle
*/
public function curl_before_send(&$handle) {
curl_setopt($handle, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($handle, CURLOPT_USERPWD, $this->getAuthString());
}
/**
* Add extra headers to the request before sending
*
* @param string $out HTTP header string
*/
public function fsockopen_header(&$out) {
$out .= sprintf("Authorization: Basic %s\r\n", base64_encode($this->getAuthString()));
}
/**
* Get the authentication string (user:pass)
*
* @return string
*/
public function getAuthString() {
return $this->user . ':' . $this->pass;
}
}
Auth.php 0000644 00000001534 15153252210 0006155 0 ustar 00 <?php
/**
* Authentication provider interface
*
* @package Requests\Authentication
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Hooks;
/**
* Authentication provider interface
*
* Implement this interface to act as an authentication provider.
*
* Parameters should be passed via the constructor where possible, as this
* makes it much easier for users to use your provider.
*
* @see \WpOrg\Requests\Hooks
*
* @package Requests\Authentication
*/
interface Auth {
/**
* Register hooks as needed
*
* This method is called in {@see \WpOrg\Requests\Requests::request()} when the user
* has set an instance as the 'auth' option. Use this callback to register all the
* hooks you'll need.
*
* @see \WpOrg\Requests\Hooks::register()
* @param \WpOrg\Requests\Hooks $hooks Hook system
*/
public function register(Hooks $hooks);
}
Autoload.php 0000644 00000022167 15153252210 0007031 0 ustar 00 <?php
/**
* Autoloader for Requests for PHP.
*
* Include this file if you'd like to avoid having to create your own autoloader.
*
* @package Requests
* @since 2.0.0
*
* @codeCoverageIgnore
*/
namespace WpOrg\Requests;
/*
* Ensure the autoloader is only declared once.
* This safeguard is in place as this is the typical entry point for this library
* and this file being required unconditionally could easily cause
* fatal "Class already declared" errors.
*/
if (class_exists('WpOrg\Requests\Autoload') === false) {
/**
* Autoloader for Requests for PHP.
*
* This autoloader supports the PSR-4 based Requests 2.0.0 classes in a case-sensitive manner
* as the most common server OS-es are case-sensitive and the file names are in mixed case.
*
* For the PSR-0 Requests 1.x BC-layer, requested classes will be treated case-insensitively.
*
* @package Requests
*/
final class Autoload {
/**
* List of the old PSR-0 class names in lowercase as keys with their PSR-4 case-sensitive name as a value.
*
* @var array
*/
private static $deprecated_classes = [
// Interfaces.
'requests_auth' => '\WpOrg\Requests\Auth',
'requests_hooker' => '\WpOrg\Requests\HookManager',
'requests_proxy' => '\WpOrg\Requests\Proxy',
'requests_transport' => '\WpOrg\Requests\Transport',
// Classes.
'requests_cookie' => '\WpOrg\Requests\Cookie',
'requests_exception' => '\WpOrg\Requests\Exception',
'requests_hooks' => '\WpOrg\Requests\Hooks',
'requests_idnaencoder' => '\WpOrg\Requests\IdnaEncoder',
'requests_ipv6' => '\WpOrg\Requests\Ipv6',
'requests_iri' => '\WpOrg\Requests\Iri',
'requests_response' => '\WpOrg\Requests\Response',
'requests_session' => '\WpOrg\Requests\Session',
'requests_ssl' => '\WpOrg\Requests\Ssl',
'requests_auth_basic' => '\WpOrg\Requests\Auth\Basic',
'requests_cookie_jar' => '\WpOrg\Requests\Cookie\Jar',
'requests_proxy_http' => '\WpOrg\Requests\Proxy\Http',
'requests_response_headers' => '\WpOrg\Requests\Response\Headers',
'requests_transport_curl' => '\WpOrg\Requests\Transport\Curl',
'requests_transport_fsockopen' => '\WpOrg\Requests\Transport\Fsockopen',
'requests_utility_caseinsensitivedictionary' => '\WpOrg\Requests\Utility\CaseInsensitiveDictionary',
'requests_utility_filterediterator' => '\WpOrg\Requests\Utility\FilteredIterator',
'requests_exception_http' => '\WpOrg\Requests\Exception\Http',
'requests_exception_transport' => '\WpOrg\Requests\Exception\Transport',
'requests_exception_transport_curl' => '\WpOrg\Requests\Exception\Transport\Curl',
'requests_exception_http_304' => '\WpOrg\Requests\Exception\Http\Status304',
'requests_exception_http_305' => '\WpOrg\Requests\Exception\Http\Status305',
'requests_exception_http_306' => '\WpOrg\Requests\Exception\Http\Status306',
'requests_exception_http_400' => '\WpOrg\Requests\Exception\Http\Status400',
'requests_exception_http_401' => '\WpOrg\Requests\Exception\Http\Status401',
'requests_exception_http_402' => '\WpOrg\Requests\Exception\Http\Status402',
'requests_exception_http_403' => '\WpOrg\Requests\Exception\Http\Status403',
'requests_exception_http_404' => '\WpOrg\Requests\Exception\Http\Status404',
'requests_exception_http_405' => '\WpOrg\Requests\Exception\Http\Status405',
'requests_exception_http_406' => '\WpOrg\Requests\Exception\Http\Status406',
'requests_exception_http_407' => '\WpOrg\Requests\Exception\Http\Status407',
'requests_exception_http_408' => '\WpOrg\Requests\Exception\Http\Status408',
'requests_exception_http_409' => '\WpOrg\Requests\Exception\Http\Status409',
'requests_exception_http_410' => '\WpOrg\Requests\Exception\Http\Status410',
'requests_exception_http_411' => '\WpOrg\Requests\Exception\Http\Status411',
'requests_exception_http_412' => '\WpOrg\Requests\Exception\Http\Status412',
'requests_exception_http_413' => '\WpOrg\Requests\Exception\Http\Status413',
'requests_exception_http_414' => '\WpOrg\Requests\Exception\Http\Status414',
'requests_exception_http_415' => '\WpOrg\Requests\Exception\Http\Status415',
'requests_exception_http_416' => '\WpOrg\Requests\Exception\Http\Status416',
'requests_exception_http_417' => '\WpOrg\Requests\Exception\Http\Status417',
'requests_exception_http_418' => '\WpOrg\Requests\Exception\Http\Status418',
'requests_exception_http_428' => '\WpOrg\Requests\Exception\Http\Status428',
'requests_exception_http_429' => '\WpOrg\Requests\Exception\Http\Status429',
'requests_exception_http_431' => '\WpOrg\Requests\Exception\Http\Status431',
'requests_exception_http_500' => '\WpOrg\Requests\Exception\Http\Status500',
'requests_exception_http_501' => '\WpOrg\Requests\Exception\Http\Status501',
'requests_exception_http_502' => '\WpOrg\Requests\Exception\Http\Status502',
'requests_exception_http_503' => '\WpOrg\Requests\Exception\Http\Status503',
'requests_exception_http_504' => '\WpOrg\Requests\Exception\Http\Status504',
'requests_exception_http_505' => '\WpOrg\Requests\Exception\Http\Status505',
'requests_exception_http_511' => '\WpOrg\Requests\Exception\Http\Status511',
'requests_exception_http_unknown' => '\WpOrg\Requests\Exception\Http\StatusUnknown',
];
/**
* Register the autoloader.
*
* Note: the autoloader is *prepended* in the autoload queue.
* This is done to ensure that the Requests 2.0 autoloader takes precedence
* over a potentially (dependency-registered) Requests 1.x autoloader.
*
* @internal This method contains a safeguard against the autoloader being
* registered multiple times. This safeguard uses a global constant to
* (hopefully/in most cases) still function correctly, even if the
* class would be renamed.
*
* @return void
*/
public static function register() {
if (defined('REQUESTS_AUTOLOAD_REGISTERED') === false) {
spl_autoload_register([self::class, 'load'], true);
define('REQUESTS_AUTOLOAD_REGISTERED', true);
}
}
/**
* Autoloader.
*
* @param string $class_name Name of the class name to load.
*
* @return bool Whether a class was loaded or not.
*/
public static function load($class_name) {
// Check that the class starts with "Requests" (PSR-0) or "WpOrg\Requests" (PSR-4).
$psr_4_prefix_pos = strpos($class_name, 'WpOrg\\Requests\\');
if (stripos($class_name, 'Requests') !== 0 && $psr_4_prefix_pos !== 0) {
return false;
}
$class_lower = strtolower($class_name);
if ($class_lower === 'requests') {
// Reference to the original PSR-0 Requests class.
$file = dirname(__DIR__) . '/library/Requests.php';
} elseif ($psr_4_prefix_pos === 0) {
// PSR-4 classname.
$file = __DIR__ . '/' . strtr(substr($class_name, 15), '\\', '/') . '.php';
}
if (isset($file) && file_exists($file)) {
include $file;
return true;
}
/*
* Okay, so the class starts with "Requests", but we couldn't find the file.
* If this is one of the deprecated/renamed PSR-0 classes being requested,
* let's alias it to the new name and throw a deprecation notice.
*/
if (isset(self::$deprecated_classes[$class_lower])) {
/*
* Integrators who cannot yet upgrade to the PSR-4 class names can silence deprecations
* by defining a `REQUESTS_SILENCE_PSR0_DEPRECATIONS` constant and setting it to `true`.
* The constant needs to be defined before the first deprecated class is requested
* via this autoloader.
*/
if (!defined('REQUESTS_SILENCE_PSR0_DEPRECATIONS') || REQUESTS_SILENCE_PSR0_DEPRECATIONS !== true) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
'The PSR-0 `Requests_...` class names in the Requests library are deprecated.'
. ' Switch to the PSR-4 `WpOrg\Requests\...` class names at your earliest convenience.',
E_USER_DEPRECATED
);
// Prevent the deprecation notice from being thrown twice.
if (!defined('REQUESTS_SILENCE_PSR0_DEPRECATIONS')) {
define('REQUESTS_SILENCE_PSR0_DEPRECATIONS', true);
}
}
// Create an alias and let the autoloader recursively kick in to load the PSR-4 class.
return class_alias(self::$deprecated_classes[$class_lower], $class_name, true);
}
return false;
}
}
}
Capability.php 0000644 00000001214 15153252210 0007330 0 ustar 00 <?php
/**
* Capability interface declaring the known capabilities.
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests;
/**
* Capability interface declaring the known capabilities.
*
* This is used as the authoritative source for which capabilities can be queried.
*
* @package Requests\Utilities
*/
interface Capability {
/**
* Support for SSL.
*
* @var string
*/
const SSL = 'ssl';
/**
* Collection of all capabilities supported in Requests.
*
* Note: this does not automatically mean that the capability will be supported for your chosen transport!
*
* @var string[]
*/
const ALL = [
self::SSL,
];
}
Cookie/Jar.php 0000644 00000010413 15153252210 0007175 0 ustar 00 <?php
/**
* Cookie holder object
*
* @package Requests\Cookies
*/
namespace WpOrg\Requests\Cookie;
use ArrayAccess;
use ArrayIterator;
use IteratorAggregate;
use ReturnTypeWillChange;
use WpOrg\Requests\Cookie;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\HookManager;
use WpOrg\Requests\Iri;
use WpOrg\Requests\Response;
/**
* Cookie holder object
*
* @package Requests\Cookies
*/
class Jar implements ArrayAccess, IteratorAggregate {
/**
* Actual item data
*
* @var array
*/
protected $cookies = [];
/**
* Create a new jar
*
* @param array $cookies Existing cookie values
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not an array.
*/
public function __construct($cookies = []) {
if (is_array($cookies) === false) {
throw InvalidArgument::create(1, '$cookies', 'array', gettype($cookies));
}
$this->cookies = $cookies;
}
/**
* Normalise cookie data into a \WpOrg\Requests\Cookie
*
* @param string|\WpOrg\Requests\Cookie $cookie Cookie header value, possibly pre-parsed (object).
* @param string $key Optional. The name for this cookie.
* @return \WpOrg\Requests\Cookie
*/
public function normalize_cookie($cookie, $key = '') {
if ($cookie instanceof Cookie) {
return $cookie;
}
return Cookie::parse($cookie, $key);
}
/**
* Check if the given item exists
*
* @param string $offset Item key
* @return boolean Does the item exist?
*/
#[ReturnTypeWillChange]
public function offsetExists($offset) {
return isset($this->cookies[$offset]);
}
/**
* Get the value for the item
*
* @param string $offset Item key
* @return string|null Item value (null if offsetExists is false)
*/
#[ReturnTypeWillChange]
public function offsetGet($offset) {
if (!isset($this->cookies[$offset])) {
return null;
}
return $this->cookies[$offset];
}
/**
* Set the given item
*
* @param string $offset Item name
* @param string $value Item value
*
* @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`)
*/
#[ReturnTypeWillChange]
public function offsetSet($offset, $value) {
if ($offset === null) {
throw new Exception('Object is a dictionary, not a list', 'invalidset');
}
$this->cookies[$offset] = $value;
}
/**
* Unset the given header
*
* @param string $offset The key for the item to unset.
*/
#[ReturnTypeWillChange]
public function offsetUnset($offset) {
unset($this->cookies[$offset]);
}
/**
* Get an iterator for the data
*
* @return \ArrayIterator
*/
#[ReturnTypeWillChange]
public function getIterator() {
return new ArrayIterator($this->cookies);
}
/**
* Register the cookie handler with the request's hooking system
*
* @param \WpOrg\Requests\HookManager $hooks Hooking system
*/
public function register(HookManager $hooks) {
$hooks->register('requests.before_request', [$this, 'before_request']);
$hooks->register('requests.before_redirect_check', [$this, 'before_redirect_check']);
}
/**
* Add Cookie header to a request if we have any
*
* As per RFC 6265, cookies are separated by '; '
*
* @param string $url
* @param array $headers
* @param array $data
* @param string $type
* @param array $options
*/
public function before_request($url, &$headers, &$data, &$type, &$options) {
if (!$url instanceof Iri) {
$url = new Iri($url);
}
if (!empty($this->cookies)) {
$cookies = [];
foreach ($this->cookies as $key => $cookie) {
$cookie = $this->normalize_cookie($cookie, $key);
// Skip expired cookies
if ($cookie->is_expired()) {
continue;
}
if ($cookie->domain_matches($url->host)) {
$cookies[] = $cookie->format_for_header();
}
}
$headers['Cookie'] = implode('; ', $cookies);
}
}
/**
* Parse all cookies from a response and attach them to the response
*
* @param \WpOrg\Requests\Response $response Response as received.
*/
public function before_redirect_check(Response $response) {
$url = $response->url;
if (!$url instanceof Iri) {
$url = new Iri($url);
}
$cookies = Cookie::parse_from_headers($response->headers, $url);
$this->cookies = array_merge($this->cookies, $cookies);
$response->cookies = $this;
}
}
Cookie.php 0000644 00000035402 15153252210 0006466 0 ustar 00 <?php
/**
* Cookie storage object
*
* @package Requests\Cookies
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Iri;
use WpOrg\Requests\Response\Headers;
use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
use WpOrg\Requests\Utility\InputValidator;
/**
* Cookie storage object
*
* @package Requests\Cookies
*/
class Cookie {
/**
* Cookie name.
*
* @var string
*/
public $name;
/**
* Cookie value.
*
* @var string
*/
public $value;
/**
* Cookie attributes
*
* Valid keys are `'path'`, `'domain'`, `'expires'`, `'max-age'`, `'secure'` and
* `'httponly'`.
*
* @var \WpOrg\Requests\Utility\CaseInsensitiveDictionary|array Array-like object
*/
public $attributes = [];
/**
* Cookie flags
*
* Valid keys are `'creation'`, `'last-access'`, `'persistent'` and `'host-only'`.
*
* @var array
*/
public $flags = [];
/**
* Reference time for relative calculations
*
* This is used in place of `time()` when calculating Max-Age expiration and
* checking time validity.
*
* @var int
*/
public $reference_time = 0;
/**
* Create a new cookie object
*
* @param string $name The name of the cookie.
* @param string $value The value for the cookie.
* @param array|\WpOrg\Requests\Utility\CaseInsensitiveDictionary $attributes Associative array of attribute data
* @param array $flags The flags for the cookie.
* Valid keys are `'creation'`, `'last-access'`,
* `'persistent'` and `'host-only'`.
* @param int|null $reference_time Reference time for relative calculations.
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $value argument is not a string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $attributes argument is not an array or iterable object with array access.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $flags argument is not an array.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $reference_time argument is not an integer or null.
*/
public function __construct($name, $value, $attributes = [], $flags = [], $reference_time = null) {
if (is_string($name) === false) {
throw InvalidArgument::create(1, '$name', 'string', gettype($name));
}
if (is_string($value) === false) {
throw InvalidArgument::create(2, '$value', 'string', gettype($value));
}
if (InputValidator::has_array_access($attributes) === false || InputValidator::is_iterable($attributes) === false) {
throw InvalidArgument::create(3, '$attributes', 'array|ArrayAccess&Traversable', gettype($attributes));
}
if (is_array($flags) === false) {
throw InvalidArgument::create(4, '$flags', 'array', gettype($flags));
}
if ($reference_time !== null && is_int($reference_time) === false) {
throw InvalidArgument::create(5, '$reference_time', 'integer|null', gettype($reference_time));
}
$this->name = $name;
$this->value = $value;
$this->attributes = $attributes;
$default_flags = [
'creation' => time(),
'last-access' => time(),
'persistent' => false,
'host-only' => true,
];
$this->flags = array_merge($default_flags, $flags);
$this->reference_time = time();
if ($reference_time !== null) {
$this->reference_time = $reference_time;
}
$this->normalize();
}
/**
* Get the cookie value
*
* Attributes and other data can be accessed via methods.
*/
public function __toString() {
return $this->value;
}
/**
* Check if a cookie is expired.
*
* Checks the age against $this->reference_time to determine if the cookie
* is expired.
*
* @return boolean True if expired, false if time is valid.
*/
public function is_expired() {
// RFC6265, s. 4.1.2.2:
// If a cookie has both the Max-Age and the Expires attribute, the Max-
// Age attribute has precedence and controls the expiration date of the
// cookie.
if (isset($this->attributes['max-age'])) {
$max_age = $this->attributes['max-age'];
return $max_age < $this->reference_time;
}
if (isset($this->attributes['expires'])) {
$expires = $this->attributes['expires'];
return $expires < $this->reference_time;
}
return false;
}
/**
* Check if a cookie is valid for a given URI
*
* @param \WpOrg\Requests\Iri $uri URI to check
* @return boolean Whether the cookie is valid for the given URI
*/
public function uri_matches(Iri $uri) {
if (!$this->domain_matches($uri->host)) {
return false;
}
if (!$this->path_matches($uri->path)) {
return false;
}
return empty($this->attributes['secure']) || $uri->scheme === 'https';
}
/**
* Check if a cookie is valid for a given domain
*
* @param string $domain Domain to check
* @return boolean Whether the cookie is valid for the given domain
*/
public function domain_matches($domain) {
if (is_string($domain) === false) {
return false;
}
if (!isset($this->attributes['domain'])) {
// Cookies created manually; cookies created by Requests will set
// the domain to the requested domain
return true;
}
$cookie_domain = $this->attributes['domain'];
if ($cookie_domain === $domain) {
// The cookie domain and the passed domain are identical.
return true;
}
// If the cookie is marked as host-only and we don't have an exact
// match, reject the cookie
if ($this->flags['host-only'] === true) {
return false;
}
if (strlen($domain) <= strlen($cookie_domain)) {
// For obvious reasons, the cookie domain cannot be a suffix if the passed domain
// is shorter than the cookie domain
return false;
}
if (substr($domain, -1 * strlen($cookie_domain)) !== $cookie_domain) {
// The cookie domain should be a suffix of the passed domain.
return false;
}
$prefix = substr($domain, 0, strlen($domain) - strlen($cookie_domain));
if (substr($prefix, -1) !== '.') {
// The last character of the passed domain that is not included in the
// domain string should be a %x2E (".") character.
return false;
}
// The passed domain should be a host name (i.e., not an IP address).
return !preg_match('#^(.+\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $domain);
}
/**
* Check if a cookie is valid for a given path
*
* From the path-match check in RFC 6265 section 5.1.4
*
* @param string $request_path Path to check
* @return boolean Whether the cookie is valid for the given path
*/
public function path_matches($request_path) {
if (empty($request_path)) {
// Normalize empty path to root
$request_path = '/';
}
if (!isset($this->attributes['path'])) {
// Cookies created manually; cookies created by Requests will set
// the path to the requested path
return true;
}
if (is_scalar($request_path) === false) {
return false;
}
$cookie_path = $this->attributes['path'];
if ($cookie_path === $request_path) {
// The cookie-path and the request-path are identical.
return true;
}
if (strlen($request_path) > strlen($cookie_path) && substr($request_path, 0, strlen($cookie_path)) === $cookie_path) {
if (substr($cookie_path, -1) === '/') {
// The cookie-path is a prefix of the request-path, and the last
// character of the cookie-path is %x2F ("/").
return true;
}
if (substr($request_path, strlen($cookie_path), 1) === '/') {
// The cookie-path is a prefix of the request-path, and the
// first character of the request-path that is not included in
// the cookie-path is a %x2F ("/") character.
return true;
}
}
return false;
}
/**
* Normalize cookie and attributes
*
* @return boolean Whether the cookie was successfully normalized
*/
public function normalize() {
foreach ($this->attributes as $key => $value) {
$orig_value = $value;
if (is_string($key)) {
$value = $this->normalize_attribute($key, $value);
}
if ($value === null) {
unset($this->attributes[$key]);
continue;
}
if ($value !== $orig_value) {
$this->attributes[$key] = $value;
}
}
return true;
}
/**
* Parse an individual cookie attribute
*
* Handles parsing individual attributes from the cookie values.
*
* @param string $name Attribute name
* @param string|int|bool $value Attribute value (string/integer value, or true if empty/flag)
* @return mixed Value if available, or null if the attribute value is invalid (and should be skipped)
*/
protected function normalize_attribute($name, $value) {
switch (strtolower($name)) {
case 'expires':
// Expiration parsing, as per RFC 6265 section 5.2.1
if (is_int($value)) {
return $value;
}
$expiry_time = strtotime($value);
if ($expiry_time === false) {
return null;
}
return $expiry_time;
case 'max-age':
// Expiration parsing, as per RFC 6265 section 5.2.2
if (is_int($value)) {
return $value;
}
// Check that we have a valid age
if (!preg_match('/^-?\d+$/', $value)) {
return null;
}
$delta_seconds = (int) $value;
if ($delta_seconds <= 0) {
$expiry_time = 0;
} else {
$expiry_time = $this->reference_time + $delta_seconds;
}
return $expiry_time;
case 'domain':
// Domains are not required as per RFC 6265 section 5.2.3
if (empty($value)) {
return null;
}
// Domain normalization, as per RFC 6265 section 5.2.3
if ($value[0] === '.') {
$value = substr($value, 1);
}
return $value;
default:
return $value;
}
}
/**
* Format a cookie for a Cookie header
*
* This is used when sending cookies to a server.
*
* @return string Cookie formatted for Cookie header
*/
public function format_for_header() {
return sprintf('%s=%s', $this->name, $this->value);
}
/**
* Format a cookie for a Set-Cookie header
*
* This is used when sending cookies to clients. This isn't really
* applicable to client-side usage, but might be handy for debugging.
*
* @return string Cookie formatted for Set-Cookie header
*/
public function format_for_set_cookie() {
$header_value = $this->format_for_header();
if (!empty($this->attributes)) {
$parts = [];
foreach ($this->attributes as $key => $value) {
// Ignore non-associative attributes
if (is_numeric($key)) {
$parts[] = $value;
} else {
$parts[] = sprintf('%s=%s', $key, $value);
}
}
$header_value .= '; ' . implode('; ', $parts);
}
return $header_value;
}
/**
* Parse a cookie string into a cookie object
*
* Based on Mozilla's parsing code in Firefox and related projects, which
* is an intentional deviation from RFC 2109 and RFC 2616. RFC 6265
* specifies some of this handling, but not in a thorough manner.
*
* @param string $cookie_header Cookie header value (from a Set-Cookie header)
* @param string $name
* @param int|null $reference_time
* @return \WpOrg\Requests\Cookie Parsed cookie object
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $cookie_header argument is not a string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string.
*/
public static function parse($cookie_header, $name = '', $reference_time = null) {
if (is_string($cookie_header) === false) {
throw InvalidArgument::create(1, '$cookie_header', 'string', gettype($cookie_header));
}
if (is_string($name) === false) {
throw InvalidArgument::create(2, '$name', 'string', gettype($name));
}
$parts = explode(';', $cookie_header);
$kvparts = array_shift($parts);
if (!empty($name)) {
$value = $cookie_header;
} elseif (strpos($kvparts, '=') === false) {
// Some sites might only have a value without the equals separator.
// Deviate from RFC 6265 and pretend it was actually a blank name
// (`=foo`)
//
// https://bugzilla.mozilla.org/show_bug.cgi?id=169091
$name = '';
$value = $kvparts;
} else {
list($name, $value) = explode('=', $kvparts, 2);
}
$name = trim($name);
$value = trim($value);
// Attribute keys are handled case-insensitively
$attributes = new CaseInsensitiveDictionary();
if (!empty($parts)) {
foreach ($parts as $part) {
if (strpos($part, '=') === false) {
$part_key = $part;
$part_value = true;
} else {
list($part_key, $part_value) = explode('=', $part, 2);
$part_value = trim($part_value);
}
$part_key = trim($part_key);
$attributes[$part_key] = $part_value;
}
}
return new static($name, $value, $attributes, [], $reference_time);
}
/**
* Parse all Set-Cookie headers from request headers
*
* @param \WpOrg\Requests\Response\Headers $headers Headers to parse from
* @param \WpOrg\Requests\Iri|null $origin URI for comparing cookie origins
* @param int|null $time Reference time for expiration calculation
* @return array
*/
public static function parse_from_headers(Headers $headers, Iri $origin = null, $time = null) {
$cookie_headers = $headers->getValues('Set-Cookie');
if (empty($cookie_headers)) {
return [];
}
$cookies = [];
foreach ($cookie_headers as $header) {
$parsed = self::parse($header, '', $time);
// Default domain/path attributes
if (empty($parsed->attributes['domain']) && !empty($origin)) {
$parsed->attributes['domain'] = $origin->host;
$parsed->flags['host-only'] = true;
} else {
$parsed->flags['host-only'] = false;
}
$path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/');
if (!$path_is_valid && !empty($origin)) {
$path = $origin->path;
// Default path normalization as per RFC 6265 section 5.1.4
if (substr($path, 0, 1) !== '/') {
// If the uri-path is empty or if the first character of
// the uri-path is not a %x2F ("/") character, output
// %x2F ("/") and skip the remaining steps.
$path = '/';
} elseif (substr_count($path, '/') === 1) {
// If the uri-path contains no more than one %x2F ("/")
// character, output %x2F ("/") and skip the remaining
// step.
$path = '/';
} else {
// Output the characters of the uri-path from the first
// character up to, but not including, the right-most
// %x2F ("/").
$path = substr($path, 0, strrpos($path, '/'));
}
$parsed->attributes['path'] = $path;
}
// Reject invalid cookie domains
if (!empty($origin) && !$parsed->domain_matches($origin->host)) {
continue;
}
$cookies[$parsed->name] = $parsed;
}
return $cookies;
}
}
Exception/ArgumentCount.php 0000644 00000002664 15153252210 0012012 0 ustar 00 <?php
namespace WpOrg\Requests\Exception;
use WpOrg\Requests\Exception;
/**
* Exception for when an incorrect number of arguments are passed to a method.
*
* Typically, this exception is used when all arguments for a method are optional,
* but certain arguments need to be passed together, i.e. a method which can be called
* with no arguments or with two arguments, but not with one argument.
*
* Along the same lines, this exception is also used if a method expects an array
* with a certain number of elements and the provided number of elements does not comply.
*
* @package Requests\Exceptions
* @since 2.0.0
*/
final class ArgumentCount extends Exception {
/**
* Create a new argument count exception with a standardized text.
*
* @param string $expected The argument count expected as a phrase.
* For example: `at least 2 arguments` or `exactly 1 argument`.
* @param int $received The actual argument count received.
* @param string $type Exception type.
*
* @return \WpOrg\Requests\Exception\ArgumentCount
*/
public static function create($expected, $received, $type) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
$stack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
return new self(
sprintf(
'%s::%s() expects %s, %d given',
$stack[1]['class'],
$stack[1]['function'],
$expected,
$received
),
$type
);
}
}
Exception/Http/Status304.php 0000644 00000000714 15153252210 0011642 0 ustar 00 <?php
/**
* Exception for 304 Not Modified responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 304 Not Modified responses
*
* @package Requests\Exceptions
*/
final class Status304 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 304;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Not Modified';
}
Exception/Http/Status305.php 0000644 00000000703 15153252210 0011641 0 ustar 00 <?php
/**
* Exception for 305 Use Proxy responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 305 Use Proxy responses
*
* @package Requests\Exceptions
*/
final class Status305 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 305;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Use Proxy';
}
Exception/Http/Status306.php 0000644 00000000714 15153252210 0011644 0 ustar 00 <?php
/**
* Exception for 306 Switch Proxy responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 306 Switch Proxy responses
*
* @package Requests\Exceptions
*/
final class Status306 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 306;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Switch Proxy';
}
Exception/Http/Status400.php 0000644 00000000711 15153252210 0011634 0 ustar 00 <?php
/**
* Exception for 400 Bad Request responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 400 Bad Request responses
*
* @package Requests\Exceptions
*/
final class Status400 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 400;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Bad Request';
}
Exception/Http/Status401.php 0000644 00000000714 15153252210 0011640 0 ustar 00 <?php
/**
* Exception for 401 Unauthorized responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 401 Unauthorized responses
*
* @package Requests\Exceptions
*/
final class Status401 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 401;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Unauthorized';
}
Exception/Http/Status402.php 0000644 00000000730 15153252210 0011637 0 ustar 00 <?php
/**
* Exception for 402 Payment Required responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 402 Payment Required responses
*
* @package Requests\Exceptions
*/
final class Status402 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 402;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Payment Required';
}
Exception/Http/Status403.php 0000644 00000000703 15153252210 0011640 0 ustar 00 <?php
/**
* Exception for 403 Forbidden responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 403 Forbidden responses
*
* @package Requests\Exceptions
*/
final class Status403 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 403;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Forbidden';
}
Exception/Http/Status404.php 0000644 00000000703 15153252210 0011641 0 ustar 00 <?php
/**
* Exception for 404 Not Found responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 404 Not Found responses
*
* @package Requests\Exceptions
*/
final class Status404 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 404;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Not Found';
}
Exception/Http/Status405.php 0000644 00000000736 15153252210 0011650 0 ustar 00 <?php
/**
* Exception for 405 Method Not Allowed responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 405 Method Not Allowed responses
*
* @package Requests\Exceptions
*/
final class Status405 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 405;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Method Not Allowed';
}
Exception/Http/Status406.php 0000644 00000000722 15153252210 0011644 0 ustar 00 <?php
/**
* Exception for 406 Not Acceptable responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 406 Not Acceptable responses
*
* @package Requests\Exceptions
*/
final class Status406 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 406;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Not Acceptable';
}
Exception/Http/Status407.php 0000644 00000000777 15153252210 0011657 0 ustar 00 <?php
/**
* Exception for 407 Proxy Authentication Required responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 407 Proxy Authentication Required responses
*
* @package Requests\Exceptions
*/
final class Status407 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 407;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Proxy Authentication Required';
}
Exception/Http/Status408.php 0000644 00000000725 15153252210 0011651 0 ustar 00 <?php
/**
* Exception for 408 Request Timeout responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 408 Request Timeout responses
*
* @package Requests\Exceptions
*/
final class Status408 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 408;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Request Timeout';
}
Exception/Http/Status409.php 0000644 00000000700 15153252210 0011643 0 ustar 00 <?php
/**
* Exception for 409 Conflict responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 409 Conflict responses
*
* @package Requests\Exceptions
*/
final class Status409 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 409;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Conflict';
}
Exception/Http/Status410.php 0000644 00000000664 15153252210 0011644 0 ustar 00 <?php
/**
* Exception for 410 Gone responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 410 Gone responses
*
* @package Requests\Exceptions
*/
final class Status410 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 410;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Gone';
}
Exception/Http/Status411.php 0000644 00000000725 15153252210 0011643 0 ustar 00 <?php
/**
* Exception for 411 Length Required responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 411 Length Required responses
*
* @package Requests\Exceptions
*/
final class Status411 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 411;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Length Required';
}
Exception/Http/Status412.php 0000644 00000000741 15153252210 0011642 0 ustar 00 <?php
/**
* Exception for 412 Precondition Failed responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 412 Precondition Failed responses
*
* @package Requests\Exceptions
*/
final class Status412 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 412;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Precondition Failed';
}
Exception/Http/Status413.php 0000644 00000000760 15153252210 0011644 0 ustar 00 <?php
/**
* Exception for 413 Request Entity Too Large responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 413 Request Entity Too Large responses
*
* @package Requests\Exceptions
*/
final class Status413 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 413;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Request Entity Too Large';
}
Exception/Http/Status414.php 0000644 00000000747 15153252210 0011652 0 ustar 00 <?php
/**
* Exception for 414 Request-URI Too Large responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 414 Request-URI Too Large responses
*
* @package Requests\Exceptions
*/
final class Status414 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 414;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Request-URI Too Large';
}
Exception/Http/Status415.php 0000644 00000000752 15153252210 0011647 0 ustar 00 <?php
/**
* Exception for 415 Unsupported Media Type responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 415 Unsupported Media Type responses
*
* @package Requests\Exceptions
*/
final class Status415 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 415;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Unsupported Media Type';
}
Exception/Http/Status416.php 0000644 00000001005 15153252210 0011640 0 ustar 00 <?php
/**
* Exception for 416 Requested Range Not Satisfiable responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 416 Requested Range Not Satisfiable responses
*
* @package Requests\Exceptions
*/
final class Status416 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 416;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Requested Range Not Satisfiable';
}
Exception/Http/Status417.php 0000644 00000000736 15153252210 0011653 0 ustar 00 <?php
/**
* Exception for 417 Expectation Failed responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 417 Expectation Failed responses
*
* @package Requests\Exceptions
*/
final class Status417 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 417;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Expectation Failed';
}
Exception/Http/Status418.php 0000644 00000001054 15153252210 0011646 0 ustar 00 <?php
/**
* Exception for 418 I'm A Teapot responses
*
* @link https://tools.ietf.org/html/rfc2324
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 418 I'm A Teapot responses
*
* @link https://tools.ietf.org/html/rfc2324
*
* @package Requests\Exceptions
*/
final class Status418 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 418;
/**
* Reason phrase
*
* @var string
*/
protected $reason = "I'm A Teapot";
}
Exception/Http/Status428.php 0000644 00000001107 15153252210 0011646 0 ustar 00 <?php
/**
* Exception for 428 Precondition Required responses
*
* @link https://tools.ietf.org/html/rfc6585
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 428 Precondition Required responses
*
* @link https://tools.ietf.org/html/rfc6585
*
* @package Requests\Exceptions
*/
final class Status428 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 428;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Precondition Required';
}
Exception/Http/Status429.php 0000644 00000001163 15153252210 0011651 0 ustar 00 <?php
/**
* Exception for 429 Too Many Requests responses
*
* @link https://tools.ietf.org/html/draft-nottingham-http-new-status-04
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 429 Too Many Requests responses
*
* @link https://tools.ietf.org/html/draft-nottingham-http-new-status-04
*
* @package Requests\Exceptions
*/
final class Status429 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 429;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Too Many Requests';
}
Exception/Http/Status431.php 0000644 00000001145 15153252210 0011642 0 ustar 00 <?php
/**
* Exception for 431 Request Header Fields Too Large responses
*
* @link https://tools.ietf.org/html/rfc6585
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 431 Request Header Fields Too Large responses
*
* @link https://tools.ietf.org/html/rfc6585
*
* @package Requests\Exceptions
*/
final class Status431 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 431;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Request Header Fields Too Large';
}
Exception/Http/Status500.php 0000644 00000000747 15153252210 0011646 0 ustar 00 <?php
/**
* Exception for 500 Internal Server Error responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 500 Internal Server Error responses
*
* @package Requests\Exceptions
*/
final class Status500 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 500;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Internal Server Error';
}
Exception/Http/Status501.php 0000644 00000000725 15153252210 0011643 0 ustar 00 <?php
/**
* Exception for 501 Not Implemented responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 501 Not Implemented responses
*
* @package Requests\Exceptions
*/
final class Status501 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 501;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Not Implemented';
}
Exception/Http/Status502.php 0000644 00000000711 15153252210 0011637 0 ustar 00 <?php
/**
* Exception for 502 Bad Gateway responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 502 Bad Gateway responses
*
* @package Requests\Exceptions
*/
final class Status502 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 502;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Bad Gateway';
}
Exception/Http/Status503.php 0000644 00000000741 15153252210 0011643 0 ustar 00 <?php
/**
* Exception for 503 Service Unavailable responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 503 Service Unavailable responses
*
* @package Requests\Exceptions
*/
final class Status503 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 503;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Service Unavailable';
}
Exception/Http/Status504.php 0000644 00000000725 15153252210 0011646 0 ustar 00 <?php
/**
* Exception for 504 Gateway Timeout responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 504 Gateway Timeout responses
*
* @package Requests\Exceptions
*/
final class Status504 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 504;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Gateway Timeout';
}
Exception/Http/Status505.php 0000644 00000000766 15153252210 0011654 0 ustar 00 <?php
/**
* Exception for 505 HTTP Version Not Supported responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 505 HTTP Version Not Supported responses
*
* @package Requests\Exceptions
*/
final class Status505 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 505;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'HTTP Version Not Supported';
}
Exception/Http/Status511.php 0000644 00000001145 15153252210 0011641 0 ustar 00 <?php
/**
* Exception for 511 Network Authentication Required responses
*
* @link https://tools.ietf.org/html/rfc6585
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
/**
* Exception for 511 Network Authentication Required responses
*
* @link https://tools.ietf.org/html/rfc6585
*
* @package Requests\Exceptions
*/
final class Status511 extends Http {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 511;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Network Authentication Required';
}
Exception/Http/StatusUnknown.php 0000644 00000001712 15153252210 0012772 0 ustar 00 <?php
/**
* Exception for unknown status responses
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Response;
/**
* Exception for unknown status responses
*
* @package Requests\Exceptions
*/
final class StatusUnknown extends Http {
/**
* HTTP status code
*
* @var integer|bool Code if available, false if an error occurred
*/
protected $code = 0;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Unknown';
/**
* Create a new exception
*
* If `$data` is an instance of {@see \WpOrg\Requests\Response}, uses the status
* code from it. Otherwise, sets as 0
*
* @param string|null $reason Reason phrase
* @param mixed $data Associated data
*/
public function __construct($reason = null, $data = null) {
if ($data instanceof Response) {
$this->code = (int) $data->status_code;
}
parent::__construct($reason, $data);
}
}
Exception/Http.php 0000644 00000003006 15153252210 0010125 0 ustar 00 <?php
/**
* Exception based on HTTP response
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\Http\StatusUnknown;
/**
* Exception based on HTTP response
*
* @package Requests\Exceptions
*/
class Http extends Exception {
/**
* HTTP status code
*
* @var integer
*/
protected $code = 0;
/**
* Reason phrase
*
* @var string
*/
protected $reason = 'Unknown';
/**
* Create a new exception
*
* There is no mechanism to pass in the status code, as this is set by the
* subclass used. Reason phrases can vary, however.
*
* @param string|null $reason Reason phrase
* @param mixed $data Associated data
*/
public function __construct($reason = null, $data = null) {
if ($reason !== null) {
$this->reason = $reason;
}
$message = sprintf('%d %s', $this->code, $this->reason);
parent::__construct($message, 'httpresponse', $data, $this->code);
}
/**
* Get the status message.
*
* @return string
*/
public function getReason() {
return $this->reason;
}
/**
* Get the correct exception class for a given error code
*
* @param int|bool $code HTTP status code, or false if unavailable
* @return string Exception class name to use
*/
public static function get_class($code) {
if (!$code) {
return StatusUnknown::class;
}
$class = sprintf('\WpOrg\Requests\Exception\Http\Status%d', $code);
if (class_exists($class)) {
return $class;
}
return StatusUnknown::class;
}
}
Exception/InvalidArgument.php 0000644 00000001425 15153252210 0012302 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidArgument
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidArgument extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when an argument value is not an object.
*
* @param string $name The name of the argument.
* @param string $method The name of the method/function.
*
* @return static
*/
public static function not_object( string $name, string $method ) {
return new static( sprintf( 'The argument "%s" provided to the function "%s" must be an object.', $name, $method ) );
}
}
Exception/Transport/Curl.php 0000644 00000002565 15153252210 0012120 0 ustar 00 <?php
/**
* CURL Transport Exception.
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception\Transport;
use WpOrg\Requests\Exception\Transport;
/**
* CURL Transport Exception.
*
* @package Requests\Exceptions
*/
final class Curl extends Transport {
const EASY = 'cURLEasy';
const MULTI = 'cURLMulti';
const SHARE = 'cURLShare';
/**
* cURL error code
*
* @var integer
*/
protected $code = -1;
/**
* Which type of cURL error
*
* EASY|MULTI|SHARE
*
* @var string
*/
protected $type = 'Unknown';
/**
* Clear text error message
*
* @var string
*/
protected $reason = 'Unknown';
/**
* Create a new exception.
*
* @param string $message Exception message.
* @param string $type Exception type.
* @param mixed $data Associated data, if applicable.
* @param int $code Exception numerical code, if applicable.
*/
public function __construct($message, $type, $data = null, $code = 0) {
if ($type !== null) {
$this->type = $type;
}
if ($code !== null) {
$this->code = (int) $code;
}
if ($message !== null) {
$this->reason = $message;
}
$message = sprintf('%d %s', $this->code, $this->reason);
parent::__construct($message, $this->type, $data, $this->code);
}
/**
* Get the error message.
*
* @return string
*/
public function getReason() {
return $this->reason;
}
}
Exception/Transport.php 0000644 00000000364 15153252210 0011206 0 ustar 00 <?php
/**
* Transport Exception
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests\Exception;
use WpOrg\Requests\Exception;
/**
* Transport Exception
*
* @package Requests\Exceptions
*/
class Transport extends Exception {}
Exception.php 0000644 00000002132 15153252210 0007205 0 ustar 00 <?php
/**
* Exception for HTTP requests
*
* @package Requests\Exceptions
*/
namespace WpOrg\Requests;
use Exception as PHPException;
/**
* Exception for HTTP requests
*
* @package Requests\Exceptions
*/
class Exception extends PHPException {
/**
* Type of exception
*
* @var string
*/
protected $type;
/**
* Data associated with the exception
*
* @var mixed
*/
protected $data;
/**
* Create a new exception
*
* @param string $message Exception message
* @param string $type Exception type
* @param mixed $data Associated data
* @param integer $code Exception numerical code, if applicable
*/
public function __construct($message, $type, $data = null, $code = 0) {
parent::__construct($message, $code);
$this->type = $type;
$this->data = $data;
}
/**
* Like {@see \Exception::getCode()}, but a string code.
*
* @codeCoverageIgnore
* @return string
*/
public function getType() {
return $this->type;
}
/**
* Gives any relevant data
*
* @codeCoverageIgnore
* @return mixed
*/
public function getData() {
return $this->data;
}
}
HookManager.php 0000644 00000001305 15153252210 0007443 0 ustar 00 <?php
/**
* Event dispatcher
*
* @package Requests\EventDispatcher
*/
namespace WpOrg\Requests;
/**
* Event dispatcher
*
* @package Requests\EventDispatcher
*/
interface HookManager {
/**
* Register a callback for a hook
*
* @param string $hook Hook name
* @param callable $callback Function/method to call on event
* @param int $priority Priority number. <0 is executed earlier, >0 is executed later
*/
public function register($hook, $callback, $priority = 0);
/**
* Dispatch a message
*
* @param string $hook Hook name
* @param array $parameters Parameters to pass to callbacks
* @return boolean Successfulness
*/
public function dispatch($hook, $parameters = []);
}
Hooks.php 0000644 00000005730 15153252210 0006341 0 ustar 00 <?php
/**
* Handles adding and dispatching events
*
* @package Requests\EventDispatcher
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\HookManager;
use WpOrg\Requests\Utility\InputValidator;
/**
* Handles adding and dispatching events
*
* @package Requests\EventDispatcher
*/
class Hooks implements HookManager {
/**
* Registered callbacks for each hook
*
* @var array
*/
protected $hooks = [];
/**
* Register a callback for a hook
*
* @param string $hook Hook name
* @param callable $callback Function/method to call on event
* @param int $priority Priority number. <0 is executed earlier, >0 is executed later
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $hook argument is not a string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $callback argument is not callable.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $priority argument is not an integer.
*/
public function register($hook, $callback, $priority = 0) {
if (is_string($hook) === false) {
throw InvalidArgument::create(1, '$hook', 'string', gettype($hook));
}
if (is_callable($callback) === false) {
throw InvalidArgument::create(2, '$callback', 'callable', gettype($callback));
}
if (InputValidator::is_numeric_array_key($priority) === false) {
throw InvalidArgument::create(3, '$priority', 'integer', gettype($priority));
}
if (!isset($this->hooks[$hook])) {
$this->hooks[$hook] = [
$priority => [],
];
} elseif (!isset($this->hooks[$hook][$priority])) {
$this->hooks[$hook][$priority] = [];
}
$this->hooks[$hook][$priority][] = $callback;
}
/**
* Dispatch a message
*
* @param string $hook Hook name
* @param array $parameters Parameters to pass to callbacks
* @return boolean Successfulness
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $hook argument is not a string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $parameters argument is not an array.
*/
public function dispatch($hook, $parameters = []) {
if (is_string($hook) === false) {
throw InvalidArgument::create(1, '$hook', 'string', gettype($hook));
}
// Check strictly against array, as Array* objects don't work in combination with `call_user_func_array()`.
if (is_array($parameters) === false) {
throw InvalidArgument::create(2, '$parameters', 'array', gettype($parameters));
}
if (empty($this->hooks[$hook])) {
return false;
}
if (!empty($parameters)) {
// Strip potential keys from the array to prevent them being interpreted as parameter names in PHP 8.0.
$parameters = array_values($parameters);
}
ksort($this->hooks[$hook]);
foreach ($this->hooks[$hook] as $priority => $hooked) {
foreach ($hooked as $callback) {
$callback(...$parameters);
}
}
return true;
}
public function __wakeup() {
throw new \LogicException( __CLASS__ . ' should never be unserialized' );
}
}
IdnaEncoder.php 0000644 00000030223 15153252210 0007424 0 ustar 00 <?php
namespace WpOrg\Requests;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Utility\InputValidator;
/**
* IDNA URL encoder
*
* Note: Not fully compliant, as nameprep does nothing yet.
*
* @package Requests\Utilities
*
* @link https://tools.ietf.org/html/rfc3490 IDNA specification
* @link https://tools.ietf.org/html/rfc3492 Punycode/Bootstrap specification
*/
class IdnaEncoder {
/**
* ACE prefix used for IDNA
*
* @link https://tools.ietf.org/html/rfc3490#section-5
* @var string
*/
const ACE_PREFIX = 'xn--';
/**
* Maximum length of a IDNA URL in ASCII.
*
* @see \WpOrg\Requests\IdnaEncoder::to_ascii()
*
* @since 2.0.0
*
* @var int
*/
const MAX_LENGTH = 64;
/**#@+
* Bootstrap constant for Punycode
*
* @link https://tools.ietf.org/html/rfc3492#section-5
* @var int
*/
const BOOTSTRAP_BASE = 36;
const BOOTSTRAP_TMIN = 1;
const BOOTSTRAP_TMAX = 26;
const BOOTSTRAP_SKEW = 38;
const BOOTSTRAP_DAMP = 700;
const BOOTSTRAP_INITIAL_BIAS = 72;
const BOOTSTRAP_INITIAL_N = 128;
/**#@-*/
/**
* Encode a hostname using Punycode
*
* @param string|Stringable $hostname Hostname
* @return string Punycode-encoded hostname
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or a stringable object.
*/
public static function encode($hostname) {
if (InputValidator::is_string_or_stringable($hostname) === false) {
throw InvalidArgument::create(1, '$hostname', 'string|Stringable', gettype($hostname));
}
$parts = explode('.', $hostname);
foreach ($parts as &$part) {
$part = self::to_ascii($part);
}
return implode('.', $parts);
}
/**
* Convert a UTF-8 text string to an ASCII string using Punycode
*
* @param string $text ASCII or UTF-8 string (max length 64 characters)
* @return string ASCII string
*
* @throws \WpOrg\Requests\Exception Provided string longer than 64 ASCII characters (`idna.provided_too_long`)
* @throws \WpOrg\Requests\Exception Prepared string longer than 64 ASCII characters (`idna.prepared_too_long`)
* @throws \WpOrg\Requests\Exception Provided string already begins with xn-- (`idna.provided_is_prefixed`)
* @throws \WpOrg\Requests\Exception Encoded string longer than 64 ASCII characters (`idna.encoded_too_long`)
*/
public static function to_ascii($text) {
// Step 1: Check if the text is already ASCII
if (self::is_ascii($text)) {
// Skip to step 7
if (strlen($text) < self::MAX_LENGTH) {
return $text;
}
throw new Exception('Provided string is too long', 'idna.provided_too_long', $text);
}
// Step 2: nameprep
$text = self::nameprep($text);
// Step 3: UseSTD3ASCIIRules is false, continue
// Step 4: Check if it's ASCII now
if (self::is_ascii($text)) {
// Skip to step 7
/*
* As the `nameprep()` method returns the original string, this code will never be reached until
* that method is properly implemented.
*/
// @codeCoverageIgnoreStart
if (strlen($text) < self::MAX_LENGTH) {
return $text;
}
throw new Exception('Prepared string is too long', 'idna.prepared_too_long', $text);
// @codeCoverageIgnoreEnd
}
// Step 5: Check ACE prefix
if (strpos($text, self::ACE_PREFIX) === 0) {
throw new Exception('Provided string begins with ACE prefix', 'idna.provided_is_prefixed', $text);
}
// Step 6: Encode with Punycode
$text = self::punycode_encode($text);
// Step 7: Prepend ACE prefix
$text = self::ACE_PREFIX . $text;
// Step 8: Check size
if (strlen($text) < self::MAX_LENGTH) {
return $text;
}
throw new Exception('Encoded string is too long', 'idna.encoded_too_long', $text);
}
/**
* Check whether a given text string contains only ASCII characters
*
* @internal (Testing found regex was the fastest implementation)
*
* @param string $text Text to examine.
* @return bool Is the text string ASCII-only?
*/
protected static function is_ascii($text) {
return (preg_match('/(?:[^\x00-\x7F])/', $text) !== 1);
}
/**
* Prepare a text string for use as an IDNA name
*
* @todo Implement this based on RFC 3491 and the newer 5891
* @param string $text Text to prepare.
* @return string Prepared string
*/
protected static function nameprep($text) {
return $text;
}
/**
* Convert a UTF-8 string to a UCS-4 codepoint array
*
* Based on \WpOrg\Requests\Iri::replace_invalid_with_pct_encoding()
*
* @param string $input Text to convert.
* @return array Unicode code points
*
* @throws \WpOrg\Requests\Exception Invalid UTF-8 codepoint (`idna.invalidcodepoint`)
*/
protected static function utf8_to_codepoints($input) {
$codepoints = [];
// Get number of bytes
$strlen = strlen($input);
// phpcs:ignore Generic.CodeAnalysis.JumbledIncrementer -- This is a deliberate choice.
for ($position = 0; $position < $strlen; $position++) {
$value = ord($input[$position]);
if ((~$value & 0x80) === 0x80) { // One byte sequence:
$character = $value;
$length = 1;
$remaining = 0;
} elseif (($value & 0xE0) === 0xC0) { // Two byte sequence:
$character = ($value & 0x1F) << 6;
$length = 2;
$remaining = 1;
} elseif (($value & 0xF0) === 0xE0) { // Three byte sequence:
$character = ($value & 0x0F) << 12;
$length = 3;
$remaining = 2;
} elseif (($value & 0xF8) === 0xF0) { // Four byte sequence:
$character = ($value & 0x07) << 18;
$length = 4;
$remaining = 3;
} else { // Invalid byte:
throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $value);
}
if ($remaining > 0) {
if ($position + $length > $strlen) {
throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character);
}
for ($position++; $remaining > 0; $position++) {
$value = ord($input[$position]);
// If it is invalid, count the sequence as invalid and reprocess the current byte:
if (($value & 0xC0) !== 0x80) {
throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character);
}
--$remaining;
$character |= ($value & 0x3F) << ($remaining * 6);
}
$position--;
}
if (// Non-shortest form sequences are invalid
$length > 1 && $character <= 0x7F
|| $length > 2 && $character <= 0x7FF
|| $length > 3 && $character <= 0xFFFF
// Outside of range of ucschar codepoints
// Noncharacters
|| ($character & 0xFFFE) === 0xFFFE
|| $character >= 0xFDD0 && $character <= 0xFDEF
|| (
// Everything else not in ucschar
$character > 0xD7FF && $character < 0xF900
|| $character < 0x20
|| $character > 0x7E && $character < 0xA0
|| $character > 0xEFFFD
)
) {
throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character);
}
$codepoints[] = $character;
}
return $codepoints;
}
/**
* RFC3492-compliant encoder
*
* @internal Pseudo-code from Section 6.3 is commented with "#" next to relevant code
*
* @param string $input UTF-8 encoded string to encode
* @return string Punycode-encoded string
*
* @throws \WpOrg\Requests\Exception On character outside of the domain (never happens with Punycode) (`idna.character_outside_domain`)
*/
public static function punycode_encode($input) {
$output = '';
// let n = initial_n
$n = self::BOOTSTRAP_INITIAL_N;
// let delta = 0
$delta = 0;
// let bias = initial_bias
$bias = self::BOOTSTRAP_INITIAL_BIAS;
// let h = b = the number of basic code points in the input
$h = 0;
$b = 0; // see loop
// copy them to the output in order
$codepoints = self::utf8_to_codepoints($input);
$extended = [];
foreach ($codepoints as $char) {
if ($char < 128) {
// Character is valid ASCII
// TODO: this should also check if it's valid for a URL
$output .= chr($char);
$h++;
// Check if the character is non-ASCII, but below initial n
// This never occurs for Punycode, so ignore in coverage
// @codeCoverageIgnoreStart
} elseif ($char < $n) {
throw new Exception('Invalid character', 'idna.character_outside_domain', $char);
// @codeCoverageIgnoreEnd
} else {
$extended[$char] = true;
}
}
$extended = array_keys($extended);
sort($extended);
$b = $h;
// [copy them] followed by a delimiter if b > 0
if (strlen($output) > 0) {
$output .= '-';
}
// {if the input contains a non-basic code point < n then fail}
// while h < length(input) do begin
$codepointcount = count($codepoints);
while ($h < $codepointcount) {
// let m = the minimum code point >= n in the input
$m = array_shift($extended);
//printf('next code point to insert is %s' . PHP_EOL, dechex($m));
// let delta = delta + (m - n) * (h + 1), fail on overflow
$delta += ($m - $n) * ($h + 1);
// let n = m
$n = $m;
// for each code point c in the input (in order) do begin
for ($num = 0; $num < $codepointcount; $num++) {
$c = $codepoints[$num];
// if c < n then increment delta, fail on overflow
if ($c < $n) {
$delta++;
} elseif ($c === $n) { // if c == n then begin
// let q = delta
$q = $delta;
// for k = base to infinity in steps of base do begin
for ($k = self::BOOTSTRAP_BASE; ; $k += self::BOOTSTRAP_BASE) {
// let t = tmin if k <= bias {+ tmin}, or
// tmax if k >= bias + tmax, or k - bias otherwise
if ($k <= ($bias + self::BOOTSTRAP_TMIN)) {
$t = self::BOOTSTRAP_TMIN;
} elseif ($k >= ($bias + self::BOOTSTRAP_TMAX)) {
$t = self::BOOTSTRAP_TMAX;
} else {
$t = $k - $bias;
}
// if q < t then break
if ($q < $t) {
break;
}
// output the code point for digit t + ((q - t) mod (base - t))
$digit = (int) ($t + (($q - $t) % (self::BOOTSTRAP_BASE - $t)));
$output .= self::digit_to_char($digit);
// let q = (q - t) div (base - t)
$q = (int) floor(($q - $t) / (self::BOOTSTRAP_BASE - $t));
} // end
// output the code point for digit q
$output .= self::digit_to_char($q);
// let bias = adapt(delta, h + 1, test h equals b?)
$bias = self::adapt($delta, $h + 1, $h === $b);
// let delta = 0
$delta = 0;
// increment h
$h++;
} // end
} // end
// increment delta and n
$delta++;
$n++;
} // end
return $output;
}
/**
* Convert a digit to its respective character
*
* @link https://tools.ietf.org/html/rfc3492#section-5
*
* @param int $digit Digit in the range 0-35
* @return string Single character corresponding to digit
*
* @throws \WpOrg\Requests\Exception On invalid digit (`idna.invalid_digit`)
*/
protected static function digit_to_char($digit) {
// @codeCoverageIgnoreStart
// As far as I know, this never happens, but still good to be sure.
if ($digit < 0 || $digit > 35) {
throw new Exception(sprintf('Invalid digit %d', $digit), 'idna.invalid_digit', $digit);
}
// @codeCoverageIgnoreEnd
$digits = 'abcdefghijklmnopqrstuvwxyz0123456789';
return substr($digits, $digit, 1);
}
/**
* Adapt the bias
*
* @link https://tools.ietf.org/html/rfc3492#section-6.1
* @param int $delta
* @param int $numpoints
* @param bool $firsttime
* @return int|float New bias
*
* function adapt(delta,numpoints,firsttime):
*/
protected static function adapt($delta, $numpoints, $firsttime) {
// if firsttime then let delta = delta div damp
if ($firsttime) {
$delta = floor($delta / self::BOOTSTRAP_DAMP);
} else {
// else let delta = delta div 2
$delta = floor($delta / 2);
}
// let delta = delta + (delta div numpoints)
$delta += floor($delta / $numpoints);
// let k = 0
$k = 0;
// while delta > ((base - tmin) * tmax) div 2 do begin
$max = floor(((self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN) * self::BOOTSTRAP_TMAX) / 2);
while ($delta > $max) {
// let delta = delta div (base - tmin)
$delta = floor($delta / (self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN));
// let k = k + base
$k += self::BOOTSTRAP_BASE;
} // end
// return k + (((base - tmin + 1) * delta) div (delta + skew))
return $k + floor(((self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN + 1) * $delta) / ($delta + self::BOOTSTRAP_SKEW));
}
}
Ipv6.php 0000644 00000013007 15153252210 0006076 0 ustar 00 <?php
/**
* Class to validate and to work with IPv6 addresses
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Utility\InputValidator;
/**
* Class to validate and to work with IPv6 addresses
*
* This was originally based on the PEAR class of the same name, but has been
* entirely rewritten.
*
* @package Requests\Utilities
*/
final class Ipv6 {
/**
* Uncompresses an IPv6 address
*
* RFC 4291 allows you to compress consecutive zero pieces in an address to
* '::'. This method expects a valid IPv6 address and expands the '::' to
* the required number of zero pieces.
*
* Example: FF01::101 -> FF01:0:0:0:0:0:0:101
* ::1 -> 0:0:0:0:0:0:0:1
*
* @author Alexander Merz <alexander.merz@web.de>
* @author elfrink at introweb dot nl
* @author Josh Peck <jmp at joshpeck dot org>
* @copyright 2003-2005 The PHP Group
* @license https://opensource.org/licenses/bsd-license.php
*
* @param string|Stringable $ip An IPv6 address
* @return string The uncompressed IPv6 address
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or a stringable object.
*/
public static function uncompress($ip) {
if (InputValidator::is_string_or_stringable($ip) === false) {
throw InvalidArgument::create(1, '$ip', 'string|Stringable', gettype($ip));
}
$ip = (string) $ip;
if (substr_count($ip, '::') !== 1) {
return $ip;
}
list($ip1, $ip2) = explode('::', $ip);
$c1 = ($ip1 === '') ? -1 : substr_count($ip1, ':');
$c2 = ($ip2 === '') ? -1 : substr_count($ip2, ':');
if (strpos($ip2, '.') !== false) {
$c2++;
}
if ($c1 === -1 && $c2 === -1) {
// ::
$ip = '0:0:0:0:0:0:0:0';
} elseif ($c1 === -1) {
// ::xxx
$fill = str_repeat('0:', 7 - $c2);
$ip = str_replace('::', $fill, $ip);
} elseif ($c2 === -1) {
// xxx::
$fill = str_repeat(':0', 7 - $c1);
$ip = str_replace('::', $fill, $ip);
} else {
// xxx::xxx
$fill = ':' . str_repeat('0:', 6 - $c2 - $c1);
$ip = str_replace('::', $fill, $ip);
}
return $ip;
}
/**
* Compresses an IPv6 address
*
* RFC 4291 allows you to compress consecutive zero pieces in an address to
* '::'. This method expects a valid IPv6 address and compresses consecutive
* zero pieces to '::'.
*
* Example: FF01:0:0:0:0:0:0:101 -> FF01::101
* 0:0:0:0:0:0:0:1 -> ::1
*
* @see \WpOrg\Requests\Ipv6::uncompress()
*
* @param string $ip An IPv6 address
* @return string The compressed IPv6 address
*/
public static function compress($ip) {
// Prepare the IP to be compressed.
// Note: Input validation is handled in the `uncompress()` method, which is the first call made in this method.
$ip = self::uncompress($ip);
$ip_parts = self::split_v6_v4($ip);
// Replace all leading zeros
$ip_parts[0] = preg_replace('/(^|:)0+([0-9])/', '\1\2', $ip_parts[0]);
// Find bunches of zeros
if (preg_match_all('/(?:^|:)(?:0(?::|$))+/', $ip_parts[0], $matches, PREG_OFFSET_CAPTURE)) {
$max = 0;
$pos = null;
foreach ($matches[0] as $match) {
if (strlen($match[0]) > $max) {
$max = strlen($match[0]);
$pos = $match[1];
}
}
$ip_parts[0] = substr_replace($ip_parts[0], '::', $pos, $max);
}
if ($ip_parts[1] !== '') {
return implode(':', $ip_parts);
} else {
return $ip_parts[0];
}
}
/**
* Splits an IPv6 address into the IPv6 and IPv4 representation parts
*
* RFC 4291 allows you to represent the last two parts of an IPv6 address
* using the standard IPv4 representation
*
* Example: 0:0:0:0:0:0:13.1.68.3
* 0:0:0:0:0:FFFF:129.144.52.38
*
* @param string $ip An IPv6 address
* @return string[] [0] contains the IPv6 represented part, and [1] the IPv4 represented part
*/
private static function split_v6_v4($ip) {
if (strpos($ip, '.') !== false) {
$pos = strrpos($ip, ':');
$ipv6_part = substr($ip, 0, $pos);
$ipv4_part = substr($ip, $pos + 1);
return [$ipv6_part, $ipv4_part];
} else {
return [$ip, ''];
}
}
/**
* Checks an IPv6 address
*
* Checks if the given IP is a valid IPv6 address
*
* @param string $ip An IPv6 address
* @return bool true if $ip is a valid IPv6 address
*/
public static function check_ipv6($ip) {
// Note: Input validation is handled in the `uncompress()` method, which is the first call made in this method.
$ip = self::uncompress($ip);
list($ipv6, $ipv4) = self::split_v6_v4($ip);
$ipv6 = explode(':', $ipv6);
$ipv4 = explode('.', $ipv4);
if (count($ipv6) === 8 && count($ipv4) === 1 || count($ipv6) === 6 && count($ipv4) === 4) {
foreach ($ipv6 as $ipv6_part) {
// The section can't be empty
if ($ipv6_part === '') {
return false;
}
// Nor can it be over four characters
if (strlen($ipv6_part) > 4) {
return false;
}
// Remove leading zeros (this is safe because of the above)
$ipv6_part = ltrim($ipv6_part, '0');
if ($ipv6_part === '') {
$ipv6_part = '0';
}
// Check the value is valid
$value = hexdec($ipv6_part);
if (dechex($value) !== strtolower($ipv6_part) || $value < 0 || $value > 0xFFFF) {
return false;
}
}
if (count($ipv4) === 4) {
foreach ($ipv4 as $ipv4_part) {
$value = (int) $ipv4_part;
if ((string) $value !== $ipv4_part || $value < 0 || $value > 0xFF) {
return false;
}
}
}
return true;
} else {
return false;
}
}
}
Iri.php 0000644 00000071666 15153252210 0006014 0 ustar 00 <?php
/**
* IRI parser/serialiser/normaliser
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Ipv6;
use WpOrg\Requests\Port;
use WpOrg\Requests\Utility\InputValidator;
/**
* IRI parser/serialiser/normaliser
*
* Copyright (c) 2007-2010, Geoffrey Sneddon and Steve Minutillo.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the SimplePie Team nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*
* @package Requests\Utilities
* @author Geoffrey Sneddon
* @author Steve Minutillo
* @copyright 2007-2009 Geoffrey Sneddon and Steve Minutillo
* @license https://opensource.org/licenses/bsd-license.php
* @link http://hg.gsnedders.com/iri/
*
* @property string $iri IRI we're working with
* @property-read string $uri IRI in URI form, {@see \WpOrg\Requests\Iri::to_uri()}
* @property string $scheme Scheme part of the IRI
* @property string $authority Authority part, formatted for a URI (userinfo + host + port)
* @property string $iauthority Authority part of the IRI (userinfo + host + port)
* @property string $userinfo Userinfo part, formatted for a URI (after '://' and before '@')
* @property string $iuserinfo Userinfo part of the IRI (after '://' and before '@')
* @property string $host Host part, formatted for a URI
* @property string $ihost Host part of the IRI
* @property string $port Port part of the IRI (after ':')
* @property string $path Path part, formatted for a URI (after first '/')
* @property string $ipath Path part of the IRI (after first '/')
* @property string $query Query part, formatted for a URI (after '?')
* @property string $iquery Query part of the IRI (after '?')
* @property string $fragment Fragment, formatted for a URI (after '#')
* @property string $ifragment Fragment part of the IRI (after '#')
*/
class Iri {
/**
* Scheme
*
* @var string|null
*/
protected $scheme = null;
/**
* User Information
*
* @var string|null
*/
protected $iuserinfo = null;
/**
* ihost
*
* @var string|null
*/
protected $ihost = null;
/**
* Port
*
* @var string|null
*/
protected $port = null;
/**
* ipath
*
* @var string
*/
protected $ipath = '';
/**
* iquery
*
* @var string|null
*/
protected $iquery = null;
/**
* ifragment|null
*
* @var string
*/
protected $ifragment = null;
/**
* Normalization database
*
* Each key is the scheme, each value is an array with each key as the IRI
* part and value as the default value for that part.
*
* @var array
*/
protected $normalization = array(
'acap' => array(
'port' => Port::ACAP,
),
'dict' => array(
'port' => Port::DICT,
),
'file' => array(
'ihost' => 'localhost',
),
'http' => array(
'port' => Port::HTTP,
),
'https' => array(
'port' => Port::HTTPS,
),
);
/**
* Return the entire IRI when you try and read the object as a string
*
* @return string
*/
public function __toString() {
return $this->get_iri();
}
/**
* Overload __set() to provide access via properties
*
* @param string $name Property name
* @param mixed $value Property value
*/
public function __set($name, $value) {
if (method_exists($this, 'set_' . $name)) {
call_user_func(array($this, 'set_' . $name), $value);
}
elseif (
$name === 'iauthority'
|| $name === 'iuserinfo'
|| $name === 'ihost'
|| $name === 'ipath'
|| $name === 'iquery'
|| $name === 'ifragment'
) {
call_user_func(array($this, 'set_' . substr($name, 1)), $value);
}
}
/**
* Overload __get() to provide access via properties
*
* @param string $name Property name
* @return mixed
*/
public function __get($name) {
// isset() returns false for null, we don't want to do that
// Also why we use array_key_exists below instead of isset()
$props = get_object_vars($this);
if (
$name === 'iri' ||
$name === 'uri' ||
$name === 'iauthority' ||
$name === 'authority'
) {
$method = 'get_' . $name;
$return = $this->$method();
}
elseif (array_key_exists($name, $props)) {
$return = $this->$name;
}
// host -> ihost
elseif (($prop = 'i' . $name) && array_key_exists($prop, $props)) {
$name = $prop;
$return = $this->$prop;
}
// ischeme -> scheme
elseif (($prop = substr($name, 1)) && array_key_exists($prop, $props)) {
$name = $prop;
$return = $this->$prop;
}
else {
trigger_error('Undefined property: ' . get_class($this) . '::' . $name, E_USER_NOTICE);
$return = null;
}
if ($return === null && isset($this->normalization[$this->scheme][$name])) {
return $this->normalization[$this->scheme][$name];
}
else {
return $return;
}
}
/**
* Overload __isset() to provide access via properties
*
* @param string $name Property name
* @return bool
*/
public function __isset($name) {
return (method_exists($this, 'get_' . $name) || isset($this->$name));
}
/**
* Overload __unset() to provide access via properties
*
* @param string $name Property name
*/
public function __unset($name) {
if (method_exists($this, 'set_' . $name)) {
call_user_func(array($this, 'set_' . $name), '');
}
}
/**
* Create a new IRI object, from a specified string
*
* @param string|Stringable|null $iri
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $iri argument is not a string, Stringable or null.
*/
public function __construct($iri = null) {
if ($iri !== null && InputValidator::is_string_or_stringable($iri) === false) {
throw InvalidArgument::create(1, '$iri', 'string|Stringable|null', gettype($iri));
}
$this->set_iri($iri);
}
/**
* Create a new IRI object by resolving a relative IRI
*
* Returns false if $base is not absolute, otherwise an IRI.
*
* @param \WpOrg\Requests\Iri|string $base (Absolute) Base IRI
* @param \WpOrg\Requests\Iri|string $relative Relative IRI
* @return \WpOrg\Requests\Iri|false
*/
public static function absolutize($base, $relative) {
if (!($relative instanceof self)) {
$relative = new self($relative);
}
if (!$relative->is_valid()) {
return false;
}
elseif ($relative->scheme !== null) {
return clone $relative;
}
if (!($base instanceof self)) {
$base = new self($base);
}
if ($base->scheme === null || !$base->is_valid()) {
return false;
}
if ($relative->get_iri() !== '') {
if ($relative->iuserinfo !== null || $relative->ihost !== null || $relative->port !== null) {
$target = clone $relative;
$target->scheme = $base->scheme;
}
else {
$target = new self;
$target->scheme = $base->scheme;
$target->iuserinfo = $base->iuserinfo;
$target->ihost = $base->ihost;
$target->port = $base->port;
if ($relative->ipath !== '') {
if ($relative->ipath[0] === '/') {
$target->ipath = $relative->ipath;
}
elseif (($base->iuserinfo !== null || $base->ihost !== null || $base->port !== null) && $base->ipath === '') {
$target->ipath = '/' . $relative->ipath;
}
elseif (($last_segment = strrpos($base->ipath, '/')) !== false) {
$target->ipath = substr($base->ipath, 0, $last_segment + 1) . $relative->ipath;
}
else {
$target->ipath = $relative->ipath;
}
$target->ipath = $target->remove_dot_segments($target->ipath);
$target->iquery = $relative->iquery;
}
else {
$target->ipath = $base->ipath;
if ($relative->iquery !== null) {
$target->iquery = $relative->iquery;
}
elseif ($base->iquery !== null) {
$target->iquery = $base->iquery;
}
}
$target->ifragment = $relative->ifragment;
}
}
else {
$target = clone $base;
$target->ifragment = null;
}
$target->scheme_normalization();
return $target;
}
/**
* Parse an IRI into scheme/authority/path/query/fragment segments
*
* @param string $iri
* @return array
*/
protected function parse_iri($iri) {
$iri = trim($iri, "\x20\x09\x0A\x0C\x0D");
$has_match = preg_match('/^((?P<scheme>[^:\/?#]+):)?(\/\/(?P<authority>[^\/?#]*))?(?P<path>[^?#]*)(\?(?P<query>[^#]*))?(#(?P<fragment>.*))?$/', $iri, $match);
if (!$has_match) {
throw new Exception('Cannot parse supplied IRI', 'iri.cannot_parse', $iri);
}
if ($match[1] === '') {
$match['scheme'] = null;
}
if (!isset($match[3]) || $match[3] === '') {
$match['authority'] = null;
}
if (!isset($match[5])) {
$match['path'] = '';
}
if (!isset($match[6]) || $match[6] === '') {
$match['query'] = null;
}
if (!isset($match[8]) || $match[8] === '') {
$match['fragment'] = null;
}
return $match;
}
/**
* Remove dot segments from a path
*
* @param string $input
* @return string
*/
protected function remove_dot_segments($input) {
$output = '';
while (strpos($input, './') !== false || strpos($input, '/.') !== false || $input === '.' || $input === '..') {
// A: If the input buffer begins with a prefix of "../" or "./",
// then remove that prefix from the input buffer; otherwise,
if (strpos($input, '../') === 0) {
$input = substr($input, 3);
}
elseif (strpos($input, './') === 0) {
$input = substr($input, 2);
}
// B: if the input buffer begins with a prefix of "/./" or "/.",
// where "." is a complete path segment, then replace that prefix
// with "/" in the input buffer; otherwise,
elseif (strpos($input, '/./') === 0) {
$input = substr($input, 2);
}
elseif ($input === '/.') {
$input = '/';
}
// C: if the input buffer begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that prefix
// with "/" in the input buffer and remove the last segment and its
// preceding "/" (if any) from the output buffer; otherwise,
elseif (strpos($input, '/../') === 0) {
$input = substr($input, 3);
$output = substr_replace($output, '', (strrpos($output, '/') ?: 0));
}
elseif ($input === '/..') {
$input = '/';
$output = substr_replace($output, '', (strrpos($output, '/') ?: 0));
}
// D: if the input buffer consists only of "." or "..", then remove
// that from the input buffer; otherwise,
elseif ($input === '.' || $input === '..') {
$input = '';
}
// E: move the first path segment in the input buffer to the end of
// the output buffer, including the initial "/" character (if any)
// and any subsequent characters up to, but not including, the next
// "/" character or the end of the input buffer
elseif (($pos = strpos($input, '/', 1)) !== false) {
$output .= substr($input, 0, $pos);
$input = substr_replace($input, '', 0, $pos);
}
else {
$output .= $input;
$input = '';
}
}
return $output . $input;
}
/**
* Replace invalid character with percent encoding
*
* @param string $text Input string
* @param string $extra_chars Valid characters not in iunreserved or
* iprivate (this is ASCII-only)
* @param bool $iprivate Allow iprivate
* @return string
*/
protected function replace_invalid_with_pct_encoding($text, $extra_chars, $iprivate = false) {
// Normalize as many pct-encoded sections as possible
$text = preg_replace_callback('/(?:%[A-Fa-f0-9]{2})+/', array($this, 'remove_iunreserved_percent_encoded'), $text);
// Replace invalid percent characters
$text = preg_replace('/%(?![A-Fa-f0-9]{2})/', '%25', $text);
// Add unreserved and % to $extra_chars (the latter is safe because all
// pct-encoded sections are now valid).
$extra_chars .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~%';
// Now replace any bytes that aren't allowed with their pct-encoded versions
$position = 0;
$strlen = strlen($text);
while (($position += strspn($text, $extra_chars, $position)) < $strlen) {
$value = ord($text[$position]);
// Start position
$start = $position;
// By default we are valid
$valid = true;
// No one byte sequences are valid due to the while.
// Two byte sequence:
if (($value & 0xE0) === 0xC0) {
$character = ($value & 0x1F) << 6;
$length = 2;
$remaining = 1;
}
// Three byte sequence:
elseif (($value & 0xF0) === 0xE0) {
$character = ($value & 0x0F) << 12;
$length = 3;
$remaining = 2;
}
// Four byte sequence:
elseif (($value & 0xF8) === 0xF0) {
$character = ($value & 0x07) << 18;
$length = 4;
$remaining = 3;
}
// Invalid byte:
else {
$valid = false;
$length = 1;
$remaining = 0;
}
if ($remaining) {
if ($position + $length <= $strlen) {
for ($position++; $remaining; $position++) {
$value = ord($text[$position]);
// Check that the byte is valid, then add it to the character:
if (($value & 0xC0) === 0x80) {
$character |= ($value & 0x3F) << (--$remaining * 6);
}
// If it is invalid, count the sequence as invalid and reprocess the current byte:
else {
$valid = false;
$position--;
break;
}
}
}
else {
$position = $strlen - 1;
$valid = false;
}
}
// Percent encode anything invalid or not in ucschar
if (
// Invalid sequences
!$valid
// Non-shortest form sequences are invalid
|| $length > 1 && $character <= 0x7F
|| $length > 2 && $character <= 0x7FF
|| $length > 3 && $character <= 0xFFFF
// Outside of range of ucschar codepoints
// Noncharacters
|| ($character & 0xFFFE) === 0xFFFE
|| $character >= 0xFDD0 && $character <= 0xFDEF
|| (
// Everything else not in ucschar
$character > 0xD7FF && $character < 0xF900
|| $character < 0xA0
|| $character > 0xEFFFD
)
&& (
// Everything not in iprivate, if it applies
!$iprivate
|| $character < 0xE000
|| $character > 0x10FFFD
)
) {
// If we were a character, pretend we weren't, but rather an error.
if ($valid) {
$position--;
}
for ($j = $start; $j <= $position; $j++) {
$text = substr_replace($text, sprintf('%%%02X', ord($text[$j])), $j, 1);
$j += 2;
$position += 2;
$strlen += 2;
}
}
}
return $text;
}
/**
* Callback function for preg_replace_callback.
*
* Removes sequences of percent encoded bytes that represent UTF-8
* encoded characters in iunreserved
*
* @param array $regex_match PCRE match
* @return string Replacement
*/
protected function remove_iunreserved_percent_encoded($regex_match) {
// As we just have valid percent encoded sequences we can just explode
// and ignore the first member of the returned array (an empty string).
$bytes = explode('%', $regex_match[0]);
// Initialize the new string (this is what will be returned) and that
// there are no bytes remaining in the current sequence (unsurprising
// at the first byte!).
$string = '';
$remaining = 0;
// Loop over each and every byte, and set $value to its value
for ($i = 1, $len = count($bytes); $i < $len; $i++) {
$value = hexdec($bytes[$i]);
// If we're the first byte of sequence:
if (!$remaining) {
// Start position
$start = $i;
// By default we are valid
$valid = true;
// One byte sequence:
if ($value <= 0x7F) {
$character = $value;
$length = 1;
}
// Two byte sequence:
elseif (($value & 0xE0) === 0xC0) {
$character = ($value & 0x1F) << 6;
$length = 2;
$remaining = 1;
}
// Three byte sequence:
elseif (($value & 0xF0) === 0xE0) {
$character = ($value & 0x0F) << 12;
$length = 3;
$remaining = 2;
}
// Four byte sequence:
elseif (($value & 0xF8) === 0xF0) {
$character = ($value & 0x07) << 18;
$length = 4;
$remaining = 3;
}
// Invalid byte:
else {
$valid = false;
$remaining = 0;
}
}
// Continuation byte:
else {
// Check that the byte is valid, then add it to the character:
if (($value & 0xC0) === 0x80) {
$remaining--;
$character |= ($value & 0x3F) << ($remaining * 6);
}
// If it is invalid, count the sequence as invalid and reprocess the current byte as the start of a sequence:
else {
$valid = false;
$remaining = 0;
$i--;
}
}
// If we've reached the end of the current byte sequence, append it to Unicode::$data
if (!$remaining) {
// Percent encode anything invalid or not in iunreserved
if (
// Invalid sequences
!$valid
// Non-shortest form sequences are invalid
|| $length > 1 && $character <= 0x7F
|| $length > 2 && $character <= 0x7FF
|| $length > 3 && $character <= 0xFFFF
// Outside of range of iunreserved codepoints
|| $character < 0x2D
|| $character > 0xEFFFD
// Noncharacters
|| ($character & 0xFFFE) === 0xFFFE
|| $character >= 0xFDD0 && $character <= 0xFDEF
// Everything else not in iunreserved (this is all BMP)
|| $character === 0x2F
|| $character > 0x39 && $character < 0x41
|| $character > 0x5A && $character < 0x61
|| $character > 0x7A && $character < 0x7E
|| $character > 0x7E && $character < 0xA0
|| $character > 0xD7FF && $character < 0xF900
) {
for ($j = $start; $j <= $i; $j++) {
$string .= '%' . strtoupper($bytes[$j]);
}
}
else {
for ($j = $start; $j <= $i; $j++) {
$string .= chr(hexdec($bytes[$j]));
}
}
}
}
// If we have any bytes left over they are invalid (i.e., we are
// mid-way through a multi-byte sequence)
if ($remaining) {
for ($j = $start; $j < $len; $j++) {
$string .= '%' . strtoupper($bytes[$j]);
}
}
return $string;
}
protected function scheme_normalization() {
if (isset($this->normalization[$this->scheme]['iuserinfo']) && $this->iuserinfo === $this->normalization[$this->scheme]['iuserinfo']) {
$this->iuserinfo = null;
}
if (isset($this->normalization[$this->scheme]['ihost']) && $this->ihost === $this->normalization[$this->scheme]['ihost']) {
$this->ihost = null;
}
if (isset($this->normalization[$this->scheme]['port']) && $this->port === $this->normalization[$this->scheme]['port']) {
$this->port = null;
}
if (isset($this->normalization[$this->scheme]['ipath']) && $this->ipath === $this->normalization[$this->scheme]['ipath']) {
$this->ipath = '';
}
if (isset($this->ihost) && empty($this->ipath)) {
$this->ipath = '/';
}
if (isset($this->normalization[$this->scheme]['iquery']) && $this->iquery === $this->normalization[$this->scheme]['iquery']) {
$this->iquery = null;
}
if (isset($this->normalization[$this->scheme]['ifragment']) && $this->ifragment === $this->normalization[$this->scheme]['ifragment']) {
$this->ifragment = null;
}
}
/**
* Check if the object represents a valid IRI. This needs to be done on each
* call as some things change depending on another part of the IRI.
*
* @return bool
*/
public function is_valid() {
$isauthority = $this->iuserinfo !== null || $this->ihost !== null || $this->port !== null;
if ($this->ipath !== '' &&
(
$isauthority && $this->ipath[0] !== '/' ||
(
$this->scheme === null &&
!$isauthority &&
strpos($this->ipath, ':') !== false &&
(strpos($this->ipath, '/') === false ? true : strpos($this->ipath, ':') < strpos($this->ipath, '/'))
)
)
) {
return false;
}
return true;
}
public function __wakeup() {
$class_props = get_class_vars( __CLASS__ );
$string_props = array( 'scheme', 'iuserinfo', 'ihost', 'port', 'ipath', 'iquery', 'ifragment' );
$array_props = array( 'normalization' );
foreach ( $class_props as $prop => $default_value ) {
if ( in_array( $prop, $string_props, true ) && ! is_string( $this->$prop ) ) {
throw new UnexpectedValueException();
} elseif ( in_array( $prop, $array_props, true ) && ! is_array( $this->$prop ) ) {
throw new UnexpectedValueException();
}
$this->$prop = null;
}
}
/**
* Set the entire IRI. Returns true on success, false on failure (if there
* are any invalid characters).
*
* @param string $iri
* @return bool
*/
protected function set_iri($iri) {
static $cache;
if (!$cache) {
$cache = array();
}
if ($iri === null) {
return true;
}
$iri = (string) $iri;
if (isset($cache[$iri])) {
list($this->scheme,
$this->iuserinfo,
$this->ihost,
$this->port,
$this->ipath,
$this->iquery,
$this->ifragment,
$return) = $cache[$iri];
return $return;
}
$parsed = $this->parse_iri($iri);
$return = $this->set_scheme($parsed['scheme'])
&& $this->set_authority($parsed['authority'])
&& $this->set_path($parsed['path'])
&& $this->set_query($parsed['query'])
&& $this->set_fragment($parsed['fragment']);
$cache[$iri] = array($this->scheme,
$this->iuserinfo,
$this->ihost,
$this->port,
$this->ipath,
$this->iquery,
$this->ifragment,
$return);
return $return;
}
/**
* Set the scheme. Returns true on success, false on failure (if there are
* any invalid characters).
*
* @param string $scheme
* @return bool
*/
protected function set_scheme($scheme) {
if ($scheme === null) {
$this->scheme = null;
}
elseif (!preg_match('/^[A-Za-z][0-9A-Za-z+\-.]*$/', $scheme)) {
$this->scheme = null;
return false;
}
else {
$this->scheme = strtolower($scheme);
}
return true;
}
/**
* Set the authority. Returns true on success, false on failure (if there are
* any invalid characters).
*
* @param string $authority
* @return bool
*/
protected function set_authority($authority) {
static $cache;
if (!$cache) {
$cache = array();
}
if ($authority === null) {
$this->iuserinfo = null;
$this->ihost = null;
$this->port = null;
return true;
}
if (isset($cache[$authority])) {
list($this->iuserinfo,
$this->ihost,
$this->port,
$return) = $cache[$authority];
return $return;
}
$remaining = $authority;
if (($iuserinfo_end = strrpos($remaining, '@')) !== false) {
$iuserinfo = substr($remaining, 0, $iuserinfo_end);
$remaining = substr($remaining, $iuserinfo_end + 1);
}
else {
$iuserinfo = null;
}
if (($port_start = strpos($remaining, ':', (strpos($remaining, ']') ?: 0))) !== false) {
$port = substr($remaining, $port_start + 1);
if ($port === false || $port === '') {
$port = null;
}
$remaining = substr($remaining, 0, $port_start);
}
else {
$port = null;
}
$return = $this->set_userinfo($iuserinfo) &&
$this->set_host($remaining) &&
$this->set_port($port);
$cache[$authority] = array($this->iuserinfo,
$this->ihost,
$this->port,
$return);
return $return;
}
/**
* Set the iuserinfo.
*
* @param string $iuserinfo
* @return bool
*/
protected function set_userinfo($iuserinfo) {
if ($iuserinfo === null) {
$this->iuserinfo = null;
}
else {
$this->iuserinfo = $this->replace_invalid_with_pct_encoding($iuserinfo, '!$&\'()*+,;=:');
$this->scheme_normalization();
}
return true;
}
/**
* Set the ihost. Returns true on success, false on failure (if there are
* any invalid characters).
*
* @param string $ihost
* @return bool
*/
protected function set_host($ihost) {
if ($ihost === null) {
$this->ihost = null;
return true;
}
if (substr($ihost, 0, 1) === '[' && substr($ihost, -1) === ']') {
if (Ipv6::check_ipv6(substr($ihost, 1, -1))) {
$this->ihost = '[' . Ipv6::compress(substr($ihost, 1, -1)) . ']';
}
else {
$this->ihost = null;
return false;
}
}
else {
$ihost = $this->replace_invalid_with_pct_encoding($ihost, '!$&\'()*+,;=');
// Lowercase, but ignore pct-encoded sections (as they should
// remain uppercase). This must be done after the previous step
// as that can add unescaped characters.
$position = 0;
$strlen = strlen($ihost);
while (($position += strcspn($ihost, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ%', $position)) < $strlen) {
if ($ihost[$position] === '%') {
$position += 3;
}
else {
$ihost[$position] = strtolower($ihost[$position]);
$position++;
}
}
$this->ihost = $ihost;
}
$this->scheme_normalization();
return true;
}
/**
* Set the port. Returns true on success, false on failure (if there are
* any invalid characters).
*
* @param string $port
* @return bool
*/
protected function set_port($port) {
if ($port === null) {
$this->port = null;
return true;
}
if (strspn($port, '0123456789') === strlen($port)) {
$this->port = (int) $port;
$this->scheme_normalization();
return true;
}
$this->port = null;
return false;
}
/**
* Set the ipath.
*
* @param string $ipath
* @return bool
*/
protected function set_path($ipath) {
static $cache;
if (!$cache) {
$cache = array();
}
$ipath = (string) $ipath;
if (isset($cache[$ipath])) {
$this->ipath = $cache[$ipath][(int) ($this->scheme !== null)];
}
else {
$valid = $this->replace_invalid_with_pct_encoding($ipath, '!$&\'()*+,;=@:/');
$removed = $this->remove_dot_segments($valid);
$cache[$ipath] = array($valid, $removed);
$this->ipath = ($this->scheme !== null) ? $removed : $valid;
}
$this->scheme_normalization();
return true;
}
/**
* Set the iquery.
*
* @param string $iquery
* @return bool
*/
protected function set_query($iquery) {
if ($iquery === null) {
$this->iquery = null;
}
else {
$this->iquery = $this->replace_invalid_with_pct_encoding($iquery, '!$&\'()*+,;=:@/?', true);
$this->scheme_normalization();
}
return true;
}
/**
* Set the ifragment.
*
* @param string $ifragment
* @return bool
*/
protected function set_fragment($ifragment) {
if ($ifragment === null) {
$this->ifragment = null;
}
else {
$this->ifragment = $this->replace_invalid_with_pct_encoding($ifragment, '!$&\'()*+,;=:@/?');
$this->scheme_normalization();
}
return true;
}
/**
* Convert an IRI to a URI (or parts thereof)
*
* @param string|bool $iri IRI to convert (or false from {@see \WpOrg\Requests\Iri::get_iri()})
* @return string|false URI if IRI is valid, false otherwise.
*/
protected function to_uri($iri) {
if (!is_string($iri)) {
return false;
}
static $non_ascii;
if (!$non_ascii) {
$non_ascii = implode('', range("\x80", "\xFF"));
}
$position = 0;
$strlen = strlen($iri);
while (($position += strcspn($iri, $non_ascii, $position)) < $strlen) {
$iri = substr_replace($iri, sprintf('%%%02X', ord($iri[$position])), $position, 1);
$position += 3;
$strlen += 2;
}
return $iri;
}
/**
* Get the complete IRI
*
* @return string|false
*/
protected function get_iri() {
if (!$this->is_valid()) {
return false;
}
$iri = '';
if ($this->scheme !== null) {
$iri .= $this->scheme . ':';
}
if (($iauthority = $this->get_iauthority()) !== null) {
$iri .= '//' . $iauthority;
}
$iri .= $this->ipath;
if ($this->iquery !== null) {
$iri .= '?' . $this->iquery;
}
if ($this->ifragment !== null) {
$iri .= '#' . $this->ifragment;
}
return $iri;
}
/**
* Get the complete URI
*
* @return string
*/
protected function get_uri() {
return $this->to_uri($this->get_iri());
}
/**
* Get the complete iauthority
*
* @return string|null
*/
protected function get_iauthority() {
if ($this->iuserinfo === null && $this->ihost === null && $this->port === null) {
return null;
}
$iauthority = '';
if ($this->iuserinfo !== null) {
$iauthority .= $this->iuserinfo . '@';
}
if ($this->ihost !== null) {
$iauthority .= $this->ihost;
}
if ($this->port !== null) {
$iauthority .= ':' . $this->port;
}
return $iauthority;
}
/**
* Get the complete authority
*
* @return string
*/
protected function get_authority() {
$iauthority = $this->get_iauthority();
if (is_string($iauthority)) {
return $this->to_uri($iauthority);
}
else {
return $iauthority;
}
}
}
Port.php 0000644 00000002741 15153252210 0006201 0 ustar 00 <?php
/**
* Port utilities for Requests
*
* @package Requests\Utilities
* @since 2.0.0
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
/**
* Find the correct port depending on the Request type.
*
* @package Requests\Utilities
* @since 2.0.0
*/
final class Port {
/**
* Port to use with Acap requests.
*
* @var int
*/
const ACAP = 674;
/**
* Port to use with Dictionary requests.
*
* @var int
*/
const DICT = 2628;
/**
* Port to use with HTTP requests.
*
* @var int
*/
const HTTP = 80;
/**
* Port to use with HTTP over SSL requests.
*
* @var int
*/
const HTTPS = 443;
/**
* Retrieve the port number to use.
*
* @param string $type Request type.
* The following requests types are supported:
* 'acap', 'dict', 'http' and 'https'.
*
* @return int
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When a non-string input has been passed.
* @throws \WpOrg\Requests\Exception When a non-supported port is requested ('portnotsupported').
*/
public static function get($type) {
if (!is_string($type)) {
throw InvalidArgument::create(1, '$type', 'string', gettype($type));
}
$type = strtoupper($type);
if (!defined("self::{$type}")) {
$message = sprintf('Invalid port type (%s) passed', $type);
throw new Exception($message, 'portnotsupported');
}
return constant("self::{$type}");
}
}
Proxy/Http.php 0000644 00000010171 15153252210 0007311 0 ustar 00 <?php
/**
* HTTP Proxy connection interface
*
* @package Requests\Proxy
* @since 1.6
*/
namespace WpOrg\Requests\Proxy;
use WpOrg\Requests\Exception\ArgumentCount;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Hooks;
use WpOrg\Requests\Proxy;
/**
* HTTP Proxy connection interface
*
* Provides a handler for connection via an HTTP proxy
*
* @package Requests\Proxy
* @since 1.6
*/
final class Http implements Proxy {
/**
* Proxy host and port
*
* Notation: "host:port" (eg 127.0.0.1:8080 or someproxy.com:3128)
*
* @var string
*/
public $proxy;
/**
* Username
*
* @var string
*/
public $user;
/**
* Password
*
* @var string
*/
public $pass;
/**
* Do we need to authenticate? (ie username & password have been provided)
*
* @var boolean
*/
public $use_authentication;
/**
* Constructor
*
* @since 1.6
*
* @param array|string|null $args Proxy as a string or an array of proxy, user and password.
* When passed as an array, must have exactly one (proxy)
* or three elements (proxy, user, password).
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not an array, a string or null.
* @throws \WpOrg\Requests\Exception\ArgumentCount On incorrect number of arguments (`proxyhttpbadargs`)
*/
public function __construct($args = null) {
if (is_string($args)) {
$this->proxy = $args;
} elseif (is_array($args)) {
if (count($args) === 1) {
list($this->proxy) = $args;
} elseif (count($args) === 3) {
list($this->proxy, $this->user, $this->pass) = $args;
$this->use_authentication = true;
} else {
throw ArgumentCount::create(
'an array with exactly one element or exactly three elements',
count($args),
'proxyhttpbadargs'
);
}
} elseif ($args !== null) {
throw InvalidArgument::create(1, '$args', 'array|string|null', gettype($args));
}
}
/**
* Register the necessary callbacks
*
* @since 1.6
* @see \WpOrg\Requests\Proxy\Http::curl_before_send()
* @see \WpOrg\Requests\Proxy\Http::fsockopen_remote_socket()
* @see \WpOrg\Requests\Proxy\Http::fsockopen_remote_host_path()
* @see \WpOrg\Requests\Proxy\Http::fsockopen_header()
* @param \WpOrg\Requests\Hooks $hooks Hook system
*/
public function register(Hooks $hooks) {
$hooks->register('curl.before_send', [$this, 'curl_before_send']);
$hooks->register('fsockopen.remote_socket', [$this, 'fsockopen_remote_socket']);
$hooks->register('fsockopen.remote_host_path', [$this, 'fsockopen_remote_host_path']);
if ($this->use_authentication) {
$hooks->register('fsockopen.after_headers', [$this, 'fsockopen_header']);
}
}
/**
* Set cURL parameters before the data is sent
*
* @since 1.6
* @param resource|\CurlHandle $handle cURL handle
*/
public function curl_before_send(&$handle) {
curl_setopt($handle, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
curl_setopt($handle, CURLOPT_PROXY, $this->proxy);
if ($this->use_authentication) {
curl_setopt($handle, CURLOPT_PROXYAUTH, CURLAUTH_ANY);
curl_setopt($handle, CURLOPT_PROXYUSERPWD, $this->get_auth_string());
}
}
/**
* Alter remote socket information before opening socket connection
*
* @since 1.6
* @param string $remote_socket Socket connection string
*/
public function fsockopen_remote_socket(&$remote_socket) {
$remote_socket = $this->proxy;
}
/**
* Alter remote path before getting stream data
*
* @since 1.6
* @param string $path Path to send in HTTP request string ("GET ...")
* @param string $url Full URL we're requesting
*/
public function fsockopen_remote_host_path(&$path, $url) {
$path = $url;
}
/**
* Add extra headers to the request before sending
*
* @since 1.6
* @param string $out HTTP header string
*/
public function fsockopen_header(&$out) {
$out .= sprintf("Proxy-Authorization: Basic %s\r\n", base64_encode($this->get_auth_string()));
}
/**
* Get the authentication string (user:pass)
*
* @since 1.6
* @return string
*/
public function get_auth_string() {
return $this->user . ':' . $this->pass;
}
}
Proxy.php 0000644 00000001543 15153252210 0006375 0 ustar 00 <?php
/**
* Proxy connection interface
*
* @package Requests\Proxy
* @since 1.6
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Hooks;
/**
* Proxy connection interface
*
* Implement this interface to handle proxy settings and authentication
*
* Parameters should be passed via the constructor where possible, as this
* makes it much easier for users to use your provider.
*
* @see \WpOrg\Requests\Hooks
*
* @package Requests\Proxy
* @since 1.6
*/
interface Proxy {
/**
* Register hooks as needed
*
* This method is called in {@see \WpOrg\Requests\Requests::request()} when the user
* has set an instance as the 'auth' option. Use this callback to register all the
* hooks you'll need.
*
* @see \WpOrg\Requests\Hooks::register()
* @param \WpOrg\Requests\Hooks $hooks Hook system
*/
public function register(Hooks $hooks);
}
Requests.php 0000644 00000102320 15153252210 0007062 0 ustar 00 <?php
/**
* Requests for PHP
*
* Inspired by Requests for Python.
*
* Based on concepts from SimplePie_File, RequestCore and WP_Http.
*
* @package Requests
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Auth\Basic;
use WpOrg\Requests\Capability;
use WpOrg\Requests\Cookie\Jar;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Hooks;
use WpOrg\Requests\IdnaEncoder;
use WpOrg\Requests\Iri;
use WpOrg\Requests\Proxy\Http;
use WpOrg\Requests\Response;
use WpOrg\Requests\Transport\Curl;
use WpOrg\Requests\Transport\Fsockopen;
use WpOrg\Requests\Utility\InputValidator;
/**
* Requests for PHP
*
* Inspired by Requests for Python.
*
* Based on concepts from SimplePie_File, RequestCore and WP_Http.
*
* @package Requests
*/
class Requests {
/**
* POST method
*
* @var string
*/
const POST = 'POST';
/**
* PUT method
*
* @var string
*/
const PUT = 'PUT';
/**
* GET method
*
* @var string
*/
const GET = 'GET';
/**
* HEAD method
*
* @var string
*/
const HEAD = 'HEAD';
/**
* DELETE method
*
* @var string
*/
const DELETE = 'DELETE';
/**
* OPTIONS method
*
* @var string
*/
const OPTIONS = 'OPTIONS';
/**
* TRACE method
*
* @var string
*/
const TRACE = 'TRACE';
/**
* PATCH method
*
* @link https://tools.ietf.org/html/rfc5789
* @var string
*/
const PATCH = 'PATCH';
/**
* Default size of buffer size to read streams
*
* @var integer
*/
const BUFFER_SIZE = 1160;
/**
* Option defaults.
*
* @see \WpOrg\Requests\Requests::get_default_options()
* @see \WpOrg\Requests\Requests::request() for values returned by this method
*
* @since 2.0.0
*
* @var array
*/
const OPTION_DEFAULTS = [
'timeout' => 10,
'connect_timeout' => 10,
'useragent' => 'php-requests/' . self::VERSION,
'protocol_version' => 1.1,
'redirected' => 0,
'redirects' => 10,
'follow_redirects' => true,
'blocking' => true,
'type' => self::GET,
'filename' => false,
'auth' => false,
'proxy' => false,
'cookies' => false,
'max_bytes' => false,
'idn' => true,
'hooks' => null,
'transport' => null,
'verify' => null,
'verifyname' => true,
];
/**
* Default supported Transport classes.
*
* @since 2.0.0
*
* @var array
*/
const DEFAULT_TRANSPORTS = [
Curl::class => Curl::class,
Fsockopen::class => Fsockopen::class,
];
/**
* Current version of Requests
*
* @var string
*/
const VERSION = '2.0.9';
/**
* Selected transport name
*
* Use {@see \WpOrg\Requests\Requests::get_transport()} instead
*
* @var array
*/
public static $transport = [];
/**
* Registered transport classes
*
* @var array
*/
protected static $transports = [];
/**
* Default certificate path.
*
* @see \WpOrg\Requests\Requests::get_certificate_path()
* @see \WpOrg\Requests\Requests::set_certificate_path()
*
* @var string
*/
protected static $certificate_path = __DIR__ . '/../certificates/cacert.pem';
/**
* All (known) valid deflate, gzip header magic markers.
*
* These markers relate to different compression levels.
*
* @link https://stackoverflow.com/a/43170354/482864 Marker source.
*
* @since 2.0.0
*
* @var array
*/
private static $magic_compression_headers = [
"\x1f\x8b" => true, // Gzip marker.
"\x78\x01" => true, // Zlib marker - level 1.
"\x78\x5e" => true, // Zlib marker - level 2 to 5.
"\x78\x9c" => true, // Zlib marker - level 6.
"\x78\xda" => true, // Zlib marker - level 7 to 9.
];
/**
* This is a static class, do not instantiate it
*
* @codeCoverageIgnore
*/
private function __construct() {}
/**
* Register a transport
*
* @param string $transport Transport class to add, must support the \WpOrg\Requests\Transport interface
*/
public static function add_transport($transport) {
if (empty(self::$transports)) {
self::$transports = self::DEFAULT_TRANSPORTS;
}
self::$transports[$transport] = $transport;
}
/**
* Get the fully qualified class name (FQCN) for a working transport.
*
* @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
* @return string FQCN of the transport to use, or an empty string if no transport was
* found which provided the requested capabilities.
*/
protected static function get_transport_class(array $capabilities = []) {
// Caching code, don't bother testing coverage.
// @codeCoverageIgnoreStart
// Array of capabilities as a string to be used as an array key.
ksort($capabilities);
$cap_string = serialize($capabilities);
// Don't search for a transport if it's already been done for these $capabilities.
if (isset(self::$transport[$cap_string])) {
return self::$transport[$cap_string];
}
// Ensure we will not run this same check again later on.
self::$transport[$cap_string] = '';
// @codeCoverageIgnoreEnd
if (empty(self::$transports)) {
self::$transports = self::DEFAULT_TRANSPORTS;
}
// Find us a working transport.
foreach (self::$transports as $class) {
if (!class_exists($class)) {
continue;
}
$result = $class::test($capabilities);
if ($result === true) {
self::$transport[$cap_string] = $class;
break;
}
}
return self::$transport[$cap_string];
}
/**
* Get a working transport.
*
* @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
* @return \WpOrg\Requests\Transport
* @throws \WpOrg\Requests\Exception If no valid transport is found (`notransport`).
*/
protected static function get_transport(array $capabilities = []) {
$class = self::get_transport_class($capabilities);
if ($class === '') {
throw new Exception('No working transports found', 'notransport', self::$transports);
}
return new $class();
}
/**
* Checks to see if we have a transport for the capabilities requested.
*
* Supported capabilities can be found in the {@see \WpOrg\Requests\Capability}
* interface as constants.
*
* Example usage:
* `Requests::has_capabilities([Capability::SSL => true])`.
*
* @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
* @return bool Whether the transport has the requested capabilities.
*/
public static function has_capabilities(array $capabilities = []) {
return self::get_transport_class($capabilities) !== '';
}
/**#@+
* @see \WpOrg\Requests\Requests::request()
* @param string $url
* @param array $headers
* @param array $options
* @return \WpOrg\Requests\Response
*/
/**
* Send a GET request
*/
public static function get($url, $headers = [], $options = []) {
return self::request($url, $headers, null, self::GET, $options);
}
/**
* Send a HEAD request
*/
public static function head($url, $headers = [], $options = []) {
return self::request($url, $headers, null, self::HEAD, $options);
}
/**
* Send a DELETE request
*/
public static function delete($url, $headers = [], $options = []) {
return self::request($url, $headers, null, self::DELETE, $options);
}
/**
* Send a TRACE request
*/
public static function trace($url, $headers = [], $options = []) {
return self::request($url, $headers, null, self::TRACE, $options);
}
/**#@-*/
/**#@+
* @see \WpOrg\Requests\Requests::request()
* @param string $url
* @param array $headers
* @param array $data
* @param array $options
* @return \WpOrg\Requests\Response
*/
/**
* Send a POST request
*/
public static function post($url, $headers = [], $data = [], $options = []) {
return self::request($url, $headers, $data, self::POST, $options);
}
/**
* Send a PUT request
*/
public static function put($url, $headers = [], $data = [], $options = []) {
return self::request($url, $headers, $data, self::PUT, $options);
}
/**
* Send an OPTIONS request
*/
public static function options($url, $headers = [], $data = [], $options = []) {
return self::request($url, $headers, $data, self::OPTIONS, $options);
}
/**
* Send a PATCH request
*
* Note: Unlike {@see \WpOrg\Requests\Requests::post()} and {@see \WpOrg\Requests\Requests::put()},
* `$headers` is required, as the specification recommends that should send an ETag
*
* @link https://tools.ietf.org/html/rfc5789
*/
public static function patch($url, $headers, $data = [], $options = []) {
return self::request($url, $headers, $data, self::PATCH, $options);
}
/**#@-*/
/**
* Main interface for HTTP requests
*
* This method initiates a request and sends it via a transport before
* parsing.
*
* The `$options` parameter takes an associative array with the following
* options:
*
* - `timeout`: How long should we wait for a response?
* Note: for cURL, a minimum of 1 second applies, as DNS resolution
* operates at second-resolution only.
* (float, seconds with a millisecond precision, default: 10, example: 0.01)
* - `connect_timeout`: How long should we wait while trying to connect?
* (float, seconds with a millisecond precision, default: 10, example: 0.01)
* - `useragent`: Useragent to send to the server
* (string, default: php-requests/$version)
* - `follow_redirects`: Should we follow 3xx redirects?
* (boolean, default: true)
* - `redirects`: How many times should we redirect before erroring?
* (integer, default: 10)
* - `blocking`: Should we block processing on this request?
* (boolean, default: true)
* - `filename`: File to stream the body to instead.
* (string|boolean, default: false)
* - `auth`: Authentication handler or array of user/password details to use
* for Basic authentication
* (\WpOrg\Requests\Auth|array|boolean, default: false)
* - `proxy`: Proxy details to use for proxy by-passing and authentication
* (\WpOrg\Requests\Proxy|array|string|boolean, default: false)
* - `max_bytes`: Limit for the response body size.
* (integer|boolean, default: false)
* - `idn`: Enable IDN parsing
* (boolean, default: true)
* - `transport`: Custom transport. Either a class name, or a
* transport object. Defaults to the first working transport from
* {@see \WpOrg\Requests\Requests::getTransport()}
* (string|\WpOrg\Requests\Transport, default: {@see \WpOrg\Requests\Requests::getTransport()})
* - `hooks`: Hooks handler.
* (\WpOrg\Requests\HookManager, default: new WpOrg\Requests\Hooks())
* - `verify`: Should we verify SSL certificates? Allows passing in a custom
* certificate file as a string. (Using true uses the system-wide root
* certificate store instead, but this may have different behaviour
* across transports.)
* (string|boolean, default: certificates/cacert.pem)
* - `verifyname`: Should we verify the common name in the SSL certificate?
* (boolean, default: true)
* - `data_format`: How should we send the `$data` parameter?
* (string, one of 'query' or 'body', default: 'query' for
* HEAD/GET/DELETE, 'body' for POST/PUT/OPTIONS/PATCH)
*
* @param string|Stringable $url URL to request
* @param array $headers Extra headers to send with the request
* @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests
* @param string $type HTTP request type (use Requests constants)
* @param array $options Options for the request (see description for more information)
* @return \WpOrg\Requests\Response
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $type argument is not a string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
* @throws \WpOrg\Requests\Exception On invalid URLs (`nonhttp`)
*/
public static function request($url, $headers = [], $data = [], $type = self::GET, $options = []) {
if (InputValidator::is_string_or_stringable($url) === false) {
throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url));
}
if (is_string($type) === false) {
throw InvalidArgument::create(4, '$type', 'string', gettype($type));
}
if (is_array($options) === false) {
throw InvalidArgument::create(5, '$options', 'array', gettype($options));
}
if (empty($options['type'])) {
$options['type'] = $type;
}
$options = array_merge(self::get_default_options(), $options);
self::set_defaults($url, $headers, $data, $type, $options);
$options['hooks']->dispatch('requests.before_request', [&$url, &$headers, &$data, &$type, &$options]);
if (!empty($options['transport'])) {
$transport = $options['transport'];
if (is_string($options['transport'])) {
$transport = new $transport();
}
} else {
$need_ssl = (stripos($url, 'https://') === 0);
$capabilities = [Capability::SSL => $need_ssl];
$transport = self::get_transport($capabilities);
}
$response = $transport->request($url, $headers, $data, $options);
$options['hooks']->dispatch('requests.before_parse', [&$response, $url, $headers, $data, $type, $options]);
return self::parse_response($response, $url, $headers, $data, $options);
}
/**
* Send multiple HTTP requests simultaneously
*
* The `$requests` parameter takes an associative or indexed array of
* request fields. The key of each request can be used to match up the
* request with the returned data, or with the request passed into your
* `multiple.request.complete` callback.
*
* The request fields value is an associative array with the following keys:
*
* - `url`: Request URL Same as the `$url` parameter to
* {@see \WpOrg\Requests\Requests::request()}
* (string, required)
* - `headers`: Associative array of header fields. Same as the `$headers`
* parameter to {@see \WpOrg\Requests\Requests::request()}
* (array, default: `array()`)
* - `data`: Associative array of data fields or a string. Same as the
* `$data` parameter to {@see \WpOrg\Requests\Requests::request()}
* (array|string, default: `array()`)
* - `type`: HTTP request type (use \WpOrg\Requests\Requests constants). Same as the `$type`
* parameter to {@see \WpOrg\Requests\Requests::request()}
* (string, default: `\WpOrg\Requests\Requests::GET`)
* - `cookies`: Associative array of cookie name to value, or cookie jar.
* (array|\WpOrg\Requests\Cookie\Jar)
*
* If the `$options` parameter is specified, individual requests will
* inherit options from it. This can be used to use a single hooking system,
* or set all the types to `\WpOrg\Requests\Requests::POST`, for example.
*
* In addition, the `$options` parameter takes the following global options:
*
* - `complete`: A callback for when a request is complete. Takes two
* parameters, a \WpOrg\Requests\Response/\WpOrg\Requests\Exception reference, and the
* ID from the request array (Note: this can also be overridden on a
* per-request basis, although that's a little silly)
* (callback)
*
* @param array $requests Requests data (see description for more information)
* @param array $options Global and default options (see {@see \WpOrg\Requests\Requests::request()})
* @return array Responses (either \WpOrg\Requests\Response or a \WpOrg\Requests\Exception object)
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
*/
public static function request_multiple($requests, $options = []) {
if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) {
throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests));
}
if (is_array($options) === false) {
throw InvalidArgument::create(2, '$options', 'array', gettype($options));
}
$options = array_merge(self::get_default_options(true), $options);
if (!empty($options['hooks'])) {
$options['hooks']->register('transport.internal.parse_response', [static::class, 'parse_multiple']);
if (!empty($options['complete'])) {
$options['hooks']->register('multiple.request.complete', $options['complete']);
}
}
foreach ($requests as $id => &$request) {
if (!isset($request['headers'])) {
$request['headers'] = [];
}
if (!isset($request['data'])) {
$request['data'] = [];
}
if (!isset($request['type'])) {
$request['type'] = self::GET;
}
if (!isset($request['options'])) {
$request['options'] = $options;
$request['options']['type'] = $request['type'];
} else {
if (empty($request['options']['type'])) {
$request['options']['type'] = $request['type'];
}
$request['options'] = array_merge($options, $request['options']);
}
self::set_defaults($request['url'], $request['headers'], $request['data'], $request['type'], $request['options']);
// Ensure we only hook in once
if ($request['options']['hooks'] !== $options['hooks']) {
$request['options']['hooks']->register('transport.internal.parse_response', [static::class, 'parse_multiple']);
if (!empty($request['options']['complete'])) {
$request['options']['hooks']->register('multiple.request.complete', $request['options']['complete']);
}
}
}
unset($request);
if (!empty($options['transport'])) {
$transport = $options['transport'];
if (is_string($options['transport'])) {
$transport = new $transport();
}
} else {
$transport = self::get_transport();
}
$responses = $transport->request_multiple($requests, $options);
foreach ($responses as $id => &$response) {
// If our hook got messed with somehow, ensure we end up with the
// correct response
if (is_string($response)) {
$request = $requests[$id];
self::parse_multiple($response, $request);
$request['options']['hooks']->dispatch('multiple.request.complete', [&$response, $id]);
}
}
return $responses;
}
/**
* Get the default options
*
* @see \WpOrg\Requests\Requests::request() for values returned by this method
* @param boolean $multirequest Is this a multirequest?
* @return array Default option values
*/
protected static function get_default_options($multirequest = false) {
$defaults = static::OPTION_DEFAULTS;
$defaults['verify'] = self::$certificate_path;
if ($multirequest !== false) {
$defaults['complete'] = null;
}
return $defaults;
}
/**
* Get default certificate path.
*
* @return string Default certificate path.
*/
public static function get_certificate_path() {
return self::$certificate_path;
}
/**
* Set default certificate path.
*
* @param string|Stringable|bool $path Certificate path, pointing to a PEM file.
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string, Stringable or boolean.
*/
public static function set_certificate_path($path) {
if (InputValidator::is_string_or_stringable($path) === false && is_bool($path) === false) {
throw InvalidArgument::create(1, '$path', 'string|Stringable|bool', gettype($path));
}
self::$certificate_path = $path;
}
/**
* Set the default values
*
* The $options parameter is updated with the results.
*
* @param string $url URL to request
* @param array $headers Extra headers to send with the request
* @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests
* @param string $type HTTP request type
* @param array $options Options for the request
* @return void
*
* @throws \WpOrg\Requests\Exception When the $url is not an http(s) URL.
*/
protected static function set_defaults(&$url, &$headers, &$data, &$type, &$options) {
if (!preg_match('/^http(s)?:\/\//i', $url, $matches)) {
throw new Exception('Only HTTP(S) requests are handled.', 'nonhttp', $url);
}
if (empty($options['hooks'])) {
$options['hooks'] = new Hooks();
}
if (is_array($options['auth'])) {
$options['auth'] = new Basic($options['auth']);
}
if ($options['auth'] !== false) {
$options['auth']->register($options['hooks']);
}
if (is_string($options['proxy']) || is_array($options['proxy'])) {
$options['proxy'] = new Http($options['proxy']);
}
if ($options['proxy'] !== false) {
$options['proxy']->register($options['hooks']);
}
if (is_array($options['cookies'])) {
$options['cookies'] = new Jar($options['cookies']);
} elseif (empty($options['cookies'])) {
$options['cookies'] = new Jar();
}
if ($options['cookies'] !== false) {
$options['cookies']->register($options['hooks']);
}
if ($options['idn'] !== false) {
$iri = new Iri($url);
$iri->host = IdnaEncoder::encode($iri->ihost);
$url = $iri->uri;
}
// Massage the type to ensure we support it.
$type = strtoupper($type);
if (!isset($options['data_format'])) {
if (in_array($type, [self::HEAD, self::GET, self::DELETE], true)) {
$options['data_format'] = 'query';
} else {
$options['data_format'] = 'body';
}
}
}
/**
* HTTP response parser
*
* @param string $headers Full response text including headers and body
* @param string $url Original request URL
* @param array $req_headers Original $headers array passed to {@link request()}, in case we need to follow redirects
* @param array $req_data Original $data array passed to {@link request()}, in case we need to follow redirects
* @param array $options Original $options array passed to {@link request()}, in case we need to follow redirects
* @return \WpOrg\Requests\Response
*
* @throws \WpOrg\Requests\Exception On missing head/body separator (`requests.no_crlf_separator`)
* @throws \WpOrg\Requests\Exception On missing head/body separator (`noversion`)
* @throws \WpOrg\Requests\Exception On missing head/body separator (`toomanyredirects`)
*/
protected static function parse_response($headers, $url, $req_headers, $req_data, $options) {
$return = new Response();
if (!$options['blocking']) {
return $return;
}
$return->raw = $headers;
$return->url = (string) $url;
$return->body = '';
if (!$options['filename']) {
$pos = strpos($headers, "\r\n\r\n");
if ($pos === false) {
// Crap!
throw new Exception('Missing header/body separator', 'requests.no_crlf_separator');
}
$headers = substr($return->raw, 0, $pos);
// Headers will always be separated from the body by two new lines - `\n\r\n\r`.
$body = substr($return->raw, $pos + 4);
if (!empty($body)) {
$return->body = $body;
}
}
// Pretend CRLF = LF for compatibility (RFC 2616, section 19.3)
$headers = str_replace("\r\n", "\n", $headers);
// Unfold headers (replace [CRLF] 1*( SP | HT ) with SP) as per RFC 2616 (section 2.2)
$headers = preg_replace('/\n[ \t]/', ' ', $headers);
$headers = explode("\n", $headers);
preg_match('#^HTTP/(1\.\d)[ \t]+(\d+)#i', array_shift($headers), $matches);
if (empty($matches)) {
throw new Exception('Response could not be parsed', 'noversion', $headers);
}
$return->protocol_version = (float) $matches[1];
$return->status_code = (int) $matches[2];
if ($return->status_code >= 200 && $return->status_code < 300) {
$return->success = true;
}
foreach ($headers as $header) {
list($key, $value) = explode(':', $header, 2);
$value = trim($value);
preg_replace('#(\s+)#i', ' ', $value);
$return->headers[$key] = $value;
}
if (isset($return->headers['transfer-encoding'])) {
$return->body = self::decode_chunked($return->body);
unset($return->headers['transfer-encoding']);
}
if (isset($return->headers['content-encoding'])) {
$return->body = self::decompress($return->body);
}
//fsockopen and cURL compatibility
if (isset($return->headers['connection'])) {
unset($return->headers['connection']);
}
$options['hooks']->dispatch('requests.before_redirect_check', [&$return, $req_headers, $req_data, $options]);
if ($return->is_redirect() && $options['follow_redirects'] === true) {
if (isset($return->headers['location']) && $options['redirected'] < $options['redirects']) {
if ($return->status_code === 303) {
$options['type'] = self::GET;
}
$options['redirected']++;
$location = $return->headers['location'];
if (strpos($location, 'http://') !== 0 && strpos($location, 'https://') !== 0) {
// relative redirect, for compatibility make it absolute
$location = Iri::absolutize($url, $location);
$location = $location->uri;
}
$hook_args = [
&$location,
&$req_headers,
&$req_data,
&$options,
$return,
];
$options['hooks']->dispatch('requests.before_redirect', $hook_args);
$redirected = self::request($location, $req_headers, $req_data, $options['type'], $options);
$redirected->history[] = $return;
return $redirected;
} elseif ($options['redirected'] >= $options['redirects']) {
throw new Exception('Too many redirects', 'toomanyredirects', $return);
}
}
$return->redirects = $options['redirected'];
$options['hooks']->dispatch('requests.after_request', [&$return, $req_headers, $req_data, $options]);
return $return;
}
/**
* Callback for `transport.internal.parse_response`
*
* Internal use only. Converts a raw HTTP response to a \WpOrg\Requests\Response
* while still executing a multiple request.
*
* `$response` is either set to a \WpOrg\Requests\Response instance, or a \WpOrg\Requests\Exception object
*
* @param string $response Full response text including headers and body (will be overwritten with Response instance)
* @param array $request Request data as passed into {@see \WpOrg\Requests\Requests::request_multiple()}
* @return void
*/
public static function parse_multiple(&$response, $request) {
try {
$url = $request['url'];
$headers = $request['headers'];
$data = $request['data'];
$options = $request['options'];
$response = self::parse_response($response, $url, $headers, $data, $options);
} catch (Exception $e) {
$response = $e;
}
}
/**
* Decoded a chunked body as per RFC 2616
*
* @link https://tools.ietf.org/html/rfc2616#section-3.6.1
* @param string $data Chunked body
* @return string Decoded body
*/
protected static function decode_chunked($data) {
if (!preg_match('/^([0-9a-f]+)(?:;(?:[\w-]*)(?:=(?:(?:[\w-]*)*|"(?:[^\r\n])*"))?)*\r\n/i', trim($data))) {
return $data;
}
$decoded = '';
$encoded = $data;
while (true) {
$is_chunked = (bool) preg_match('/^([0-9a-f]+)(?:;(?:[\w-]*)(?:=(?:(?:[\w-]*)*|"(?:[^\r\n])*"))?)*\r\n/i', $encoded, $matches);
if (!$is_chunked) {
// Looks like it's not chunked after all
return $data;
}
$length = hexdec(trim($matches[1]));
if ($length === 0) {
// Ignore trailer headers
return $decoded;
}
$chunk_length = strlen($matches[0]);
$decoded .= substr($encoded, $chunk_length, $length);
$encoded = substr($encoded, $chunk_length + $length + 2);
if (trim($encoded) === '0' || empty($encoded)) {
return $decoded;
}
}
// We'll never actually get down here
// @codeCoverageIgnoreStart
}
// @codeCoverageIgnoreEnd
/**
* Convert a key => value array to a 'key: value' array for headers
*
* @param iterable $dictionary Dictionary of header values
* @return array List of headers
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not iterable.
*/
public static function flatten($dictionary) {
if (InputValidator::is_iterable($dictionary) === false) {
throw InvalidArgument::create(1, '$dictionary', 'iterable', gettype($dictionary));
}
$return = [];
foreach ($dictionary as $key => $value) {
$return[] = sprintf('%s: %s', $key, $value);
}
return $return;
}
/**
* Decompress an encoded body
*
* Implements gzip, compress and deflate. Guesses which it is by attempting
* to decode.
*
* @param string $data Compressed data in one of the above formats
* @return string Decompressed string
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string.
*/
public static function decompress($data) {
if (is_string($data) === false) {
throw InvalidArgument::create(1, '$data', 'string', gettype($data));
}
if (trim($data) === '') {
// Empty body does not need further processing.
return $data;
}
$marker = substr($data, 0, 2);
if (!isset(self::$magic_compression_headers[$marker])) {
// Not actually compressed. Probably cURL ruining this for us.
return $data;
}
if (function_exists('gzdecode')) {
$decoded = @gzdecode($data);
if ($decoded !== false) {
return $decoded;
}
}
if (function_exists('gzinflate')) {
$decoded = @gzinflate($data);
if ($decoded !== false) {
return $decoded;
}
}
$decoded = self::compatible_gzinflate($data);
if ($decoded !== false) {
return $decoded;
}
if (function_exists('gzuncompress')) {
$decoded = @gzuncompress($data);
if ($decoded !== false) {
return $decoded;
}
}
return $data;
}
/**
* Decompression of deflated string while staying compatible with the majority of servers.
*
* Certain Servers will return deflated data with headers which PHP's gzinflate()
* function cannot handle out of the box. The following function has been created from
* various snippets on the gzinflate() PHP documentation.
*
* Warning: Magic numbers within. Due to the potential different formats that the compressed
* data may be returned in, some "magic offsets" are needed to ensure proper decompression
* takes place. For a simple progmatic way to determine the magic offset in use, see:
* https://core.trac.wordpress.org/ticket/18273
*
* @since 1.6.0
* @link https://core.trac.wordpress.org/ticket/18273
* @link https://www.php.net/gzinflate#70875
* @link https://www.php.net/gzinflate#77336
*
* @param string $gz_data String to decompress.
* @return string|bool False on failure.
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string.
*/
public static function compatible_gzinflate($gz_data) {
if (is_string($gz_data) === false) {
throw InvalidArgument::create(1, '$gz_data', 'string', gettype($gz_data));
}
if (trim($gz_data) === '') {
return false;
}
// Compressed data might contain a full zlib header, if so strip it for
// gzinflate()
if (substr($gz_data, 0, 3) === "\x1f\x8b\x08") {
$i = 10;
$flg = ord(substr($gz_data, 3, 1));
if ($flg > 0) {
if ($flg & 4) {
list($xlen) = unpack('v', substr($gz_data, $i, 2));
$i += 2 + $xlen;
}
if ($flg & 8) {
$i = strpos($gz_data, "\0", $i) + 1;
}
if ($flg & 16) {
$i = strpos($gz_data, "\0", $i) + 1;
}
if ($flg & 2) {
$i += 2;
}
}
$decompressed = self::compatible_gzinflate(substr($gz_data, $i));
if ($decompressed !== false) {
return $decompressed;
}
}
// If the data is Huffman Encoded, we must first strip the leading 2
// byte Huffman marker for gzinflate()
// The response is Huffman coded by many compressors such as
// java.util.zip.Deflater, Ruby's Zlib::Deflate, and .NET's
// System.IO.Compression.DeflateStream.
//
// See https://decompres.blogspot.com/ for a quick explanation of this
// data type
$huffman_encoded = false;
// low nibble of first byte should be 0x08
list(, $first_nibble) = unpack('h', $gz_data);
// First 2 bytes should be divisible by 0x1F
list(, $first_two_bytes) = unpack('n', $gz_data);
if ($first_nibble === 0x08 && ($first_two_bytes % 0x1F) === 0) {
$huffman_encoded = true;
}
if ($huffman_encoded) {
$decompressed = @gzinflate(substr($gz_data, 2));
if ($decompressed !== false) {
return $decompressed;
}
}
if (substr($gz_data, 0, 4) === "\x50\x4b\x03\x04") {
// ZIP file format header
// Offset 6: 2 bytes, General-purpose field
// Offset 26: 2 bytes, filename length
// Offset 28: 2 bytes, optional field length
// Offset 30: Filename field, followed by optional field, followed
// immediately by data
list(, $general_purpose_flag) = unpack('v', substr($gz_data, 6, 2));
// If the file has been compressed on the fly, 0x08 bit is set of
// the general purpose field. We can use this to differentiate
// between a compressed document, and a ZIP file
$zip_compressed_on_the_fly = ((0x08 & $general_purpose_flag) === 0x08);
if (!$zip_compressed_on_the_fly) {
// Don't attempt to decode a compressed zip file
return $gz_data;
}
// Determine the first byte of data, based on the above ZIP header
// offsets:
$first_file_start = array_sum(unpack('v2', substr($gz_data, 26, 4)));
$decompressed = @gzinflate(substr($gz_data, 30 + $first_file_start));
if ($decompressed !== false) {
return $decompressed;
}
return false;
}
// Finally fall back to straight gzinflate
$decompressed = @gzinflate($gz_data);
if ($decompressed !== false) {
return $decompressed;
}
// Fallback for all above failing, not expected, but included for
// debugging and preventing regressions and to track stats
$decompressed = @gzinflate(substr($gz_data, 2));
if ($decompressed !== false) {
return $decompressed;
}
return false;
}
}
Response/Headers.php 0000644 00000006035 15153252210 0010426 0 ustar 00 <?php
/**
* Case-insensitive dictionary, suitable for HTTP headers
*
* @package Requests
*/
namespace WpOrg\Requests\Response;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
use WpOrg\Requests\Utility\FilteredIterator;
/**
* Case-insensitive dictionary, suitable for HTTP headers
*
* @package Requests
*/
class Headers extends CaseInsensitiveDictionary {
/**
* Get the given header
*
* Unlike {@see \WpOrg\Requests\Response\Headers::getValues()}, this returns a string. If there are
* multiple values, it concatenates them with a comma as per RFC2616.
*
* Avoid using this where commas may be used unquoted in values, such as
* Set-Cookie headers.
*
* @param string $offset Name of the header to retrieve.
* @return string|null Header value
*/
public function offsetGet($offset) {
if (is_string($offset)) {
$offset = strtolower($offset);
}
if (!isset($this->data[$offset])) {
return null;
}
return $this->flatten($this->data[$offset]);
}
/**
* Set the given item
*
* @param string $offset Item name
* @param string $value Item value
*
* @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`)
*/
public function offsetSet($offset, $value) {
if ($offset === null) {
throw new Exception('Object is a dictionary, not a list', 'invalidset');
}
if (is_string($offset)) {
$offset = strtolower($offset);
}
if (!isset($this->data[$offset])) {
$this->data[$offset] = [];
}
$this->data[$offset][] = $value;
}
/**
* Get all values for a given header
*
* @param string $offset Name of the header to retrieve.
* @return array|null Header values
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not valid as an array key.
*/
public function getValues($offset) {
if (!is_string($offset) && !is_int($offset)) {
throw InvalidArgument::create(1, '$offset', 'string|int', gettype($offset));
}
if (is_string($offset)) {
$offset = strtolower($offset);
}
if (!isset($this->data[$offset])) {
return null;
}
return $this->data[$offset];
}
/**
* Flattens a value into a string
*
* Converts an array into a string by imploding values with a comma, as per
* RFC2616's rules for folding headers.
*
* @param string|array $value Value to flatten
* @return string Flattened value
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or an array.
*/
public function flatten($value) {
if (is_string($value)) {
return $value;
}
if (is_array($value)) {
return implode(',', $value);
}
throw InvalidArgument::create(1, '$value', 'string|array', gettype($value));
}
/**
* Get an iterator for the data
*
* Converts the internally stored values to a comma-separated string if there is more
* than one value for a key.
*
* @return \ArrayIterator
*/
public function getIterator() {
return new FilteredIterator($this->data, [$this, 'flatten']);
}
}
Response.php 0000644 00000010271 15153252210 0007050 0 ustar 00 <?php
/**
* HTTP response class
*
* Contains a response from \WpOrg\Requests\Requests::request()
*
* @package Requests
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Cookie\Jar;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\Http;
use WpOrg\Requests\Response\Headers;
/**
* HTTP response class
*
* Contains a response from \WpOrg\Requests\Requests::request()
*
* @package Requests
*/
class Response {
/**
* Response body
*
* @var string
*/
public $body = '';
/**
* Raw HTTP data from the transport
*
* @var string
*/
public $raw = '';
/**
* Headers, as an associative array
*
* @var \WpOrg\Requests\Response\Headers Array-like object representing headers
*/
public $headers = [];
/**
* Status code, false if non-blocking
*
* @var integer|boolean
*/
public $status_code = false;
/**
* Protocol version, false if non-blocking
*
* @var float|boolean
*/
public $protocol_version = false;
/**
* Whether the request succeeded or not
*
* @var boolean
*/
public $success = false;
/**
* Number of redirects the request used
*
* @var integer
*/
public $redirects = 0;
/**
* URL requested
*
* @var string
*/
public $url = '';
/**
* Previous requests (from redirects)
*
* @var array Array of \WpOrg\Requests\Response objects
*/
public $history = [];
/**
* Cookies from the request
*
* @var \WpOrg\Requests\Cookie\Jar Array-like object representing a cookie jar
*/
public $cookies = [];
/**
* Constructor
*/
public function __construct() {
$this->headers = new Headers();
$this->cookies = new Jar();
}
/**
* Is the response a redirect?
*
* @return boolean True if redirect (3xx status), false if not.
*/
public function is_redirect() {
$code = $this->status_code;
return in_array($code, [300, 301, 302, 303, 307], true) || $code > 307 && $code < 400;
}
/**
* Throws an exception if the request was not successful
*
* @param boolean $allow_redirects Set to false to throw on a 3xx as well
*
* @throws \WpOrg\Requests\Exception If `$allow_redirects` is false, and code is 3xx (`response.no_redirects`)
* @throws \WpOrg\Requests\Exception\Http On non-successful status code. Exception class corresponds to "Status" + code (e.g. {@see \WpOrg\Requests\Exception\Http\Status404})
*/
public function throw_for_status($allow_redirects = true) {
if ($this->is_redirect()) {
if ($allow_redirects !== true) {
throw new Exception('Redirection not allowed', 'response.no_redirects', $this);
}
} elseif (!$this->success) {
$exception = Http::get_class($this->status_code);
throw new $exception(null, $this);
}
}
/**
* JSON decode the response body.
*
* The method parameters are the same as those for the PHP native `json_decode()` function.
*
* @link https://php.net/json-decode
*
* @param bool|null $associative Optional. When `true`, JSON objects will be returned as associative arrays;
* When `false`, JSON objects will be returned as objects.
* When `null`, JSON objects will be returned as associative arrays
* or objects depending on whether `JSON_OBJECT_AS_ARRAY` is set in the flags.
* Defaults to `true` (in contrast to the PHP native default of `null`).
* @param int $depth Optional. Maximum nesting depth of the structure being decoded.
* Defaults to `512`.
* @param int $options Optional. Bitmask of JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_IGNORE,
* JSON_INVALID_UTF8_SUBSTITUTE, JSON_OBJECT_AS_ARRAY, JSON_THROW_ON_ERROR.
* Defaults to `0` (no options set).
*
* @return array
*
* @throws \WpOrg\Requests\Exception If `$this->body` is not valid json.
*/
public function decode_body($associative = true, $depth = 512, $options = 0) {
$data = json_decode($this->body, $associative, $depth, $options);
if (json_last_error() !== JSON_ERROR_NONE) {
$last_error = json_last_error_msg();
throw new Exception('Unable to parse JSON data: ' . $last_error, 'response.invalid', $this);
}
return $data;
}
}
Session.php 0000644 00000021623 15153252210 0006700 0 ustar 00 <?php
/**
* Session handler for persistent requests and default parameters
*
* @package Requests\SessionHandler
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Cookie\Jar;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Iri;
use WpOrg\Requests\Requests;
use WpOrg\Requests\Utility\InputValidator;
/**
* Session handler for persistent requests and default parameters
*
* Allows various options to be set as default values, and merges both the
* options and URL properties together. A base URL can be set for all requests,
* with all subrequests resolved from this. Base options can be set (including
* a shared cookie jar), then overridden for individual requests.
*
* @package Requests\SessionHandler
*/
class Session {
/**
* Base URL for requests
*
* URLs will be made absolute using this as the base
*
* @var string|null
*/
public $url = null;
/**
* Base headers for requests
*
* @var array
*/
public $headers = [];
/**
* Base data for requests
*
* If both the base data and the per-request data are arrays, the data will
* be merged before sending the request.
*
* @var array
*/
public $data = [];
/**
* Base options for requests
*
* The base options are merged with the per-request data for each request.
* The only default option is a shared cookie jar between requests.
*
* Values here can also be set directly via properties on the Session
* object, e.g. `$session->useragent = 'X';`
*
* @var array
*/
public $options = [];
/**
* Create a new session
*
* @param string|Stringable|null $url Base URL for requests
* @param array $headers Default headers for requests
* @param array $data Default data for requests
* @param array $options Default options for requests
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string, Stringable or null.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data argument is not an array.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
*/
public function __construct($url = null, $headers = [], $data = [], $options = []) {
if ($url !== null && InputValidator::is_string_or_stringable($url) === false) {
throw InvalidArgument::create(1, '$url', 'string|Stringable|null', gettype($url));
}
if (is_array($headers) === false) {
throw InvalidArgument::create(2, '$headers', 'array', gettype($headers));
}
if (is_array($data) === false) {
throw InvalidArgument::create(3, '$data', 'array', gettype($data));
}
if (is_array($options) === false) {
throw InvalidArgument::create(4, '$options', 'array', gettype($options));
}
$this->url = $url;
$this->headers = $headers;
$this->data = $data;
$this->options = $options;
if (empty($this->options['cookies'])) {
$this->options['cookies'] = new Jar();
}
}
/**
* Get a property's value
*
* @param string $name Property name.
* @return mixed|null Property value, null if none found
*/
public function __get($name) {
if (isset($this->options[$name])) {
return $this->options[$name];
}
return null;
}
/**
* Set a property's value
*
* @param string $name Property name.
* @param mixed $value Property value
*/
public function __set($name, $value) {
$this->options[$name] = $value;
}
/**
* Remove a property's value
*
* @param string $name Property name.
*/
public function __isset($name) {
return isset($this->options[$name]);
}
/**
* Remove a property's value
*
* @param string $name Property name.
*/
public function __unset($name) {
unset($this->options[$name]);
}
/**#@+
* @see \WpOrg\Requests\Session::request()
* @param string $url
* @param array $headers
* @param array $options
* @return \WpOrg\Requests\Response
*/
/**
* Send a GET request
*/
public function get($url, $headers = [], $options = []) {
return $this->request($url, $headers, null, Requests::GET, $options);
}
/**
* Send a HEAD request
*/
public function head($url, $headers = [], $options = []) {
return $this->request($url, $headers, null, Requests::HEAD, $options);
}
/**
* Send a DELETE request
*/
public function delete($url, $headers = [], $options = []) {
return $this->request($url, $headers, null, Requests::DELETE, $options);
}
/**#@-*/
/**#@+
* @see \WpOrg\Requests\Session::request()
* @param string $url
* @param array $headers
* @param array $data
* @param array $options
* @return \WpOrg\Requests\Response
*/
/**
* Send a POST request
*/
public function post($url, $headers = [], $data = [], $options = []) {
return $this->request($url, $headers, $data, Requests::POST, $options);
}
/**
* Send a PUT request
*/
public function put($url, $headers = [], $data = [], $options = []) {
return $this->request($url, $headers, $data, Requests::PUT, $options);
}
/**
* Send a PATCH request
*
* Note: Unlike {@see \WpOrg\Requests\Session::post()} and {@see \WpOrg\Requests\Session::put()},
* `$headers` is required, as the specification recommends that should send an ETag
*
* @link https://tools.ietf.org/html/rfc5789
*/
public function patch($url, $headers, $data = [], $options = []) {
return $this->request($url, $headers, $data, Requests::PATCH, $options);
}
/**#@-*/
/**
* Main interface for HTTP requests
*
* This method initiates a request and sends it via a transport before
* parsing.
*
* @see \WpOrg\Requests\Requests::request()
*
* @param string $url URL to request
* @param array $headers Extra headers to send with the request
* @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests
* @param string $type HTTP request type (use \WpOrg\Requests\Requests constants)
* @param array $options Options for the request (see {@see \WpOrg\Requests\Requests::request()})
* @return \WpOrg\Requests\Response
*
* @throws \WpOrg\Requests\Exception On invalid URLs (`nonhttp`)
*/
public function request($url, $headers = [], $data = [], $type = Requests::GET, $options = []) {
$request = $this->merge_request(compact('url', 'headers', 'data', 'options'));
return Requests::request($request['url'], $request['headers'], $request['data'], $type, $request['options']);
}
/**
* Send multiple HTTP requests simultaneously
*
* @see \WpOrg\Requests\Requests::request_multiple()
*
* @param array $requests Requests data (see {@see \WpOrg\Requests\Requests::request_multiple()})
* @param array $options Global and default options (see {@see \WpOrg\Requests\Requests::request()})
* @return array Responses (either \WpOrg\Requests\Response or a \WpOrg\Requests\Exception object)
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
*/
public function request_multiple($requests, $options = []) {
if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) {
throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests));
}
if (is_array($options) === false) {
throw InvalidArgument::create(2, '$options', 'array', gettype($options));
}
foreach ($requests as $key => $request) {
$requests[$key] = $this->merge_request($request, false);
}
$options = array_merge($this->options, $options);
// Disallow forcing the type, as that's a per request setting
unset($options['type']);
return Requests::request_multiple($requests, $options);
}
public function __wakeup() {
throw new \LogicException( __CLASS__ . ' should never be unserialized' );
}
/**
* Merge a request's data with the default data
*
* @param array $request Request data (same form as {@see \WpOrg\Requests\Session::request_multiple()})
* @param boolean $merge_options Should we merge options as well?
* @return array Request data
*/
protected function merge_request($request, $merge_options = true) {
if ($this->url !== null) {
$request['url'] = Iri::absolutize($this->url, $request['url']);
$request['url'] = $request['url']->uri;
}
if (empty($request['headers'])) {
$request['headers'] = [];
}
$request['headers'] = array_merge($this->headers, $request['headers']);
if (empty($request['data'])) {
if (is_array($this->data)) {
$request['data'] = $this->data;
}
} elseif (is_array($request['data']) && is_array($this->data)) {
$request['data'] = array_merge($this->data, $request['data']);
}
if ($merge_options === true) {
$request['options'] = array_merge($this->options, $request['options']);
// Disallow forcing the type, as that's a per request setting
unset($request['options']['type']);
}
return $request;
}
}
Ssl.php 0000644 00000012461 15153252210 0006016 0 ustar 00 <?php
/**
* SSL utilities for Requests
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Utility\InputValidator;
/**
* SSL utilities for Requests
*
* Collection of utilities for working with and verifying SSL certificates.
*
* @package Requests\Utilities
*/
final class Ssl {
/**
* Verify the certificate against common name and subject alternative names
*
* Unfortunately, PHP doesn't check the certificate against the alternative
* names, leading things like 'https://www.github.com/' to be invalid.
*
* @link https://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1
*
* @param string|Stringable $host Host name to verify against
* @param array $cert Certificate data from openssl_x509_parse()
* @return bool
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $host argument is not a string or a stringable object.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $cert argument is not an array or array accessible.
*/
public static function verify_certificate($host, $cert) {
if (InputValidator::is_string_or_stringable($host) === false) {
throw InvalidArgument::create(1, '$host', 'string|Stringable', gettype($host));
}
if (InputValidator::has_array_access($cert) === false) {
throw InvalidArgument::create(2, '$cert', 'array|ArrayAccess', gettype($cert));
}
$has_dns_alt = false;
// Check the subjectAltName
if (!empty($cert['extensions']['subjectAltName'])) {
$altnames = explode(',', $cert['extensions']['subjectAltName']);
foreach ($altnames as $altname) {
$altname = trim($altname);
if (strpos($altname, 'DNS:') !== 0) {
continue;
}
$has_dns_alt = true;
// Strip the 'DNS:' prefix and trim whitespace
$altname = trim(substr($altname, 4));
// Check for a match
if (self::match_domain($host, $altname) === true) {
return true;
}
}
if ($has_dns_alt === true) {
return false;
}
}
// Fall back to checking the common name if we didn't get any dNSName
// alt names, as per RFC2818
if (!empty($cert['subject']['CN'])) {
// Check for a match
return (self::match_domain($host, $cert['subject']['CN']) === true);
}
return false;
}
/**
* Verify that a reference name is valid
*
* Verifies a dNSName for HTTPS usage, (almost) as per Firefox's rules:
* - Wildcards can only occur in a name with more than 3 components
* - Wildcards can only occur as the last character in the first
* component
* - Wildcards may be preceded by additional characters
*
* We modify these rules to be a bit stricter and only allow the wildcard
* character to be the full first component; that is, with the exclusion of
* the third rule.
*
* @param string|Stringable $reference Reference dNSName
* @return boolean Is the name valid?
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or a stringable object.
*/
public static function verify_reference_name($reference) {
if (InputValidator::is_string_or_stringable($reference) === false) {
throw InvalidArgument::create(1, '$reference', 'string|Stringable', gettype($reference));
}
if ($reference === '') {
return false;
}
if (preg_match('`\s`', $reference) > 0) {
// Whitespace detected. This can never be a dNSName.
return false;
}
$parts = explode('.', $reference);
if ($parts !== array_filter($parts)) {
// DNSName cannot contain two dots next to each other.
return false;
}
// Check the first part of the name
$first = array_shift($parts);
if (strpos($first, '*') !== false) {
// Check that the wildcard is the full part
if ($first !== '*') {
return false;
}
// Check that we have at least 3 components (including first)
if (count($parts) < 2) {
return false;
}
}
// Check the remaining parts
foreach ($parts as $part) {
if (strpos($part, '*') !== false) {
return false;
}
}
// Nothing found, verified!
return true;
}
/**
* Match a hostname against a dNSName reference
*
* @param string|Stringable $host Requested host
* @param string|Stringable $reference dNSName to match against
* @return boolean Does the domain match?
* @throws \WpOrg\Requests\Exception\InvalidArgument When either of the passed arguments is not a string or a stringable object.
*/
public static function match_domain($host, $reference) {
if (InputValidator::is_string_or_stringable($host) === false) {
throw InvalidArgument::create(1, '$host', 'string|Stringable', gettype($host));
}
// Check if the reference is blocklisted first
if (self::verify_reference_name($reference) !== true) {
return false;
}
// Check for a direct match
if ((string) $host === (string) $reference) {
return true;
}
// Calculate the valid wildcard match if the host is not an IP address
// Also validates that the host has 3 parts or more, as per Firefox's ruleset,
// as a wildcard reference is only allowed with 3 parts or more, so the
// comparison will never match if host doesn't contain 3 parts or more as well.
if (ip2long($host) === false) {
$parts = explode('.', $host);
$parts[0] = '*';
$wildcard = implode('.', $parts);
if ($wildcard === (string) $reference) {
return true;
}
}
return false;
}
}
Transport/Curl.php 0000644 00000046163 15153252210 0010164 0 ustar 00 <?php
/**
* cURL HTTP transport
*
* @package Requests\Transport
*/
namespace WpOrg\Requests\Transport;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;
use WpOrg\Requests\Capability;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Exception\Transport\Curl as CurlException;
use WpOrg\Requests\Requests;
use WpOrg\Requests\Transport;
use WpOrg\Requests\Utility\InputValidator;
/**
* cURL HTTP transport
*
* @package Requests\Transport
*/
final class Curl implements Transport {
const CURL_7_10_5 = 0x070A05;
const CURL_7_16_2 = 0x071002;
/**
* Raw HTTP data
*
* @var string
*/
public $headers = '';
/**
* Raw body data
*
* @var string
*/
public $response_data = '';
/**
* Information on the current request
*
* @var array cURL information array, see {@link https://www.php.net/curl_getinfo}
*/
public $info;
/**
* cURL version number
*
* @var int
*/
public $version;
/**
* cURL handle
*
* @var resource|\CurlHandle Resource in PHP < 8.0, Instance of CurlHandle in PHP >= 8.0.
*/
private $handle;
/**
* Hook dispatcher instance
*
* @var \WpOrg\Requests\Hooks
*/
private $hooks;
/**
* Have we finished the headers yet?
*
* @var boolean
*/
private $done_headers = false;
/**
* If streaming to a file, keep the file pointer
*
* @var resource
*/
private $stream_handle;
/**
* How many bytes are in the response body?
*
* @var int
*/
private $response_bytes;
/**
* What's the maximum number of bytes we should keep?
*
* @var int|bool Byte count, or false if no limit.
*/
private $response_byte_limit;
/**
* Constructor
*/
public function __construct() {
$curl = curl_version();
$this->version = $curl['version_number'];
$this->handle = curl_init();
curl_setopt($this->handle, CURLOPT_HEADER, false);
curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, 1);
if ($this->version >= self::CURL_7_10_5) {
curl_setopt($this->handle, CURLOPT_ENCODING, '');
}
if (defined('CURLOPT_PROTOCOLS')) {
// phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_protocolsFound
curl_setopt($this->handle, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
if (defined('CURLOPT_REDIR_PROTOCOLS')) {
// phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_redir_protocolsFound
curl_setopt($this->handle, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
}
/**
* Destructor
*/
public function __destruct() {
if (is_resource($this->handle)) {
curl_close($this->handle);
}
}
/**
* Perform a request
*
* @param string|Stringable $url URL to request
* @param array $headers Associative array of request headers
* @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD
* @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation
* @return string Raw HTTP result
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data parameter is not an array or string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
* @throws \WpOrg\Requests\Exception On a cURL error (`curlerror`)
*/
public function request($url, $headers = [], $data = [], $options = []) {
if (InputValidator::is_string_or_stringable($url) === false) {
throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url));
}
if (is_array($headers) === false) {
throw InvalidArgument::create(2, '$headers', 'array', gettype($headers));
}
if (!is_array($data) && !is_string($data)) {
if ($data === null) {
$data = '';
} else {
throw InvalidArgument::create(3, '$data', 'array|string', gettype($data));
}
}
if (is_array($options) === false) {
throw InvalidArgument::create(4, '$options', 'array', gettype($options));
}
$this->hooks = $options['hooks'];
$this->setup_handle($url, $headers, $data, $options);
$options['hooks']->dispatch('curl.before_send', [&$this->handle]);
if ($options['filename'] !== false) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception.
$this->stream_handle = @fopen($options['filename'], 'wb');
if ($this->stream_handle === false) {
$error = error_get_last();
throw new Exception($error['message'], 'fopen');
}
}
$this->response_data = '';
$this->response_bytes = 0;
$this->response_byte_limit = false;
if ($options['max_bytes'] !== false) {
$this->response_byte_limit = $options['max_bytes'];
}
if (isset($options['verify'])) {
if ($options['verify'] === false) {
curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($this->handle, CURLOPT_SSL_VERIFYPEER, 0);
} elseif (is_string($options['verify'])) {
curl_setopt($this->handle, CURLOPT_CAINFO, $options['verify']);
}
}
if (isset($options['verifyname']) && $options['verifyname'] === false) {
curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST, 0);
}
curl_exec($this->handle);
$response = $this->response_data;
$options['hooks']->dispatch('curl.after_send', []);
if (curl_errno($this->handle) === CURLE_WRITE_ERROR || curl_errno($this->handle) === CURLE_BAD_CONTENT_ENCODING) {
// Reset encoding and try again
curl_setopt($this->handle, CURLOPT_ENCODING, 'none');
$this->response_data = '';
$this->response_bytes = 0;
curl_exec($this->handle);
$response = $this->response_data;
}
$this->process_response($response, $options);
// Need to remove the $this reference from the curl handle.
// Otherwise \WpOrg\Requests\Transport\Curl won't be garbage collected and the curl_close() will never be called.
curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, null);
curl_setopt($this->handle, CURLOPT_WRITEFUNCTION, null);
return $this->headers;
}
/**
* Send multiple requests simultaneously
*
* @param array $requests Request data
* @param array $options Global options
* @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well)
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
*/
public function request_multiple($requests, $options) {
// If you're not requesting, we can't get any responses ¯\_(ツ)_/¯
if (empty($requests)) {
return [];
}
if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) {
throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests));
}
if (is_array($options) === false) {
throw InvalidArgument::create(2, '$options', 'array', gettype($options));
}
$multihandle = curl_multi_init();
$subrequests = [];
$subhandles = [];
$class = get_class($this);
foreach ($requests as $id => $request) {
$subrequests[$id] = new $class();
$subhandles[$id] = $subrequests[$id]->get_subrequest_handle($request['url'], $request['headers'], $request['data'], $request['options']);
$request['options']['hooks']->dispatch('curl.before_multi_add', [&$subhandles[$id]]);
curl_multi_add_handle($multihandle, $subhandles[$id]);
}
$completed = 0;
$responses = [];
$subrequestcount = count($subrequests);
$request['options']['hooks']->dispatch('curl.before_multi_exec', [&$multihandle]);
do {
$active = 0;
do {
$status = curl_multi_exec($multihandle, $active);
} while ($status === CURLM_CALL_MULTI_PERFORM);
$to_process = [];
// Read the information as needed
while ($done = curl_multi_info_read($multihandle)) {
$key = array_search($done['handle'], $subhandles, true);
if (!isset($to_process[$key])) {
$to_process[$key] = $done;
}
}
// Parse the finished requests before we start getting the new ones
foreach ($to_process as $key => $done) {
$options = $requests[$key]['options'];
if ($done['result'] !== CURLE_OK) {
//get error string for handle.
$reason = curl_error($done['handle']);
$exception = new CurlException(
$reason,
CurlException::EASY,
$done['handle'],
$done['result']
);
$responses[$key] = $exception;
$options['hooks']->dispatch('transport.internal.parse_error', [&$responses[$key], $requests[$key]]);
} else {
$responses[$key] = $subrequests[$key]->process_response($subrequests[$key]->response_data, $options);
$options['hooks']->dispatch('transport.internal.parse_response', [&$responses[$key], $requests[$key]]);
}
curl_multi_remove_handle($multihandle, $done['handle']);
curl_close($done['handle']);
if (!is_string($responses[$key])) {
$options['hooks']->dispatch('multiple.request.complete', [&$responses[$key], $key]);
}
$completed++;
}
} while ($active || $completed < $subrequestcount);
$request['options']['hooks']->dispatch('curl.after_multi_exec', [&$multihandle]);
curl_multi_close($multihandle);
return $responses;
}
/**
* Get the cURL handle for use in a multi-request
*
* @param string $url URL to request
* @param array $headers Associative array of request headers
* @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD
* @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation
* @return resource|\CurlHandle Subrequest's cURL handle
*/
public function &get_subrequest_handle($url, $headers, $data, $options) {
$this->setup_handle($url, $headers, $data, $options);
if ($options['filename'] !== false) {
$this->stream_handle = fopen($options['filename'], 'wb');
}
$this->response_data = '';
$this->response_bytes = 0;
$this->response_byte_limit = false;
if ($options['max_bytes'] !== false) {
$this->response_byte_limit = $options['max_bytes'];
}
$this->hooks = $options['hooks'];
return $this->handle;
}
/**
* Setup the cURL handle for the given data
*
* @param string $url URL to request
* @param array $headers Associative array of request headers
* @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD
* @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation
*/
private function setup_handle($url, $headers, $data, $options) {
$options['hooks']->dispatch('curl.before_request', [&$this->handle]);
// Force closing the connection for old versions of cURL (<7.22).
if (!isset($headers['Connection'])) {
$headers['Connection'] = 'close';
}
/**
* Add "Expect" header.
*
* By default, cURL adds a "Expect: 100-Continue" to most requests. This header can
* add as much as a second to the time it takes for cURL to perform a request. To
* prevent this, we need to set an empty "Expect" header. To match the behaviour of
* Guzzle, we'll add the empty header to requests that are smaller than 1 MB and use
* HTTP/1.1.
*
* https://curl.se/mail/lib-2017-07/0013.html
*/
if (!isset($headers['Expect']) && $options['protocol_version'] === 1.1) {
$headers['Expect'] = $this->get_expect_header($data);
}
$headers = Requests::flatten($headers);
if (!empty($data)) {
$data_format = $options['data_format'];
if ($data_format === 'query') {
$url = self::format_get($url, $data);
$data = '';
} elseif (!is_string($data)) {
$data = http_build_query($data, '', '&');
}
}
switch ($options['type']) {
case Requests::POST:
curl_setopt($this->handle, CURLOPT_POST, true);
curl_setopt($this->handle, CURLOPT_POSTFIELDS, $data);
break;
case Requests::HEAD:
curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']);
curl_setopt($this->handle, CURLOPT_NOBODY, true);
break;
case Requests::TRACE:
curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']);
break;
case Requests::PATCH:
case Requests::PUT:
case Requests::DELETE:
case Requests::OPTIONS:
default:
curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']);
if (!empty($data)) {
curl_setopt($this->handle, CURLOPT_POSTFIELDS, $data);
}
}
// cURL requires a minimum timeout of 1 second when using the system
// DNS resolver, as it uses `alarm()`, which is second resolution only.
// There's no way to detect which DNS resolver is being used from our
// end, so we need to round up regardless of the supplied timeout.
//
// https://github.com/curl/curl/blob/4f45240bc84a9aa648c8f7243be7b79e9f9323a5/lib/hostip.c#L606-L609
$timeout = max($options['timeout'], 1);
if (is_int($timeout) || $this->version < self::CURL_7_16_2) {
curl_setopt($this->handle, CURLOPT_TIMEOUT, ceil($timeout));
} else {
// phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_timeout_msFound
curl_setopt($this->handle, CURLOPT_TIMEOUT_MS, round($timeout * 1000));
}
if (is_int($options['connect_timeout']) || $this->version < self::CURL_7_16_2) {
curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT, ceil($options['connect_timeout']));
} else {
// phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttimeout_msFound
curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000));
}
curl_setopt($this->handle, CURLOPT_URL, $url);
curl_setopt($this->handle, CURLOPT_USERAGENT, $options['useragent']);
if (!empty($headers)) {
curl_setopt($this->handle, CURLOPT_HTTPHEADER, $headers);
}
if ($options['protocol_version'] === 1.1) {
curl_setopt($this->handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
} else {
curl_setopt($this->handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
}
if ($options['blocking'] === true) {
curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, [$this, 'stream_headers']);
curl_setopt($this->handle, CURLOPT_WRITEFUNCTION, [$this, 'stream_body']);
curl_setopt($this->handle, CURLOPT_BUFFERSIZE, Requests::BUFFER_SIZE);
}
}
/**
* Process a response
*
* @param string $response Response data from the body
* @param array $options Request options
* @return string|false HTTP response data including headers. False if non-blocking.
* @throws \WpOrg\Requests\Exception If the request resulted in a cURL error.
*/
public function process_response($response, $options) {
if ($options['blocking'] === false) {
$fake_headers = '';
$options['hooks']->dispatch('curl.after_request', [&$fake_headers]);
return false;
}
if ($options['filename'] !== false && $this->stream_handle) {
fclose($this->stream_handle);
$this->headers = trim($this->headers);
} else {
$this->headers .= $response;
}
if (curl_errno($this->handle)) {
$error = sprintf(
'cURL error %s: %s',
curl_errno($this->handle),
curl_error($this->handle)
);
throw new Exception($error, 'curlerror', $this->handle);
}
$this->info = curl_getinfo($this->handle);
$options['hooks']->dispatch('curl.after_request', [&$this->headers, &$this->info]);
return $this->headers;
}
/**
* Collect the headers as they are received
*
* @param resource|\CurlHandle $handle cURL handle
* @param string $headers Header string
* @return integer Length of provided header
*/
public function stream_headers($handle, $headers) {
// Why do we do this? cURL will send both the final response and any
// interim responses, such as a 100 Continue. We don't need that.
// (We may want to keep this somewhere just in case)
if ($this->done_headers) {
$this->headers = '';
$this->done_headers = false;
}
$this->headers .= $headers;
if ($headers === "\r\n") {
$this->done_headers = true;
}
return strlen($headers);
}
/**
* Collect data as it's received
*
* @since 1.6.1
*
* @param resource|\CurlHandle $handle cURL handle
* @param string $data Body data
* @return integer Length of provided data
*/
public function stream_body($handle, $data) {
$this->hooks->dispatch('request.progress', [$data, $this->response_bytes, $this->response_byte_limit]);
$data_length = strlen($data);
// Are we limiting the response size?
if ($this->response_byte_limit) {
if ($this->response_bytes === $this->response_byte_limit) {
// Already at maximum, move on
return $data_length;
}
if (($this->response_bytes + $data_length) > $this->response_byte_limit) {
// Limit the length
$limited_length = ($this->response_byte_limit - $this->response_bytes);
$data = substr($data, 0, $limited_length);
}
}
if ($this->stream_handle) {
fwrite($this->stream_handle, $data);
} else {
$this->response_data .= $data;
}
$this->response_bytes += strlen($data);
return $data_length;
}
/**
* Format a URL given GET data
*
* @param string $url Original URL.
* @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query}
* @return string URL with data
*/
private static function format_get($url, $data) {
if (!empty($data)) {
$query = '';
$url_parts = parse_url($url);
if (empty($url_parts['query'])) {
$url_parts['query'] = '';
} else {
$query = $url_parts['query'];
}
$query .= '&' . http_build_query($data, '', '&');
$query = trim($query, '&');
if (empty($url_parts['query'])) {
$url .= '?' . $query;
} else {
$url = str_replace($url_parts['query'], $query, $url);
}
}
return $url;
}
/**
* Self-test whether the transport can be used.
*
* The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}.
*
* @codeCoverageIgnore
* @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
* @return bool Whether the transport can be used.
*/
public static function test($capabilities = []) {
if (!function_exists('curl_init') || !function_exists('curl_exec')) {
return false;
}
// If needed, check that our installed curl version supports SSL
if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) {
$curl_version = curl_version();
if (!(CURL_VERSION_SSL & $curl_version['features'])) {
return false;
}
}
return true;
}
/**
* Get the correct "Expect" header for the given request data.
*
* @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD.
* @return string The "Expect" header.
*/
private function get_expect_header($data) {
if (!is_array($data)) {
return strlen((string) $data) >= 1048576 ? '100-Continue' : '';
}
$bytesize = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveArrayIterator($data));
foreach ($iterator as $datum) {
$bytesize += strlen((string) $datum);
if ($bytesize >= 1048576) {
return '100-Continue';
}
}
return '';
}
}
Transport/Fsockopen.php 0000644 00000036201 15153252210 0011176 0 ustar 00 <?php
/**
* fsockopen HTTP transport
*
* @package Requests\Transport
*/
namespace WpOrg\Requests\Transport;
use WpOrg\Requests\Capability;
use WpOrg\Requests\Exception;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Port;
use WpOrg\Requests\Requests;
use WpOrg\Requests\Ssl;
use WpOrg\Requests\Transport;
use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
use WpOrg\Requests\Utility\InputValidator;
/**
* fsockopen HTTP transport
*
* @package Requests\Transport
*/
final class Fsockopen implements Transport {
/**
* Second to microsecond conversion
*
* @var integer
*/
const SECOND_IN_MICROSECONDS = 1000000;
/**
* Raw HTTP data
*
* @var string
*/
public $headers = '';
/**
* Stream metadata
*
* @var array Associative array of properties, see {@link https://www.php.net/stream_get_meta_data}
*/
public $info;
/**
* What's the maximum number of bytes we should keep?
*
* @var int|bool Byte count, or false if no limit.
*/
private $max_bytes = false;
/**
* Cache for received connection errors.
*
* @var string
*/
private $connect_error = '';
/**
* Perform a request
*
* @param string|Stringable $url URL to request
* @param array $headers Associative array of request headers
* @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD
* @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation
* @return string Raw HTTP result
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data parameter is not an array or string.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
* @throws \WpOrg\Requests\Exception On failure to connect to socket (`fsockopenerror`)
* @throws \WpOrg\Requests\Exception On socket timeout (`timeout`)
*/
public function request($url, $headers = [], $data = [], $options = []) {
if (InputValidator::is_string_or_stringable($url) === false) {
throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url));
}
if (is_array($headers) === false) {
throw InvalidArgument::create(2, '$headers', 'array', gettype($headers));
}
if (!is_array($data) && !is_string($data)) {
if ($data === null) {
$data = '';
} else {
throw InvalidArgument::create(3, '$data', 'array|string', gettype($data));
}
}
if (is_array($options) === false) {
throw InvalidArgument::create(4, '$options', 'array', gettype($options));
}
$options['hooks']->dispatch('fsockopen.before_request');
$url_parts = parse_url($url);
if (empty($url_parts)) {
throw new Exception('Invalid URL.', 'invalidurl', $url);
}
$host = $url_parts['host'];
$context = stream_context_create();
$verifyname = false;
$case_insensitive_headers = new CaseInsensitiveDictionary($headers);
// HTTPS support
if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') {
$remote_socket = 'ssl://' . $host;
if (!isset($url_parts['port'])) {
$url_parts['port'] = Port::HTTPS;
}
$context_options = [
'verify_peer' => true,
'capture_peer_cert' => true,
];
$verifyname = true;
// SNI, if enabled (OpenSSL >=0.9.8j)
// phpcs:ignore PHPCompatibility.Constants.NewConstants.openssl_tlsext_server_nameFound
if (defined('OPENSSL_TLSEXT_SERVER_NAME') && OPENSSL_TLSEXT_SERVER_NAME) {
$context_options['SNI_enabled'] = true;
if (isset($options['verifyname']) && $options['verifyname'] === false) {
$context_options['SNI_enabled'] = false;
}
}
if (isset($options['verify'])) {
if ($options['verify'] === false) {
$context_options['verify_peer'] = false;
$context_options['verify_peer_name'] = false;
$verifyname = false;
} elseif (is_string($options['verify'])) {
$context_options['cafile'] = $options['verify'];
}
}
if (isset($options['verifyname']) && $options['verifyname'] === false) {
$context_options['verify_peer_name'] = false;
$verifyname = false;
}
stream_context_set_option($context, ['ssl' => $context_options]);
} else {
$remote_socket = 'tcp://' . $host;
}
$this->max_bytes = $options['max_bytes'];
if (!isset($url_parts['port'])) {
$url_parts['port'] = Port::HTTP;
}
$remote_socket .= ':' . $url_parts['port'];
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
set_error_handler([$this, 'connect_error_handler'], E_WARNING | E_NOTICE);
$options['hooks']->dispatch('fsockopen.remote_socket', [&$remote_socket]);
$socket = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context);
restore_error_handler();
if ($verifyname && !$this->verify_certificate_from_context($host, $context)) {
throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match');
}
if (!$socket) {
if ($errno === 0) {
// Connection issue
throw new Exception(rtrim($this->connect_error), 'fsockopen.connect_error');
}
throw new Exception($errstr, 'fsockopenerror', null, $errno);
}
$data_format = $options['data_format'];
if ($data_format === 'query') {
$path = self::format_get($url_parts, $data);
$data = '';
} else {
$path = self::format_get($url_parts, []);
}
$options['hooks']->dispatch('fsockopen.remote_host_path', [&$path, $url]);
$request_body = '';
$out = sprintf("%s %s HTTP/%.1F\r\n", $options['type'], $path, $options['protocol_version']);
if ($options['type'] !== Requests::TRACE) {
if (is_array($data)) {
$request_body = http_build_query($data, '', '&');
} else {
$request_body = $data;
}
// Always include Content-length on POST requests to prevent
// 411 errors from some servers when the body is empty.
if (!empty($data) || $options['type'] === Requests::POST) {
if (!isset($case_insensitive_headers['Content-Length'])) {
$headers['Content-Length'] = strlen($request_body);
}
if (!isset($case_insensitive_headers['Content-Type'])) {
$headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
}
}
}
if (!isset($case_insensitive_headers['Host'])) {
$out .= sprintf('Host: %s', $url_parts['host']);
$scheme_lower = strtolower($url_parts['scheme']);
if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) {
$out .= ':' . $url_parts['port'];
}
$out .= "\r\n";
}
if (!isset($case_insensitive_headers['User-Agent'])) {
$out .= sprintf("User-Agent: %s\r\n", $options['useragent']);
}
$accept_encoding = $this->accept_encoding();
if (!isset($case_insensitive_headers['Accept-Encoding']) && !empty($accept_encoding)) {
$out .= sprintf("Accept-Encoding: %s\r\n", $accept_encoding);
}
$headers = Requests::flatten($headers);
if (!empty($headers)) {
$out .= implode("\r\n", $headers) . "\r\n";
}
$options['hooks']->dispatch('fsockopen.after_headers', [&$out]);
if (substr($out, -2) !== "\r\n") {
$out .= "\r\n";
}
if (!isset($case_insensitive_headers['Connection'])) {
$out .= "Connection: Close\r\n";
}
$out .= "\r\n" . $request_body;
$options['hooks']->dispatch('fsockopen.before_send', [&$out]);
fwrite($socket, $out);
$options['hooks']->dispatch('fsockopen.after_send', [$out]);
if (!$options['blocking']) {
fclose($socket);
$fake_headers = '';
$options['hooks']->dispatch('fsockopen.after_request', [&$fake_headers]);
return '';
}
$timeout_sec = (int) floor($options['timeout']);
if ($timeout_sec === $options['timeout']) {
$timeout_msec = 0;
} else {
$timeout_msec = self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS;
}
stream_set_timeout($socket, $timeout_sec, $timeout_msec);
$response = '';
$body = '';
$headers = '';
$this->info = stream_get_meta_data($socket);
$size = 0;
$doingbody = false;
$download = false;
if ($options['filename']) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception.
$download = @fopen($options['filename'], 'wb');
if ($download === false) {
$error = error_get_last();
throw new Exception($error['message'], 'fopen');
}
}
while (!feof($socket)) {
$this->info = stream_get_meta_data($socket);
if ($this->info['timed_out']) {
throw new Exception('fsocket timed out', 'timeout');
}
$block = fread($socket, Requests::BUFFER_SIZE);
if (!$doingbody) {
$response .= $block;
if (strpos($response, "\r\n\r\n")) {
list($headers, $block) = explode("\r\n\r\n", $response, 2);
$doingbody = true;
}
}
// Are we in body mode now?
if ($doingbody) {
$options['hooks']->dispatch('request.progress', [$block, $size, $this->max_bytes]);
$data_length = strlen($block);
if ($this->max_bytes) {
// Have we already hit a limit?
if ($size === $this->max_bytes) {
continue;
}
if (($size + $data_length) > $this->max_bytes) {
// Limit the length
$limited_length = ($this->max_bytes - $size);
$block = substr($block, 0, $limited_length);
}
}
$size += strlen($block);
if ($download) {
fwrite($download, $block);
} else {
$body .= $block;
}
}
}
$this->headers = $headers;
if ($download) {
fclose($download);
} else {
$this->headers .= "\r\n\r\n" . $body;
}
fclose($socket);
$options['hooks']->dispatch('fsockopen.after_request', [&$this->headers, &$this->info]);
return $this->headers;
}
/**
* Send multiple requests simultaneously
*
* @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see \WpOrg\Requests\Transport::request()}
* @param array $options Global options, see {@see \WpOrg\Requests\Requests::response()} for documentation
* @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well)
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access.
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
*/
public function request_multiple($requests, $options) {
// If you're not requesting, we can't get any responses ¯\_(ツ)_/¯
if (empty($requests)) {
return [];
}
if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) {
throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests));
}
if (is_array($options) === false) {
throw InvalidArgument::create(2, '$options', 'array', gettype($options));
}
$responses = [];
$class = get_class($this);
foreach ($requests as $id => $request) {
try {
$handler = new $class();
$responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']);
$request['options']['hooks']->dispatch('transport.internal.parse_response', [&$responses[$id], $request]);
} catch (Exception $e) {
$responses[$id] = $e;
}
if (!is_string($responses[$id])) {
$request['options']['hooks']->dispatch('multiple.request.complete', [&$responses[$id], $id]);
}
}
return $responses;
}
/**
* Retrieve the encodings we can accept
*
* @return string Accept-Encoding header value
*/
private static function accept_encoding() {
$type = [];
if (function_exists('gzinflate')) {
$type[] = 'deflate;q=1.0';
}
if (function_exists('gzuncompress')) {
$type[] = 'compress;q=0.5';
}
$type[] = 'gzip;q=0.5';
return implode(', ', $type);
}
/**
* Format a URL given GET data
*
* @param array $url_parts Array of URL parts as received from {@link https://www.php.net/parse_url}
* @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query}
* @return string URL with data
*/
private static function format_get($url_parts, $data) {
if (!empty($data)) {
if (empty($url_parts['query'])) {
$url_parts['query'] = '';
}
$url_parts['query'] .= '&' . http_build_query($data, '', '&');
$url_parts['query'] = trim($url_parts['query'], '&');
}
if (isset($url_parts['path'])) {
if (isset($url_parts['query'])) {
$get = $url_parts['path'] . '?' . $url_parts['query'];
} else {
$get = $url_parts['path'];
}
} else {
$get = '/';
}
return $get;
}
/**
* Error handler for stream_socket_client()
*
* @param int $errno Error number (e.g. E_WARNING)
* @param string $errstr Error message
*/
public function connect_error_handler($errno, $errstr) {
// Double-check we can handle it
if (($errno & E_WARNING) === 0 && ($errno & E_NOTICE) === 0) {
// Return false to indicate the default error handler should engage
return false;
}
$this->connect_error .= $errstr . "\n";
return true;
}
/**
* Verify the certificate against common name and subject alternative names
*
* Unfortunately, PHP doesn't check the certificate against the alternative
* names, leading things like 'https://www.github.com/' to be invalid.
* Instead
*
* @link https://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1
*
* @param string $host Host name to verify against
* @param resource $context Stream context
* @return bool
*
* @throws \WpOrg\Requests\Exception On failure to connect via TLS (`fsockopen.ssl.connect_error`)
* @throws \WpOrg\Requests\Exception On not obtaining a match for the host (`fsockopen.ssl.no_match`)
*/
public function verify_certificate_from_context($host, $context) {
$meta = stream_context_get_options($context);
// If we don't have SSL options, then we couldn't make the connection at
// all
if (empty($meta) || empty($meta['ssl']) || empty($meta['ssl']['peer_certificate'])) {
throw new Exception(rtrim($this->connect_error), 'ssl.connect_error');
}
$cert = openssl_x509_parse($meta['ssl']['peer_certificate']);
return Ssl::verify_certificate($host, $cert);
}
/**
* Self-test whether the transport can be used.
*
* The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}.
*
* @codeCoverageIgnore
* @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
* @return bool Whether the transport can be used.
*/
public static function test($capabilities = []) {
if (!function_exists('fsockopen')) {
return false;
}
// If needed, check that streams support SSL
if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) {
if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) {
return false;
}
}
return true;
}
}
Transport.php 0000644 00000003010 15153252210 0007237 0 ustar 00 <?php
/**
* Base HTTP transport
*
* @package Requests\Transport
*/
namespace WpOrg\Requests;
/**
* Base HTTP transport
*
* @package Requests\Transport
*/
interface Transport {
/**
* Perform a request
*
* @param string $url URL to request
* @param array $headers Associative array of request headers
* @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD
* @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation
* @return string Raw HTTP result
*/
public function request($url, $headers = [], $data = [], $options = []);
/**
* Send multiple requests simultaneously
*
* @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see \WpOrg\Requests\Transport::request()}
* @param array $options Global options, see {@see \WpOrg\Requests\Requests::response()} for documentation
* @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well)
*/
public function request_multiple($requests, $options);
/**
* Self-test whether the transport can be used.
*
* The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}.
*
* @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
* @return bool Whether the transport can be used.
*/
public static function test($capabilities = []);
}
Utility/CaseInsensitiveDictionary.php 0000644 00000004713 15153252210 0014043 0 ustar 00 <?php
/**
* Case-insensitive dictionary, suitable for HTTP headers
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests\Utility;
use ArrayAccess;
use ArrayIterator;
use IteratorAggregate;
use ReturnTypeWillChange;
use WpOrg\Requests\Exception;
/**
* Case-insensitive dictionary, suitable for HTTP headers
*
* @package Requests\Utilities
*/
class CaseInsensitiveDictionary implements ArrayAccess, IteratorAggregate {
/**
* Actual item data
*
* @var array
*/
protected $data = [];
/**
* Creates a case insensitive dictionary.
*
* @param array $data Dictionary/map to convert to case-insensitive
*/
public function __construct(array $data = []) {
foreach ($data as $offset => $value) {
$this->offsetSet($offset, $value);
}
}
/**
* Check if the given item exists
*
* @param string $offset Item key
* @return boolean Does the item exist?
*/
#[ReturnTypeWillChange]
public function offsetExists($offset) {
if (is_string($offset)) {
$offset = strtolower($offset);
}
return isset($this->data[$offset]);
}
/**
* Get the value for the item
*
* @param string $offset Item key
* @return string|null Item value (null if the item key doesn't exist)
*/
#[ReturnTypeWillChange]
public function offsetGet($offset) {
if (is_string($offset)) {
$offset = strtolower($offset);
}
if (!isset($this->data[$offset])) {
return null;
}
return $this->data[$offset];
}
/**
* Set the given item
*
* @param string $offset Item name
* @param string $value Item value
*
* @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`)
*/
#[ReturnTypeWillChange]
public function offsetSet($offset, $value) {
if ($offset === null) {
throw new Exception('Object is a dictionary, not a list', 'invalidset');
}
if (is_string($offset)) {
$offset = strtolower($offset);
}
$this->data[$offset] = $value;
}
/**
* Unset the given header
*
* @param string $offset The key for the item to unset.
*/
#[ReturnTypeWillChange]
public function offsetUnset($offset) {
if (is_string($offset)) {
$offset = strtolower($offset);
}
unset($this->data[$offset]);
}
/**
* Get an iterator for the data
*
* @return \ArrayIterator
*/
#[ReturnTypeWillChange]
public function getIterator() {
return new ArrayIterator($this->data);
}
/**
* Get the headers as an array
*
* @return array Header data
*/
public function getAll() {
return $this->data;
}
}
Utility/FilteredIterator.php 0000644 00000004155 15153252210 0012171 0 ustar 00 <?php
/**
* Iterator for arrays requiring filtered values
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests\Utility;
use ArrayIterator;
use ReturnTypeWillChange;
use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Utility\InputValidator;
/**
* Iterator for arrays requiring filtered values
*
* @package Requests\Utilities
*/
final class FilteredIterator extends ArrayIterator {
/**
* Callback to run as a filter
*
* @var callable
*/
private $callback;
/**
* Create a new iterator
*
* @param array $data The array or object to be iterated on.
* @param callable $callback Callback to be called on each value
*
* @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data argument is not iterable.
*/
public function __construct($data, $callback) {
if (InputValidator::is_iterable($data) === false) {
throw InvalidArgument::create(1, '$data', 'iterable', gettype($data));
}
parent::__construct($data);
if (is_callable($callback)) {
$this->callback = $callback;
}
}
/**
* Prevent unserialization of the object for security reasons.
*
* @phpcs:disable PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__unserializeFound
*
* @param array $data Restored array of data originally serialized.
*
* @return void
*/
#[ReturnTypeWillChange]
public function __unserialize($data) {}
// phpcs:enable
/**
* Perform reinitialization tasks.
*
* Prevents a callback from being injected during unserialization of an object.
*
* @return void
*/
public function __wakeup() {
unset($this->callback);
}
/**
* Get the current item's value after filtering
*
* @return string
*/
#[ReturnTypeWillChange]
public function current() {
$value = parent::current();
if (is_callable($this->callback)) {
$value = call_user_func($this->callback, $value);
}
return $value;
}
/**
* Prevent creating a PHP value from a stored representation of the object for security reasons.
*
* @param string $data The serialized string.
*
* @return void
*/
#[ReturnTypeWillChange]
public function unserialize($data) {}
}
Utility/InputValidator.php 0000644 00000004720 15153252210 0011664 0 ustar 00 <?php
/**
* Input validation utilities.
*
* @package Requests\Utilities
*/
namespace WpOrg\Requests\Utility;
use ArrayAccess;
use CurlHandle;
use Traversable;
/**
* Input validation utilities.
*
* @package Requests\Utilities
*/
final class InputValidator {
/**
* Verify that a received input parameter is of type string or is "stringable".
*
* @param mixed $input Input parameter to verify.
*
* @return bool
*/
public static function is_string_or_stringable($input) {
return is_string($input) || self::is_stringable_object($input);
}
/**
* Verify whether a received input parameter is usable as an integer array key.
*
* @param mixed $input Input parameter to verify.
*
* @return bool
*/
public static function is_numeric_array_key($input) {
if (is_int($input)) {
return true;
}
if (!is_string($input)) {
return false;
}
return (bool) preg_match('`^-?[0-9]+$`', $input);
}
/**
* Verify whether a received input parameter is "stringable".
*
* @param mixed $input Input parameter to verify.
*
* @return bool
*/
public static function is_stringable_object($input) {
return is_object($input) && method_exists($input, '__toString');
}
/**
* Verify whether a received input parameter is _accessible as if it were an array_.
*
* @param mixed $input Input parameter to verify.
*
* @return bool
*/
public static function has_array_access($input) {
return is_array($input) || $input instanceof ArrayAccess;
}
/**
* Verify whether a received input parameter is "iterable".
*
* @internal The PHP native `is_iterable()` function was only introduced in PHP 7.1
* and this library still supports PHP 5.6.
*
* @param mixed $input Input parameter to verify.
*
* @return bool
*/
public static function is_iterable($input) {
return is_array($input) || $input instanceof Traversable;
}
/**
* Verify whether a received input parameter is a Curl handle.
*
* The PHP Curl extension worked with resources prior to PHP 8.0 and with
* an instance of the `CurlHandle` class since PHP 8.0.
* {@link https://www.php.net/manual/en/migration80.incompatible.php#migration80.incompatible.resource2object}
*
* @param mixed $input Input parameter to verify.
*
* @return bool
*/
public static function is_curl_handle($input) {
if (is_resource($input)) {
return get_resource_type($input) === 'curl';
}
if (is_object($input)) {
return $input instanceof CurlHandle;
}
return false;
}
}
Compat.php 0000644 00000445302 15153427537 0006524 0 ustar 00 <?php
/**
* Libsodium compatibility layer
*
* This is the only class you should be interfacing with, as a user of
* sodium_compat.
*
* If the PHP extension for libsodium is installed, it will always use that
* instead of our implementations. You get better performance and stronger
* guarantees against side-channels that way.
*
* However, if your users don't have the PHP extension installed, we offer a
* compatible interface here. It will give you the correct results as if the
* PHP extension was installed. It won't be as fast, of course.
*
* CAUTION * CAUTION * CAUTION * CAUTION * CAUTION * CAUTION * CAUTION * CAUTION *
* *
* Until audited, this is probably not safe to use! DANGER WILL ROBINSON *
* *
* CAUTION * CAUTION * CAUTION * CAUTION * CAUTION * CAUTION * CAUTION * CAUTION *
*/
if (class_exists('ParagonIE_Sodium_Compat', false)) {
return;
}
class ParagonIE_Sodium_Compat
{
/**
* This parameter prevents the use of the PECL extension.
* It should only be used for unit testing.
*
* @var bool
*/
public static $disableFallbackForUnitTests = false;
/**
* Use fast multiplication rather than our constant-time multiplication
* implementation. Can be enabled at runtime. Only enable this if you
* are absolutely certain that there is no timing leak on your platform.
*
* @var bool
*/
public static $fastMult = false;
const LIBRARY_MAJOR_VERSION = 9;
const LIBRARY_MINOR_VERSION = 1;
const LIBRARY_VERSION_MAJOR = 9;
const LIBRARY_VERSION_MINOR = 1;
const VERSION_STRING = 'polyfill-1.0.8';
// From libsodium
const BASE64_VARIANT_ORIGINAL = 1;
const BASE64_VARIANT_ORIGINAL_NO_PADDING = 3;
const BASE64_VARIANT_URLSAFE = 5;
const BASE64_VARIANT_URLSAFE_NO_PADDING = 7;
const CRYPTO_AEAD_AES256GCM_KEYBYTES = 32;
const CRYPTO_AEAD_AES256GCM_NSECBYTES = 0;
const CRYPTO_AEAD_AES256GCM_NPUBBYTES = 12;
const CRYPTO_AEAD_AES256GCM_ABYTES = 16;
const CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES = 32;
const CRYPTO_AEAD_CHACHA20POLY1305_NSECBYTES = 0;
const CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES = 8;
const CRYPTO_AEAD_CHACHA20POLY1305_ABYTES = 16;
const CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES = 32;
const CRYPTO_AEAD_CHACHA20POLY1305_IETF_NSECBYTES = 0;
const CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES = 12;
const CRYPTO_AEAD_CHACHA20POLY1305_IETF_ABYTES = 16;
const CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES = 32;
const CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NSECBYTES = 0;
const CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES = 24;
const CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES = 16;
const CRYPTO_AUTH_BYTES = 32;
const CRYPTO_AUTH_KEYBYTES = 32;
const CRYPTO_BOX_SEALBYTES = 16;
const CRYPTO_BOX_SECRETKEYBYTES = 32;
const CRYPTO_BOX_PUBLICKEYBYTES = 32;
const CRYPTO_BOX_KEYPAIRBYTES = 64;
const CRYPTO_BOX_MACBYTES = 16;
const CRYPTO_BOX_NONCEBYTES = 24;
const CRYPTO_BOX_SEEDBYTES = 32;
const CRYPTO_CORE_RISTRETTO255_BYTES = 32;
const CRYPTO_CORE_RISTRETTO255_SCALARBYTES = 32;
const CRYPTO_CORE_RISTRETTO255_HASHBYTES = 64;
const CRYPTO_CORE_RISTRETTO255_NONREDUCEDSCALARBYTES = 64;
const CRYPTO_KDF_BYTES_MIN = 16;
const CRYPTO_KDF_BYTES_MAX = 64;
const CRYPTO_KDF_CONTEXTBYTES = 8;
const CRYPTO_KDF_KEYBYTES = 32;
const CRYPTO_KX_BYTES = 32;
const CRYPTO_KX_PRIMITIVE = 'x25519blake2b';
const CRYPTO_KX_SEEDBYTES = 32;
const CRYPTO_KX_KEYPAIRBYTES = 64;
const CRYPTO_KX_PUBLICKEYBYTES = 32;
const CRYPTO_KX_SECRETKEYBYTES = 32;
const CRYPTO_KX_SESSIONKEYBYTES = 32;
const CRYPTO_GENERICHASH_BYTES = 32;
const CRYPTO_GENERICHASH_BYTES_MIN = 16;
const CRYPTO_GENERICHASH_BYTES_MAX = 64;
const CRYPTO_GENERICHASH_KEYBYTES = 32;
const CRYPTO_GENERICHASH_KEYBYTES_MIN = 16;
const CRYPTO_GENERICHASH_KEYBYTES_MAX = 64;
const CRYPTO_PWHASH_SALTBYTES = 16;
const CRYPTO_PWHASH_STRPREFIX = '$argon2id$';
const CRYPTO_PWHASH_ALG_ARGON2I13 = 1;
const CRYPTO_PWHASH_ALG_ARGON2ID13 = 2;
const CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE = 33554432;
const CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE = 4;
const CRYPTO_PWHASH_MEMLIMIT_MODERATE = 134217728;
const CRYPTO_PWHASH_OPSLIMIT_MODERATE = 6;
const CRYPTO_PWHASH_MEMLIMIT_SENSITIVE = 536870912;
const CRYPTO_PWHASH_OPSLIMIT_SENSITIVE = 8;
const CRYPTO_PWHASH_SCRYPTSALSA208SHA256_SALTBYTES = 32;
const CRYPTO_PWHASH_SCRYPTSALSA208SHA256_STRPREFIX = '$7$';
const CRYPTO_PWHASH_SCRYPTSALSA208SHA256_OPSLIMIT_INTERACTIVE = 534288;
const CRYPTO_PWHASH_SCRYPTSALSA208SHA256_MEMLIMIT_INTERACTIVE = 16777216;
const CRYPTO_PWHASH_SCRYPTSALSA208SHA256_OPSLIMIT_SENSITIVE = 33554432;
const CRYPTO_PWHASH_SCRYPTSALSA208SHA256_MEMLIMIT_SENSITIVE = 1073741824;
const CRYPTO_SCALARMULT_BYTES = 32;
const CRYPTO_SCALARMULT_SCALARBYTES = 32;
const CRYPTO_SCALARMULT_RISTRETTO255_BYTES = 32;
const CRYPTO_SCALARMULT_RISTRETTO255_SCALARBYTES = 32;
const CRYPTO_SHORTHASH_BYTES = 8;
const CRYPTO_SHORTHASH_KEYBYTES = 16;
const CRYPTO_SECRETBOX_KEYBYTES = 32;
const CRYPTO_SECRETBOX_MACBYTES = 16;
const CRYPTO_SECRETBOX_NONCEBYTES = 24;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES = 17;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES = 24;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES = 32;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PUSH = 0;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PULL = 1;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY = 2;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL = 3;
const CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX = 0x3fffffff80;
const CRYPTO_SIGN_BYTES = 64;
const CRYPTO_SIGN_SEEDBYTES = 32;
const CRYPTO_SIGN_PUBLICKEYBYTES = 32;
const CRYPTO_SIGN_SECRETKEYBYTES = 64;
const CRYPTO_SIGN_KEYPAIRBYTES = 96;
const CRYPTO_STREAM_KEYBYTES = 32;
const CRYPTO_STREAM_NONCEBYTES = 24;
const CRYPTO_STREAM_XCHACHA20_KEYBYTES = 32;
const CRYPTO_STREAM_XCHACHA20_NONCEBYTES = 24;
/**
* Add two numbers (little-endian unsigned), storing the value in the first
* parameter.
*
* This mutates $val.
*
* @param string $val
* @param string $addv
* @return void
* @throws SodiumException
*/
public static function add(&$val, $addv)
{
$val_len = ParagonIE_Sodium_Core_Util::strlen($val);
$addv_len = ParagonIE_Sodium_Core_Util::strlen($addv);
if ($val_len !== $addv_len) {
throw new SodiumException('values must have the same length');
}
$A = ParagonIE_Sodium_Core_Util::stringToIntArray($val);
$B = ParagonIE_Sodium_Core_Util::stringToIntArray($addv);
$c = 0;
for ($i = 0; $i < $val_len; $i++) {
$c += ($A[$i] + $B[$i]);
$A[$i] = ($c & 0xff);
$c >>= 8;
}
$val = ParagonIE_Sodium_Core_Util::intArrayToString($A);
}
/**
* @param string $encoded
* @param int $variant
* @param string $ignore
* @return string
* @throws SodiumException
*/
public static function base642bin($encoded, $variant, $ignore = '')
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($encoded, 'string', 1);
/** @var string $encoded */
$encoded = (string) $encoded;
if (ParagonIE_Sodium_Core_Util::strlen($encoded) === 0) {
return '';
}
// Just strip before decoding
if (!empty($ignore)) {
$encoded = str_replace($ignore, '', $encoded);
}
try {
switch ($variant) {
case self::BASE64_VARIANT_ORIGINAL:
return ParagonIE_Sodium_Core_Base64_Original::decode($encoded, true);
case self::BASE64_VARIANT_ORIGINAL_NO_PADDING:
return ParagonIE_Sodium_Core_Base64_Original::decode($encoded, false);
case self::BASE64_VARIANT_URLSAFE:
return ParagonIE_Sodium_Core_Base64_UrlSafe::decode($encoded, true);
case self::BASE64_VARIANT_URLSAFE_NO_PADDING:
return ParagonIE_Sodium_Core_Base64_UrlSafe::decode($encoded, false);
default:
throw new SodiumException('invalid base64 variant identifier');
}
} catch (Exception $ex) {
if ($ex instanceof SodiumException) {
throw $ex;
}
throw new SodiumException('invalid base64 string');
}
}
/**
* @param string $decoded
* @param int $variant
* @return string
* @throws SodiumException
*/
public static function bin2base64($decoded, $variant)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($decoded, 'string', 1);
/** @var string $decoded */
$decoded = (string) $decoded;
if (ParagonIE_Sodium_Core_Util::strlen($decoded) === 0) {
return '';
}
switch ($variant) {
case self::BASE64_VARIANT_ORIGINAL:
return ParagonIE_Sodium_Core_Base64_Original::encode($decoded);
case self::BASE64_VARIANT_ORIGINAL_NO_PADDING:
return ParagonIE_Sodium_Core_Base64_Original::encodeUnpadded($decoded);
case self::BASE64_VARIANT_URLSAFE:
return ParagonIE_Sodium_Core_Base64_UrlSafe::encode($decoded);
case self::BASE64_VARIANT_URLSAFE_NO_PADDING:
return ParagonIE_Sodium_Core_Base64_UrlSafe::encodeUnpadded($decoded);
default:
throw new SodiumException('invalid base64 variant identifier');
}
}
/**
* Cache-timing-safe implementation of bin2hex().
*
* @param string $string A string (probably raw binary)
* @return string A hexadecimal-encoded string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function bin2hex($string)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($string, 'string', 1);
if (self::useNewSodiumAPI()) {
return (string) sodium_bin2hex($string);
}
if (self::use_fallback('bin2hex')) {
return (string) call_user_func('\\Sodium\\bin2hex', $string);
}
return ParagonIE_Sodium_Core_Util::bin2hex($string);
}
/**
* Compare two strings, in constant-time.
* Compared to memcmp(), compare() is more useful for sorting.
*
* @param string $left The left operand; must be a string
* @param string $right The right operand; must be a string
* @return int If < 0 if the left operand is less than the right
* If = 0 if both strings are equal
* If > 0 if the right operand is less than the left
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function compare($left, $right)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($left, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($right, 'string', 2);
if (self::useNewSodiumAPI()) {
return (int) sodium_compare($left, $right);
}
if (self::use_fallback('compare')) {
return (int) call_user_func('\\Sodium\\compare', $left, $right);
}
return ParagonIE_Sodium_Core_Util::compare($left, $right);
}
/**
* Is AES-256-GCM even available to use?
*
* @return bool
* @psalm-suppress UndefinedFunction
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_aead_aes256gcm_is_available()
{
if (self::useNewSodiumAPI()) {
return sodium_crypto_aead_aes256gcm_is_available();
}
if (self::use_fallback('crypto_aead_aes256gcm_is_available')) {
return call_user_func('\\Sodium\\crypto_aead_aes256gcm_is_available');
}
if (PHP_VERSION_ID < 70100) {
// OpenSSL doesn't support AEAD before 7.1.0
return false;
}
if (!is_callable('openssl_encrypt') || !is_callable('openssl_decrypt')) {
// OpenSSL isn't installed
return false;
}
return (bool) in_array('aes-256-gcm', openssl_get_cipher_methods());
}
/**
* Authenticated Encryption with Associated Data: Decryption
*
* Algorithm:
* AES-256-GCM
*
* This mode uses a 64-bit random nonce with a 64-bit counter.
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
*
* @param string $ciphertext Encrypted message (with Poly1305 MAC appended)
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
*
* @return string|bool The original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_aead_aes256gcm_decrypt(
$ciphertext = '',
$assocData = '',
$nonce = '',
$key = ''
) {
if (!self::crypto_aead_aes256gcm_is_available()) {
throw new SodiumException('AES-256-GCM is not available');
}
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_AES256GCM_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_AES256GCM_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_AES256GCM_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_AES256GCM_KEYBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($ciphertext) < self::CRYPTO_AEAD_AES256GCM_ABYTES) {
throw new SodiumException('Message must be at least CRYPTO_AEAD_AES256GCM_ABYTES long');
}
if (!is_callable('openssl_decrypt')) {
throw new SodiumException('The OpenSSL extension is not installed, or openssl_decrypt() is not available');
}
/** @var string $ctext */
$ctext = ParagonIE_Sodium_Core_Util::substr($ciphertext, 0, -self::CRYPTO_AEAD_AES256GCM_ABYTES);
/** @var string $authTag */
$authTag = ParagonIE_Sodium_Core_Util::substr($ciphertext, -self::CRYPTO_AEAD_AES256GCM_ABYTES, 16);
return openssl_decrypt(
$ctext,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$nonce,
$authTag,
$assocData
);
}
/**
* Authenticated Encryption with Associated Data: Encryption
*
* Algorithm:
* AES-256-GCM
*
* @param string $plaintext Message to be encrypted
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
*
* @return string Ciphertext with a 16-byte GCM message
* authentication code appended
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_aead_aes256gcm_encrypt(
$plaintext = '',
$assocData = '',
$nonce = '',
$key = ''
) {
if (!self::crypto_aead_aes256gcm_is_available()) {
throw new SodiumException('AES-256-GCM is not available');
}
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_AES256GCM_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_AES256GCM_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_AES256GCM_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_AES256GCM_KEYBYTES long');
}
if (!is_callable('openssl_encrypt')) {
throw new SodiumException('The OpenSSL extension is not installed, or openssl_encrypt() is not available');
}
$authTag = '';
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$nonce,
$authTag,
$assocData
);
return $ciphertext . $authTag;
}
/**
* Return a secure random key for use with the AES-256-GCM
* symmetric AEAD interface.
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_aead_aes256gcm_keygen()
{
return random_bytes(self::CRYPTO_AEAD_AES256GCM_KEYBYTES);
}
/**
* Authenticated Encryption with Associated Data: Decryption
*
* Algorithm:
* ChaCha20-Poly1305
*
* This mode uses a 64-bit random nonce with a 64-bit counter.
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
*
* @param string $ciphertext Encrypted message (with Poly1305 MAC appended)
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
*
* @return string The original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_aead_chacha20poly1305_decrypt(
$ciphertext = '',
$assocData = '',
$nonce = '',
$key = ''
) {
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($ciphertext) < self::CRYPTO_AEAD_CHACHA20POLY1305_ABYTES) {
throw new SodiumException('Message must be at least CRYPTO_AEAD_CHACHA20POLY1305_ABYTES long');
}
if (self::useNewSodiumAPI()) {
/**
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress FalsableReturnStatement
*/
return sodium_crypto_aead_chacha20poly1305_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
if (self::use_fallback('crypto_aead_chacha20poly1305_decrypt')) {
return call_user_func(
'\\Sodium\\crypto_aead_chacha20poly1305_decrypt',
$ciphertext,
$assocData,
$nonce,
$key
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::aead_chacha20poly1305_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
return ParagonIE_Sodium_Crypto::aead_chacha20poly1305_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
/**
* Authenticated Encryption with Associated Data
*
* Algorithm:
* ChaCha20-Poly1305
*
* This mode uses a 64-bit random nonce with a 64-bit counter.
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
*
* @param string $plaintext Message to be encrypted
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
*
* @return string Ciphertext with a 16-byte Poly1305 message
* authentication code appended
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_aead_chacha20poly1305_encrypt(
$plaintext = '',
$assocData = '',
$nonce = '',
$key = ''
) {
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES long');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_aead_chacha20poly1305_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
if (self::use_fallback('crypto_aead_chacha20poly1305_encrypt')) {
return (string) call_user_func(
'\\Sodium\\crypto_aead_chacha20poly1305_encrypt',
$plaintext,
$assocData,
$nonce,
$key
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::aead_chacha20poly1305_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
return ParagonIE_Sodium_Crypto::aead_chacha20poly1305_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
/**
* Authenticated Encryption with Associated Data: Decryption
*
* Algorithm:
* ChaCha20-Poly1305
*
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
* Regular mode uses a 64-bit random nonce with a 64-bit counter.
*
* @param string $ciphertext Encrypted message (with Poly1305 MAC appended)
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 12 bytes
* @param string $key Encryption key
*
* @return string The original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_aead_chacha20poly1305_ietf_decrypt(
$ciphertext = '',
$assocData = '',
$nonce = '',
$key = ''
) {
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($ciphertext) < self::CRYPTO_AEAD_CHACHA20POLY1305_ABYTES) {
throw new SodiumException('Message must be at least CRYPTO_AEAD_CHACHA20POLY1305_ABYTES long');
}
if (self::useNewSodiumAPI()) {
/**
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress FalsableReturnStatement
*/
return sodium_crypto_aead_chacha20poly1305_ietf_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
if (self::use_fallback('crypto_aead_chacha20poly1305_ietf_decrypt')) {
return call_user_func(
'\\Sodium\\crypto_aead_chacha20poly1305_ietf_decrypt',
$ciphertext,
$assocData,
$nonce,
$key
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::aead_chacha20poly1305_ietf_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
return ParagonIE_Sodium_Crypto::aead_chacha20poly1305_ietf_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
/**
* Return a secure random key for use with the ChaCha20-Poly1305
* symmetric AEAD interface.
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_aead_chacha20poly1305_keygen()
{
return random_bytes(self::CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES);
}
/**
* Authenticated Encryption with Associated Data
*
* Algorithm:
* ChaCha20-Poly1305
*
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
* Regular mode uses a 64-bit random nonce with a 64-bit counter.
*
* @param string $plaintext Message to be encrypted
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
*
* @return string Ciphertext with a 16-byte Poly1305 message
* authentication code appended
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_aead_chacha20poly1305_ietf_encrypt(
$plaintext = '',
$assocData = '',
$nonce = '',
$key = ''
) {
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
if (!is_null($assocData)) {
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
}
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES long');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_aead_chacha20poly1305_ietf_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
if (self::use_fallback('crypto_aead_chacha20poly1305_ietf_encrypt')) {
return (string) call_user_func(
'\\Sodium\\crypto_aead_chacha20poly1305_ietf_encrypt',
$plaintext,
$assocData,
$nonce,
$key
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::aead_chacha20poly1305_ietf_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
return ParagonIE_Sodium_Crypto::aead_chacha20poly1305_ietf_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
/**
* Return a secure random key for use with the ChaCha20-Poly1305
* symmetric AEAD interface. (IETF version)
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_aead_chacha20poly1305_ietf_keygen()
{
return random_bytes(self::CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES);
}
/**
* Authenticated Encryption with Associated Data: Decryption
*
* Algorithm:
* XChaCha20-Poly1305
*
* This mode uses a 64-bit random nonce with a 64-bit counter.
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
*
* @param string $ciphertext Encrypted message (with Poly1305 MAC appended)
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
* @param bool $dontFallback Don't fallback to ext/sodium
*
* @return string|bool The original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_aead_xchacha20poly1305_ietf_decrypt(
$ciphertext = '',
$assocData = '',
$nonce = '',
$key = '',
$dontFallback = false
) {
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
if (!is_null($assocData)) {
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
} else {
$assocData = '';
}
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($ciphertext) < self::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) {
throw new SodiumException('Message must be at least CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES long');
}
if (self::useNewSodiumAPI() && !$dontFallback) {
if (is_callable('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) {
return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::aead_xchacha20poly1305_ietf_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
return ParagonIE_Sodium_Crypto::aead_xchacha20poly1305_ietf_decrypt(
$ciphertext,
$assocData,
$nonce,
$key
);
}
/**
* Authenticated Encryption with Associated Data
*
* Algorithm:
* XChaCha20-Poly1305
*
* This mode uses a 64-bit random nonce with a 64-bit counter.
* IETF mode uses a 96-bit random nonce with a 32-bit counter.
*
* @param string $plaintext Message to be encrypted
* @param string $assocData Authenticated Associated Data (unencrypted)
* @param string $nonce Number to be used only Once; must be 8 bytes
* @param string $key Encryption key
* @param bool $dontFallback Don't fallback to ext/sodium
*
* @return string Ciphertext with a 16-byte Poly1305 message
* authentication code appended
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_aead_xchacha20poly1305_ietf_encrypt(
$plaintext = '',
$assocData = '',
$nonce = '',
$key = '',
$dontFallback = false
) {
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
if (!is_null($assocData)) {
ParagonIE_Sodium_Core_Util::declareScalarType($assocData, 'string', 2);
} else {
$assocData = '';
}
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
throw new SodiumException('Nonce must be CRYPTO_AEAD_XCHACHA20POLY1305_NPUBBYTES long');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new SodiumException('Key must be CRYPTO_AEAD_XCHACHA20POLY1305_KEYBYTES long');
}
if (self::useNewSodiumAPI() && !$dontFallback) {
if (is_callable('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')) {
return sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::aead_xchacha20poly1305_ietf_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
return ParagonIE_Sodium_Crypto::aead_xchacha20poly1305_ietf_encrypt(
$plaintext,
$assocData,
$nonce,
$key
);
}
/**
* Return a secure random key for use with the XChaCha20-Poly1305
* symmetric AEAD interface.
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_aead_xchacha20poly1305_ietf_keygen()
{
return random_bytes(self::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
}
/**
* Authenticate a message. Uses symmetric-key cryptography.
*
* Algorithm:
* HMAC-SHA512-256. Which is HMAC-SHA-512 truncated to 256 bits.
* Not to be confused with HMAC-SHA-512/256 which would use the
* SHA-512/256 hash function (uses different initial parameters
* but still truncates to 256 bits to sidestep length-extension
* attacks).
*
* @param string $message Message to be authenticated
* @param string $key Symmetric authentication key
* @return string Message authentication code
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_auth($message, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AUTH_KEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_AUTH_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_auth($message, $key);
}
if (self::use_fallback('crypto_auth')) {
return (string) call_user_func('\\Sodium\\crypto_auth', $message, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::auth($message, $key);
}
return ParagonIE_Sodium_Crypto::auth($message, $key);
}
/**
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_auth_keygen()
{
return random_bytes(self::CRYPTO_AUTH_KEYBYTES);
}
/**
* Verify the MAC of a message previously authenticated with crypto_auth.
*
* @param string $mac Message authentication code
* @param string $message Message whose authenticity you are attempting to
* verify (with a given MAC and key)
* @param string $key Symmetric authentication key
* @return bool TRUE if authenticated, FALSE otherwise
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_auth_verify($mac, $message, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($mac, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($mac) !== self::CRYPTO_AUTH_BYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_AUTH_BYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_AUTH_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_AUTH_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (bool) sodium_crypto_auth_verify($mac, $message, $key);
}
if (self::use_fallback('crypto_auth_verify')) {
return (bool) call_user_func('\\Sodium\\crypto_auth_verify', $mac, $message, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::auth_verify($mac, $message, $key);
}
return ParagonIE_Sodium_Crypto::auth_verify($mac, $message, $key);
}
/**
* Authenticated asymmetric-key encryption. Both the sender and recipient
* may decrypt messages.
*
* Algorithm: X25519-XSalsa20-Poly1305.
* X25519: Elliptic-Curve Diffie Hellman over Curve25519.
* XSalsa20: Extended-nonce variant of salsa20.
* Poyl1305: Polynomial MAC for one-time message authentication.
*
* @param string $plaintext The message to be encrypted
* @param string $nonce A Number to only be used Once; must be 24 bytes
* @param string $keypair Your secret key and your recipient's public key
* @return string Ciphertext with 16-byte Poly1305 MAC
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box($plaintext, $nonce, $keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_BOX_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_BOX_KEYPAIRBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_BOX_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box($plaintext, $nonce, $keypair);
}
if (self::use_fallback('crypto_box')) {
return (string) call_user_func('\\Sodium\\crypto_box', $plaintext, $nonce, $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box($plaintext, $nonce, $keypair);
}
return ParagonIE_Sodium_Crypto::box($plaintext, $nonce, $keypair);
}
/**
* Anonymous public-key encryption. Only the recipient may decrypt messages.
*
* Algorithm: X25519-XSalsa20-Poly1305, as with crypto_box.
* The sender's X25519 keypair is ephemeral.
* Nonce is generated from the BLAKE2b hash of both public keys.
*
* This provides ciphertext integrity.
*
* @param string $plaintext Message to be sealed
* @param string $publicKey Your recipient's public key
* @return string Sealed message that only your recipient can
* decrypt
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box_seal($plaintext, $publicKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($publicKey, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($publicKey) !== self::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_seal($plaintext, $publicKey);
}
if (self::use_fallback('crypto_box_seal')) {
return (string) call_user_func('\\Sodium\\crypto_box_seal', $plaintext, $publicKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_seal($plaintext, $publicKey);
}
return ParagonIE_Sodium_Crypto::box_seal($plaintext, $publicKey);
}
/**
* Opens a message encrypted with crypto_box_seal(). Requires
* the recipient's keypair (sk || pk) to decrypt successfully.
*
* This validates ciphertext integrity.
*
* @param string $ciphertext Sealed message to be opened
* @param string $keypair Your crypto_box keypair
* @return string The original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_box_seal_open($ciphertext, $keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_BOX_KEYPAIRBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
/**
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress FalsableReturnStatement
*/
return sodium_crypto_box_seal_open($ciphertext, $keypair);
}
if (self::use_fallback('crypto_box_seal_open')) {
return call_user_func('\\Sodium\\crypto_box_seal_open', $ciphertext, $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_seal_open($ciphertext, $keypair);
}
return ParagonIE_Sodium_Crypto::box_seal_open($ciphertext, $keypair);
}
/**
* Generate a new random X25519 keypair.
*
* @return string A 64-byte string; the first 32 are your secret key, while
* the last 32 are your public key. crypto_box_secretkey()
* and crypto_box_publickey() exist to separate them so you
* don't accidentally get them mixed up!
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box_keypair()
{
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_keypair();
}
if (self::use_fallback('crypto_box_keypair')) {
return (string) call_user_func('\\Sodium\\crypto_box_keypair');
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_keypair();
}
return ParagonIE_Sodium_Crypto::box_keypair();
}
/**
* Combine two keys into a keypair for use in library methods that expect
* a keypair. This doesn't necessarily have to be the same person's keys.
*
* @param string $secretKey Secret key
* @param string $publicKey Public key
* @return string Keypair
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box_keypair_from_secretkey_and_publickey($secretKey, $publicKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($publicKey, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_BOX_SECRETKEYBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_SECRETKEYBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($publicKey) !== self::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_keypair_from_secretkey_and_publickey($secretKey, $publicKey);
}
if (self::use_fallback('crypto_box_keypair_from_secretkey_and_publickey')) {
return (string) call_user_func('\\Sodium\\crypto_box_keypair_from_secretkey_and_publickey', $secretKey, $publicKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_keypair_from_secretkey_and_publickey($secretKey, $publicKey);
}
return ParagonIE_Sodium_Crypto::box_keypair_from_secretkey_and_publickey($secretKey, $publicKey);
}
/**
* Decrypt a message previously encrypted with crypto_box().
*
* @param string $ciphertext Encrypted message
* @param string $nonce Number to only be used Once; must be 24 bytes
* @param string $keypair Your secret key and the sender's public key
* @return string The original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_box_open($ciphertext, $nonce, $keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($ciphertext) < self::CRYPTO_BOX_MACBYTES) {
throw new SodiumException('Argument 1 must be at least CRYPTO_BOX_MACBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_BOX_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_BOX_KEYPAIRBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_BOX_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
/**
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress FalsableReturnStatement
*/
return sodium_crypto_box_open($ciphertext, $nonce, $keypair);
}
if (self::use_fallback('crypto_box_open')) {
return call_user_func('\\Sodium\\crypto_box_open', $ciphertext, $nonce, $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_open($ciphertext, $nonce, $keypair);
}
return ParagonIE_Sodium_Crypto::box_open($ciphertext, $nonce, $keypair);
}
/**
* Extract the public key from a crypto_box keypair.
*
* @param string $keypair Keypair containing secret and public key
* @return string Your crypto_box public key
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box_publickey($keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_BOX_KEYPAIRBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_publickey($keypair);
}
if (self::use_fallback('crypto_box_publickey')) {
return (string) call_user_func('\\Sodium\\crypto_box_publickey', $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_publickey($keypair);
}
return ParagonIE_Sodium_Crypto::box_publickey($keypair);
}
/**
* Calculate the X25519 public key from a given X25519 secret key.
*
* @param string $secretKey Any X25519 secret key
* @return string The corresponding X25519 public key
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box_publickey_from_secretkey($secretKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_BOX_SECRETKEYBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_SECRETKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_publickey_from_secretkey($secretKey);
}
if (self::use_fallback('crypto_box_publickey_from_secretkey')) {
return (string) call_user_func('\\Sodium\\crypto_box_publickey_from_secretkey', $secretKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_publickey_from_secretkey($secretKey);
}
return ParagonIE_Sodium_Crypto::box_publickey_from_secretkey($secretKey);
}
/**
* Extract the secret key from a crypto_box keypair.
*
* @param string $keypair
* @return string Your crypto_box secret key
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_box_secretkey($keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_BOX_KEYPAIRBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_secretkey($keypair);
}
if (self::use_fallback('crypto_box_secretkey')) {
return (string) call_user_func('\\Sodium\\crypto_box_secretkey', $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_secretkey($keypair);
}
return ParagonIE_Sodium_Crypto::box_secretkey($keypair);
}
/**
* Generate an X25519 keypair from a seed.
*
* @param string $seed
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress UndefinedFunction
*/
public static function crypto_box_seed_keypair($seed)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($seed, 'string', 1);
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_box_seed_keypair($seed);
}
if (self::use_fallback('crypto_box_seed_keypair')) {
return (string) call_user_func('\\Sodium\\crypto_box_seed_keypair', $seed);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::box_seed_keypair($seed);
}
return ParagonIE_Sodium_Crypto::box_seed_keypair($seed);
}
/**
* Calculates a BLAKE2b hash, with an optional key.
*
* @param string $message The message to be hashed
* @param string|null $key If specified, must be a string between 16
* and 64 bytes long
* @param int $length Output length in bytes; must be between 16
* and 64 (default = 32)
* @return string Raw binary
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_generichash($message, $key = '', $length = self::CRYPTO_GENERICHASH_BYTES)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
if (is_null($key)) {
$key = '';
}
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($length, 'int', 3);
/* Input validation: */
if (!empty($key)) {
if (ParagonIE_Sodium_Core_Util::strlen($key) < self::CRYPTO_GENERICHASH_KEYBYTES_MIN) {
throw new SodiumException('Unsupported key size. Must be at least CRYPTO_GENERICHASH_KEYBYTES_MIN bytes long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) > self::CRYPTO_GENERICHASH_KEYBYTES_MAX) {
throw new SodiumException('Unsupported key size. Must be at most CRYPTO_GENERICHASH_KEYBYTES_MAX bytes long.');
}
}
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_generichash($message, $key, $length);
}
if (self::use_fallback('crypto_generichash')) {
return (string) call_user_func('\\Sodium\\crypto_generichash', $message, $key, $length);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::generichash($message, $key, $length);
}
return ParagonIE_Sodium_Crypto::generichash($message, $key, $length);
}
/**
* Get the final BLAKE2b hash output for a given context.
*
* @param string $ctx BLAKE2 hashing context. Generated by crypto_generichash_init().
* @param int $length Hash output size.
* @return string Final BLAKE2b hash.
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress ReferenceConstraintViolation
* @psalm-suppress ConflictingReferenceConstraint
*/
public static function crypto_generichash_final(&$ctx, $length = self::CRYPTO_GENERICHASH_BYTES)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ctx, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($length, 'int', 2);
if (self::useNewSodiumAPI()) {
return sodium_crypto_generichash_final($ctx, $length);
}
if (self::use_fallback('crypto_generichash_final')) {
$func = '\\Sodium\\crypto_generichash_final';
return (string) $func($ctx, $length);
}
if ($length < 1) {
try {
self::memzero($ctx);
} catch (SodiumException $ex) {
unset($ctx);
}
return '';
}
if (PHP_INT_SIZE === 4) {
$result = ParagonIE_Sodium_Crypto32::generichash_final($ctx, $length);
} else {
$result = ParagonIE_Sodium_Crypto::generichash_final($ctx, $length);
}
try {
self::memzero($ctx);
} catch (SodiumException $ex) {
unset($ctx);
}
return $result;
}
/**
* Initialize a BLAKE2b hashing context, for use in a streaming interface.
*
* @param string|null $key If specified must be a string between 16 and 64 bytes
* @param int $length The size of the desired hash output
* @return string A BLAKE2 hashing context, encoded as a string
* (To be 100% compatible with ext/libsodium)
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_generichash_init($key = '', $length = self::CRYPTO_GENERICHASH_BYTES)
{
/* Type checks: */
if (is_null($key)) {
$key = '';
}
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($length, 'int', 2);
/* Input validation: */
if (!empty($key)) {
if (ParagonIE_Sodium_Core_Util::strlen($key) < self::CRYPTO_GENERICHASH_KEYBYTES_MIN) {
throw new SodiumException('Unsupported key size. Must be at least CRYPTO_GENERICHASH_KEYBYTES_MIN bytes long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) > self::CRYPTO_GENERICHASH_KEYBYTES_MAX) {
throw new SodiumException('Unsupported key size. Must be at most CRYPTO_GENERICHASH_KEYBYTES_MAX bytes long.');
}
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_generichash_init($key, $length);
}
if (self::use_fallback('crypto_generichash_init')) {
return (string) call_user_func('\\Sodium\\crypto_generichash_init', $key, $length);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::generichash_init($key, $length);
}
return ParagonIE_Sodium_Crypto::generichash_init($key, $length);
}
/**
* Initialize a BLAKE2b hashing context, for use in a streaming interface.
*
* @param string|null $key If specified must be a string between 16 and 64 bytes
* @param int $length The size of the desired hash output
* @param string $salt Salt (up to 16 bytes)
* @param string $personal Personalization string (up to 16 bytes)
* @return string A BLAKE2 hashing context, encoded as a string
* (To be 100% compatible with ext/libsodium)
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_generichash_init_salt_personal(
$key = '',
$length = self::CRYPTO_GENERICHASH_BYTES,
$salt = '',
$personal = ''
) {
/* Type checks: */
if (is_null($key)) {
$key = '';
}
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($length, 'int', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($salt, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($personal, 'string', 4);
$salt = str_pad($salt, 16, "\0", STR_PAD_RIGHT);
$personal = str_pad($personal, 16, "\0", STR_PAD_RIGHT);
/* Input validation: */
if (!empty($key)) {
/*
if (ParagonIE_Sodium_Core_Util::strlen($key) < self::CRYPTO_GENERICHASH_KEYBYTES_MIN) {
throw new SodiumException('Unsupported key size. Must be at least CRYPTO_GENERICHASH_KEYBYTES_MIN bytes long.');
}
*/
if (ParagonIE_Sodium_Core_Util::strlen($key) > self::CRYPTO_GENERICHASH_KEYBYTES_MAX) {
throw new SodiumException('Unsupported key size. Must be at most CRYPTO_GENERICHASH_KEYBYTES_MAX bytes long.');
}
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::generichash_init_salt_personal($key, $length, $salt, $personal);
}
return ParagonIE_Sodium_Crypto::generichash_init_salt_personal($key, $length, $salt, $personal);
}
/**
* Update a BLAKE2b hashing context with additional data.
*
* @param string $ctx BLAKE2 hashing context. Generated by crypto_generichash_init().
* $ctx is passed by reference and gets updated in-place.
* @param-out string $ctx
* @param string $message The message to append to the existing hash state.
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress ReferenceConstraintViolation
*/
public static function crypto_generichash_update(&$ctx, $message)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ctx, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 2);
if (self::useNewSodiumAPI()) {
sodium_crypto_generichash_update($ctx, $message);
return;
}
if (self::use_fallback('crypto_generichash_update')) {
$func = '\\Sodium\\crypto_generichash_update';
$func($ctx, $message);
return;
}
if (PHP_INT_SIZE === 4) {
$ctx = ParagonIE_Sodium_Crypto32::generichash_update($ctx, $message);
} else {
$ctx = ParagonIE_Sodium_Crypto::generichash_update($ctx, $message);
}
}
/**
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_generichash_keygen()
{
return random_bytes(self::CRYPTO_GENERICHASH_KEYBYTES);
}
/**
* @param int $subkey_len
* @param int $subkey_id
* @param string $context
* @param string $key
* @return string
* @throws SodiumException
*/
public static function crypto_kdf_derive_from_key(
$subkey_len,
$subkey_id,
$context,
$key
) {
ParagonIE_Sodium_Core_Util::declareScalarType($subkey_len, 'int', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($subkey_id, 'int', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($context, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
$subkey_id = (int) $subkey_id;
$subkey_len = (int) $subkey_len;
$context = (string) $context;
$key = (string) $key;
if ($subkey_len < self::CRYPTO_KDF_BYTES_MIN) {
throw new SodiumException('subkey cannot be smaller than SODIUM_CRYPTO_KDF_BYTES_MIN');
}
if ($subkey_len > self::CRYPTO_KDF_BYTES_MAX) {
throw new SodiumException('subkey cannot be larger than SODIUM_CRYPTO_KDF_BYTES_MAX');
}
if ($subkey_id < 0) {
throw new SodiumException('subkey_id cannot be negative');
}
if (ParagonIE_Sodium_Core_Util::strlen($context) !== self::CRYPTO_KDF_CONTEXTBYTES) {
throw new SodiumException('context should be SODIUM_CRYPTO_KDF_CONTEXTBYTES bytes');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_KDF_KEYBYTES) {
throw new SodiumException('key should be SODIUM_CRYPTO_KDF_KEYBYTES bytes');
}
$salt = ParagonIE_Sodium_Core_Util::store64_le($subkey_id);
$state = self::crypto_generichash_init_salt_personal(
$key,
$subkey_len,
$salt,
$context
);
return self::crypto_generichash_final($state, $subkey_len);
}
/**
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_kdf_keygen()
{
return random_bytes(self::CRYPTO_KDF_KEYBYTES);
}
/**
* Perform a key exchange, between a designated client and a server.
*
* Typically, you would designate one machine to be the client and the
* other to be the server. The first two keys are what you'd expect for
* scalarmult() below, but the latter two public keys don't swap places.
*
* | ALICE | BOB |
* | Client | Server |
* |--------------------------------|-------------------------------------|
* | shared = crypto_kx( | shared = crypto_kx( |
* | alice_sk, | bob_sk, | <- contextual
* | bob_pk, | alice_pk, | <- contextual
* | alice_pk, | alice_pk, | <----- static
* | bob_pk | bob_pk | <----- static
* | ) | ) |
*
* They are used along with the scalarmult product to generate a 256-bit
* BLAKE2b hash unique to the client and server keys.
*
* @param string $my_secret
* @param string $their_public
* @param string $client_public
* @param string $server_public
* @param bool $dontFallback
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_kx($my_secret, $their_public, $client_public, $server_public, $dontFallback = false)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($my_secret, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($their_public, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($client_public, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($server_public, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($my_secret) !== self::CRYPTO_BOX_SECRETKEYBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_SECRETKEYBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($their_public) !== self::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_PUBLICKEYBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($client_public) !== self::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_BOX_PUBLICKEYBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($server_public) !== self::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new SodiumException('Argument 4 must be CRYPTO_BOX_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI() && !$dontFallback) {
if (is_callable('sodium_crypto_kx')) {
return (string) sodium_crypto_kx(
$my_secret,
$their_public,
$client_public,
$server_public
);
}
}
if (self::use_fallback('crypto_kx')) {
return (string) call_user_func(
'\\Sodium\\crypto_kx',
$my_secret,
$their_public,
$client_public,
$server_public
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::keyExchange(
$my_secret,
$their_public,
$client_public,
$server_public
);
}
return ParagonIE_Sodium_Crypto::keyExchange(
$my_secret,
$their_public,
$client_public,
$server_public
);
}
/**
* @param string $seed
* @return string
* @throws SodiumException
*/
public static function crypto_kx_seed_keypair($seed)
{
ParagonIE_Sodium_Core_Util::declareScalarType($seed, 'string', 1);
$seed = (string) $seed;
if (ParagonIE_Sodium_Core_Util::strlen($seed) !== self::CRYPTO_KX_SEEDBYTES) {
throw new SodiumException('seed must be SODIUM_CRYPTO_KX_SEEDBYTES bytes');
}
$sk = self::crypto_generichash($seed, '', self::CRYPTO_KX_SECRETKEYBYTES);
$pk = self::crypto_scalarmult_base($sk);
return $sk . $pk;
}
/**
* @return string
* @throws Exception
*/
public static function crypto_kx_keypair()
{
$sk = self::randombytes_buf(self::CRYPTO_KX_SECRETKEYBYTES);
$pk = self::crypto_scalarmult_base($sk);
return $sk . $pk;
}
/**
* @param string $keypair
* @param string $serverPublicKey
* @return array{0: string, 1: string}
* @throws SodiumException
*/
public static function crypto_kx_client_session_keys($keypair, $serverPublicKey)
{
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($serverPublicKey, 'string', 2);
$keypair = (string) $keypair;
$serverPublicKey = (string) $serverPublicKey;
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_KX_KEYPAIRBYTES) {
throw new SodiumException('keypair should be SODIUM_CRYPTO_KX_KEYPAIRBYTES bytes');
}
if (ParagonIE_Sodium_Core_Util::strlen($serverPublicKey) !== self::CRYPTO_KX_PUBLICKEYBYTES) {
throw new SodiumException('public keys must be SODIUM_CRYPTO_KX_PUBLICKEYBYTES bytes');
}
$sk = self::crypto_kx_secretkey($keypair);
$pk = self::crypto_kx_publickey($keypair);
$h = self::crypto_generichash_init(null, self::CRYPTO_KX_SESSIONKEYBYTES * 2);
self::crypto_generichash_update($h, self::crypto_scalarmult($sk, $serverPublicKey));
self::crypto_generichash_update($h, $pk);
self::crypto_generichash_update($h, $serverPublicKey);
$sessionKeys = self::crypto_generichash_final($h, self::CRYPTO_KX_SESSIONKEYBYTES * 2);
return array(
ParagonIE_Sodium_Core_Util::substr(
$sessionKeys,
0,
self::CRYPTO_KX_SESSIONKEYBYTES
),
ParagonIE_Sodium_Core_Util::substr(
$sessionKeys,
self::CRYPTO_KX_SESSIONKEYBYTES,
self::CRYPTO_KX_SESSIONKEYBYTES
)
);
}
/**
* @param string $keypair
* @param string $clientPublicKey
* @return array{0: string, 1: string}
* @throws SodiumException
*/
public static function crypto_kx_server_session_keys($keypair, $clientPublicKey)
{
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($clientPublicKey, 'string', 2);
$keypair = (string) $keypair;
$clientPublicKey = (string) $clientPublicKey;
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_KX_KEYPAIRBYTES) {
throw new SodiumException('keypair should be SODIUM_CRYPTO_KX_KEYPAIRBYTES bytes');
}
if (ParagonIE_Sodium_Core_Util::strlen($clientPublicKey) !== self::CRYPTO_KX_PUBLICKEYBYTES) {
throw new SodiumException('public keys must be SODIUM_CRYPTO_KX_PUBLICKEYBYTES bytes');
}
$sk = self::crypto_kx_secretkey($keypair);
$pk = self::crypto_kx_publickey($keypair);
$h = self::crypto_generichash_init(null, self::CRYPTO_KX_SESSIONKEYBYTES * 2);
self::crypto_generichash_update($h, self::crypto_scalarmult($sk, $clientPublicKey));
self::crypto_generichash_update($h, $clientPublicKey);
self::crypto_generichash_update($h, $pk);
$sessionKeys = self::crypto_generichash_final($h, self::CRYPTO_KX_SESSIONKEYBYTES * 2);
return array(
ParagonIE_Sodium_Core_Util::substr(
$sessionKeys,
self::CRYPTO_KX_SESSIONKEYBYTES,
self::CRYPTO_KX_SESSIONKEYBYTES
),
ParagonIE_Sodium_Core_Util::substr(
$sessionKeys,
0,
self::CRYPTO_KX_SESSIONKEYBYTES
)
);
}
/**
* @param string $kp
* @return string
* @throws SodiumException
*/
public static function crypto_kx_secretkey($kp)
{
return ParagonIE_Sodium_Core_Util::substr(
$kp,
0,
self::CRYPTO_KX_SECRETKEYBYTES
);
}
/**
* @param string $kp
* @return string
* @throws SodiumException
*/
public static function crypto_kx_publickey($kp)
{
return ParagonIE_Sodium_Core_Util::substr(
$kp,
self::CRYPTO_KX_SECRETKEYBYTES,
self::CRYPTO_KX_PUBLICKEYBYTES
);
}
/**
* @param int $outlen
* @param string $passwd
* @param string $salt
* @param int $opslimit
* @param int $memlimit
* @param int|null $alg
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_pwhash($outlen, $passwd, $salt, $opslimit, $memlimit, $alg = null)
{
ParagonIE_Sodium_Core_Util::declareScalarType($outlen, 'int', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($passwd, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($salt, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($opslimit, 'int', 4);
ParagonIE_Sodium_Core_Util::declareScalarType($memlimit, 'int', 5);
if (self::useNewSodiumAPI()) {
if (!is_null($alg)) {
ParagonIE_Sodium_Core_Util::declareScalarType($alg, 'int', 6);
return sodium_crypto_pwhash($outlen, $passwd, $salt, $opslimit, $memlimit, $alg);
}
return sodium_crypto_pwhash($outlen, $passwd, $salt, $opslimit, $memlimit);
}
if (self::use_fallback('crypto_pwhash')) {
return (string) call_user_func('\\Sodium\\crypto_pwhash', $outlen, $passwd, $salt, $opslimit, $memlimit);
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented, as it is not possible to implement Argon2i with acceptable performance in pure-PHP'
);
}
/**
* !Exclusive to sodium_compat!
*
* This returns TRUE if the native crypto_pwhash API is available by libsodium.
* This returns FALSE if only sodium_compat is available.
*
* @return bool
*/
public static function crypto_pwhash_is_available()
{
if (self::useNewSodiumAPI()) {
return true;
}
if (self::use_fallback('crypto_pwhash')) {
return true;
}
return false;
}
/**
* @param string $passwd
* @param int $opslimit
* @param int $memlimit
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_pwhash_str($passwd, $opslimit, $memlimit)
{
ParagonIE_Sodium_Core_Util::declareScalarType($passwd, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($opslimit, 'int', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($memlimit, 'int', 3);
if (self::useNewSodiumAPI()) {
return sodium_crypto_pwhash_str($passwd, $opslimit, $memlimit);
}
if (self::use_fallback('crypto_pwhash_str')) {
return (string) call_user_func('\\Sodium\\crypto_pwhash_str', $passwd, $opslimit, $memlimit);
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented, as it is not possible to implement Argon2i with acceptable performance in pure-PHP'
);
}
/**
* Do we need to rehash this password?
*
* @param string $hash
* @param int $opslimit
* @param int $memlimit
* @return bool
* @throws SodiumException
*/
public static function crypto_pwhash_str_needs_rehash($hash, $opslimit, $memlimit)
{
ParagonIE_Sodium_Core_Util::declareScalarType($hash, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($opslimit, 'int', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($memlimit, 'int', 3);
// Just grab the first 4 pieces.
$pieces = explode('$', (string) $hash);
$prefix = implode('$', array_slice($pieces, 0, 4));
// Rebuild the expected header.
/** @var int $ops */
$ops = (int) $opslimit;
/** @var int $mem */
$mem = (int) $memlimit >> 10;
$encoded = self::CRYPTO_PWHASH_STRPREFIX . 'v=19$m=' . $mem . ',t=' . $ops . ',p=1';
// Do they match? If so, we don't need to rehash, so return false.
return !ParagonIE_Sodium_Core_Util::hashEquals($encoded, $prefix);
}
/**
* @param string $passwd
* @param string $hash
* @return bool
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_pwhash_str_verify($passwd, $hash)
{
ParagonIE_Sodium_Core_Util::declareScalarType($passwd, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($hash, 'string', 2);
if (self::useNewSodiumAPI()) {
return (bool) sodium_crypto_pwhash_str_verify($passwd, $hash);
}
if (self::use_fallback('crypto_pwhash_str_verify')) {
return (bool) call_user_func('\\Sodium\\crypto_pwhash_str_verify', $passwd, $hash);
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented, as it is not possible to implement Argon2i with acceptable performance in pure-PHP'
);
}
/**
* @param int $outlen
* @param string $passwd
* @param string $salt
* @param int $opslimit
* @param int $memlimit
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_pwhash_scryptsalsa208sha256($outlen, $passwd, $salt, $opslimit, $memlimit)
{
ParagonIE_Sodium_Core_Util::declareScalarType($outlen, 'int', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($passwd, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($salt, 'string', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($opslimit, 'int', 4);
ParagonIE_Sodium_Core_Util::declareScalarType($memlimit, 'int', 5);
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_pwhash_scryptsalsa208sha256(
(int) $outlen,
(string) $passwd,
(string) $salt,
(int) $opslimit,
(int) $memlimit
);
}
if (self::use_fallback('crypto_pwhash_scryptsalsa208sha256')) {
return (string) call_user_func(
'\\Sodium\\crypto_pwhash_scryptsalsa208sha256',
(int) $outlen,
(string) $passwd,
(string) $salt,
(int) $opslimit,
(int) $memlimit
);
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented, as it is not possible to implement Scrypt with acceptable performance in pure-PHP'
);
}
/**
* !Exclusive to sodium_compat!
*
* This returns TRUE if the native crypto_pwhash API is available by libsodium.
* This returns FALSE if only sodium_compat is available.
*
* @return bool
*/
public static function crypto_pwhash_scryptsalsa208sha256_is_available()
{
if (self::useNewSodiumAPI()) {
return true;
}
if (self::use_fallback('crypto_pwhash_scryptsalsa208sha256')) {
return true;
}
return false;
}
/**
* @param string $passwd
* @param int $opslimit
* @param int $memlimit
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_pwhash_scryptsalsa208sha256_str($passwd, $opslimit, $memlimit)
{
ParagonIE_Sodium_Core_Util::declareScalarType($passwd, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($opslimit, 'int', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($memlimit, 'int', 3);
if (self::useNewSodiumAPI()) {
return (string) sodium_crypto_pwhash_scryptsalsa208sha256_str(
(string) $passwd,
(int) $opslimit,
(int) $memlimit
);
}
if (self::use_fallback('crypto_pwhash_scryptsalsa208sha256_str')) {
return (string) call_user_func(
'\\Sodium\\crypto_pwhash_scryptsalsa208sha256_str',
(string) $passwd,
(int) $opslimit,
(int) $memlimit
);
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented, as it is not possible to implement Scrypt with acceptable performance in pure-PHP'
);
}
/**
* @param string $passwd
* @param string $hash
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_pwhash_scryptsalsa208sha256_str_verify($passwd, $hash)
{
ParagonIE_Sodium_Core_Util::declareScalarType($passwd, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($hash, 'string', 2);
if (self::useNewSodiumAPI()) {
return (bool) sodium_crypto_pwhash_scryptsalsa208sha256_str_verify(
(string) $passwd,
(string) $hash
);
}
if (self::use_fallback('crypto_pwhash_scryptsalsa208sha256_str_verify')) {
return (bool) call_user_func(
'\\Sodium\\crypto_pwhash_scryptsalsa208sha256_str_verify',
(string) $passwd,
(string) $hash
);
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented, as it is not possible to implement Scrypt with acceptable performance in pure-PHP'
);
}
/**
* Calculate the shared secret between your secret key and your
* recipient's public key.
*
* Algorithm: X25519 (ECDH over Curve25519)
*
* @param string $secretKey
* @param string $publicKey
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_scalarmult($secretKey, $publicKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($publicKey, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_BOX_SECRETKEYBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_SECRETKEYBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($publicKey) !== self::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_BOX_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_scalarmult($secretKey, $publicKey);
}
if (self::use_fallback('crypto_scalarmult')) {
return (string) call_user_func('\\Sodium\\crypto_scalarmult', $secretKey, $publicKey);
}
/* Output validation: Forbid all-zero keys */
if (ParagonIE_Sodium_Core_Util::hashEquals($secretKey, str_repeat("\0", self::CRYPTO_BOX_SECRETKEYBYTES))) {
throw new SodiumException('Zero secret key is not allowed');
}
if (ParagonIE_Sodium_Core_Util::hashEquals($publicKey, str_repeat("\0", self::CRYPTO_BOX_PUBLICKEYBYTES))) {
throw new SodiumException('Zero public key is not allowed');
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::scalarmult($secretKey, $publicKey);
}
return ParagonIE_Sodium_Crypto::scalarmult($secretKey, $publicKey);
}
/**
* Calculate an X25519 public key from an X25519 secret key.
*
* @param string $secretKey
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress TooFewArguments
* @psalm-suppress MixedArgument
*/
public static function crypto_scalarmult_base($secretKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_BOX_SECRETKEYBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_BOX_SECRETKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_scalarmult_base($secretKey);
}
if (self::use_fallback('crypto_scalarmult_base')) {
return (string) call_user_func('\\Sodium\\crypto_scalarmult_base', $secretKey);
}
if (ParagonIE_Sodium_Core_Util::hashEquals($secretKey, str_repeat("\0", self::CRYPTO_BOX_SECRETKEYBYTES))) {
throw new SodiumException('Zero secret key is not allowed');
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::scalarmult_base($secretKey);
}
return ParagonIE_Sodium_Crypto::scalarmult_base($secretKey);
}
/**
* Authenticated symmetric-key encryption.
*
* Algorithm: XSalsa20-Poly1305
*
* @param string $plaintext The message you're encrypting
* @param string $nonce A Number to be used Once; must be 24 bytes
* @param string $key Symmetric encryption key
* @return string Ciphertext with Poly1305 MAC
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_secretbox($plaintext, $nonce, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_SECRETBOX_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_SECRETBOX_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_secretbox($plaintext, $nonce, $key);
}
if (self::use_fallback('crypto_secretbox')) {
return (string) call_user_func('\\Sodium\\crypto_secretbox', $plaintext, $nonce, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretbox($plaintext, $nonce, $key);
}
return ParagonIE_Sodium_Crypto::secretbox($plaintext, $nonce, $key);
}
/**
* Decrypts a message previously encrypted with crypto_secretbox().
*
* @param string $ciphertext Ciphertext with Poly1305 MAC
* @param string $nonce A Number to be used Once; must be 24 bytes
* @param string $key Symmetric encryption key
* @return string Original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_secretbox_open($ciphertext, $nonce, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_SECRETBOX_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_SECRETBOX_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
/**
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress FalsableReturnStatement
*/
return sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
}
if (self::use_fallback('crypto_secretbox_open')) {
return call_user_func('\\Sodium\\crypto_secretbox_open', $ciphertext, $nonce, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretbox_open($ciphertext, $nonce, $key);
}
return ParagonIE_Sodium_Crypto::secretbox_open($ciphertext, $nonce, $key);
}
/**
* Return a secure random key for use with crypto_secretbox
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_secretbox_keygen()
{
return random_bytes(self::CRYPTO_SECRETBOX_KEYBYTES);
}
/**
* Authenticated symmetric-key encryption.
*
* Algorithm: XChaCha20-Poly1305
*
* @param string $plaintext The message you're encrypting
* @param string $nonce A Number to be used Once; must be 24 bytes
* @param string $key Symmetric encryption key
* @return string Ciphertext with Poly1305 MAC
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_secretbox_xchacha20poly1305($plaintext, $nonce, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($plaintext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_SECRETBOX_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_SECRETBOX_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_KEYBYTES long.');
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretbox_xchacha20poly1305($plaintext, $nonce, $key);
}
return ParagonIE_Sodium_Crypto::secretbox_xchacha20poly1305($plaintext, $nonce, $key);
}
/**
* Decrypts a message previously encrypted with crypto_secretbox_xchacha20poly1305().
*
* @param string $ciphertext Ciphertext with Poly1305 MAC
* @param string $nonce A Number to be used Once; must be 24 bytes
* @param string $key Symmetric encryption key
* @return string Original plaintext message
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_secretbox_xchacha20poly1305_open($ciphertext, $nonce, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($ciphertext, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_SECRETBOX_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_SECRETBOX_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_KEYBYTES long.');
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretbox_xchacha20poly1305_open($ciphertext, $nonce, $key);
}
return ParagonIE_Sodium_Crypto::secretbox_xchacha20poly1305_open($ciphertext, $nonce, $key);
}
/**
* @param string $key
* @return array<int, string> Returns a state and a header.
* @throws Exception
* @throws SodiumException
*/
public static function crypto_secretstream_xchacha20poly1305_init_push($key)
{
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretstream_xchacha20poly1305_init_push($key);
}
return ParagonIE_Sodium_Crypto::secretstream_xchacha20poly1305_init_push($key);
}
/**
* @param string $header
* @param string $key
* @return string Returns a state.
* @throws Exception
*/
public static function crypto_secretstream_xchacha20poly1305_init_pull($header, $key)
{
if (ParagonIE_Sodium_Core_Util::strlen($header) < self::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES) {
throw new SodiumException(
'header size should be SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES bytes'
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretstream_xchacha20poly1305_init_pull($key, $header);
}
return ParagonIE_Sodium_Crypto::secretstream_xchacha20poly1305_init_pull($key, $header);
}
/**
* @param string $state
* @param string $msg
* @param string $aad
* @param int $tag
* @return string
* @throws SodiumException
*/
public static function crypto_secretstream_xchacha20poly1305_push(&$state, $msg, $aad = '', $tag = 0)
{
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretstream_xchacha20poly1305_push(
$state,
$msg,
$aad,
$tag
);
}
return ParagonIE_Sodium_Crypto::secretstream_xchacha20poly1305_push(
$state,
$msg,
$aad,
$tag
);
}
/**
* @param string $state
* @param string $msg
* @param string $aad
* @return bool|array{0: string, 1: int}
* @throws SodiumException
*/
public static function crypto_secretstream_xchacha20poly1305_pull(&$state, $msg, $aad = '')
{
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::secretstream_xchacha20poly1305_pull(
$state,
$msg,
$aad
);
}
return ParagonIE_Sodium_Crypto::secretstream_xchacha20poly1305_pull(
$state,
$msg,
$aad
);
}
/**
* @return string
* @throws Exception
*/
public static function crypto_secretstream_xchacha20poly1305_keygen()
{
return random_bytes(self::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES);
}
/**
* @param string $state
* @return void
* @throws SodiumException
*/
public static function crypto_secretstream_xchacha20poly1305_rekey(&$state)
{
if (PHP_INT_SIZE === 4) {
ParagonIE_Sodium_Crypto32::secretstream_xchacha20poly1305_rekey($state);
} else {
ParagonIE_Sodium_Crypto::secretstream_xchacha20poly1305_rekey($state);
}
}
/**
* Calculates a SipHash-2-4 hash of a message for a given key.
*
* @param string $message Input message
* @param string $key SipHash-2-4 key
* @return string Hash
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_shorthash($message, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_SHORTHASH_KEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SHORTHASH_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_shorthash($message, $key);
}
if (self::use_fallback('crypto_shorthash')) {
return (string) call_user_func('\\Sodium\\crypto_shorthash', $message, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_SipHash::sipHash24($message, $key);
}
return ParagonIE_Sodium_Core_SipHash::sipHash24($message, $key);
}
/**
* Return a secure random key for use with crypto_shorthash
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_shorthash_keygen()
{
return random_bytes(self::CRYPTO_SHORTHASH_KEYBYTES);
}
/**
* Returns a signed message. You probably want crypto_sign_detached()
* instead, which only returns the signature.
*
* Algorithm: Ed25519 (EdDSA over Curve25519)
*
* @param string $message Message to be signed.
* @param string $secretKey Secret signing key.
* @return string Signed message (signature is prefixed).
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_sign($message, $secretKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_SIGN_SECRETKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SIGN_SECRETKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign($message, $secretKey);
}
if (self::use_fallback('crypto_sign')) {
return (string) call_user_func('\\Sodium\\crypto_sign', $message, $secretKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::sign($message, $secretKey);
}
return ParagonIE_Sodium_Crypto::sign($message, $secretKey);
}
/**
* Validates a signed message then returns the message.
*
* @param string $signedMessage A signed message
* @param string $publicKey A public key
* @return string The original message (if the signature is
* valid for this public key)
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress MixedReturnStatement
*/
public static function crypto_sign_open($signedMessage, $publicKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($signedMessage, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($publicKey, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($signedMessage) < self::CRYPTO_SIGN_BYTES) {
throw new SodiumException('Argument 1 must be at least CRYPTO_SIGN_BYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($publicKey) !== self::CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SIGN_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
/**
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress FalsableReturnStatement
*/
return sodium_crypto_sign_open($signedMessage, $publicKey);
}
if (self::use_fallback('crypto_sign_open')) {
return call_user_func('\\Sodium\\crypto_sign_open', $signedMessage, $publicKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::sign_open($signedMessage, $publicKey);
}
return ParagonIE_Sodium_Crypto::sign_open($signedMessage, $publicKey);
}
/**
* Generate a new random Ed25519 keypair.
*
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_sign_keypair()
{
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_keypair();
}
if (self::use_fallback('crypto_sign_keypair')) {
return (string) call_user_func('\\Sodium\\crypto_sign_keypair');
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_Ed25519::keypair();
}
return ParagonIE_Sodium_Core_Ed25519::keypair();
}
/**
* @param string $sk
* @param string $pk
* @return string
* @throws SodiumException
*/
public static function crypto_sign_keypair_from_secretkey_and_publickey($sk, $pk)
{
ParagonIE_Sodium_Core_Util::declareScalarType($sk, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($pk, 'string', 1);
$sk = (string) $sk;
$pk = (string) $pk;
if (ParagonIE_Sodium_Core_Util::strlen($sk) !== self::CRYPTO_SIGN_SECRETKEYBYTES) {
throw new SodiumException('secretkey should be SODIUM_CRYPTO_SIGN_SECRETKEYBYTES bytes');
}
if (ParagonIE_Sodium_Core_Util::strlen($pk) !== self::CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new SodiumException('publickey should be SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES bytes');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_keypair_from_secretkey_and_publickey($sk, $pk);
}
return $sk . $pk;
}
/**
* Generate an Ed25519 keypair from a seed.
*
* @param string $seed Input seed
* @return string Keypair
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_seed_keypair($seed)
{
ParagonIE_Sodium_Core_Util::declareScalarType($seed, 'string', 1);
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_seed_keypair($seed);
}
if (self::use_fallback('crypto_sign_keypair')) {
return (string) call_user_func('\\Sodium\\crypto_sign_seed_keypair', $seed);
}
$publicKey = '';
$secretKey = '';
if (PHP_INT_SIZE === 4) {
ParagonIE_Sodium_Core32_Ed25519::seed_keypair($publicKey, $secretKey, $seed);
} else {
ParagonIE_Sodium_Core_Ed25519::seed_keypair($publicKey, $secretKey, $seed);
}
return $secretKey . $publicKey;
}
/**
* Extract an Ed25519 public key from an Ed25519 keypair.
*
* @param string $keypair Keypair
* @return string Public key
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_publickey($keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_SIGN_KEYPAIRBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_SIGN_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_publickey($keypair);
}
if (self::use_fallback('crypto_sign_publickey')) {
return (string) call_user_func('\\Sodium\\crypto_sign_publickey', $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_Ed25519::publickey($keypair);
}
return ParagonIE_Sodium_Core_Ed25519::publickey($keypair);
}
/**
* Calculate an Ed25519 public key from an Ed25519 secret key.
*
* @param string $secretKey Your Ed25519 secret key
* @return string The corresponding Ed25519 public key
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_publickey_from_secretkey($secretKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_SIGN_SECRETKEYBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_SIGN_SECRETKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_publickey_from_secretkey($secretKey);
}
if (self::use_fallback('crypto_sign_publickey_from_secretkey')) {
return (string) call_user_func('\\Sodium\\crypto_sign_publickey_from_secretkey', $secretKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_Ed25519::publickey_from_secretkey($secretKey);
}
return ParagonIE_Sodium_Core_Ed25519::publickey_from_secretkey($secretKey);
}
/**
* Extract an Ed25519 secret key from an Ed25519 keypair.
*
* @param string $keypair Keypair
* @return string Secret key
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_secretkey($keypair)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($keypair, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== self::CRYPTO_SIGN_KEYPAIRBYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_SIGN_KEYPAIRBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_secretkey($keypair);
}
if (self::use_fallback('crypto_sign_secretkey')) {
return (string) call_user_func('\\Sodium\\crypto_sign_secretkey', $keypair);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_Ed25519::secretkey($keypair);
}
return ParagonIE_Sodium_Core_Ed25519::secretkey($keypair);
}
/**
* Calculate the Ed25519 signature of a message and return ONLY the signature.
*
* Algorithm: Ed25519 (EdDSA over Curve25519)
*
* @param string $message Message to be signed
* @param string $secretKey Secret signing key
* @return string Digital signature
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_detached($message, $secretKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($secretKey, 'string', 2);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($secretKey) !== self::CRYPTO_SIGN_SECRETKEYBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SIGN_SECRETKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_detached($message, $secretKey);
}
if (self::use_fallback('crypto_sign_detached')) {
return (string) call_user_func('\\Sodium\\crypto_sign_detached', $message, $secretKey);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::sign_detached($message, $secretKey);
}
return ParagonIE_Sodium_Crypto::sign_detached($message, $secretKey);
}
/**
* Verify the Ed25519 signature of a message.
*
* @param string $signature Digital sginature
* @param string $message Message to be verified
* @param string $publicKey Public key
* @return bool TRUE if this signature is good for this public key;
* FALSE otherwise
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_verify_detached($signature, $message, $publicKey)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($signature, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($publicKey, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($signature) !== self::CRYPTO_SIGN_BYTES) {
throw new SodiumException('Argument 1 must be CRYPTO_SIGN_BYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($publicKey) !== self::CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SIGN_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_sign_verify_detached($signature, $message, $publicKey);
}
if (self::use_fallback('crypto_sign_verify_detached')) {
return (bool) call_user_func(
'\\Sodium\\crypto_sign_verify_detached',
$signature,
$message,
$publicKey
);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Crypto32::sign_verify_detached($signature, $message, $publicKey);
}
return ParagonIE_Sodium_Crypto::sign_verify_detached($signature, $message, $publicKey);
}
/**
* Convert an Ed25519 public key to a Curve25519 public key
*
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_ed25519_pk_to_curve25519($pk)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($pk, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($pk) < self::CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new SodiumException('Argument 1 must be at least CRYPTO_SIGN_PUBLICKEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
if (is_callable('crypto_sign_ed25519_pk_to_curve25519')) {
return (string) sodium_crypto_sign_ed25519_pk_to_curve25519($pk);
}
}
if (self::use_fallback('crypto_sign_ed25519_pk_to_curve25519')) {
return (string) call_user_func('\\Sodium\\crypto_sign_ed25519_pk_to_curve25519', $pk);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_Ed25519::pk_to_curve25519($pk);
}
return ParagonIE_Sodium_Core_Ed25519::pk_to_curve25519($pk);
}
/**
* Convert an Ed25519 secret key to a Curve25519 secret key
*
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_sign_ed25519_sk_to_curve25519($sk)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($sk, 'string', 1);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($sk) < self::CRYPTO_SIGN_SEEDBYTES) {
throw new SodiumException('Argument 1 must be at least CRYPTO_SIGN_SEEDBYTES long.');
}
if (self::useNewSodiumAPI()) {
if (is_callable('crypto_sign_ed25519_sk_to_curve25519')) {
return sodium_crypto_sign_ed25519_sk_to_curve25519($sk);
}
}
if (self::use_fallback('crypto_sign_ed25519_sk_to_curve25519')) {
return (string) call_user_func('\\Sodium\\crypto_sign_ed25519_sk_to_curve25519', $sk);
}
$h = hash('sha512', ParagonIE_Sodium_Core_Util::substr($sk, 0, 32), true);
$h[0] = ParagonIE_Sodium_Core_Util::intToChr(
ParagonIE_Sodium_Core_Util::chrToInt($h[0]) & 248
);
$h[31] = ParagonIE_Sodium_Core_Util::intToChr(
(ParagonIE_Sodium_Core_Util::chrToInt($h[31]) & 127) | 64
);
return ParagonIE_Sodium_Core_Util::substr($h, 0, 32);
}
/**
* Expand a key and nonce into a keystream of pseudorandom bytes.
*
* @param int $len Number of bytes desired
* @param string $nonce Number to be used Once; must be 24 bytes
* @param string $key XSalsa20 key
* @return string Pseudorandom stream that can be XORed with messages
* to provide encryption (but not authentication; see
* Poly1305 or crypto_auth() for that, which is not
* optional for security)
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_stream($len, $nonce, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($len, 'int', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_STREAM_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_STREAM_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_STREAM_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_stream($len, $nonce, $key);
}
if (self::use_fallback('crypto_stream')) {
return (string) call_user_func('\\Sodium\\crypto_stream', $len, $nonce, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_XSalsa20::xsalsa20($len, $nonce, $key);
}
return ParagonIE_Sodium_Core_XSalsa20::xsalsa20($len, $nonce, $key);
}
/**
* DANGER! UNAUTHENTICATED ENCRYPTION!
*
* Unless you are following expert advice, do not use this feature.
*
* Algorithm: XSalsa20
*
* This DOES NOT provide ciphertext integrity.
*
* @param string $message Plaintext message
* @param string $nonce Number to be used Once; must be 24 bytes
* @param string $key Encryption key
* @return string Encrypted text which is vulnerable to chosen-
* ciphertext attacks unless you implement some
* other mitigation to the ciphertext (i.e.
* Encrypt then MAC)
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_stream_xor($message, $nonce, $key)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_STREAM_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_STREAM_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_KEYBYTES long.');
}
if (self::useNewSodiumAPI()) {
return sodium_crypto_stream_xor($message, $nonce, $key);
}
if (self::use_fallback('crypto_stream_xor')) {
return (string) call_user_func('\\Sodium\\crypto_stream_xor', $message, $nonce, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_XSalsa20::xsalsa20_xor($message, $nonce, $key);
}
return ParagonIE_Sodium_Core_XSalsa20::xsalsa20_xor($message, $nonce, $key);
}
/**
* Return a secure random key for use with crypto_stream
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_stream_keygen()
{
return random_bytes(self::CRYPTO_STREAM_KEYBYTES);
}
/**
* Expand a key and nonce into a keystream of pseudorandom bytes.
*
* @param int $len Number of bytes desired
* @param string $nonce Number to be used Once; must be 24 bytes
* @param string $key XChaCha20 key
* @param bool $dontFallback
* @return string Pseudorandom stream that can be XORed with messages
* to provide encryption (but not authentication; see
* Poly1305 or crypto_auth() for that, which is not
* optional for security)
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_stream_xchacha20($len, $nonce, $key, $dontFallback = false)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($len, 'int', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_STREAM_XCHACHA20_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_XCHACHA20_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_STREAM_XCHACHA20_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_STREAM_XCHACHA20_KEYBYTES long.');
}
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_stream_xchacha20($len, $nonce, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_XChaCha20::stream($len, $nonce, $key);
}
return ParagonIE_Sodium_Core_XChaCha20::stream($len, $nonce, $key);
}
/**
* DANGER! UNAUTHENTICATED ENCRYPTION!
*
* Unless you are following expert advice, do not use this feature.
*
* Algorithm: XChaCha20
*
* This DOES NOT provide ciphertext integrity.
*
* @param string $message Plaintext message
* @param string $nonce Number to be used Once; must be 24 bytes
* @param string $key Encryption key
* @return string Encrypted text which is vulnerable to chosen-
* ciphertext attacks unless you implement some
* other mitigation to the ciphertext (i.e.
* Encrypt then MAC)
* @param bool $dontFallback
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_stream_xchacha20_xor($message, $nonce, $key, $dontFallback = false)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 3);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_STREAM_XCHACHA20_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_XCHACHA20_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_STREAM_XCHACHA20_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_XCHACHA20_KEYBYTES long.');
}
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_stream_xchacha20_xor($message, $nonce, $key);
}
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_XChaCha20::streamXorIc($message, $nonce, $key);
}
return ParagonIE_Sodium_Core_XChaCha20::streamXorIc($message, $nonce, $key);
}
/**
* DANGER! UNAUTHENTICATED ENCRYPTION!
*
* Unless you are following expert advice, do not use this feature.
*
* Algorithm: XChaCha20
*
* This DOES NOT provide ciphertext integrity.
*
* @param string $message Plaintext message
* @param string $nonce Number to be used Once; must be 24 bytes
* @param int $counter
* @param string $key Encryption key
* @return string Encrypted text which is vulnerable to chosen-
* ciphertext attacks unless you implement some
* other mitigation to the ciphertext (i.e.
* Encrypt then MAC)
* @param bool $dontFallback
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function crypto_stream_xchacha20_xor_ic($message, $nonce, $counter, $key, $dontFallback = false)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($message, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($nonce, 'string', 2);
ParagonIE_Sodium_Core_Util::declareScalarType($counter, 'int', 3);
ParagonIE_Sodium_Core_Util::declareScalarType($key, 'string', 4);
/* Input validation: */
if (ParagonIE_Sodium_Core_Util::strlen($nonce) !== self::CRYPTO_STREAM_XCHACHA20_NONCEBYTES) {
throw new SodiumException('Argument 2 must be CRYPTO_SECRETBOX_XCHACHA20_NONCEBYTES long.');
}
if (ParagonIE_Sodium_Core_Util::strlen($key) !== self::CRYPTO_STREAM_XCHACHA20_KEYBYTES) {
throw new SodiumException('Argument 3 must be CRYPTO_SECRETBOX_XCHACHA20_KEYBYTES long.');
}
if (is_callable('sodium_crypto_stream_xchacha20_xor_ic') && !$dontFallback) {
return sodium_crypto_stream_xchacha20_xor_ic($message, $nonce, $counter, $key);
}
$ic = ParagonIE_Sodium_Core_Util::store64_le($counter);
if (PHP_INT_SIZE === 4) {
return ParagonIE_Sodium_Core32_XChaCha20::streamXorIc($message, $nonce, $key, $ic);
}
return ParagonIE_Sodium_Core_XChaCha20::streamXorIc($message, $nonce, $key, $ic);
}
/**
* Return a secure random key for use with crypto_stream_xchacha20
*
* @return string
* @throws Exception
* @throws Error
*/
public static function crypto_stream_xchacha20_keygen()
{
return random_bytes(self::CRYPTO_STREAM_XCHACHA20_KEYBYTES);
}
/**
* Cache-timing-safe implementation of hex2bin().
*
* @param string $string Hexadecimal string
* @param string $ignore List of characters to ignore; useful for whitespace
* @return string Raw binary string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress TooFewArguments
* @psalm-suppress MixedArgument
*/
public static function hex2bin($string, $ignore = '')
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($string, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($ignore, 'string', 2);
if (self::useNewSodiumAPI()) {
if (is_callable('sodium_hex2bin')) {
return (string) sodium_hex2bin($string, $ignore);
}
}
if (self::use_fallback('hex2bin')) {
return (string) call_user_func('\\Sodium\\hex2bin', $string, $ignore);
}
return ParagonIE_Sodium_Core_Util::hex2bin($string, $ignore);
}
/**
* Increase a string (little endian)
*
* @param string $var
*
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function increment(&$var)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($var, 'string', 1);
if (self::useNewSodiumAPI()) {
sodium_increment($var);
return;
}
if (self::use_fallback('increment')) {
$func = '\\Sodium\\increment';
$func($var);
return;
}
$len = ParagonIE_Sodium_Core_Util::strlen($var);
$c = 1;
$copy = '';
for ($i = 0; $i < $len; ++$i) {
$c += ParagonIE_Sodium_Core_Util::chrToInt(
ParagonIE_Sodium_Core_Util::substr($var, $i, 1)
);
$copy .= ParagonIE_Sodium_Core_Util::intToChr($c);
$c >>= 8;
}
$var = $copy;
}
/**
* @param string $str
* @return bool
*
* @throws SodiumException
*/
public static function is_zero($str)
{
$d = 0;
for ($i = 0; $i < 32; ++$i) {
$d |= ParagonIE_Sodium_Core_Util::chrToInt($str[$i]);
}
return ((($d - 1) >> 31) & 1) === 1;
}
/**
* The equivalent to the libsodium minor version we aim to be compatible
* with (sans pwhash and memzero).
*
* @return int
*/
public static function library_version_major()
{
if (self::useNewSodiumAPI() && defined('SODIUM_LIBRARY_MAJOR_VERSION')) {
return SODIUM_LIBRARY_MAJOR_VERSION;
}
if (self::use_fallback('library_version_major')) {
/** @psalm-suppress UndefinedFunction */
return (int) call_user_func('\\Sodium\\library_version_major');
}
return self::LIBRARY_VERSION_MAJOR;
}
/**
* The equivalent to the libsodium minor version we aim to be compatible
* with (sans pwhash and memzero).
*
* @return int
*/
public static function library_version_minor()
{
if (self::useNewSodiumAPI() && defined('SODIUM_LIBRARY_MINOR_VERSION')) {
return SODIUM_LIBRARY_MINOR_VERSION;
}
if (self::use_fallback('library_version_minor')) {
/** @psalm-suppress UndefinedFunction */
return (int) call_user_func('\\Sodium\\library_version_minor');
}
return self::LIBRARY_VERSION_MINOR;
}
/**
* Compare two strings.
*
* @param string $left
* @param string $right
* @return int
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
*/
public static function memcmp($left, $right)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($left, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($right, 'string', 2);
if (self::useNewSodiumAPI()) {
return sodium_memcmp($left, $right);
}
if (self::use_fallback('memcmp')) {
return (int) call_user_func('\\Sodium\\memcmp', $left, $right);
}
/** @var string $left */
/** @var string $right */
return ParagonIE_Sodium_Core_Util::memcmp($left, $right);
}
/**
* It's actually not possible to zero memory buffers in PHP. You need the
* native library for that.
*
* @param string|null $var
* @param-out string|null $var
*
* @return void
* @throws SodiumException (Unless libsodium is installed)
* @throws TypeError
* @psalm-suppress TooFewArguments
*/
public static function memzero(&$var)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($var, 'string', 1);
if (self::useNewSodiumAPI()) {
/** @psalm-suppress MixedArgument */
sodium_memzero($var);
return;
}
if (self::use_fallback('memzero')) {
$func = '\\Sodium\\memzero';
$func($var);
if ($var === null) {
return;
}
}
// This is the best we can do.
throw new SodiumException(
'This is not implemented in sodium_compat, as it is not possible to securely wipe memory from PHP. ' .
'To fix this error, make sure libsodium is installed and the PHP extension is enabled.'
);
}
/**
* @param string $unpadded
* @param int $blockSize
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function pad($unpadded, $blockSize, $dontFallback = false)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($unpadded, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($blockSize, 'int', 2);
$unpadded = (string) $unpadded;
$blockSize = (int) $blockSize;
if (self::useNewSodiumAPI() && !$dontFallback) {
return (string) sodium_pad($unpadded, $blockSize);
}
if ($blockSize <= 0) {
throw new SodiumException(
'block size cannot be less than 1'
);
}
$unpadded_len = ParagonIE_Sodium_Core_Util::strlen($unpadded);
$xpadlen = ($blockSize - 1);
if (($blockSize & ($blockSize - 1)) === 0) {
$xpadlen -= $unpadded_len & ($blockSize - 1);
} else {
$xpadlen -= $unpadded_len % $blockSize;
}
$xpadded_len = $unpadded_len + $xpadlen;
$padded = str_repeat("\0", $xpadded_len - 1);
if ($unpadded_len > 0) {
$st = 1;
$i = 0;
$k = $unpadded_len;
for ($j = 0; $j <= $xpadded_len; ++$j) {
$i = (int) $i;
$k = (int) $k;
$st = (int) $st;
if ($j >= $unpadded_len) {
$padded[$j] = "\0";
} else {
$padded[$j] = $unpadded[$j];
}
/** @var int $k */
$k -= $st;
$st = (int) (~(
(
(
($k >> 48)
|
($k >> 32)
|
($k >> 16)
|
$k
) - 1
) >> 16
)
) & 1;
$i += $st;
}
}
$mask = 0;
$tail = $xpadded_len;
for ($i = 0; $i < $blockSize; ++$i) {
# barrier_mask = (unsigned char)
# (((i ^ xpadlen) - 1U) >> ((sizeof(size_t) - 1U) * CHAR_BIT));
$barrier_mask = (($i ^ $xpadlen) -1) >> ((PHP_INT_SIZE << 3) - 1);
# tail[-i] = (tail[-i] & mask) | (0x80 & barrier_mask);
$padded[$tail - $i] = ParagonIE_Sodium_Core_Util::intToChr(
(ParagonIE_Sodium_Core_Util::chrToInt($padded[$tail - $i]) & $mask)
|
(0x80 & $barrier_mask)
);
# mask |= barrier_mask;
$mask |= $barrier_mask;
}
return $padded;
}
/**
* @param string $padded
* @param int $blockSize
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function unpad($padded, $blockSize, $dontFallback = false)
{
/* Type checks: */
ParagonIE_Sodium_Core_Util::declareScalarType($padded, 'string', 1);
ParagonIE_Sodium_Core_Util::declareScalarType($blockSize, 'int', 2);
$padded = (string) $padded;
$blockSize = (int) $blockSize;
if (self::useNewSodiumAPI() && !$dontFallback) {
return (string) sodium_unpad($padded, $blockSize);
}
if ($blockSize <= 0) {
throw new SodiumException('block size cannot be less than 1');
}
$padded_len = ParagonIE_Sodium_Core_Util::strlen($padded);
if ($padded_len < $blockSize) {
throw new SodiumException('invalid padding');
}
# tail = &padded[padded_len - 1U];
$tail = $padded_len - 1;
$acc = 0;
$valid = 0;
$pad_len = 0;
$found = 0;
for ($i = 0; $i < $blockSize; ++$i) {
# c = tail[-i];
$c = ParagonIE_Sodium_Core_Util::chrToInt($padded[$tail - $i]);
# is_barrier =
# (( (acc - 1U) & (pad_len - 1U) & ((c ^ 0x80) - 1U) ) >> 8) & 1U;
$is_barrier = (
(
($acc - 1) & ($pad_len - 1) & (($c ^ 80) - 1)
) >> 7
) & 1;
$is_barrier &= ~$found;
$found |= $is_barrier;
# acc |= c;
$acc |= $c;
# pad_len |= i & (1U + ~is_barrier);
$pad_len |= $i & (1 + ~$is_barrier);
# valid |= (unsigned char) is_barrier;
$valid |= ($is_barrier & 0xff);
}
# unpadded_len = padded_len - 1U - pad_len;
$unpadded_len = $padded_len - 1 - $pad_len;
if ($valid !== 1) {
throw new SodiumException('invalid padding');
}
return ParagonIE_Sodium_Core_Util::substr($padded, 0, $unpadded_len);
}
/**
* Will sodium_compat run fast on the current hardware and PHP configuration?
*
* @return bool
*/
public static function polyfill_is_fast()
{
if (extension_loaded('sodium')) {
return true;
}
if (extension_loaded('libsodium')) {
return true;
}
return PHP_INT_SIZE === 8;
}
/**
* Generate a string of bytes from the kernel's CSPRNG.
* Proudly uses /dev/urandom (if getrandom(2) is not available).
*
* @param int $numBytes
* @return string
* @throws Exception
* @throws TypeError
*/
public static function randombytes_buf($numBytes)
{
/* Type checks: */
if (!is_int($numBytes)) {
if (is_numeric($numBytes)) {
$numBytes = (int) $numBytes;
} else {
throw new TypeError(
'Argument 1 must be an integer, ' . gettype($numBytes) . ' given.'
);
}
}
/** @var positive-int $numBytes */
if (self::use_fallback('randombytes_buf')) {
return (string) call_user_func('\\Sodium\\randombytes_buf', $numBytes);
}
if ($numBytes < 0) {
throw new SodiumException("Number of bytes must be a positive integer");
}
return random_bytes($numBytes);
}
/**
* Generate an integer between 0 and $range (non-inclusive).
*
* @param int $range
* @return int
* @throws Exception
* @throws Error
* @throws TypeError
*/
public static function randombytes_uniform($range)
{
/* Type checks: */
if (!is_int($range)) {
if (is_numeric($range)) {
$range = (int) $range;
} else {
throw new TypeError(
'Argument 1 must be an integer, ' . gettype($range) . ' given.'
);
}
}
if (self::use_fallback('randombytes_uniform')) {
return (int) call_user_func('\\Sodium\\randombytes_uniform', $range);
}
return random_int(0, $range - 1);
}
/**
* Generate a random 16-bit integer.
*
* @return int
* @throws Exception
* @throws Error
* @throws TypeError
*/
public static function randombytes_random16()
{
if (self::use_fallback('randombytes_random16')) {
return (int) call_user_func('\\Sodium\\randombytes_random16');
}
return random_int(0, 65535);
}
/**
* @param string $p
* @param bool $dontFallback
* @return bool
* @throws SodiumException
*/
public static function ristretto255_is_valid_point($p, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_is_valid_point($p);
}
try {
$r = ParagonIE_Sodium_Core_Ristretto255::ristretto255_frombytes($p);
return $r['res'] === 0 &&
ParagonIE_Sodium_Core_Ristretto255::ristretto255_point_is_canonical($p) === 1;
} catch (SodiumException $ex) {
if ($ex->getMessage() === 'S is not canonical') {
return false;
}
throw $ex;
}
}
/**
* @param string $p
* @param string $q
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_add($p, $q, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_add($p, $q);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_add($p, $q);
}
/**
* @param string $p
* @param string $q
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_sub($p, $q, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_sub($p, $q);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_sub($p, $q);
}
/**
* @param string $r
* @param bool $dontFallback
* @return string
*
* @throws SodiumException
*/
public static function ristretto255_from_hash($r, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_from_hash($r);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_from_hash($r);
}
/**
* @param bool $dontFallback
* @return string
*
* @throws SodiumException
*/
public static function ristretto255_random($dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_random();
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_random();
}
/**
* @param bool $dontFallback
* @return string
*
* @throws SodiumException
*/
public static function ristretto255_scalar_random($dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_random();
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_random();
}
/**
* @param string $s
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_invert($s, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_invert($s);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_invert($s);
}
/**
* @param string $s
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_negate($s, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_negate($s);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_negate($s);
}
/**
* @param string $s
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_complement($s, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_complement($s);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_complement($s);
}
/**
* @param string $x
* @param string $y
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_add($x, $y, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_add($x, $y);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_add($x, $y);
}
/**
* @param string $x
* @param string $y
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_sub($x, $y, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_sub($x, $y);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_sub($x, $y);
}
/**
* @param string $x
* @param string $y
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_mul($x, $y, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_mul($x, $y);
}
return ParagonIE_Sodium_Core_Ristretto255::ristretto255_scalar_mul($x, $y);
}
/**
* @param string $n
* @param string $p
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function scalarmult_ristretto255($n, $p, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_scalarmult_ristretto255($n, $p);
}
return ParagonIE_Sodium_Core_Ristretto255::scalarmult_ristretto255($n, $p);
}
/**
* @param string $n
* @param string $p
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function scalarmult_ristretto255_base($n, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_scalarmult_ristretto255_base($n);
}
return ParagonIE_Sodium_Core_Ristretto255::scalarmult_ristretto255_base($n);
}
/**
* @param string $s
* @param bool $dontFallback
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_reduce($s, $dontFallback = false)
{
if (self::useNewSodiumAPI() && !$dontFallback) {
return sodium_crypto_core_ristretto255_scalar_reduce($s);
}
return ParagonIE_Sodium_Core_Ristretto255::sc_reduce($s);
}
/**
* Runtime testing method for 32-bit platforms.
*
* Usage: If runtime_speed_test() returns FALSE, then our 32-bit
* implementation is to slow to use safely without risking timeouts.
* If this happens, install sodium from PECL to get acceptable
* performance.
*
* @param int $iterations Number of multiplications to attempt
* @param int $maxTimeout Milliseconds
* @return bool TRUE if we're fast enough, FALSE is not
* @throws SodiumException
*/
public static function runtime_speed_test($iterations, $maxTimeout)
{
if (self::polyfill_is_fast()) {
return true;
}
/** @var float $end */
$end = 0.0;
/** @var float $start */
$start = microtime(true);
/** @var ParagonIE_Sodium_Core32_Int64 $a */
$a = ParagonIE_Sodium_Core32_Int64::fromInt(random_int(3, 1 << 16));
for ($i = 0; $i < $iterations; ++$i) {
/** @var ParagonIE_Sodium_Core32_Int64 $b */
$b = ParagonIE_Sodium_Core32_Int64::fromInt(random_int(3, 1 << 16));
$a->mulInt64($b);
}
/** @var float $end */
$end = microtime(true);
/** @var int $diff */
$diff = (int) ceil(($end - $start) * 1000);
return $diff < $maxTimeout;
}
/**
* Add two numbers (little-endian unsigned), storing the value in the first
* parameter.
*
* This mutates $val.
*
* @param string $val
* @param string $addv
* @return void
* @throws SodiumException
*/
public static function sub(&$val, $addv)
{
$val_len = ParagonIE_Sodium_Core_Util::strlen($val);
$addv_len = ParagonIE_Sodium_Core_Util::strlen($addv);
if ($val_len !== $addv_len) {
throw new SodiumException('values must have the same length');
}
$A = ParagonIE_Sodium_Core_Util::stringToIntArray($val);
$B = ParagonIE_Sodium_Core_Util::stringToIntArray($addv);
$c = 0;
for ($i = 0; $i < $val_len; $i++) {
$c = ($A[$i] - $B[$i] - $c);
$A[$i] = ($c & 0xff);
$c = ($c >> 8) & 1;
}
$val = ParagonIE_Sodium_Core_Util::intArrayToString($A);
}
/**
* This emulates libsodium's version_string() function, except ours is
* prefixed with 'polyfill-'.
*
* @return string
* @psalm-suppress MixedInferredReturnType
* @psalm-suppress UndefinedFunction
*/
public static function version_string()
{
if (self::useNewSodiumAPI()) {
return (string) sodium_version_string();
}
if (self::use_fallback('version_string')) {
return (string) call_user_func('\\Sodium\\version_string');
}
return (string) self::VERSION_STRING;
}
/**
* Should we use the libsodium core function instead?
* This is always a good idea, if it's available. (Unless we're in the
* middle of running our unit test suite.)
*
* If ext/libsodium is available, use it. Return TRUE.
* Otherwise, we have to use the code provided herein. Return FALSE.
*
* @param string $sodium_func_name
*
* @return bool
*/
protected static function use_fallback($sodium_func_name = '')
{
static $res = null;
if ($res === null) {
$res = extension_loaded('libsodium') && PHP_VERSION_ID >= 50300;
}
if ($res === false) {
// No libsodium installed
return false;
}
if (self::$disableFallbackForUnitTests) {
// Don't fallback. Use the PHP implementation.
return false;
}
if (!empty($sodium_func_name)) {
return is_callable('\\Sodium\\' . $sodium_func_name);
}
return true;
}
/**
* Libsodium as implemented in PHP 7.2
* and/or ext/sodium (via PECL)
*
* @ref https://wiki.php.net/rfc/libsodium
* @return bool
*/
protected static function useNewSodiumAPI()
{
static $res = null;
if ($res === null) {
$res = PHP_VERSION_ID >= 70000 && extension_loaded('sodium');
}
if (self::$disableFallbackForUnitTests) {
// Don't fallback. Use the PHP implementation.
return false;
}
return (bool) $res;
}
}
Core/BLAKE2b.php 0000644 00000057200 15153427537 0007227 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_BLAKE2b', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_BLAKE2b
*
* Based on the work of Devi Mandiri in devi/salt.
*/
abstract class ParagonIE_Sodium_Core_BLAKE2b extends ParagonIE_Sodium_Core_Util
{
/**
* @var SplFixedArray
*/
protected static $iv;
/**
* @var array<int, array<int, int>>
*/
protected static $sigma = array(
array( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
array( 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3),
array( 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4),
array( 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8),
array( 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13),
array( 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9),
array( 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11),
array( 13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10),
array( 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5),
array( 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13 , 0),
array( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
array( 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3)
);
const BLOCKBYTES = 128;
const OUTBYTES = 64;
const KEYBYTES = 64;
/**
* Turn two 32-bit integers into a fixed array representing a 64-bit integer.
*
* @internal You should not use this directly from another application
*
* @param int $high
* @param int $low
* @return SplFixedArray
* @psalm-suppress MixedAssignment
*/
public static function new64($high, $low)
{
if (PHP_INT_SIZE === 4) {
throw new SodiumException("Error, use 32-bit");
}
$i64 = new SplFixedArray(2);
$i64[0] = $high & 0xffffffff;
$i64[1] = $low & 0xffffffff;
return $i64;
}
/**
* Convert an arbitrary number into an SplFixedArray of two 32-bit integers
* that represents a 64-bit integer.
*
* @internal You should not use this directly from another application
*
* @param int $num
* @return SplFixedArray
*/
protected static function to64($num)
{
list($hi, $lo) = self::numericTo64BitInteger($num);
return self::new64($hi, $lo);
}
/**
* Adds two 64-bit integers together, returning their sum as a SplFixedArray
* containing two 32-bit integers (representing a 64-bit integer).
*
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param SplFixedArray $y
* @return SplFixedArray
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedOperand
*/
protected static function add64($x, $y)
{
if (PHP_INT_SIZE === 4) {
throw new SodiumException("Error, use 32-bit");
}
$l = ($x[1] + $y[1]) & 0xffffffff;
return self::new64(
(int) ($x[0] + $y[0] + (
($l < $x[1]) ? 1 : 0
)),
(int) $l
);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param SplFixedArray $y
* @param SplFixedArray $z
* @return SplFixedArray
*/
protected static function add364($x, $y, $z)
{
return self::add64($x, self::add64($y, $z));
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param SplFixedArray $y
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
*/
protected static function xor64(SplFixedArray $x, SplFixedArray $y)
{
if (PHP_INT_SIZE === 4) {
throw new SodiumException("Error, use 32-bit");
}
if (!is_numeric($x[0])) {
throw new SodiumException('x[0] is not an integer');
}
if (!is_numeric($x[1])) {
throw new SodiumException('x[1] is not an integer');
}
if (!is_numeric($y[0])) {
throw new SodiumException('y[0] is not an integer');
}
if (!is_numeric($y[1])) {
throw new SodiumException('y[1] is not an integer');
}
return self::new64(
(int) (($x[0] ^ $y[0]) & 0xffffffff),
(int) (($x[1] ^ $y[1]) & 0xffffffff)
);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param int $c
* @return SplFixedArray
* @psalm-suppress MixedAssignment
*/
public static function rotr64($x, $c)
{
if (PHP_INT_SIZE === 4) {
throw new SodiumException("Error, use 32-bit");
}
if ($c >= 64) {
$c %= 64;
}
if ($c >= 32) {
/** @var int $tmp */
$tmp = $x[0];
$x[0] = $x[1];
$x[1] = $tmp;
$c -= 32;
}
if ($c === 0) {
return $x;
}
$l0 = 0;
$c = 64 - $c;
/** @var int $c */
if ($c < 32) {
$h0 = ((int) ($x[0]) << $c) | (
(
(int) ($x[1]) & ((1 << $c) - 1)
<<
(32 - $c)
) >> (32 - $c)
);
$l0 = (int) ($x[1]) << $c;
} else {
$h0 = (int) ($x[1]) << ($c - 32);
}
$h1 = 0;
$c1 = 64 - $c;
if ($c1 < 32) {
$h1 = (int) ($x[0]) >> $c1;
$l1 = ((int) ($x[1]) >> $c1) | ((int) ($x[0]) & ((1 << $c1) - 1)) << (32 - $c1);
} else {
$l1 = (int) ($x[0]) >> ($c1 - 32);
}
return self::new64($h0 | $h1, $l0 | $l1);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @return int
* @psalm-suppress MixedOperand
*/
protected static function flatten64($x)
{
return (int) ($x[0] * 4294967296 + $x[1]);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param int $i
* @return SplFixedArray
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayOffset
*/
protected static function load64(SplFixedArray $x, $i)
{
/** @var int $l */
$l = (int) ($x[$i])
| ((int) ($x[$i+1]) << 8)
| ((int) ($x[$i+2]) << 16)
| ((int) ($x[$i+3]) << 24);
/** @var int $h */
$h = (int) ($x[$i+4])
| ((int) ($x[$i+5]) << 8)
| ((int) ($x[$i+6]) << 16)
| ((int) ($x[$i+7]) << 24);
return self::new64($h, $l);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param int $i
* @param SplFixedArray $u
* @return void
* @psalm-suppress MixedAssignment
*/
protected static function store64(SplFixedArray $x, $i, SplFixedArray $u)
{
$maxLength = $x->getSize() - 1;
for ($j = 0; $j < 8; ++$j) {
/*
[0, 1, 2, 3, 4, 5, 6, 7]
... becomes ...
[0, 0, 0, 0, 1, 1, 1, 1]
*/
/** @var int $uIdx */
$uIdx = ((7 - $j) & 4) >> 2;
$x[$i] = ((int) ($u[$uIdx]) & 0xff);
if (++$i > $maxLength) {
return;
}
/** @psalm-suppress MixedOperand */
$u[$uIdx] >>= 8;
}
}
/**
* This just sets the $iv static variable.
*
* @internal You should not use this directly from another application
*
* @return void
*/
public static function pseudoConstructor()
{
static $called = false;
if ($called) {
return;
}
self::$iv = new SplFixedArray(8);
self::$iv[0] = self::new64(0x6a09e667, 0xf3bcc908);
self::$iv[1] = self::new64(0xbb67ae85, 0x84caa73b);
self::$iv[2] = self::new64(0x3c6ef372, 0xfe94f82b);
self::$iv[3] = self::new64(0xa54ff53a, 0x5f1d36f1);
self::$iv[4] = self::new64(0x510e527f, 0xade682d1);
self::$iv[5] = self::new64(0x9b05688c, 0x2b3e6c1f);
self::$iv[6] = self::new64(0x1f83d9ab, 0xfb41bd6b);
self::$iv[7] = self::new64(0x5be0cd19, 0x137e2179);
$called = true;
}
/**
* Returns a fresh BLAKE2 context.
*
* @internal You should not use this directly from another application
*
* @return SplFixedArray
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
*/
protected static function context()
{
$ctx = new SplFixedArray(6);
$ctx[0] = new SplFixedArray(8); // h
$ctx[1] = new SplFixedArray(2); // t
$ctx[2] = new SplFixedArray(2); // f
$ctx[3] = new SplFixedArray(256); // buf
$ctx[4] = 0; // buflen
$ctx[5] = 0; // last_node (uint8_t)
for ($i = 8; $i--;) {
$ctx[0][$i] = self::$iv[$i];
}
for ($i = 256; $i--;) {
$ctx[3][$i] = 0;
}
$zero = self::new64(0, 0);
$ctx[1][0] = $zero;
$ctx[1][1] = $zero;
$ctx[2][0] = $zero;
$ctx[2][1] = $zero;
return $ctx;
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param SplFixedArray $buf
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
*/
protected static function compress(SplFixedArray $ctx, SplFixedArray $buf)
{
$m = new SplFixedArray(16);
$v = new SplFixedArray(16);
for ($i = 16; $i--;) {
$m[$i] = self::load64($buf, $i << 3);
}
for ($i = 8; $i--;) {
$v[$i] = $ctx[0][$i];
}
$v[ 8] = self::$iv[0];
$v[ 9] = self::$iv[1];
$v[10] = self::$iv[2];
$v[11] = self::$iv[3];
$v[12] = self::xor64($ctx[1][0], self::$iv[4]);
$v[13] = self::xor64($ctx[1][1], self::$iv[5]);
$v[14] = self::xor64($ctx[2][0], self::$iv[6]);
$v[15] = self::xor64($ctx[2][1], self::$iv[7]);
for ($r = 0; $r < 12; ++$r) {
$v = self::G($r, 0, 0, 4, 8, 12, $v, $m);
$v = self::G($r, 1, 1, 5, 9, 13, $v, $m);
$v = self::G($r, 2, 2, 6, 10, 14, $v, $m);
$v = self::G($r, 3, 3, 7, 11, 15, $v, $m);
$v = self::G($r, 4, 0, 5, 10, 15, $v, $m);
$v = self::G($r, 5, 1, 6, 11, 12, $v, $m);
$v = self::G($r, 6, 2, 7, 8, 13, $v, $m);
$v = self::G($r, 7, 3, 4, 9, 14, $v, $m);
}
for ($i = 8; $i--;) {
$ctx[0][$i] = self::xor64(
$ctx[0][$i], self::xor64($v[$i], $v[$i+8])
);
}
}
/**
* @internal You should not use this directly from another application
*
* @param int $r
* @param int $i
* @param int $a
* @param int $b
* @param int $c
* @param int $d
* @param SplFixedArray $v
* @param SplFixedArray $m
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayOffset
*/
public static function G($r, $i, $a, $b, $c, $d, SplFixedArray $v, SplFixedArray $m)
{
$v[$a] = self::add364($v[$a], $v[$b], $m[self::$sigma[$r][$i << 1]]);
$v[$d] = self::rotr64(self::xor64($v[$d], $v[$a]), 32);
$v[$c] = self::add64($v[$c], $v[$d]);
$v[$b] = self::rotr64(self::xor64($v[$b], $v[$c]), 24);
$v[$a] = self::add364($v[$a], $v[$b], $m[self::$sigma[$r][($i << 1) + 1]]);
$v[$d] = self::rotr64(self::xor64($v[$d], $v[$a]), 16);
$v[$c] = self::add64($v[$c], $v[$d]);
$v[$b] = self::rotr64(self::xor64($v[$b], $v[$c]), 63);
return $v;
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param int $inc
* @return void
* @throws SodiumException
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
*/
public static function increment_counter($ctx, $inc)
{
if ($inc < 0) {
throw new SodiumException('Increasing by a negative number makes no sense.');
}
$t = self::to64($inc);
# S->t is $ctx[1] in our implementation
# S->t[0] = ( uint64_t )( t >> 0 );
$ctx[1][0] = self::add64($ctx[1][0], $t);
# S->t[1] += ( S->t[0] < inc );
if (self::flatten64($ctx[1][0]) < $inc) {
$ctx[1][1] = self::add64($ctx[1][1], self::to64(1));
}
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param SplFixedArray $p
* @param int $plen
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
* @psalm-suppress MixedOperand
*/
public static function update(SplFixedArray $ctx, SplFixedArray $p, $plen)
{
self::pseudoConstructor();
$offset = 0;
while ($plen > 0) {
$left = $ctx[4];
$fill = 256 - $left;
if ($plen > $fill) {
# memcpy( S->buf + left, in, fill ); /* Fill buffer */
for ($i = $fill; $i--;) {
$ctx[3][$i + $left] = $p[$i + $offset];
}
# S->buflen += fill;
$ctx[4] += $fill;
# blake2b_increment_counter( S, BLAKE2B_BLOCKBYTES );
self::increment_counter($ctx, 128);
# blake2b_compress( S, S->buf ); /* Compress */
self::compress($ctx, $ctx[3]);
# memcpy( S->buf, S->buf + BLAKE2B_BLOCKBYTES, BLAKE2B_BLOCKBYTES ); /* Shift buffer left */
for ($i = 128; $i--;) {
$ctx[3][$i] = $ctx[3][$i + 128];
}
# S->buflen -= BLAKE2B_BLOCKBYTES;
$ctx[4] -= 128;
# in += fill;
$offset += $fill;
# inlen -= fill;
$plen -= $fill;
} else {
for ($i = $plen; $i--;) {
$ctx[3][$i + $left] = $p[$i + $offset];
}
$ctx[4] += $plen;
$offset += $plen;
$plen -= $plen;
}
}
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param SplFixedArray $out
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
* @psalm-suppress MixedOperand
*/
public static function finish(SplFixedArray $ctx, SplFixedArray $out)
{
self::pseudoConstructor();
if ($ctx[4] > 128) {
self::increment_counter($ctx, 128);
self::compress($ctx, $ctx[3]);
$ctx[4] -= 128;
if ($ctx[4] > 128) {
throw new SodiumException('Failed to assert that buflen <= 128 bytes');
}
for ($i = $ctx[4]; $i--;) {
$ctx[3][$i] = $ctx[3][$i + 128];
}
}
self::increment_counter($ctx, $ctx[4]);
$ctx[2][0] = self::new64(0xffffffff, 0xffffffff);
for ($i = 256 - $ctx[4]; $i--;) {
$ctx[3][$i+$ctx[4]] = 0;
}
self::compress($ctx, $ctx[3]);
$i = (int) (($out->getSize() - 1) / 8);
for (; $i >= 0; --$i) {
self::store64($out, $i << 3, $ctx[0][$i]);
}
return $out;
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray|null $key
* @param int $outlen
* @param SplFixedArray|null $salt
* @param SplFixedArray|null $personal
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
*/
public static function init(
$key = null,
$outlen = 64,
$salt = null,
$personal = null
) {
self::pseudoConstructor();
$klen = 0;
if ($key !== null) {
if (count($key) > 64) {
throw new SodiumException('Invalid key size');
}
$klen = count($key);
}
if ($outlen > 64) {
throw new SodiumException('Invalid output size');
}
$ctx = self::context();
$p = new SplFixedArray(64);
// Zero our param buffer...
for ($i = 64; --$i;) {
$p[$i] = 0;
}
$p[0] = $outlen; // digest_length
$p[1] = $klen; // key_length
$p[2] = 1; // fanout
$p[3] = 1; // depth
if ($salt instanceof SplFixedArray) {
// salt: [32] through [47]
for ($i = 0; $i < 16; ++$i) {
$p[32 + $i] = (int) $salt[$i];
}
}
if ($personal instanceof SplFixedArray) {
// personal: [48] through [63]
for ($i = 0; $i < 16; ++$i) {
$p[48 + $i] = (int) $personal[$i];
}
}
$ctx[0][0] = self::xor64(
$ctx[0][0],
self::load64($p, 0)
);
if ($salt instanceof SplFixedArray || $personal instanceof SplFixedArray) {
// We need to do what blake2b_init_param() does:
for ($i = 1; $i < 8; ++$i) {
$ctx[0][$i] = self::xor64(
$ctx[0][$i],
self::load64($p, $i << 3)
);
}
}
if ($klen > 0 && $key instanceof SplFixedArray) {
$block = new SplFixedArray(128);
for ($i = 128; $i--;) {
$block[$i] = 0;
}
for ($i = $klen; $i--;) {
$block[$i] = $key[$i];
}
self::update($ctx, $block, 128);
$ctx[4] = 128;
}
return $ctx;
}
/**
* Convert a string into an SplFixedArray of integers
*
* @internal You should not use this directly from another application
*
* @param string $str
* @return SplFixedArray
* @psalm-suppress MixedArgumentTypeCoercion
*/
public static function stringToSplFixedArray($str = '')
{
$values = unpack('C*', $str);
return SplFixedArray::fromArray(array_values($values));
}
/**
* Convert an SplFixedArray of integers into a string
*
* @internal You should not use this directly from another application
*
* @param SplFixedArray $a
* @return string
* @throws TypeError
*/
public static function SplFixedArrayToString(SplFixedArray $a)
{
/**
* @var array<int, int|string> $arr
*/
$arr = $a->toArray();
$c = $a->count();
array_unshift($arr, str_repeat('C', $c));
return (string) (call_user_func_array('pack', $arr));
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @return string
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
* @psalm-suppress MixedMethodCall
*/
public static function contextToString(SplFixedArray $ctx)
{
$str = '';
/** @var array<int, array<int, int>> $ctxA */
$ctxA = $ctx[0]->toArray();
# uint64_t h[8];
for ($i = 0; $i < 8; ++$i) {
$str .= self::store32_le($ctxA[$i][1]);
$str .= self::store32_le($ctxA[$i][0]);
}
# uint64_t t[2];
# uint64_t f[2];
for ($i = 1; $i < 3; ++$i) {
$ctxA = $ctx[$i]->toArray();
$str .= self::store32_le($ctxA[0][1]);
$str .= self::store32_le($ctxA[0][0]);
$str .= self::store32_le($ctxA[1][1]);
$str .= self::store32_le($ctxA[1][0]);
}
# uint8_t buf[2 * 128];
$str .= self::SplFixedArrayToString($ctx[3]);
/** @var int $ctx4 */
$ctx4 = (int) $ctx[4];
# size_t buflen;
$str .= implode('', array(
self::intToChr($ctx4 & 0xff),
self::intToChr(($ctx4 >> 8) & 0xff),
self::intToChr(($ctx4 >> 16) & 0xff),
self::intToChr(($ctx4 >> 24) & 0xff),
self::intToChr(($ctx4 >> 32) & 0xff),
self::intToChr(($ctx4 >> 40) & 0xff),
self::intToChr(($ctx4 >> 48) & 0xff),
self::intToChr(($ctx4 >> 56) & 0xff)
));
# uint8_t last_node;
return $str . self::intToChr($ctx[5]) . str_repeat("\x00", 23);
}
/**
* Creates an SplFixedArray containing other SplFixedArray elements, from
* a string (compatible with \Sodium\crypto_generichash_{init, update, final})
*
* @internal You should not use this directly from another application
*
* @param string $string
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAssignment
*/
public static function stringToContext($string)
{
$ctx = self::context();
# uint64_t h[8];
for ($i = 0; $i < 8; ++$i) {
$ctx[0][$i] = SplFixedArray::fromArray(
array(
self::load_4(
self::substr($string, (($i << 3) + 4), 4)
),
self::load_4(
self::substr($string, (($i << 3) + 0), 4)
)
)
);
}
# uint64_t t[2];
# uint64_t f[2];
for ($i = 1; $i < 3; ++$i) {
$ctx[$i][1] = SplFixedArray::fromArray(
array(
self::load_4(self::substr($string, 76 + (($i - 1) << 4), 4)),
self::load_4(self::substr($string, 72 + (($i - 1) << 4), 4))
)
);
$ctx[$i][0] = SplFixedArray::fromArray(
array(
self::load_4(self::substr($string, 68 + (($i - 1) << 4), 4)),
self::load_4(self::substr($string, 64 + (($i - 1) << 4), 4))
)
);
}
# uint8_t buf[2 * 128];
$ctx[3] = self::stringToSplFixedArray(self::substr($string, 96, 256));
# uint8_t buf[2 * 128];
$int = 0;
for ($i = 0; $i < 8; ++$i) {
$int |= self::chrToInt($string[352 + $i]) << ($i << 3);
}
$ctx[4] = $int;
return $ctx;
}
}
Core/Base64/Common.php 0000644 00000015027 15153427537 0010442 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core_Base64
*
* Copyright (c) 2016 - 2018 Paragon Initiative Enterprises.
* Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com)
*
* We have to copy/paste the contents into the variant files because PHP 5.2
* doesn't support late static binding, and we have no better workaround
* available that won't break PHP 7+. Therefore, we're forced to duplicate code.
*/
abstract class ParagonIE_Sodium_Core_Base64_Common
{
/**
* Encode into Base64
*
* Base64 character set "[A-Z][a-z][0-9]+/"
*
* @param string $src
* @return string
* @throws TypeError
*/
public static function encode($src)
{
return self::doEncode($src, true);
}
/**
* Encode into Base64, no = padding
*
* Base64 character set "[A-Z][a-z][0-9]+/"
*
* @param string $src
* @return string
* @throws TypeError
*/
public static function encodeUnpadded($src)
{
return self::doEncode($src, false);
}
/**
* @param string $src
* @param bool $pad Include = padding?
* @return string
* @throws TypeError
*/
protected static function doEncode($src, $pad = true)
{
$dest = '';
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
// Main loop (no padding):
for ($i = 0; $i + 3 <= $srcLen; $i += 3) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, 3));
$b0 = $chunk[1];
$b1 = $chunk[2];
$b2 = $chunk[3];
$dest .=
self::encode6Bits( $b0 >> 2 ) .
self::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
self::encode6Bits((($b1 << 2) | ($b2 >> 6)) & 63) .
self::encode6Bits( $b2 & 63);
}
// The last chunk, which may have padding:
if ($i < $srcLen) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, $srcLen - $i));
$b0 = $chunk[1];
if ($i + 1 < $srcLen) {
$b1 = $chunk[2];
$dest .=
self::encode6Bits($b0 >> 2) .
self::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
self::encode6Bits(($b1 << 2) & 63);
if ($pad) {
$dest .= '=';
}
} else {
$dest .=
self::encode6Bits( $b0 >> 2) .
self::encode6Bits(($b0 << 4) & 63);
if ($pad) {
$dest .= '==';
}
}
}
return $dest;
}
/**
* decode from base64 into binary
*
* Base64 character set "./[A-Z][a-z][0-9]"
*
* @param string $src
* @param bool $strictPadding
* @return string
* @throws RangeException
* @throws TypeError
* @psalm-suppress RedundantCondition
*/
public static function decode($src, $strictPadding = false)
{
// Remove padding
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
if ($srcLen === 0) {
return '';
}
if ($strictPadding) {
if (($srcLen & 3) === 0) {
if ($src[$srcLen - 1] === '=') {
$srcLen--;
if ($src[$srcLen - 1] === '=') {
$srcLen--;
}
}
}
if (($srcLen & 3) === 1) {
throw new RangeException(
'Incorrect padding'
);
}
if ($src[$srcLen - 1] === '=') {
throw new RangeException(
'Incorrect padding'
);
}
} else {
$src = rtrim($src, '=');
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
}
$err = 0;
$dest = '';
// Main loop (no padding):
for ($i = 0; $i + 4 <= $srcLen; $i += 4) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, 4));
$c0 = self::decode6Bits($chunk[1]);
$c1 = self::decode6Bits($chunk[2]);
$c2 = self::decode6Bits($chunk[3]);
$c3 = self::decode6Bits($chunk[4]);
$dest .= pack(
'CCC',
((($c0 << 2) | ($c1 >> 4)) & 0xff),
((($c1 << 4) | ($c2 >> 2)) & 0xff),
((($c2 << 6) | $c3 ) & 0xff)
);
$err |= ($c0 | $c1 | $c2 | $c3) >> 8;
}
// The last chunk, which may have padding:
if ($i < $srcLen) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, $srcLen - $i));
$c0 = self::decode6Bits($chunk[1]);
if ($i + 2 < $srcLen) {
$c1 = self::decode6Bits($chunk[2]);
$c2 = self::decode6Bits($chunk[3]);
$dest .= pack(
'CC',
((($c0 << 2) | ($c1 >> 4)) & 0xff),
((($c1 << 4) | ($c2 >> 2)) & 0xff)
);
$err |= ($c0 | $c1 | $c2) >> 8;
} elseif ($i + 1 < $srcLen) {
$c1 = self::decode6Bits($chunk[2]);
$dest .= pack(
'C',
((($c0 << 2) | ($c1 >> 4)) & 0xff)
);
$err |= ($c0 | $c1) >> 8;
} elseif ($i < $srcLen && $strictPadding) {
$err |= 1;
}
}
/** @var bool $check */
$check = ($err === 0);
if (!$check) {
throw new RangeException(
'Base64::decode() only expects characters in the correct base64 alphabet'
);
}
return $dest;
}
/**
* Uses bitwise operators instead of table-lookups to turn 6-bit integers
* into 8-bit integers.
*
* Base64 character set:
* [A-Z] [a-z] [0-9] + /
* 0x41-0x5a, 0x61-0x7a, 0x30-0x39, 0x2b, 0x2f
*
* @param int $src
* @return int
*/
abstract protected static function decode6Bits($src);
/**
* Uses bitwise operators instead of table-lookups to turn 8-bit integers
* into 6-bit integers.
*
* @param int $src
* @return string
*/
abstract protected static function encode6Bits($src);
}
Core/Base64/Original.php 0000644 00000017055 15153427537 0010761 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core_Base64
*
* Copyright (c) 2016 - 2018 Paragon Initiative Enterprises.
* Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com)
*/
class ParagonIE_Sodium_Core_Base64_Original
{
// COPY ParagonIE_Sodium_Core_Base64_Common STARTING HERE
/**
* Encode into Base64
*
* Base64 character set "[A-Z][a-z][0-9]+/"
*
* @param string $src
* @return string
* @throws TypeError
*/
public static function encode($src)
{
return self::doEncode($src, true);
}
/**
* Encode into Base64, no = padding
*
* Base64 character set "[A-Z][a-z][0-9]+/"
*
* @param string $src
* @return string
* @throws TypeError
*/
public static function encodeUnpadded($src)
{
return self::doEncode($src, false);
}
/**
* @param string $src
* @param bool $pad Include = padding?
* @return string
* @throws TypeError
*/
protected static function doEncode($src, $pad = true)
{
$dest = '';
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
// Main loop (no padding):
for ($i = 0; $i + 3 <= $srcLen; $i += 3) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, 3));
$b0 = $chunk[1];
$b1 = $chunk[2];
$b2 = $chunk[3];
$dest .=
self::encode6Bits( $b0 >> 2 ) .
self::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
self::encode6Bits((($b1 << 2) | ($b2 >> 6)) & 63) .
self::encode6Bits( $b2 & 63);
}
// The last chunk, which may have padding:
if ($i < $srcLen) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, $srcLen - $i));
$b0 = $chunk[1];
if ($i + 1 < $srcLen) {
$b1 = $chunk[2];
$dest .=
self::encode6Bits($b0 >> 2) .
self::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
self::encode6Bits(($b1 << 2) & 63);
if ($pad) {
$dest .= '=';
}
} else {
$dest .=
self::encode6Bits( $b0 >> 2) .
self::encode6Bits(($b0 << 4) & 63);
if ($pad) {
$dest .= '==';
}
}
}
return $dest;
}
/**
* decode from base64 into binary
*
* Base64 character set "./[A-Z][a-z][0-9]"
*
* @param string $src
* @param bool $strictPadding
* @return string
* @throws RangeException
* @throws TypeError
* @psalm-suppress RedundantCondition
*/
public static function decode($src, $strictPadding = false)
{
// Remove padding
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
if ($srcLen === 0) {
return '';
}
if ($strictPadding) {
if (($srcLen & 3) === 0) {
if ($src[$srcLen - 1] === '=') {
$srcLen--;
if ($src[$srcLen - 1] === '=') {
$srcLen--;
}
}
}
if (($srcLen & 3) === 1) {
throw new RangeException(
'Incorrect padding'
);
}
if ($src[$srcLen - 1] === '=') {
throw new RangeException(
'Incorrect padding'
);
}
} else {
$src = rtrim($src, '=');
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
}
$err = 0;
$dest = '';
// Main loop (no padding):
for ($i = 0; $i + 4 <= $srcLen; $i += 4) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, 4));
$c0 = self::decode6Bits($chunk[1]);
$c1 = self::decode6Bits($chunk[2]);
$c2 = self::decode6Bits($chunk[3]);
$c3 = self::decode6Bits($chunk[4]);
$dest .= pack(
'CCC',
((($c0 << 2) | ($c1 >> 4)) & 0xff),
((($c1 << 4) | ($c2 >> 2)) & 0xff),
((($c2 << 6) | $c3) & 0xff)
);
$err |= ($c0 | $c1 | $c2 | $c3) >> 8;
}
// The last chunk, which may have padding:
if ($i < $srcLen) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, $srcLen - $i));
$c0 = self::decode6Bits($chunk[1]);
if ($i + 2 < $srcLen) {
$c1 = self::decode6Bits($chunk[2]);
$c2 = self::decode6Bits($chunk[3]);
$dest .= pack(
'CC',
((($c0 << 2) | ($c1 >> 4)) & 0xff),
((($c1 << 4) | ($c2 >> 2)) & 0xff)
);
$err |= ($c0 | $c1 | $c2) >> 8;
} elseif ($i + 1 < $srcLen) {
$c1 = self::decode6Bits($chunk[2]);
$dest .= pack(
'C',
((($c0 << 2) | ($c1 >> 4)) & 0xff)
);
$err |= ($c0 | $c1) >> 8;
} elseif ($i < $srcLen && $strictPadding) {
$err |= 1;
}
}
/** @var bool $check */
$check = ($err === 0);
if (!$check) {
throw new RangeException(
'Base64::decode() only expects characters in the correct base64 alphabet'
);
}
return $dest;
}
// COPY ParagonIE_Sodium_Core_Base64_Common ENDING HERE
/**
* Uses bitwise operators instead of table-lookups to turn 6-bit integers
* into 8-bit integers.
*
* Base64 character set:
* [A-Z] [a-z] [0-9] + /
* 0x41-0x5a, 0x61-0x7a, 0x30-0x39, 0x2b, 0x2f
*
* @param int $src
* @return int
*/
protected static function decode6Bits($src)
{
$ret = -1;
// if ($src > 0x40 && $src < 0x5b) $ret += $src - 0x41 + 1; // -64
$ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 64);
// if ($src > 0x60 && $src < 0x7b) $ret += $src - 0x61 + 26 + 1; // -70
$ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 70);
// if ($src > 0x2f && $src < 0x3a) $ret += $src - 0x30 + 52 + 1; // 5
$ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src + 5);
// if ($src == 0x2b) $ret += 62 + 1;
$ret += (((0x2a - $src) & ($src - 0x2c)) >> 8) & 63;
// if ($src == 0x2f) ret += 63 + 1;
$ret += (((0x2e - $src) & ($src - 0x30)) >> 8) & 64;
return $ret;
}
/**
* Uses bitwise operators instead of table-lookups to turn 8-bit integers
* into 6-bit integers.
*
* @param int $src
* @return string
*/
protected static function encode6Bits($src)
{
$diff = 0x41;
// if ($src > 25) $diff += 0x61 - 0x41 - 26; // 6
$diff += ((25 - $src) >> 8) & 6;
// if ($src > 51) $diff += 0x30 - 0x61 - 26; // -75
$diff -= ((51 - $src) >> 8) & 75;
// if ($src > 61) $diff += 0x2b - 0x30 - 10; // -15
$diff -= ((61 - $src) >> 8) & 15;
// if ($src > 62) $diff += 0x2f - 0x2b - 1; // 3
$diff += ((62 - $src) >> 8) & 3;
return pack('C', $src + $diff);
}
}
Core/Base64/UrlSafe.php 0000644 00000017063 15153427537 0010555 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core_Base64UrlSafe
*
* Copyright (c) 2016 - 2018 Paragon Initiative Enterprises.
* Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com)
*/
class ParagonIE_Sodium_Core_Base64_UrlSafe
{
// COPY ParagonIE_Sodium_Core_Base64_Common STARTING HERE
/**
* Encode into Base64
*
* Base64 character set "[A-Z][a-z][0-9]+/"
*
* @param string $src
* @return string
* @throws TypeError
*/
public static function encode($src)
{
return self::doEncode($src, true);
}
/**
* Encode into Base64, no = padding
*
* Base64 character set "[A-Z][a-z][0-9]+/"
*
* @param string $src
* @return string
* @throws TypeError
*/
public static function encodeUnpadded($src)
{
return self::doEncode($src, false);
}
/**
* @param string $src
* @param bool $pad Include = padding?
* @return string
* @throws TypeError
*/
protected static function doEncode($src, $pad = true)
{
$dest = '';
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
// Main loop (no padding):
for ($i = 0; $i + 3 <= $srcLen; $i += 3) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, 3));
$b0 = $chunk[1];
$b1 = $chunk[2];
$b2 = $chunk[3];
$dest .=
self::encode6Bits( $b0 >> 2 ) .
self::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
self::encode6Bits((($b1 << 2) | ($b2 >> 6)) & 63) .
self::encode6Bits( $b2 & 63);
}
// The last chunk, which may have padding:
if ($i < $srcLen) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, $srcLen - $i));
$b0 = $chunk[1];
if ($i + 1 < $srcLen) {
$b1 = $chunk[2];
$dest .=
self::encode6Bits($b0 >> 2) .
self::encode6Bits((($b0 << 4) | ($b1 >> 4)) & 63) .
self::encode6Bits(($b1 << 2) & 63);
if ($pad) {
$dest .= '=';
}
} else {
$dest .=
self::encode6Bits( $b0 >> 2) .
self::encode6Bits(($b0 << 4) & 63);
if ($pad) {
$dest .= '==';
}
}
}
return $dest;
}
/**
* decode from base64 into binary
*
* Base64 character set "./[A-Z][a-z][0-9]"
*
* @param string $src
* @param bool $strictPadding
* @return string
* @throws RangeException
* @throws TypeError
* @psalm-suppress RedundantCondition
*/
public static function decode($src, $strictPadding = false)
{
// Remove padding
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
if ($srcLen === 0) {
return '';
}
if ($strictPadding) {
if (($srcLen & 3) === 0) {
if ($src[$srcLen - 1] === '=') {
$srcLen--;
if ($src[$srcLen - 1] === '=') {
$srcLen--;
}
}
}
if (($srcLen & 3) === 1) {
throw new RangeException(
'Incorrect padding'
);
}
if ($src[$srcLen - 1] === '=') {
throw new RangeException(
'Incorrect padding'
);
}
} else {
$src = rtrim($src, '=');
$srcLen = ParagonIE_Sodium_Core_Util::strlen($src);
}
$err = 0;
$dest = '';
// Main loop (no padding):
for ($i = 0; $i + 4 <= $srcLen; $i += 4) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, 4));
$c0 = self::decode6Bits($chunk[1]);
$c1 = self::decode6Bits($chunk[2]);
$c2 = self::decode6Bits($chunk[3]);
$c3 = self::decode6Bits($chunk[4]);
$dest .= pack(
'CCC',
((($c0 << 2) | ($c1 >> 4)) & 0xff),
((($c1 << 4) | ($c2 >> 2)) & 0xff),
((($c2 << 6) | $c3) & 0xff)
);
$err |= ($c0 | $c1 | $c2 | $c3) >> 8;
}
// The last chunk, which may have padding:
if ($i < $srcLen) {
/** @var array<int, int> $chunk */
$chunk = unpack('C*', ParagonIE_Sodium_Core_Util::substr($src, $i, $srcLen - $i));
$c0 = self::decode6Bits($chunk[1]);
if ($i + 2 < $srcLen) {
$c1 = self::decode6Bits($chunk[2]);
$c2 = self::decode6Bits($chunk[3]);
$dest .= pack(
'CC',
((($c0 << 2) | ($c1 >> 4)) & 0xff),
((($c1 << 4) | ($c2 >> 2)) & 0xff)
);
$err |= ($c0 | $c1 | $c2) >> 8;
} elseif ($i + 1 < $srcLen) {
$c1 = self::decode6Bits($chunk[2]);
$dest .= pack(
'C',
((($c0 << 2) | ($c1 >> 4)) & 0xff)
);
$err |= ($c0 | $c1) >> 8;
} elseif ($i < $srcLen && $strictPadding) {
$err |= 1;
}
}
/** @var bool $check */
$check = ($err === 0);
if (!$check) {
throw new RangeException(
'Base64::decode() only expects characters in the correct base64 alphabet'
);
}
return $dest;
}
// COPY ParagonIE_Sodium_Core_Base64_Common ENDING HERE
/**
* Uses bitwise operators instead of table-lookups to turn 6-bit integers
* into 8-bit integers.
*
* Base64 character set:
* [A-Z] [a-z] [0-9] + /
* 0x41-0x5a, 0x61-0x7a, 0x30-0x39, 0x2b, 0x2f
*
* @param int $src
* @return int
*/
protected static function decode6Bits($src)
{
$ret = -1;
// if ($src > 0x40 && $src < 0x5b) $ret += $src - 0x41 + 1; // -64
$ret += (((0x40 - $src) & ($src - 0x5b)) >> 8) & ($src - 64);
// if ($src > 0x60 && $src < 0x7b) $ret += $src - 0x61 + 26 + 1; // -70
$ret += (((0x60 - $src) & ($src - 0x7b)) >> 8) & ($src - 70);
// if ($src > 0x2f && $src < 0x3a) $ret += $src - 0x30 + 52 + 1; // 5
$ret += (((0x2f - $src) & ($src - 0x3a)) >> 8) & ($src + 5);
// if ($src == 0x2c) $ret += 62 + 1;
$ret += (((0x2c - $src) & ($src - 0x2e)) >> 8) & 63;
// if ($src == 0x5f) ret += 63 + 1;
$ret += (((0x5e - $src) & ($src - 0x60)) >> 8) & 64;
return $ret;
}
/**
* Uses bitwise operators instead of table-lookups to turn 8-bit integers
* into 6-bit integers.
*
* @param int $src
* @return string
*/
protected static function encode6Bits($src)
{
$diff = 0x41;
// if ($src > 25) $diff += 0x61 - 0x41 - 26; // 6
$diff += ((25 - $src) >> 8) & 6;
// if ($src > 51) $diff += 0x30 - 0x61 - 26; // -75
$diff -= ((51 - $src) >> 8) & 75;
// if ($src > 61) $diff += 0x2d - 0x30 - 10; // -13
$diff -= ((61 - $src) >> 8) & 13;
// if ($src > 62) $diff += 0x5f - 0x2b - 1; // 3
$diff += ((62 - $src) >> 8) & 49;
return pack('C', $src + $diff);
}
}
Core/ChaCha20/Ctx.php 0000644 00000007546 15153427537 0010204 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_ChaCha20_Ctx', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_ChaCha20_Ctx
*/
class ParagonIE_Sodium_Core_ChaCha20_Ctx extends ParagonIE_Sodium_Core_Util implements ArrayAccess
{
/**
* @var SplFixedArray internally, <int, int>
*/
protected $container;
/**
* ParagonIE_Sodium_Core_ChaCha20_Ctx constructor.
*
* @internal You should not use this directly from another application
*
* @param string $key ChaCha20 key.
* @param string $iv Initialization Vector (a.k.a. nonce).
* @param string $counter The initial counter value.
* Defaults to 8 0x00 bytes.
* @throws InvalidArgumentException
* @throws TypeError
*/
public function __construct($key = '', $iv = '', $counter = '')
{
if (self::strlen($key) !== 32) {
throw new InvalidArgumentException('ChaCha20 expects a 256-bit key.');
}
if (self::strlen($iv) !== 8) {
throw new InvalidArgumentException('ChaCha20 expects a 64-bit nonce.');
}
$this->container = new SplFixedArray(16);
/* "expand 32-byte k" as per ChaCha20 spec */
$this->container[0] = 0x61707865;
$this->container[1] = 0x3320646e;
$this->container[2] = 0x79622d32;
$this->container[3] = 0x6b206574;
$this->container[4] = self::load_4(self::substr($key, 0, 4));
$this->container[5] = self::load_4(self::substr($key, 4, 4));
$this->container[6] = self::load_4(self::substr($key, 8, 4));
$this->container[7] = self::load_4(self::substr($key, 12, 4));
$this->container[8] = self::load_4(self::substr($key, 16, 4));
$this->container[9] = self::load_4(self::substr($key, 20, 4));
$this->container[10] = self::load_4(self::substr($key, 24, 4));
$this->container[11] = self::load_4(self::substr($key, 28, 4));
if (empty($counter)) {
$this->container[12] = 0;
$this->container[13] = 0;
} else {
$this->container[12] = self::load_4(self::substr($counter, 0, 4));
$this->container[13] = self::load_4(self::substr($counter, 4, 4));
}
$this->container[14] = self::load_4(self::substr($iv, 0, 4));
$this->container[15] = self::load_4(self::substr($iv, 4, 4));
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @param int $value
* @return void
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if (!is_int($offset)) {
throw new InvalidArgumentException('Expected an integer');
}
if (!is_int($value)) {
throw new InvalidArgumentException('Expected an integer');
}
$this->container[$offset] = $value;
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return bool
*/
#[ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return void
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return mixed|null
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetGet($offset)
{
return isset($this->container[$offset])
? $this->container[$offset]
: null;
}
}
Core/ChaCha20/IetfCtx.php 0000644 00000002452 15153427537 0011003 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_ChaCha20_IetfCtx', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_ChaCha20_IetfCtx
*/
class ParagonIE_Sodium_Core_ChaCha20_IetfCtx extends ParagonIE_Sodium_Core_ChaCha20_Ctx
{
/**
* ParagonIE_Sodium_Core_ChaCha20_IetfCtx constructor.
*
* @internal You should not use this directly from another application
*
* @param string $key ChaCha20 key.
* @param string $iv Initialization Vector (a.k.a. nonce).
* @param string $counter The initial counter value.
* Defaults to 4 0x00 bytes.
* @throws InvalidArgumentException
* @throws TypeError
*/
public function __construct($key = '', $iv = '', $counter = '')
{
if (self::strlen($iv) !== 12) {
throw new InvalidArgumentException('ChaCha20 expects a 96-bit nonce in IETF mode.');
}
parent::__construct($key, self::substr($iv, 0, 8), $counter);
if (!empty($counter)) {
$this->container[12] = self::load_4(self::substr($counter, 0, 4));
}
$this->container[13] = self::load_4(self::substr($iv, 0, 4));
$this->container[14] = self::load_4(self::substr($iv, 4, 4));
$this->container[15] = self::load_4(self::substr($iv, 8, 4));
}
}
Core/ChaCha20.php 0000644 00000031206 15153427537 0007434 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_ChaCha20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_ChaCha20
*/
class ParagonIE_Sodium_Core_ChaCha20 extends ParagonIE_Sodium_Core_Util
{
/**
* Bitwise left rotation
*
* @internal You should not use this directly from another application
*
* @param int $v
* @param int $n
* @return int
*/
public static function rotate($v, $n)
{
$v &= 0xffffffff;
$n &= 31;
return (int) (
0xffffffff & (
($v << $n)
|
($v >> (32 - $n))
)
);
}
/**
* The ChaCha20 quarter round function. Works on four 32-bit integers.
*
* @internal You should not use this directly from another application
*
* @param int $a
* @param int $b
* @param int $c
* @param int $d
* @return array<int, int>
*/
protected static function quarterRound($a, $b, $c, $d)
{
# a = PLUS(a,b); d = ROTATE(XOR(d,a),16);
/** @var int $a */
$a = ($a + $b) & 0xffffffff;
$d = self::rotate($d ^ $a, 16);
# c = PLUS(c,d); b = ROTATE(XOR(b,c),12);
/** @var int $c */
$c = ($c + $d) & 0xffffffff;
$b = self::rotate($b ^ $c, 12);
# a = PLUS(a,b); d = ROTATE(XOR(d,a), 8);
/** @var int $a */
$a = ($a + $b) & 0xffffffff;
$d = self::rotate($d ^ $a, 8);
# c = PLUS(c,d); b = ROTATE(XOR(b,c), 7);
/** @var int $c */
$c = ($c + $d) & 0xffffffff;
$b = self::rotate($b ^ $c, 7);
return array((int) $a, (int) $b, (int) $c, (int) $d);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_ChaCha20_Ctx $ctx
* @param string $message
*
* @return string
* @throws TypeError
* @throws SodiumException
*/
public static function encryptBytes(
ParagonIE_Sodium_Core_ChaCha20_Ctx $ctx,
$message = ''
) {
$bytes = self::strlen($message);
/*
j0 = ctx->input[0];
j1 = ctx->input[1];
j2 = ctx->input[2];
j3 = ctx->input[3];
j4 = ctx->input[4];
j5 = ctx->input[5];
j6 = ctx->input[6];
j7 = ctx->input[7];
j8 = ctx->input[8];
j9 = ctx->input[9];
j10 = ctx->input[10];
j11 = ctx->input[11];
j12 = ctx->input[12];
j13 = ctx->input[13];
j14 = ctx->input[14];
j15 = ctx->input[15];
*/
$j0 = (int) $ctx[0];
$j1 = (int) $ctx[1];
$j2 = (int) $ctx[2];
$j3 = (int) $ctx[3];
$j4 = (int) $ctx[4];
$j5 = (int) $ctx[5];
$j6 = (int) $ctx[6];
$j7 = (int) $ctx[7];
$j8 = (int) $ctx[8];
$j9 = (int) $ctx[9];
$j10 = (int) $ctx[10];
$j11 = (int) $ctx[11];
$j12 = (int) $ctx[12];
$j13 = (int) $ctx[13];
$j14 = (int) $ctx[14];
$j15 = (int) $ctx[15];
$c = '';
for (;;) {
if ($bytes < 64) {
$message .= str_repeat("\x00", 64 - $bytes);
}
$x0 = (int) $j0;
$x1 = (int) $j1;
$x2 = (int) $j2;
$x3 = (int) $j3;
$x4 = (int) $j4;
$x5 = (int) $j5;
$x6 = (int) $j6;
$x7 = (int) $j7;
$x8 = (int) $j8;
$x9 = (int) $j9;
$x10 = (int) $j10;
$x11 = (int) $j11;
$x12 = (int) $j12;
$x13 = (int) $j13;
$x14 = (int) $j14;
$x15 = (int) $j15;
# for (i = 20; i > 0; i -= 2) {
for ($i = 20; $i > 0; $i -= 2) {
# QUARTERROUND( x0, x4, x8, x12)
list($x0, $x4, $x8, $x12) = self::quarterRound($x0, $x4, $x8, $x12);
# QUARTERROUND( x1, x5, x9, x13)
list($x1, $x5, $x9, $x13) = self::quarterRound($x1, $x5, $x9, $x13);
# QUARTERROUND( x2, x6, x10, x14)
list($x2, $x6, $x10, $x14) = self::quarterRound($x2, $x6, $x10, $x14);
# QUARTERROUND( x3, x7, x11, x15)
list($x3, $x7, $x11, $x15) = self::quarterRound($x3, $x7, $x11, $x15);
# QUARTERROUND( x0, x5, x10, x15)
list($x0, $x5, $x10, $x15) = self::quarterRound($x0, $x5, $x10, $x15);
# QUARTERROUND( x1, x6, x11, x12)
list($x1, $x6, $x11, $x12) = self::quarterRound($x1, $x6, $x11, $x12);
# QUARTERROUND( x2, x7, x8, x13)
list($x2, $x7, $x8, $x13) = self::quarterRound($x2, $x7, $x8, $x13);
# QUARTERROUND( x3, x4, x9, x14)
list($x3, $x4, $x9, $x14) = self::quarterRound($x3, $x4, $x9, $x14);
}
/*
x0 = PLUS(x0, j0);
x1 = PLUS(x1, j1);
x2 = PLUS(x2, j2);
x3 = PLUS(x3, j3);
x4 = PLUS(x4, j4);
x5 = PLUS(x5, j5);
x6 = PLUS(x6, j6);
x7 = PLUS(x7, j7);
x8 = PLUS(x8, j8);
x9 = PLUS(x9, j9);
x10 = PLUS(x10, j10);
x11 = PLUS(x11, j11);
x12 = PLUS(x12, j12);
x13 = PLUS(x13, j13);
x14 = PLUS(x14, j14);
x15 = PLUS(x15, j15);
*/
/** @var int $x0 */
$x0 = ($x0 & 0xffffffff) + $j0;
/** @var int $x1 */
$x1 = ($x1 & 0xffffffff) + $j1;
/** @var int $x2 */
$x2 = ($x2 & 0xffffffff) + $j2;
/** @var int $x3 */
$x3 = ($x3 & 0xffffffff) + $j3;
/** @var int $x4 */
$x4 = ($x4 & 0xffffffff) + $j4;
/** @var int $x5 */
$x5 = ($x5 & 0xffffffff) + $j5;
/** @var int $x6 */
$x6 = ($x6 & 0xffffffff) + $j6;
/** @var int $x7 */
$x7 = ($x7 & 0xffffffff) + $j7;
/** @var int $x8 */
$x8 = ($x8 & 0xffffffff) + $j8;
/** @var int $x9 */
$x9 = ($x9 & 0xffffffff) + $j9;
/** @var int $x10 */
$x10 = ($x10 & 0xffffffff) + $j10;
/** @var int $x11 */
$x11 = ($x11 & 0xffffffff) + $j11;
/** @var int $x12 */
$x12 = ($x12 & 0xffffffff) + $j12;
/** @var int $x13 */
$x13 = ($x13 & 0xffffffff) + $j13;
/** @var int $x14 */
$x14 = ($x14 & 0xffffffff) + $j14;
/** @var int $x15 */
$x15 = ($x15 & 0xffffffff) + $j15;
/*
x0 = XOR(x0, LOAD32_LE(m + 0));
x1 = XOR(x1, LOAD32_LE(m + 4));
x2 = XOR(x2, LOAD32_LE(m + 8));
x3 = XOR(x3, LOAD32_LE(m + 12));
x4 = XOR(x4, LOAD32_LE(m + 16));
x5 = XOR(x5, LOAD32_LE(m + 20));
x6 = XOR(x6, LOAD32_LE(m + 24));
x7 = XOR(x7, LOAD32_LE(m + 28));
x8 = XOR(x8, LOAD32_LE(m + 32));
x9 = XOR(x9, LOAD32_LE(m + 36));
x10 = XOR(x10, LOAD32_LE(m + 40));
x11 = XOR(x11, LOAD32_LE(m + 44));
x12 = XOR(x12, LOAD32_LE(m + 48));
x13 = XOR(x13, LOAD32_LE(m + 52));
x14 = XOR(x14, LOAD32_LE(m + 56));
x15 = XOR(x15, LOAD32_LE(m + 60));
*/
$x0 ^= self::load_4(self::substr($message, 0, 4));
$x1 ^= self::load_4(self::substr($message, 4, 4));
$x2 ^= self::load_4(self::substr($message, 8, 4));
$x3 ^= self::load_4(self::substr($message, 12, 4));
$x4 ^= self::load_4(self::substr($message, 16, 4));
$x5 ^= self::load_4(self::substr($message, 20, 4));
$x6 ^= self::load_4(self::substr($message, 24, 4));
$x7 ^= self::load_4(self::substr($message, 28, 4));
$x8 ^= self::load_4(self::substr($message, 32, 4));
$x9 ^= self::load_4(self::substr($message, 36, 4));
$x10 ^= self::load_4(self::substr($message, 40, 4));
$x11 ^= self::load_4(self::substr($message, 44, 4));
$x12 ^= self::load_4(self::substr($message, 48, 4));
$x13 ^= self::load_4(self::substr($message, 52, 4));
$x14 ^= self::load_4(self::substr($message, 56, 4));
$x15 ^= self::load_4(self::substr($message, 60, 4));
/*
j12 = PLUSONE(j12);
if (!j12) {
j13 = PLUSONE(j13);
}
*/
++$j12;
if ($j12 & 0xf0000000) {
throw new SodiumException('Overflow');
}
/*
STORE32_LE(c + 0, x0);
STORE32_LE(c + 4, x1);
STORE32_LE(c + 8, x2);
STORE32_LE(c + 12, x3);
STORE32_LE(c + 16, x4);
STORE32_LE(c + 20, x5);
STORE32_LE(c + 24, x6);
STORE32_LE(c + 28, x7);
STORE32_LE(c + 32, x8);
STORE32_LE(c + 36, x9);
STORE32_LE(c + 40, x10);
STORE32_LE(c + 44, x11);
STORE32_LE(c + 48, x12);
STORE32_LE(c + 52, x13);
STORE32_LE(c + 56, x14);
STORE32_LE(c + 60, x15);
*/
$block = self::store32_le((int) ($x0 & 0xffffffff)) .
self::store32_le((int) ($x1 & 0xffffffff)) .
self::store32_le((int) ($x2 & 0xffffffff)) .
self::store32_le((int) ($x3 & 0xffffffff)) .
self::store32_le((int) ($x4 & 0xffffffff)) .
self::store32_le((int) ($x5 & 0xffffffff)) .
self::store32_le((int) ($x6 & 0xffffffff)) .
self::store32_le((int) ($x7 & 0xffffffff)) .
self::store32_le((int) ($x8 & 0xffffffff)) .
self::store32_le((int) ($x9 & 0xffffffff)) .
self::store32_le((int) ($x10 & 0xffffffff)) .
self::store32_le((int) ($x11 & 0xffffffff)) .
self::store32_le((int) ($x12 & 0xffffffff)) .
self::store32_le((int) ($x13 & 0xffffffff)) .
self::store32_le((int) ($x14 & 0xffffffff)) .
self::store32_le((int) ($x15 & 0xffffffff));
/* Partial block */
if ($bytes < 64) {
$c .= self::substr($block, 0, $bytes);
break;
}
/* Full block */
$c .= $block;
$bytes -= 64;
if ($bytes <= 0) {
break;
}
$message = self::substr($message, 64);
}
/* end for(;;) loop */
$ctx[12] = $j12;
$ctx[13] = $j13;
return $c;
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function stream($len = 64, $nonce = '', $key = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_Ctx($key, $nonce),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStream($len, $nonce = '', $key = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_IetfCtx($key, $nonce),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStreamXorIc($message, $nonce = '', $key = '', $ic = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_IetfCtx($key, $nonce, $ic),
$message
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function streamXorIc($message, $nonce = '', $key = '', $ic = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_Ctx($key, $nonce, $ic),
$message
);
}
}
Core/Curve25519/Fe.php 0000644 00000006021 15153427537 0010204 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_Fe', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_Fe
*
* This represents a Field Element
*/
class ParagonIE_Sodium_Core_Curve25519_Fe implements ArrayAccess
{
/**
* @var array<int, int>
*/
protected $container = array();
/**
* @var int
*/
protected $size = 10;
/**
* @internal You should not use this directly from another application
*
* @param array<int, int> $array
* @param bool $save_indexes
* @return self
*/
public static function fromArray($array, $save_indexes = null)
{
$count = count($array);
if ($save_indexes) {
$keys = array_keys($array);
} else {
$keys = range(0, $count - 1);
}
$array = array_values($array);
/** @var array<int, int> $keys */
$obj = new ParagonIE_Sodium_Core_Curve25519_Fe();
if ($save_indexes) {
for ($i = 0; $i < $count; ++$i) {
$obj->offsetSet($keys[$i], $array[$i]);
}
} else {
for ($i = 0; $i < $count; ++$i) {
$obj->offsetSet($i, $array[$i]);
}
}
return $obj;
}
/**
* @internal You should not use this directly from another application
*
* @param int|null $offset
* @param int $value
* @return void
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if (!is_int($value)) {
throw new InvalidArgumentException('Expected an integer');
}
if (is_null($offset)) {
$this->container[] = $value;
} else {
$this->container[$offset] = $value;
}
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return bool
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return void
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return int
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetGet($offset)
{
if (!isset($this->container[$offset])) {
$this->container[$offset] = 0;
}
return (int) ($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @return array
*/
public function __debugInfo()
{
return array(implode(', ', $this->container));
}
}
Core/Curve25519/Ge/Cached.php 0000644 00000003345 15153427537 0011362 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_Ge_Cached', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_Ge_Cached
*/
class ParagonIE_Sodium_Core_Curve25519_Ge_Cached
{
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $YplusX;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $YminusX;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Z;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $T2d;
/**
* ParagonIE_Sodium_Core_Curve25519_Ge_Cached constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $YplusX
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $YminusX
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $Z
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $T2d
*/
public function __construct(
ParagonIE_Sodium_Core_Curve25519_Fe $YplusX = null,
ParagonIE_Sodium_Core_Curve25519_Fe $YminusX = null,
ParagonIE_Sodium_Core_Curve25519_Fe $Z = null,
ParagonIE_Sodium_Core_Curve25519_Fe $T2d = null
) {
if ($YplusX === null) {
$YplusX = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->YplusX = $YplusX;
if ($YminusX === null) {
$YminusX = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->YminusX = $YminusX;
if ($Z === null) {
$Z = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Z = $Z;
if ($T2d === null) {
$T2d = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->T2d = $T2d;
}
}
Core/Curve25519/Ge/P1p1.php 0000644 00000003201 15153427537 0010723 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_Ge_P1p1', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
class ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
{
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $X;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Y;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Z;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $T;
/**
* ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $x
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $y
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $z
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $t
*/
public function __construct(
ParagonIE_Sodium_Core_Curve25519_Fe $x = null,
ParagonIE_Sodium_Core_Curve25519_Fe $y = null,
ParagonIE_Sodium_Core_Curve25519_Fe $z = null,
ParagonIE_Sodium_Core_Curve25519_Fe $t = null
) {
if ($x === null) {
$x = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->X = $x;
if ($y === null) {
$y = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Y = $y;
if ($z === null) {
$z = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Z = $z;
if ($t === null) {
$t = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->T = $t;
}
}
Core/Curve25519/Ge/P2.php 0000644 00000002501 15153427537 0010465 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_Ge_P2', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_Ge_P2
*/
class ParagonIE_Sodium_Core_Curve25519_Ge_P2
{
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $X;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Y;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Z;
/**
* ParagonIE_Sodium_Core_Curve25519_Ge_P2 constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $x
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $y
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $z
*/
public function __construct(
ParagonIE_Sodium_Core_Curve25519_Fe $x = null,
ParagonIE_Sodium_Core_Curve25519_Fe $y = null,
ParagonIE_Sodium_Core_Curve25519_Fe $z = null
) {
if ($x === null) {
$x = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->X = $x;
if ($y === null) {
$y = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Y = $y;
if ($z === null) {
$z = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Z = $z;
}
}
Core/Curve25519/Ge/P3.php 0000644 00000003172 15153427537 0010473 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_Ge_P3', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_Ge_P3
*/
class ParagonIE_Sodium_Core_Curve25519_Ge_P3
{
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $X;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Y;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $Z;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $T;
/**
* ParagonIE_Sodium_Core_Curve25519_Ge_P3 constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $x
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $y
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $z
* @param ParagonIE_Sodium_Core_Curve25519_Fe|null $t
*/
public function __construct(
ParagonIE_Sodium_Core_Curve25519_Fe $x = null,
ParagonIE_Sodium_Core_Curve25519_Fe $y = null,
ParagonIE_Sodium_Core_Curve25519_Fe $z = null,
ParagonIE_Sodium_Core_Curve25519_Fe $t = null
) {
if ($x === null) {
$x = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->X = $x;
if ($y === null) {
$y = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Y = $y;
if ($z === null) {
$z = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->Z = $z;
if ($t === null) {
$t = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->T = $t;
}
}
Core/Curve25519/Ge/Precomp.php 0000644 00000002650 15153427537 0011616 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_Ge_Precomp', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_Ge_Precomp
*/
class ParagonIE_Sodium_Core_Curve25519_Ge_Precomp
{
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $yplusx;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $yminusx;
/**
* @var ParagonIE_Sodium_Core_Curve25519_Fe
*/
public $xy2d;
/**
* ParagonIE_Sodium_Core_Curve25519_Ge_Precomp constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $yplusx
* @param ParagonIE_Sodium_Core_Curve25519_Fe $yminusx
* @param ParagonIE_Sodium_Core_Curve25519_Fe $xy2d
*/
public function __construct(
ParagonIE_Sodium_Core_Curve25519_Fe $yplusx = null,
ParagonIE_Sodium_Core_Curve25519_Fe $yminusx = null,
ParagonIE_Sodium_Core_Curve25519_Fe $xy2d = null
) {
if ($yplusx === null) {
$yplusx = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->yplusx = $yplusx;
if ($yminusx === null) {
$yminusx = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->yminusx = $yminusx;
if ($xy2d === null) {
$xy2d = new ParagonIE_Sodium_Core_Curve25519_Fe();
}
$this->xy2d = $xy2d;
}
}
Core/Curve25519/H.php 0000644 00000327571 15153427537 0010061 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519_H', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519_H
*
* This just contains the constants in the ref10/base.h file
*/
class ParagonIE_Sodium_Core_Curve25519_H extends ParagonIE_Sodium_Core_Util
{
/**
* See: libsodium's crypto_core/curve25519/ref10/base.h
*
* @var array<int, array<int, array<int, array<int, int>>>> Basically, int[32][8][3][10]
*/
protected static $base = array(
array(
array(
array(25967493, -14356035, 29566456, 3660896, -12694345, 4014787, 27544626, -11754271, -6079156, 2047605),
array(-12545711, 934262, -2722910, 3049990, -727428, 9406986, 12720692, 5043384, 19500929, -15469378),
array(-8738181, 4489570, 9688441, -14785194, 10184609, -12363380, 29287919, 11864899, -24514362, -4438546),
),
array(
array(-12815894, -12976347, -21581243, 11784320, -25355658, -2750717, -11717903, -3814571, -358445, -10211303),
array(-21703237, 6903825, 27185491, 6451973, -29577724, -9554005, -15616551, 11189268, -26829678, -5319081),
array(26966642, 11152617, 32442495, 15396054, 14353839, -12752335, -3128826, -9541118, -15472047, -4166697),
),
array(
array(15636291, -9688557, 24204773, -7912398, 616977, -16685262, 27787600, -14772189, 28944400, -1550024),
array(16568933, 4717097, -11556148, -1102322, 15682896, -11807043, 16354577, -11775962, 7689662, 11199574),
array(30464156, -5976125, -11779434, -15670865, 23220365, 15915852, 7512774, 10017326, -17749093, -9920357),
),
array(
array(-17036878, 13921892, 10945806, -6033431, 27105052, -16084379, -28926210, 15006023, 3284568, -6276540),
array(23599295, -8306047, -11193664, -7687416, 13236774, 10506355, 7464579, 9656445, 13059162, 10374397),
array(7798556, 16710257, 3033922, 2874086, 28997861, 2835604, 32406664, -3839045, -641708, -101325),
),
array(
array(10861363, 11473154, 27284546, 1981175, -30064349, 12577861, 32867885, 14515107, -15438304, 10819380),
array(4708026, 6336745, 20377586, 9066809, -11272109, 6594696, -25653668, 12483688, -12668491, 5581306),
array(19563160, 16186464, -29386857, 4097519, 10237984, -4348115, 28542350, 13850243, -23678021, -15815942),
),
array(
array(-15371964, -12862754, 32573250, 4720197, -26436522, 5875511, -19188627, -15224819, -9818940, -12085777),
array(-8549212, 109983, 15149363, 2178705, 22900618, 4543417, 3044240, -15689887, 1762328, 14866737),
array(-18199695, -15951423, -10473290, 1707278, -17185920, 3916101, -28236412, 3959421, 27914454, 4383652),
),
array(
array(5153746, 9909285, 1723747, -2777874, 30523605, 5516873, 19480852, 5230134, -23952439, -15175766),
array(-30269007, -3463509, 7665486, 10083793, 28475525, 1649722, 20654025, 16520125, 30598449, 7715701),
array(28881845, 14381568, 9657904, 3680757, -20181635, 7843316, -31400660, 1370708, 29794553, -1409300),
),
array(
array(14499471, -2729599, -33191113, -4254652, 28494862, 14271267, 30290735, 10876454, -33154098, 2381726),
array(-7195431, -2655363, -14730155, 462251, -27724326, 3941372, -6236617, 3696005, -32300832, 15351955),
array(27431194, 8222322, 16448760, -3907995, -18707002, 11938355, -32961401, -2970515, 29551813, 10109425),
),
),
array(
array(
array(-13657040, -13155431, -31283750, 11777098, 21447386, 6519384, -2378284, -1627556, 10092783, -4764171),
array(27939166, 14210322, 4677035, 16277044, -22964462, -12398139, -32508754, 12005538, -17810127, 12803510),
array(17228999, -15661624, -1233527, 300140, -1224870, -11714777, 30364213, -9038194, 18016357, 4397660),
),
array(
array(-10958843, -7690207, 4776341, -14954238, 27850028, -15602212, -26619106, 14544525, -17477504, 982639),
array(29253598, 15796703, -2863982, -9908884, 10057023, 3163536, 7332899, -4120128, -21047696, 9934963),
array(5793303, 16271923, -24131614, -10116404, 29188560, 1206517, -14747930, 4559895, -30123922, -10897950),
),
array(
array(-27643952, -11493006, 16282657, -11036493, 28414021, -15012264, 24191034, 4541697, -13338309, 5500568),
array(12650548, -1497113, 9052871, 11355358, -17680037, -8400164, -17430592, 12264343, 10874051, 13524335),
array(25556948, -3045990, 714651, 2510400, 23394682, -10415330, 33119038, 5080568, -22528059, 5376628),
),
array(
array(-26088264, -4011052, -17013699, -3537628, -6726793, 1920897, -22321305, -9447443, 4535768, 1569007),
array(-2255422, 14606630, -21692440, -8039818, 28430649, 8775819, -30494562, 3044290, 31848280, 12543772),
array(-22028579, 2943893, -31857513, 6777306, 13784462, -4292203, -27377195, -2062731, 7718482, 14474653),
),
array(
array(2385315, 2454213, -22631320, 46603, -4437935, -15680415, 656965, -7236665, 24316168, -5253567),
array(13741529, 10911568, -33233417, -8603737, -20177830, -1033297, 33040651, -13424532, -20729456, 8321686),
array(21060490, -2212744, 15712757, -4336099, 1639040, 10656336, 23845965, -11874838, -9984458, 608372),
),
array(
array(-13672732, -15087586, -10889693, -7557059, -6036909, 11305547, 1123968, -6780577, 27229399, 23887),
array(-23244140, -294205, -11744728, 14712571, -29465699, -2029617, 12797024, -6440308, -1633405, 16678954),
array(-29500620, 4770662, -16054387, 14001338, 7830047, 9564805, -1508144, -4795045, -17169265, 4904953),
),
array(
array(24059557, 14617003, 19037157, -15039908, 19766093, -14906429, 5169211, 16191880, 2128236, -4326833),
array(-16981152, 4124966, -8540610, -10653797, 30336522, -14105247, -29806336, 916033, -6882542, -2986532),
array(-22630907, 12419372, -7134229, -7473371, -16478904, 16739175, 285431, 2763829, 15736322, 4143876),
),
array(
array(2379352, 11839345, -4110402, -5988665, 11274298, 794957, 212801, -14594663, 23527084, -16458268),
array(33431127, -11130478, -17838966, -15626900, 8909499, 8376530, -32625340, 4087881, -15188911, -14416214),
array(1767683, 7197987, -13205226, -2022635, -13091350, 448826, 5799055, 4357868, -4774191, -16323038),
),
),
array(
array(
array(6721966, 13833823, -23523388, -1551314, 26354293, -11863321, 23365147, -3949732, 7390890, 2759800),
array(4409041, 2052381, 23373853, 10530217, 7676779, -12885954, 21302353, -4264057, 1244380, -12919645),
array(-4421239, 7169619, 4982368, -2957590, 30256825, -2777540, 14086413, 9208236, 15886429, 16489664),
),
array(
array(1996075, 10375649, 14346367, 13311202, -6874135, -16438411, -13693198, 398369, -30606455, -712933),
array(-25307465, 9795880, -2777414, 14878809, -33531835, 14780363, 13348553, 12076947, -30836462, 5113182),
array(-17770784, 11797796, 31950843, 13929123, -25888302, 12288344, -30341101, -7336386, 13847711, 5387222),
),
array(
array(-18582163, -3416217, 17824843, -2340966, 22744343, -10442611, 8763061, 3617786, -19600662, 10370991),
array(20246567, -14369378, 22358229, -543712, 18507283, -10413996, 14554437, -8746092, 32232924, 16763880),
array(9648505, 10094563, 26416693, 14745928, -30374318, -6472621, 11094161, 15689506, 3140038, -16510092),
),
array(
array(-16160072, 5472695, 31895588, 4744994, 8823515, 10365685, -27224800, 9448613, -28774454, 366295),
array(19153450, 11523972, -11096490, -6503142, -24647631, 5420647, 28344573, 8041113, 719605, 11671788),
array(8678025, 2694440, -6808014, 2517372, 4964326, 11152271, -15432916, -15266516, 27000813, -10195553),
),
array(
array(-15157904, 7134312, 8639287, -2814877, -7235688, 10421742, 564065, 5336097, 6750977, -14521026),
array(11836410, -3979488, 26297894, 16080799, 23455045, 15735944, 1695823, -8819122, 8169720, 16220347),
array(-18115838, 8653647, 17578566, -6092619, -8025777, -16012763, -11144307, -2627664, -5990708, -14166033),
),
array(
array(-23308498, -10968312, 15213228, -10081214, -30853605, -11050004, 27884329, 2847284, 2655861, 1738395),
array(-27537433, -14253021, -25336301, -8002780, -9370762, 8129821, 21651608, -3239336, -19087449, -11005278),
array(1533110, 3437855, 23735889, 459276, 29970501, 11335377, 26030092, 5821408, 10478196, 8544890),
),
array(
array(32173121, -16129311, 24896207, 3921497, 22579056, -3410854, 19270449, 12217473, 17789017, -3395995),
array(-30552961, -2228401, -15578829, -10147201, 13243889, 517024, 15479401, -3853233, 30460520, 1052596),
array(-11614875, 13323618, 32618793, 8175907, -15230173, 12596687, 27491595, -4612359, 3179268, -9478891),
),
array(
array(31947069, -14366651, -4640583, -15339921, -15125977, -6039709, -14756777, -16411740, 19072640, -9511060),
array(11685058, 11822410, 3158003, -13952594, 33402194, -4165066, 5977896, -5215017, 473099, 5040608),
array(-20290863, 8198642, -27410132, 11602123, 1290375, -2799760, 28326862, 1721092, -19558642, -3131606),
),
),
array(
array(
array(7881532, 10687937, 7578723, 7738378, -18951012, -2553952, 21820786, 8076149, -27868496, 11538389),
array(-19935666, 3899861, 18283497, -6801568, -15728660, -11249211, 8754525, 7446702, -5676054, 5797016),
array(-11295600, -3793569, -15782110, -7964573, 12708869, -8456199, 2014099, -9050574, -2369172, -5877341),
),
array(
array(-22472376, -11568741, -27682020, 1146375, 18956691, 16640559, 1192730, -3714199, 15123619, 10811505),
array(14352098, -3419715, -18942044, 10822655, 32750596, 4699007, -70363, 15776356, -28886779, -11974553),
array(-28241164, -8072475, -4978962, -5315317, 29416931, 1847569, -20654173, -16484855, 4714547, -9600655),
),
array(
array(15200332, 8368572, 19679101, 15970074, -31872674, 1959451, 24611599, -4543832, -11745876, 12340220),
array(12876937, -10480056, 33134381, 6590940, -6307776, 14872440, 9613953, 8241152, 15370987, 9608631),
array(-4143277, -12014408, 8446281, -391603, 4407738, 13629032, -7724868, 15866074, -28210621, -8814099),
),
array(
array(26660628, -15677655, 8393734, 358047, -7401291, 992988, -23904233, 858697, 20571223, 8420556),
array(14620715, 13067227, -15447274, 8264467, 14106269, 15080814, 33531827, 12516406, -21574435, -12476749),
array(236881, 10476226, 57258, -14677024, 6472998, 2466984, 17258519, 7256740, 8791136, 15069930),
),
array(
array(1276410, -9371918, 22949635, -16322807, -23493039, -5702186, 14711875, 4874229, -30663140, -2331391),
array(5855666, 4990204, -13711848, 7294284, -7804282, 1924647, -1423175, -7912378, -33069337, 9234253),
array(20590503, -9018988, 31529744, -7352666, -2706834, 10650548, 31559055, -11609587, 18979186, 13396066),
),
array(
array(24474287, 4968103, 22267082, 4407354, 24063882, -8325180, -18816887, 13594782, 33514650, 7021958),
array(-11566906, -6565505, -21365085, 15928892, -26158305, 4315421, -25948728, -3916677, -21480480, 12868082),
array(-28635013, 13504661, 19988037, -2132761, 21078225, 6443208, -21446107, 2244500, -12455797, -8089383),
),
array(
array(-30595528, 13793479, -5852820, 319136, -25723172, -6263899, 33086546, 8957937, -15233648, 5540521),
array(-11630176, -11503902, -8119500, -7643073, 2620056, 1022908, -23710744, -1568984, -16128528, -14962807),
array(23152971, 775386, 27395463, 14006635, -9701118, 4649512, 1689819, 892185, -11513277, -15205948),
),
array(
array(9770129, 9586738, 26496094, 4324120, 1556511, -3550024, 27453819, 4763127, -19179614, 5867134),
array(-32765025, 1927590, 31726409, -4753295, 23962434, -16019500, 27846559, 5931263, -29749703, -16108455),
array(27461885, -2977536, 22380810, 1815854, -23033753, -3031938, 7283490, -15148073, -19526700, 7734629),
),
),
array(
array(
array(-8010264, -9590817, -11120403, 6196038, 29344158, -13430885, 7585295, -3176626, 18549497, 15302069),
array(-32658337, -6171222, -7672793, -11051681, 6258878, 13504381, 10458790, -6418461, -8872242, 8424746),
array(24687205, 8613276, -30667046, -3233545, 1863892, -1830544, 19206234, 7134917, -11284482, -828919),
),
array(
array(11334899, -9218022, 8025293, 12707519, 17523892, -10476071, 10243738, -14685461, -5066034, 16498837),
array(8911542, 6887158, -9584260, -6958590, 11145641, -9543680, 17303925, -14124238, 6536641, 10543906),
array(-28946384, 15479763, -17466835, 568876, -1497683, 11223454, -2669190, -16625574, -27235709, 8876771),
),
array(
array(-25742899, -12566864, -15649966, -846607, -33026686, -796288, -33481822, 15824474, -604426, -9039817),
array(10330056, 70051, 7957388, -9002667, 9764902, 15609756, 27698697, -4890037, 1657394, 3084098),
array(10477963, -7470260, 12119566, -13250805, 29016247, -5365589, 31280319, 14396151, -30233575, 15272409),
),
array(
array(-12288309, 3169463, 28813183, 16658753, 25116432, -5630466, -25173957, -12636138, -25014757, 1950504),
array(-26180358, 9489187, 11053416, -14746161, -31053720, 5825630, -8384306, -8767532, 15341279, 8373727),
array(28685821, 7759505, -14378516, -12002860, -31971820, 4079242, 298136, -10232602, -2878207, 15190420),
),
array(
array(-32932876, 13806336, -14337485, -15794431, -24004620, 10940928, 8669718, 2742393, -26033313, -6875003),
array(-1580388, -11729417, -25979658, -11445023, -17411874, -10912854, 9291594, -16247779, -12154742, 6048605),
array(-30305315, 14843444, 1539301, 11864366, 20201677, 1900163, 13934231, 5128323, 11213262, 9168384),
),
array(
array(-26280513, 11007847, 19408960, -940758, -18592965, -4328580, -5088060, -11105150, 20470157, -16398701),
array(-23136053, 9282192, 14855179, -15390078, -7362815, -14408560, -22783952, 14461608, 14042978, 5230683),
array(29969567, -2741594, -16711867, -8552442, 9175486, -2468974, 21556951, 3506042, -5933891, -12449708),
),
array(
array(-3144746, 8744661, 19704003, 4581278, -20430686, 6830683, -21284170, 8971513, -28539189, 15326563),
array(-19464629, 10110288, -17262528, -3503892, -23500387, 1355669, -15523050, 15300988, -20514118, 9168260),
array(-5353335, 4488613, -23803248, 16314347, 7780487, -15638939, -28948358, 9601605, 33087103, -9011387),
),
array(
array(-19443170, -15512900, -20797467, -12445323, -29824447, 10229461, -27444329, -15000531, -5996870, 15664672),
array(23294591, -16632613, -22650781, -8470978, 27844204, 11461195, 13099750, -2460356, 18151676, 13417686),
array(-24722913, -4176517, -31150679, 5988919, -26858785, 6685065, 1661597, -12551441, 15271676, -15452665),
),
),
array(
array(
array(11433042, -13228665, 8239631, -5279517, -1985436, -725718, -18698764, 2167544, -6921301, -13440182),
array(-31436171, 15575146, 30436815, 12192228, -22463353, 9395379, -9917708, -8638997, 12215110, 12028277),
array(14098400, 6555944, 23007258, 5757252, -15427832, -12950502, 30123440, 4617780, -16900089, -655628),
),
array(
array(-4026201, -15240835, 11893168, 13718664, -14809462, 1847385, -15819999, 10154009, 23973261, -12684474),
array(-26531820, -3695990, -1908898, 2534301, -31870557, -16550355, 18341390, -11419951, 32013174, -10103539),
array(-25479301, 10876443, -11771086, -14625140, -12369567, 1838104, 21911214, 6354752, 4425632, -837822),
),
array(
array(-10433389, -14612966, 22229858, -3091047, -13191166, 776729, -17415375, -12020462, 4725005, 14044970),
array(19268650, -7304421, 1555349, 8692754, -21474059, -9910664, 6347390, -1411784, -19522291, -16109756),
array(-24864089, 12986008, -10898878, -5558584, -11312371, -148526, 19541418, 8180106, 9282262, 10282508),
),
array(
array(-26205082, 4428547, -8661196, -13194263, 4098402, -14165257, 15522535, 8372215, 5542595, -10702683),
array(-10562541, 14895633, 26814552, -16673850, -17480754, -2489360, -2781891, 6993761, -18093885, 10114655),
array(-20107055, -929418, 31422704, 10427861, -7110749, 6150669, -29091755, -11529146, 25953725, -106158),
),
array(
array(-4234397, -8039292, -9119125, 3046000, 2101609, -12607294, 19390020, 6094296, -3315279, 12831125),
array(-15998678, 7578152, 5310217, 14408357, -33548620, -224739, 31575954, 6326196, 7381791, -2421839),
array(-20902779, 3296811, 24736065, -16328389, 18374254, 7318640, 6295303, 8082724, -15362489, 12339664),
),
array(
array(27724736, 2291157, 6088201, -14184798, 1792727, 5857634, 13848414, 15768922, 25091167, 14856294),
array(-18866652, 8331043, 24373479, 8541013, -701998, -9269457, 12927300, -12695493, -22182473, -9012899),
array(-11423429, -5421590, 11632845, 3405020, 30536730, -11674039, -27260765, 13866390, 30146206, 9142070),
),
array(
array(3924129, -15307516, -13817122, -10054960, 12291820, -668366, -27702774, 9326384, -8237858, 4171294),
array(-15921940, 16037937, 6713787, 16606682, -21612135, 2790944, 26396185, 3731949, 345228, -5462949),
array(-21327538, 13448259, 25284571, 1143661, 20614966, -8849387, 2031539, -12391231, -16253183, -13582083),
),
array(
array(31016211, -16722429, 26371392, -14451233, -5027349, 14854137, 17477601, 3842657, 28012650, -16405420),
array(-5075835, 9368966, -8562079, -4600902, -15249953, 6970560, -9189873, 16292057, -8867157, 3507940),
array(29439664, 3537914, 23333589, 6997794, -17555561, -11018068, -15209202, -15051267, -9164929, 6580396),
),
),
array(
array(
array(-12185861, -7679788, 16438269, 10826160, -8696817, -6235611, 17860444, -9273846, -2095802, 9304567),
array(20714564, -4336911, 29088195, 7406487, 11426967, -5095705, 14792667, -14608617, 5289421, -477127),
array(-16665533, -10650790, -6160345, -13305760, 9192020, -1802462, 17271490, 12349094, 26939669, -3752294),
),
array(
array(-12889898, 9373458, 31595848, 16374215, 21471720, 13221525, -27283495, -12348559, -3698806, 117887),
array(22263325, -6560050, 3984570, -11174646, -15114008, -566785, 28311253, 5358056, -23319780, 541964),
array(16259219, 3261970, 2309254, -15534474, -16885711, -4581916, 24134070, -16705829, -13337066, -13552195),
),
array(
array(9378160, -13140186, -22845982, -12745264, 28198281, -7244098, -2399684, -717351, 690426, 14876244),
array(24977353, -314384, -8223969, -13465086, 28432343, -1176353, -13068804, -12297348, -22380984, 6618999),
array(-1538174, 11685646, 12944378, 13682314, -24389511, -14413193, 8044829, -13817328, 32239829, -5652762),
),
array(
array(-18603066, 4762990, -926250, 8885304, -28412480, -3187315, 9781647, -10350059, 32779359, 5095274),
array(-33008130, -5214506, -32264887, -3685216, 9460461, -9327423, -24601656, 14506724, 21639561, -2630236),
array(-16400943, -13112215, 25239338, 15531969, 3987758, -4499318, -1289502, -6863535, 17874574, 558605),
),
array(
array(-13600129, 10240081, 9171883, 16131053, -20869254, 9599700, 33499487, 5080151, 2085892, 5119761),
array(-22205145, -2519528, -16381601, 414691, -25019550, 2170430, 30634760, -8363614, -31999993, -5759884),
array(-6845704, 15791202, 8550074, -1312654, 29928809, -12092256, 27534430, -7192145, -22351378, 12961482),
),
array(
array(-24492060, -9570771, 10368194, 11582341, -23397293, -2245287, 16533930, 8206996, -30194652, -5159638),
array(-11121496, -3382234, 2307366, 6362031, -135455, 8868177, -16835630, 7031275, 7589640, 8945490),
array(-32152748, 8917967, 6661220, -11677616, -1192060, -15793393, 7251489, -11182180, 24099109, -14456170),
),
array(
array(5019558, -7907470, 4244127, -14714356, -26933272, 6453165, -19118182, -13289025, -6231896, -10280736),
array(10853594, 10721687, 26480089, 5861829, -22995819, 1972175, -1866647, -10557898, -3363451, -6441124),
array(-17002408, 5906790, 221599, -6563147, 7828208, -13248918, 24362661, -2008168, -13866408, 7421392),
),
array(
array(8139927, -6546497, 32257646, -5890546, 30375719, 1886181, -21175108, 15441252, 28826358, -4123029),
array(6267086, 9695052, 7709135, -16603597, -32869068, -1886135, 14795160, -7840124, 13746021, -1742048),
array(28584902, 7787108, -6732942, -15050729, 22846041, -7571236, -3181936, -363524, 4771362, -8419958),
),
),
array(
array(
array(24949256, 6376279, -27466481, -8174608, -18646154, -9930606, 33543569, -12141695, 3569627, 11342593),
array(26514989, 4740088, 27912651, 3697550, 19331575, -11472339, 6809886, 4608608, 7325975, -14801071),
array(-11618399, -14554430, -24321212, 7655128, -1369274, 5214312, -27400540, 10258390, -17646694, -8186692),
),
array(
array(11431204, 15823007, 26570245, 14329124, 18029990, 4796082, -31446179, 15580664, 9280358, -3973687),
array(-160783, -10326257, -22855316, -4304997, -20861367, -13621002, -32810901, -11181622, -15545091, 4387441),
array(-20799378, 12194512, 3937617, -5805892, -27154820, 9340370, -24513992, 8548137, 20617071, -7482001),
),
array(
array(-938825, -3930586, -8714311, 16124718, 24603125, -6225393, -13775352, -11875822, 24345683, 10325460),
array(-19855277, -1568885, -22202708, 8714034, 14007766, 6928528, 16318175, -1010689, 4766743, 3552007),
array(-21751364, -16730916, 1351763, -803421, -4009670, 3950935, 3217514, 14481909, 10988822, -3994762),
),
array(
array(15564307, -14311570, 3101243, 5684148, 30446780, -8051356, 12677127, -6505343, -8295852, 13296005),
array(-9442290, 6624296, -30298964, -11913677, -4670981, -2057379, 31521204, 9614054, -30000824, 12074674),
array(4771191, -135239, 14290749, -13089852, 27992298, 14998318, -1413936, -1556716, 29832613, -16391035),
),
array(
array(7064884, -7541174, -19161962, -5067537, -18891269, -2912736, 25825242, 5293297, -27122660, 13101590),
array(-2298563, 2439670, -7466610, 1719965, -27267541, -16328445, 32512469, -5317593, -30356070, -4190957),
array(-30006540, 10162316, -33180176, 3981723, -16482138, -13070044, 14413974, 9515896, 19568978, 9628812),
),
array(
array(33053803, 199357, 15894591, 1583059, 27380243, -4580435, -17838894, -6106839, -6291786, 3437740),
array(-18978877, 3884493, 19469877, 12726490, 15913552, 13614290, -22961733, 70104, 7463304, 4176122),
array(-27124001, 10659917, 11482427, -16070381, 12771467, -6635117, -32719404, -5322751, 24216882, 5944158),
),
array(
array(8894125, 7450974, -2664149, -9765752, -28080517, -12389115, 19345746, 14680796, 11632993, 5847885),
array(26942781, -2315317, 9129564, -4906607, 26024105, 11769399, -11518837, 6367194, -9727230, 4782140),
array(19916461, -4828410, -22910704, -11414391, 25606324, -5972441, 33253853, 8220911, 6358847, -1873857),
),
array(
array(801428, -2081702, 16569428, 11065167, 29875704, 96627, 7908388, -4480480, -13538503, 1387155),
array(19646058, 5720633, -11416706, 12814209, 11607948, 12749789, 14147075, 15156355, -21866831, 11835260),
array(19299512, 1155910, 28703737, 14890794, 2925026, 7269399, 26121523, 15467869, -26560550, 5052483),
),
),
array(
array(
array(-3017432, 10058206, 1980837, 3964243, 22160966, 12322533, -6431123, -12618185, 12228557, -7003677),
array(32944382, 14922211, -22844894, 5188528, 21913450, -8719943, 4001465, 13238564, -6114803, 8653815),
array(22865569, -4652735, 27603668, -12545395, 14348958, 8234005, 24808405, 5719875, 28483275, 2841751),
),
array(
array(-16420968, -1113305, -327719, -12107856, 21886282, -15552774, -1887966, -315658, 19932058, -12739203),
array(-11656086, 10087521, -8864888, -5536143, -19278573, -3055912, 3999228, 13239134, -4777469, -13910208),
array(1382174, -11694719, 17266790, 9194690, -13324356, 9720081, 20403944, 11284705, -14013818, 3093230),
),
array(
array(16650921, -11037932, -1064178, 1570629, -8329746, 7352753, -302424, 16271225, -24049421, -6691850),
array(-21911077, -5927941, -4611316, -5560156, -31744103, -10785293, 24123614, 15193618, -21652117, -16739389),
array(-9935934, -4289447, -25279823, 4372842, 2087473, 10399484, 31870908, 14690798, 17361620, 11864968),
),
array(
array(-11307610, 6210372, 13206574, 5806320, -29017692, -13967200, -12331205, -7486601, -25578460, -16240689),
array(14668462, -12270235, 26039039, 15305210, 25515617, 4542480, 10453892, 6577524, 9145645, -6443880),
array(5974874, 3053895, -9433049, -10385191, -31865124, 3225009, -7972642, 3936128, -5652273, -3050304),
),
array(
array(30625386, -4729400, -25555961, -12792866, -20484575, 7695099, 17097188, -16303496, -27999779, 1803632),
array(-3553091, 9865099, -5228566, 4272701, -5673832, -16689700, 14911344, 12196514, -21405489, 7047412),
array(20093277, 9920966, -11138194, -5343857, 13161587, 12044805, -32856851, 4124601, -32343828, -10257566),
),
array(
array(-20788824, 14084654, -13531713, 7842147, 19119038, -13822605, 4752377, -8714640, -21679658, 2288038),
array(-26819236, -3283715, 29965059, 3039786, -14473765, 2540457, 29457502, 14625692, -24819617, 12570232),
array(-1063558, -11551823, 16920318, 12494842, 1278292, -5869109, -21159943, -3498680, -11974704, 4724943),
),
array(
array(17960970, -11775534, -4140968, -9702530, -8876562, -1410617, -12907383, -8659932, -29576300, 1903856),
array(23134274, -14279132, -10681997, -1611936, 20684485, 15770816, -12989750, 3190296, 26955097, 14109738),
array(15308788, 5320727, -30113809, -14318877, 22902008, 7767164, 29425325, -11277562, 31960942, 11934971),
),
array(
array(-27395711, 8435796, 4109644, 12222639, -24627868, 14818669, 20638173, 4875028, 10491392, 1379718),
array(-13159415, 9197841, 3875503, -8936108, -1383712, -5879801, 33518459, 16176658, 21432314, 12180697),
array(-11787308, 11500838, 13787581, -13832590, -22430679, 10140205, 1465425, 12689540, -10301319, -13872883),
),
),
array(
array(
array(5414091, -15386041, -21007664, 9643570, 12834970, 1186149, -2622916, -1342231, 26128231, 6032912),
array(-26337395, -13766162, 32496025, -13653919, 17847801, -12669156, 3604025, 8316894, -25875034, -10437358),
array(3296484, 6223048, 24680646, -12246460, -23052020, 5903205, -8862297, -4639164, 12376617, 3188849),
),
array(
array(29190488, -14659046, 27549113, -1183516, 3520066, -10697301, 32049515, -7309113, -16109234, -9852307),
array(-14744486, -9309156, 735818, -598978, -20407687, -5057904, 25246078, -15795669, 18640741, -960977),
array(-6928835, -16430795, 10361374, 5642961, 4910474, 12345252, -31638386, -494430, 10530747, 1053335),
),
array(
array(-29265967, -14186805, -13538216, -12117373, -19457059, -10655384, -31462369, -2948985, 24018831, 15026644),
array(-22592535, -3145277, -2289276, 5953843, -13440189, 9425631, 25310643, 13003497, -2314791, -15145616),
array(-27419985, -603321, -8043984, -1669117, -26092265, 13987819, -27297622, 187899, -23166419, -2531735),
),
array(
array(-21744398, -13810475, 1844840, 5021428, -10434399, -15911473, 9716667, 16266922, -5070217, 726099),
array(29370922, -6053998, 7334071, -15342259, 9385287, 2247707, -13661962, -4839461, 30007388, -15823341),
array(-936379, 16086691, 23751945, -543318, -1167538, -5189036, 9137109, 730663, 9835848, 4555336),
),
array(
array(-23376435, 1410446, -22253753, -12899614, 30867635, 15826977, 17693930, 544696, -11985298, 12422646),
array(31117226, -12215734, -13502838, 6561947, -9876867, -12757670, -5118685, -4096706, 29120153, 13924425),
array(-17400879, -14233209, 19675799, -2734756, -11006962, -5858820, -9383939, -11317700, 7240931, -237388),
),
array(
array(-31361739, -11346780, -15007447, -5856218, -22453340, -12152771, 1222336, 4389483, 3293637, -15551743),
array(-16684801, -14444245, 11038544, 11054958, -13801175, -3338533, -24319580, 7733547, 12796905, -6335822),
array(-8759414, -10817836, -25418864, 10783769, -30615557, -9746811, -28253339, 3647836, 3222231, -11160462),
),
array(
array(18606113, 1693100, -25448386, -15170272, 4112353, 10045021, 23603893, -2048234, -7550776, 2484985),
array(9255317, -3131197, -12156162, -1004256, 13098013, -9214866, 16377220, -2102812, -19802075, -3034702),
array(-22729289, 7496160, -5742199, 11329249, 19991973, -3347502, -31718148, 9936966, -30097688, -10618797),
),
array(
array(21878590, -5001297, 4338336, 13643897, -3036865, 13160960, 19708896, 5415497, -7360503, -4109293),
array(27736861, 10103576, 12500508, 8502413, -3413016, -9633558, 10436918, -1550276, -23659143, -8132100),
array(19492550, -12104365, -29681976, -852630, -3208171, 12403437, 30066266, 8367329, 13243957, 8709688),
),
),
array(
array(
array(12015105, 2801261, 28198131, 10151021, 24818120, -4743133, -11194191, -5645734, 5150968, 7274186),
array(2831366, -12492146, 1478975, 6122054, 23825128, -12733586, 31097299, 6083058, 31021603, -9793610),
array(-2529932, -2229646, 445613, 10720828, -13849527, -11505937, -23507731, 16354465, 15067285, -14147707),
),
array(
array(7840942, 14037873, -33364863, 15934016, -728213, -3642706, 21403988, 1057586, -19379462, -12403220),
array(915865, -16469274, 15608285, -8789130, -24357026, 6060030, -17371319, 8410997, -7220461, 16527025),
array(32922597, -556987, 20336074, -16184568, 10903705, -5384487, 16957574, 52992, 23834301, 6588044),
),
array(
array(32752030, 11232950, 3381995, -8714866, 22652988, -10744103, 17159699, 16689107, -20314580, -1305992),
array(-4689649, 9166776, -25710296, -10847306, 11576752, 12733943, 7924251, -2752281, 1976123, -7249027),
array(21251222, 16309901, -2983015, -6783122, 30810597, 12967303, 156041, -3371252, 12331345, -8237197),
),
array(
array(8651614, -4477032, -16085636, -4996994, 13002507, 2950805, 29054427, -5106970, 10008136, -4667901),
array(31486080, 15114593, -14261250, 12951354, 14369431, -7387845, 16347321, -13662089, 8684155, -10532952),
array(19443825, 11385320, 24468943, -9659068, -23919258, 2187569, -26263207, -6086921, 31316348, 14219878),
),
array(
array(-28594490, 1193785, 32245219, 11392485, 31092169, 15722801, 27146014, 6992409, 29126555, 9207390),
array(32382935, 1110093, 18477781, 11028262, -27411763, -7548111, -4980517, 10843782, -7957600, -14435730),
array(2814918, 7836403, 27519878, -7868156, -20894015, -11553689, -21494559, 8550130, 28346258, 1994730),
),
array(
array(-19578299, 8085545, -14000519, -3948622, 2785838, -16231307, -19516951, 7174894, 22628102, 8115180),
array(-30405132, 955511, -11133838, -15078069, -32447087, -13278079, -25651578, 3317160, -9943017, 930272),
array(-15303681, -6833769, 28856490, 1357446, 23421993, 1057177, 24091212, -1388970, -22765376, -10650715),
),
array(
array(-22751231, -5303997, -12907607, -12768866, -15811511, -7797053, -14839018, -16554220, -1867018, 8398970),
array(-31969310, 2106403, -4736360, 1362501, 12813763, 16200670, 22981545, -6291273, 18009408, -15772772),
array(-17220923, -9545221, -27784654, 14166835, 29815394, 7444469, 29551787, -3727419, 19288549, 1325865),
),
array(
array(15100157, -15835752, -23923978, -1005098, -26450192, 15509408, 12376730, -3479146, 33166107, -8042750),
array(20909231, 13023121, -9209752, 16251778, -5778415, -8094914, 12412151, 10018715, 2213263, -13878373),
array(32529814, -11074689, 30361439, -16689753, -9135940, 1513226, 22922121, 6382134, -5766928, 8371348),
),
),
array(
array(
array(9923462, 11271500, 12616794, 3544722, -29998368, -1721626, 12891687, -8193132, -26442943, 10486144),
array(-22597207, -7012665, 8587003, -8257861, 4084309, -12970062, 361726, 2610596, -23921530, -11455195),
array(5408411, -1136691, -4969122, 10561668, 24145918, 14240566, 31319731, -4235541, 19985175, -3436086),
),
array(
array(-13994457, 16616821, 14549246, 3341099, 32155958, 13648976, -17577068, 8849297, 65030, 8370684),
array(-8320926, -12049626, 31204563, 5839400, -20627288, -1057277, -19442942, 6922164, 12743482, -9800518),
array(-2361371, 12678785, 28815050, 4759974, -23893047, 4884717, 23783145, 11038569, 18800704, 255233),
),
array(
array(-5269658, -1773886, 13957886, 7990715, 23132995, 728773, 13393847, 9066957, 19258688, -14753793),
array(-2936654, -10827535, -10432089, 14516793, -3640786, 4372541, -31934921, 2209390, -1524053, 2055794),
array(580882, 16705327, 5468415, -2683018, -30926419, -14696000, -7203346, -8994389, -30021019, 7394435),
),
array(
array(23838809, 1822728, -15738443, 15242727, 8318092, -3733104, -21672180, -3492205, -4821741, 14799921),
array(13345610, 9759151, 3371034, -16137791, 16353039, 8577942, 31129804, 13496856, -9056018, 7402518),
array(2286874, -4435931, -20042458, -2008336, -13696227, 5038122, 11006906, -15760352, 8205061, 1607563),
),
array(
array(14414086, -8002132, 3331830, -3208217, 22249151, -5594188, 18364661, -2906958, 30019587, -9029278),
array(-27688051, 1585953, -10775053, 931069, -29120221, -11002319, -14410829, 12029093, 9944378, 8024),
array(4368715, -3709630, 29874200, -15022983, -20230386, -11410704, -16114594, -999085, -8142388, 5640030),
),
array(
array(10299610, 13746483, 11661824, 16234854, 7630238, 5998374, 9809887, -16694564, 15219798, -14327783),
array(27425505, -5719081, 3055006, 10660664, 23458024, 595578, -15398605, -1173195, -18342183, 9742717),
array(6744077, 2427284, 26042789, 2720740, -847906, 1118974, 32324614, 7406442, 12420155, 1994844),
),
array(
array(14012521, -5024720, -18384453, -9578469, -26485342, -3936439, -13033478, -10909803, 24319929, -6446333),
array(16412690, -4507367, 10772641, 15929391, -17068788, -4658621, 10555945, -10484049, -30102368, -4739048),
array(22397382, -7767684, -9293161, -12792868, 17166287, -9755136, -27333065, 6199366, 21880021, -12250760),
),
array(
array(-4283307, 5368523, -31117018, 8163389, -30323063, 3209128, 16557151, 8890729, 8840445, 4957760),
array(-15447727, 709327, -6919446, -10870178, -29777922, 6522332, -21720181, 12130072, -14796503, 5005757),
array(-2114751, -14308128, 23019042, 15765735, -25269683, 6002752, 10183197, -13239326, -16395286, -2176112),
),
),
array(
array(
array(-19025756, 1632005, 13466291, -7995100, -23640451, 16573537, -32013908, -3057104, 22208662, 2000468),
array(3065073, -1412761, -25598674, -361432, -17683065, -5703415, -8164212, 11248527, -3691214, -7414184),
array(10379208, -6045554, 8877319, 1473647, -29291284, -12507580, 16690915, 2553332, -3132688, 16400289),
),
array(
array(15716668, 1254266, -18472690, 7446274, -8448918, 6344164, -22097271, -7285580, 26894937, 9132066),
array(24158887, 12938817, 11085297, -8177598, -28063478, -4457083, -30576463, 64452, -6817084, -2692882),
array(13488534, 7794716, 22236231, 5989356, 25426474, -12578208, 2350710, -3418511, -4688006, 2364226),
),
array(
array(16335052, 9132434, 25640582, 6678888, 1725628, 8517937, -11807024, -11697457, 15445875, -7798101),
array(29004207, -7867081, 28661402, -640412, -12794003, -7943086, 31863255, -4135540, -278050, -15759279),
array(-6122061, -14866665, -28614905, 14569919, -10857999, -3591829, 10343412, -6976290, -29828287, -10815811),
),
array(
array(27081650, 3463984, 14099042, -4517604, 1616303, -6205604, 29542636, 15372179, 17293797, 960709),
array(20263915, 11434237, -5765435, 11236810, 13505955, -10857102, -16111345, 6493122, -19384511, 7639714),
array(-2830798, -14839232, 25403038, -8215196, -8317012, -16173699, 18006287, -16043750, 29994677, -15808121),
),
array(
array(9769828, 5202651, -24157398, -13631392, -28051003, -11561624, -24613141, -13860782, -31184575, 709464),
array(12286395, 13076066, -21775189, -1176622, -25003198, 4057652, -32018128, -8890874, 16102007, 13205847),
array(13733362, 5599946, 10557076, 3195751, -5557991, 8536970, -25540170, 8525972, 10151379, 10394400),
),
array(
array(4024660, -16137551, 22436262, 12276534, -9099015, -2686099, 19698229, 11743039, -33302334, 8934414),
array(-15879800, -4525240, -8580747, -2934061, 14634845, -698278, -9449077, 3137094, -11536886, 11721158),
array(17555939, -5013938, 8268606, 2331751, -22738815, 9761013, 9319229, 8835153, -9205489, -1280045),
),
array(
array(-461409, -7830014, 20614118, 16688288, -7514766, -4807119, 22300304, 505429, 6108462, -6183415),
array(-5070281, 12367917, -30663534, 3234473, 32617080, -8422642, 29880583, -13483331, -26898490, -7867459),
array(-31975283, 5726539, 26934134, 10237677, -3173717, -605053, 24199304, 3795095, 7592688, -14992079),
),
array(
array(21594432, -14964228, 17466408, -4077222, 32537084, 2739898, 6407723, 12018833, -28256052, 4298412),
array(-20650503, -11961496, -27236275, 570498, 3767144, -1717540, 13891942, -1569194, 13717174, 10805743),
array(-14676630, -15644296, 15287174, 11927123, 24177847, -8175568, -796431, 14860609, -26938930, -5863836),
),
),
array(
array(
array(12962541, 5311799, -10060768, 11658280, 18855286, -7954201, 13286263, -12808704, -4381056, 9882022),
array(18512079, 11319350, -20123124, 15090309, 18818594, 5271736, -22727904, 3666879, -23967430, -3299429),
array(-6789020, -3146043, 16192429, 13241070, 15898607, -14206114, -10084880, -6661110, -2403099, 5276065),
),
array(
array(30169808, -5317648, 26306206, -11750859, 27814964, 7069267, 7152851, 3684982, 1449224, 13082861),
array(10342826, 3098505, 2119311, 193222, 25702612, 12233820, 23697382, 15056736, -21016438, -8202000),
array(-33150110, 3261608, 22745853, 7948688, 19370557, -15177665, -26171976, 6482814, -10300080, -11060101),
),
array(
array(32869458, -5408545, 25609743, 15678670, -10687769, -15471071, 26112421, 2521008, -22664288, 6904815),
array(29506923, 4457497, 3377935, -9796444, -30510046, 12935080, 1561737, 3841096, -29003639, -6657642),
array(10340844, -6630377, -18656632, -2278430, 12621151, -13339055, 30878497, -11824370, -25584551, 5181966),
),
array(
array(25940115, -12658025, 17324188, -10307374, -8671468, 15029094, 24396252, -16450922, -2322852, -12388574),
array(-21765684, 9916823, -1300409, 4079498, -1028346, 11909559, 1782390, 12641087, 20603771, -6561742),
array(-18882287, -11673380, 24849422, 11501709, 13161720, -4768874, 1925523, 11914390, 4662781, 7820689),
),
array(
array(12241050, -425982, 8132691, 9393934, 32846760, -1599620, 29749456, 12172924, 16136752, 15264020),
array(-10349955, -14680563, -8211979, 2330220, -17662549, -14545780, 10658213, 6671822, 19012087, 3772772),
array(3753511, -3421066, 10617074, 2028709, 14841030, -6721664, 28718732, -15762884, 20527771, 12988982),
),
array(
array(-14822485, -5797269, -3707987, 12689773, -898983, -10914866, -24183046, -10564943, 3299665, -12424953),
array(-16777703, -15253301, -9642417, 4978983, 3308785, 8755439, 6943197, 6461331, -25583147, 8991218),
array(-17226263, 1816362, -1673288, -6086439, 31783888, -8175991, -32948145, 7417950, -30242287, 1507265),
),
array(
array(29692663, 6829891, -10498800, 4334896, 20945975, -11906496, -28887608, 8209391, 14606362, -10647073),
array(-3481570, 8707081, 32188102, 5672294, 22096700, 1711240, -33020695, 9761487, 4170404, -2085325),
array(-11587470, 14855945, -4127778, -1531857, -26649089, 15084046, 22186522, 16002000, -14276837, -8400798),
),
array(
array(-4811456, 13761029, -31703877, -2483919, -3312471, 7869047, -7113572, -9620092, 13240845, 10965870),
array(-7742563, -8256762, -14768334, -13656260, -23232383, 12387166, 4498947, 14147411, 29514390, 4302863),
array(-13413405, -12407859, 20757302, -13801832, 14785143, 8976368, -5061276, -2144373, 17846988, -13971927),
),
),
array(
array(
array(-2244452, -754728, -4597030, -1066309, -6247172, 1455299, -21647728, -9214789, -5222701, 12650267),
array(-9906797, -16070310, 21134160, 12198166, -27064575, 708126, 387813, 13770293, -19134326, 10958663),
array(22470984, 12369526, 23446014, -5441109, -21520802, -9698723, -11772496, -11574455, -25083830, 4271862),
),
array(
array(-25169565, -10053642, -19909332, 15361595, -5984358, 2159192, 75375, -4278529, -32526221, 8469673),
array(15854970, 4148314, -8893890, 7259002, 11666551, 13824734, -30531198, 2697372, 24154791, -9460943),
array(15446137, -15806644, 29759747, 14019369, 30811221, -9610191, -31582008, 12840104, 24913809, 9815020),
),
array(
array(-4709286, -5614269, -31841498, -12288893, -14443537, 10799414, -9103676, 13438769, 18735128, 9466238),
array(11933045, 9281483, 5081055, -5183824, -2628162, -4905629, -7727821, -10896103, -22728655, 16199064),
array(14576810, 379472, -26786533, -8317236, -29426508, -10812974, -102766, 1876699, 30801119, 2164795),
),
array(
array(15995086, 3199873, 13672555, 13712240, -19378835, -4647646, -13081610, -15496269, -13492807, 1268052),
array(-10290614, -3659039, -3286592, 10948818, 23037027, 3794475, -3470338, -12600221, -17055369, 3565904),
array(29210088, -9419337, -5919792, -4952785, 10834811, -13327726, -16512102, -10820713, -27162222, -14030531),
),
array(
array(-13161890, 15508588, 16663704, -8156150, -28349942, 9019123, -29183421, -3769423, 2244111, -14001979),
array(-5152875, -3800936, -9306475, -6071583, 16243069, 14684434, -25673088, -16180800, 13491506, 4641841),
array(10813417, 643330, -19188515, -728916, 30292062, -16600078, 27548447, -7721242, 14476989, -12767431),
),
array(
array(10292079, 9984945, 6481436, 8279905, -7251514, 7032743, 27282937, -1644259, -27912810, 12651324),
array(-31185513, -813383, 22271204, 11835308, 10201545, 15351028, 17099662, 3988035, 21721536, -3148940),
array(10202177, -6545839, -31373232, -9574638, -32150642, -8119683, -12906320, 3852694, 13216206, 14842320),
),
array(
array(-15815640, -10601066, -6538952, -7258995, -6984659, -6581778, -31500847, 13765824, -27434397, 9900184),
array(14465505, -13833331, -32133984, -14738873, -27443187, 12990492, 33046193, 15796406, -7051866, -8040114),
array(30924417, -8279620, 6359016, -12816335, 16508377, 9071735, -25488601, 15413635, 9524356, -7018878),
),
array(
array(12274201, -13175547, 32627641, -1785326, 6736625, 13267305, 5237659, -5109483, 15663516, 4035784),
array(-2951309, 8903985, 17349946, 601635, -16432815, -4612556, -13732739, -15889334, -22258478, 4659091),
array(-16916263, -4952973, -30393711, -15158821, 20774812, 15897498, 5736189, 15026997, -2178256, -13455585),
),
),
array(
array(
array(-8858980, -2219056, 28571666, -10155518, -474467, -10105698, -3801496, 278095, 23440562, -290208),
array(10226241, -5928702, 15139956, 120818, -14867693, 5218603, 32937275, 11551483, -16571960, -7442864),
array(17932739, -12437276, -24039557, 10749060, 11316803, 7535897, 22503767, 5561594, -3646624, 3898661),
),
array(
array(7749907, -969567, -16339731, -16464, -25018111, 15122143, -1573531, 7152530, 21831162, 1245233),
array(26958459, -14658026, 4314586, 8346991, -5677764, 11960072, -32589295, -620035, -30402091, -16716212),
array(-12165896, 9166947, 33491384, 13673479, 29787085, 13096535, 6280834, 14587357, -22338025, 13987525),
),
array(
array(-24349909, 7778775, 21116000, 15572597, -4833266, -5357778, -4300898, -5124639, -7469781, -2858068),
array(9681908, -6737123, -31951644, 13591838, -6883821, 386950, 31622781, 6439245, -14581012, 4091397),
array(-8426427, 1470727, -28109679, -1596990, 3978627, -5123623, -19622683, 12092163, 29077877, -14741988),
),
array(
array(5269168, -6859726, -13230211, -8020715, 25932563, 1763552, -5606110, -5505881, -20017847, 2357889),
array(32264008, -15407652, -5387735, -1160093, -2091322, -3946900, 23104804, -12869908, 5727338, 189038),
array(14609123, -8954470, -6000566, -16622781, -14577387, -7743898, -26745169, 10942115, -25888931, -14884697),
),
array(
array(20513500, 5557931, -15604613, 7829531, 26413943, -2019404, -21378968, 7471781, 13913677, -5137875),
array(-25574376, 11967826, 29233242, 12948236, -6754465, 4713227, -8940970, 14059180, 12878652, 8511905),
array(-25656801, 3393631, -2955415, -7075526, -2250709, 9366908, -30223418, 6812974, 5568676, -3127656),
),
array(
array(11630004, 12144454, 2116339, 13606037, 27378885, 15676917, -17408753, -13504373, -14395196, 8070818),
array(27117696, -10007378, -31282771, -5570088, 1127282, 12772488, -29845906, 10483306, -11552749, -1028714),
array(10637467, -5688064, 5674781, 1072708, -26343588, -6982302, -1683975, 9177853, -27493162, 15431203),
),
array(
array(20525145, 10892566, -12742472, 12779443, -29493034, 16150075, -28240519, 14943142, -15056790, -7935931),
array(-30024462, 5626926, -551567, -9981087, 753598, 11981191, 25244767, -3239766, -3356550, 9594024),
array(-23752644, 2636870, -5163910, -10103818, 585134, 7877383, 11345683, -6492290, 13352335, -10977084),
),
array(
array(-1931799, -5407458, 3304649, -12884869, 17015806, -4877091, -29783850, -7752482, -13215537, -319204),
array(20239939, 6607058, 6203985, 3483793, -18386976, -779229, -20723742, 15077870, -22750759, 14523817),
array(27406042, -6041657, 27423596, -4497394, 4996214, 10002360, -28842031, -4545494, -30172742, -4805667),
),
),
array(
array(
array(11374242, 12660715, 17861383, -12540833, 10935568, 1099227, -13886076, -9091740, -27727044, 11358504),
array(-12730809, 10311867, 1510375, 10778093, -2119455, -9145702, 32676003, 11149336, -26123651, 4985768),
array(-19096303, 341147, -6197485, -239033, 15756973, -8796662, -983043, 13794114, -19414307, -15621255),
),
array(
array(6490081, 11940286, 25495923, -7726360, 8668373, -8751316, 3367603, 6970005, -1691065, -9004790),
array(1656497, 13457317, 15370807, 6364910, 13605745, 8362338, -19174622, -5475723, -16796596, -5031438),
array(-22273315, -13524424, -64685, -4334223, -18605636, -10921968, -20571065, -7007978, -99853, -10237333),
),
array(
array(17747465, 10039260, 19368299, -4050591, -20630635, -16041286, 31992683, -15857976, -29260363, -5511971),
array(31932027, -4986141, -19612382, 16366580, 22023614, 88450, 11371999, -3744247, 4882242, -10626905),
array(29796507, 37186, 19818052, 10115756, -11829032, 3352736, 18551198, 3272828, -5190932, -4162409),
),
array(
array(12501286, 4044383, -8612957, -13392385, -32430052, 5136599, -19230378, -3529697, 330070, -3659409),
array(6384877, 2899513, 17807477, 7663917, -2358888, 12363165, 25366522, -8573892, -271295, 12071499),
array(-8365515, -4042521, 25133448, -4517355, -6211027, 2265927, -32769618, 1936675, -5159697, 3829363),
),
array(
array(28425966, -5835433, -577090, -4697198, -14217555, 6870930, 7921550, -6567787, 26333140, 14267664),
array(-11067219, 11871231, 27385719, -10559544, -4585914, -11189312, 10004786, -8709488, -21761224, 8930324),
array(-21197785, -16396035, 25654216, -1725397, 12282012, 11008919, 1541940, 4757911, -26491501, -16408940),
),
array(
array(13537262, -7759490, -20604840, 10961927, -5922820, -13218065, -13156584, 6217254, -15943699, 13814990),
array(-17422573, 15157790, 18705543, 29619, 24409717, -260476, 27361681, 9257833, -1956526, -1776914),
array(-25045300, -10191966, 15366585, 15166509, -13105086, 8423556, -29171540, 12361135, -18685978, 4578290),
),
array(
array(24579768, 3711570, 1342322, -11180126, -27005135, 14124956, -22544529, 14074919, 21964432, 8235257),
array(-6528613, -2411497, 9442966, -5925588, 12025640, -1487420, -2981514, -1669206, 13006806, 2355433),
array(-16304899, -13605259, -6632427, -5142349, 16974359, -10911083, 27202044, 1719366, 1141648, -12796236),
),
array(
array(-12863944, -13219986, -8318266, -11018091, -6810145, -4843894, 13475066, -3133972, 32674895, 13715045),
array(11423335, -5468059, 32344216, 8962751, 24989809, 9241752, -13265253, 16086212, -28740881, -15642093),
array(-1409668, 12530728, -6368726, 10847387, 19531186, -14132160, -11709148, 7791794, -27245943, 4383347),
),
),
array(
array(
array(-28970898, 5271447, -1266009, -9736989, -12455236, 16732599, -4862407, -4906449, 27193557, 6245191),
array(-15193956, 5362278, -1783893, 2695834, 4960227, 12840725, 23061898, 3260492, 22510453, 8577507),
array(-12632451, 11257346, -32692994, 13548177, -721004, 10879011, 31168030, 13952092, -29571492, -3635906),
),
array(
array(3877321, -9572739, 32416692, 5405324, -11004407, -13656635, 3759769, 11935320, 5611860, 8164018),
array(-16275802, 14667797, 15906460, 12155291, -22111149, -9039718, 32003002, -8832289, 5773085, -8422109),
array(-23788118, -8254300, 1950875, 8937633, 18686727, 16459170, -905725, 12376320, 31632953, 190926),
),
array(
array(-24593607, -16138885, -8423991, 13378746, 14162407, 6901328, -8288749, 4508564, -25341555, -3627528),
array(8884438, -5884009, 6023974, 10104341, -6881569, -4941533, 18722941, -14786005, -1672488, 827625),
array(-32720583, -16289296, -32503547, 7101210, 13354605, 2659080, -1800575, -14108036, -24878478, 1541286),
),
array(
array(2901347, -1117687, 3880376, -10059388, -17620940, -3612781, -21802117, -3567481, 20456845, -1885033),
array(27019610, 12299467, -13658288, -1603234, -12861660, -4861471, -19540150, -5016058, 29439641, 15138866),
array(21536104, -6626420, -32447818, -10690208, -22408077, 5175814, -5420040, -16361163, 7779328, 109896),
),
array(
array(30279744, 14648750, -8044871, 6425558, 13639621, -743509, 28698390, 12180118, 23177719, -554075),
array(26572847, 3405927, -31701700, 12890905, -19265668, 5335866, -6493768, 2378492, 4439158, -13279347),
array(-22716706, 3489070, -9225266, -332753, 18875722, -1140095, 14819434, -12731527, -17717757, -5461437),
),
array(
array(-5056483, 16566551, 15953661, 3767752, -10436499, 15627060, -820954, 2177225, 8550082, -15114165),
array(-18473302, 16596775, -381660, 15663611, 22860960, 15585581, -27844109, -3582739, -23260460, -8428588),
array(-32480551, 15707275, -8205912, -5652081, 29464558, 2713815, -22725137, 15860482, -21902570, 1494193),
),
array(
array(-19562091, -14087393, -25583872, -9299552, 13127842, 759709, 21923482, 16529112, 8742704, 12967017),
array(-28464899, 1553205, 32536856, -10473729, -24691605, -406174, -8914625, -2933896, -29903758, 15553883),
array(21877909, 3230008, 9881174, 10539357, -4797115, 2841332, 11543572, 14513274, 19375923, -12647961),
),
array(
array(8832269, -14495485, 13253511, 5137575, 5037871, 4078777, 24880818, -6222716, 2862653, 9455043),
array(29306751, 5123106, 20245049, -14149889, 9592566, 8447059, -2077124, -2990080, 15511449, 4789663),
array(-20679756, 7004547, 8824831, -9434977, -4045704, -3750736, -5754762, 108893, 23513200, 16652362),
),
),
array(
array(
array(-33256173, 4144782, -4476029, -6579123, 10770039, -7155542, -6650416, -12936300, -18319198, 10212860),
array(2756081, 8598110, 7383731, -6859892, 22312759, -1105012, 21179801, 2600940, -9988298, -12506466),
array(-24645692, 13317462, -30449259, -15653928, 21365574, -10869657, 11344424, 864440, -2499677, -16710063),
),
array(
array(-26432803, 6148329, -17184412, -14474154, 18782929, -275997, -22561534, 211300, 2719757, 4940997),
array(-1323882, 3911313, -6948744, 14759765, -30027150, 7851207, 21690126, 8518463, 26699843, 5276295),
array(-13149873, -6429067, 9396249, 365013, 24703301, -10488939, 1321586, 149635, -15452774, 7159369),
),
array(
array(9987780, -3404759, 17507962, 9505530, 9731535, -2165514, 22356009, 8312176, 22477218, -8403385),
array(18155857, -16504990, 19744716, 9006923, 15154154, -10538976, 24256460, -4864995, -22548173, 9334109),
array(2986088, -4911893, 10776628, -3473844, 10620590, -7083203, -21413845, 14253545, -22587149, 536906),
),
array(
array(4377756, 8115836, 24567078, 15495314, 11625074, 13064599, 7390551, 10589625, 10838060, -15420424),
array(-19342404, 867880, 9277171, -3218459, -14431572, -1986443, 19295826, -15796950, 6378260, 699185),
array(7895026, 4057113, -7081772, -13077756, -17886831, -323126, -716039, 15693155, -5045064, -13373962),
),
array(
array(-7737563, -5869402, -14566319, -7406919, 11385654, 13201616, 31730678, -10962840, -3918636, -9669325),
array(10188286, -15770834, -7336361, 13427543, 22223443, 14896287, 30743455, 7116568, -21786507, 5427593),
array(696102, 13206899, 27047647, -10632082, 15285305, -9853179, 10798490, -4578720, 19236243, 12477404),
),
array(
array(-11229439, 11243796, -17054270, -8040865, -788228, -8167967, -3897669, 11180504, -23169516, 7733644),
array(17800790, -14036179, -27000429, -11766671, 23887827, 3149671, 23466177, -10538171, 10322027, 15313801),
array(26246234, 11968874, 32263343, -5468728, 6830755, -13323031, -15794704, -101982, -24449242, 10890804),
),
array(
array(-31365647, 10271363, -12660625, -6267268, 16690207, -13062544, -14982212, 16484931, 25180797, -5334884),
array(-586574, 10376444, -32586414, -11286356, 19801893, 10997610, 2276632, 9482883, 316878, 13820577),
array(-9882808, -4510367, -2115506, 16457136, -11100081, 11674996, 30756178, -7515054, 30696930, -3712849),
),
array(
array(32988917, -9603412, 12499366, 7910787, -10617257, -11931514, -7342816, -9985397, -32349517, 7392473),
array(-8855661, 15927861, 9866406, -3649411, -2396914, -16655781, -30409476, -9134995, 25112947, -2926644),
array(-2504044, -436966, 25621774, -5678772, 15085042, -5479877, -24884878, -13526194, 5537438, -13914319),
),
),
array(
array(
array(-11225584, 2320285, -9584280, 10149187, -33444663, 5808648, -14876251, -1729667, 31234590, 6090599),
array(-9633316, 116426, 26083934, 2897444, -6364437, -2688086, 609721, 15878753, -6970405, -9034768),
array(-27757857, 247744, -15194774, -9002551, 23288161, -10011936, -23869595, 6503646, 20650474, 1804084),
),
array(
array(-27589786, 15456424, 8972517, 8469608, 15640622, 4439847, 3121995, -10329713, 27842616, -202328),
array(-15306973, 2839644, 22530074, 10026331, 4602058, 5048462, 28248656, 5031932, -11375082, 12714369),
array(20807691, -7270825, 29286141, 11421711, -27876523, -13868230, -21227475, 1035546, -19733229, 12796920),
),
array(
array(12076899, -14301286, -8785001, -11848922, -25012791, 16400684, -17591495, -12899438, 3480665, -15182815),
array(-32361549, 5457597, 28548107, 7833186, 7303070, -11953545, -24363064, -15921875, -33374054, 2771025),
array(-21389266, 421932, 26597266, 6860826, 22486084, -6737172, -17137485, -4210226, -24552282, 15673397),
),
array(
array(-20184622, 2338216, 19788685, -9620956, -4001265, -8740893, -20271184, 4733254, 3727144, -12934448),
array(6120119, 814863, -11794402, -622716, 6812205, -15747771, 2019594, 7975683, 31123697, -10958981),
array(30069250, -11435332, 30434654, 2958439, 18399564, -976289, 12296869, 9204260, -16432438, 9648165),
),
array(
array(32705432, -1550977, 30705658, 7451065, -11805606, 9631813, 3305266, 5248604, -26008332, -11377501),
array(17219865, 2375039, -31570947, -5575615, -19459679, 9219903, 294711, 15298639, 2662509, -16297073),
array(-1172927, -7558695, -4366770, -4287744, -21346413, -8434326, 32087529, -1222777, 32247248, -14389861),
),
array(
array(14312628, 1221556, 17395390, -8700143, -4945741, -8684635, -28197744, -9637817, -16027623, -13378845),
array(-1428825, -9678990, -9235681, 6549687, -7383069, -468664, 23046502, 9803137, 17597934, 2346211),
array(18510800, 15337574, 26171504, 981392, -22241552, 7827556, -23491134, -11323352, 3059833, -11782870),
),
array(
array(10141598, 6082907, 17829293, -1947643, 9830092, 13613136, -25556636, -5544586, -33502212, 3592096),
array(33114168, -15889352, -26525686, -13343397, 33076705, 8716171, 1151462, 1521897, -982665, -6837803),
array(-32939165, -4255815, 23947181, -324178, -33072974, -12305637, -16637686, 3891704, 26353178, 693168),
),
array(
array(30374239, 1595580, -16884039, 13186931, 4600344, 406904, 9585294, -400668, 31375464, 14369965),
array(-14370654, -7772529, 1510301, 6434173, -18784789, -6262728, 32732230, -13108839, 17901441, 16011505),
array(18171223, -11934626, -12500402, 15197122, -11038147, -15230035, -19172240, -16046376, 8764035, 12309598),
),
),
array(
array(
array(5975908, -5243188, -19459362, -9681747, -11541277, 14015782, -23665757, 1228319, 17544096, -10593782),
array(5811932, -1715293, 3442887, -2269310, -18367348, -8359541, -18044043, -15410127, -5565381, 12348900),
array(-31399660, 11407555, 25755363, 6891399, -3256938, 14872274, -24849353, 8141295, -10632534, -585479),
),
array(
array(-12675304, 694026, -5076145, 13300344, 14015258, -14451394, -9698672, -11329050, 30944593, 1130208),
array(8247766, -6710942, -26562381, -7709309, -14401939, -14648910, 4652152, 2488540, 23550156, -271232),
array(17294316, -3788438, 7026748, 15626851, 22990044, 113481, 2267737, -5908146, -408818, -137719),
),
array(
array(16091085, -16253926, 18599252, 7340678, 2137637, -1221657, -3364161, 14550936, 3260525, -7166271),
array(-4910104, -13332887, 18550887, 10864893, -16459325, -7291596, -23028869, -13204905, -12748722, 2701326),
array(-8574695, 16099415, 4629974, -16340524, -20786213, -6005432, -10018363, 9276971, 11329923, 1862132),
),
array(
array(14763076, -15903608, -30918270, 3689867, 3511892, 10313526, -21951088, 12219231, -9037963, -940300),
array(8894987, -3446094, 6150753, 3013931, 301220, 15693451, -31981216, -2909717, -15438168, 11595570),
array(15214962, 3537601, -26238722, -14058872, 4418657, -15230761, 13947276, 10730794, -13489462, -4363670),
),
array(
array(-2538306, 7682793, 32759013, 263109, -29984731, -7955452, -22332124, -10188635, 977108, 699994),
array(-12466472, 4195084, -9211532, 550904, -15565337, 12917920, 19118110, -439841, -30534533, -14337913),
array(31788461, -14507657, 4799989, 7372237, 8808585, -14747943, 9408237, -10051775, 12493932, -5409317),
),
array(
array(-25680606, 5260744, -19235809, -6284470, -3695942, 16566087, 27218280, 2607121, 29375955, 6024730),
array(842132, -2794693, -4763381, -8722815, 26332018, -12405641, 11831880, 6985184, -9940361, 2854096),
array(-4847262, -7969331, 2516242, -5847713, 9695691, -7221186, 16512645, 960770, 12121869, 16648078),
),
array(
array(-15218652, 14667096, -13336229, 2013717, 30598287, -464137, -31504922, -7882064, 20237806, 2838411),
array(-19288047, 4453152, 15298546, -16178388, 22115043, -15972604, 12544294, -13470457, 1068881, -12499905),
array(-9558883, -16518835, 33238498, 13506958, 30505848, -1114596, -8486907, -2630053, 12521378, 4845654),
),
array(
array(-28198521, 10744108, -2958380, 10199664, 7759311, -13088600, 3409348, -873400, -6482306, -12885870),
array(-23561822, 6230156, -20382013, 10655314, -24040585, -11621172, 10477734, -1240216, -3113227, 13974498),
array(12966261, 15550616, -32038948, -1615346, 21025980, -629444, 5642325, 7188737, 18895762, 12629579),
),
),
array(
array(
array(14741879, -14946887, 22177208, -11721237, 1279741, 8058600, 11758140, 789443, 32195181, 3895677),
array(10758205, 15755439, -4509950, 9243698, -4879422, 6879879, -2204575, -3566119, -8982069, 4429647),
array(-2453894, 15725973, -20436342, -10410672, -5803908, -11040220, -7135870, -11642895, 18047436, -15281743),
),
array(
array(-25173001, -11307165, 29759956, 11776784, -22262383, -15820455, 10993114, -12850837, -17620701, -9408468),
array(21987233, 700364, -24505048, 14972008, -7774265, -5718395, 32155026, 2581431, -29958985, 8773375),
array(-25568350, 454463, -13211935, 16126715, 25240068, 8594567, 20656846, 12017935, -7874389, -13920155),
),
array(
array(6028182, 6263078, -31011806, -11301710, -818919, 2461772, -31841174, -5468042, -1721788, -2776725),
array(-12278994, 16624277, 987579, -5922598, 32908203, 1248608, 7719845, -4166698, 28408820, 6816612),
array(-10358094, -8237829, 19549651, -12169222, 22082623, 16147817, 20613181, 13982702, -10339570, 5067943),
),
array(
array(-30505967, -3821767, 12074681, 13582412, -19877972, 2443951, -19719286, 12746132, 5331210, -10105944),
array(30528811, 3601899, -1957090, 4619785, -27361822, -15436388, 24180793, -12570394, 27679908, -1648928),
array(9402404, -13957065, 32834043, 10838634, -26580150, -13237195, 26653274, -8685565, 22611444, -12715406),
),
array(
array(22190590, 1118029, 22736441, 15130463, -30460692, -5991321, 19189625, -4648942, 4854859, 6622139),
array(-8310738, -2953450, -8262579, -3388049, -10401731, -271929, 13424426, -3567227, 26404409, 13001963),
array(-31241838, -15415700, -2994250, 8939346, 11562230, -12840670, -26064365, -11621720, -15405155, 11020693),
),
array(
array(1866042, -7949489, -7898649, -10301010, 12483315, 13477547, 3175636, -12424163, 28761762, 1406734),
array(-448555, -1777666, 13018551, 3194501, -9580420, -11161737, 24760585, -4347088, 25577411, -13378680),
array(-24290378, 4759345, -690653, -1852816, 2066747, 10693769, -29595790, 9884936, -9368926, 4745410),
),
array(
array(-9141284, 6049714, -19531061, -4341411, -31260798, 9944276, -15462008, -11311852, 10931924, -11931931),
array(-16561513, 14112680, -8012645, 4817318, -8040464, -11414606, -22853429, 10856641, -20470770, 13434654),
array(22759489, -10073434, -16766264, -1871422, 13637442, -10168091, 1765144, -12654326, 28445307, -5364710),
),
array(
array(29875063, 12493613, 2795536, -3786330, 1710620, 15181182, -10195717, -8788675, 9074234, 1167180),
array(-26205683, 11014233, -9842651, -2635485, -26908120, 7532294, -18716888, -9535498, 3843903, 9367684),
array(-10969595, -6403711, 9591134, 9582310, 11349256, 108879, 16235123, 8601684, -139197, 4242895),
),
),
array(
array(
array(22092954, -13191123, -2042793, -11968512, 32186753, -11517388, -6574341, 2470660, -27417366, 16625501),
array(-11057722, 3042016, 13770083, -9257922, 584236, -544855, -7770857, 2602725, -27351616, 14247413),
array(6314175, -10264892, -32772502, 15957557, -10157730, 168750, -8618807, 14290061, 27108877, -1180880),
),
array(
array(-8586597, -7170966, 13241782, 10960156, -32991015, -13794596, 33547976, -11058889, -27148451, 981874),
array(22833440, 9293594, -32649448, -13618667, -9136966, 14756819, -22928859, -13970780, -10479804, -16197962),
array(-7768587, 3326786, -28111797, 10783824, 19178761, 14905060, 22680049, 13906969, -15933690, 3797899),
),
array(
array(21721356, -4212746, -12206123, 9310182, -3882239, -13653110, 23740224, -2709232, 20491983, -8042152),
array(9209270, -15135055, -13256557, -6167798, -731016, 15289673, 25947805, 15286587, 30997318, -6703063),
array(7392032, 16618386, 23946583, -8039892, -13265164, -1533858, -14197445, -2321576, 17649998, -250080),
),
array(
array(-9301088, -14193827, 30609526, -3049543, -25175069, -1283752, -15241566, -9525724, -2233253, 7662146),
array(-17558673, 1763594, -33114336, 15908610, -30040870, -12174295, 7335080, -8472199, -3174674, 3440183),
array(-19889700, -5977008, -24111293, -9688870, 10799743, -16571957, 40450, -4431835, 4862400, 1133),
),
array(
array(-32856209, -7873957, -5422389, 14860950, -16319031, 7956142, 7258061, 311861, -30594991, -7379421),
array(-3773428, -1565936, 28985340, 7499440, 24445838, 9325937, 29727763, 16527196, 18278453, 15405622),
array(-4381906, 8508652, -19898366, -3674424, -5984453, 15149970, -13313598, 843523, -21875062, 13626197),
),
array(
array(2281448, -13487055, -10915418, -2609910, 1879358, 16164207, -10783882, 3953792, 13340839, 15928663),
array(31727126, -7179855, -18437503, -8283652, 2875793, -16390330, -25269894, -7014826, -23452306, 5964753),
array(4100420, -5959452, -17179337, 6017714, -18705837, 12227141, -26684835, 11344144, 2538215, -7570755),
),
array(
array(-9433605, 6123113, 11159803, -2156608, 30016280, 14966241, -20474983, 1485421, -629256, -15958862),
array(-26804558, 4260919, 11851389, 9658551, -32017107, 16367492, -20205425, -13191288, 11659922, -11115118),
array(26180396, 10015009, -30844224, -8581293, 5418197, 9480663, 2231568, -10170080, 33100372, -1306171),
),
array(
array(15121113, -5201871, -10389905, 15427821, -27509937, -15992507, 21670947, 4486675, -5931810, -14466380),
array(16166486, -9483733, -11104130, 6023908, -31926798, -1364923, 2340060, -16254968, -10735770, -10039824),
array(28042865, -3557089, -12126526, 12259706, -3717498, -6945899, 6766453, -8689599, 18036436, 5803270),
),
),
array(
array(
array(-817581, 6763912, 11803561, 1585585, 10958447, -2671165, 23855391, 4598332, -6159431, -14117438),
array(-31031306, -14256194, 17332029, -2383520, 31312682, -5967183, 696309, 50292, -20095739, 11763584),
array(-594563, -2514283, -32234153, 12643980, 12650761, 14811489, 665117, -12613632, -19773211, -10713562),
),
array(
array(30464590, -11262872, -4127476, -12734478, 19835327, -7105613, -24396175, 2075773, -17020157, 992471),
array(18357185, -6994433, 7766382, 16342475, -29324918, 411174, 14578841, 8080033, -11574335, -10601610),
array(19598397, 10334610, 12555054, 2555664, 18821899, -10339780, 21873263, 16014234, 26224780, 16452269),
),
array(
array(-30223925, 5145196, 5944548, 16385966, 3976735, 2009897, -11377804, -7618186, -20533829, 3698650),
array(14187449, 3448569, -10636236, -10810935, -22663880, -3433596, 7268410, -10890444, 27394301, 12015369),
array(19695761, 16087646, 28032085, 12999827, 6817792, 11427614, 20244189, -1312777, -13259127, -3402461),
),
array(
array(30860103, 12735208, -1888245, -4699734, -16974906, 2256940, -8166013, 12298312, -8550524, -10393462),
array(-5719826, -11245325, -1910649, 15569035, 26642876, -7587760, -5789354, -15118654, -4976164, 12651793),
array(-2848395, 9953421, 11531313, -5282879, 26895123, -12697089, -13118820, -16517902, 9768698, -2533218),
),
array(
array(-24719459, 1894651, -287698, -4704085, 15348719, -8156530, 32767513, 12765450, 4940095, 10678226),
array(18860224, 15980149, -18987240, -1562570, -26233012, -11071856, -7843882, 13944024, -24372348, 16582019),
array(-15504260, 4970268, -29893044, 4175593, -20993212, -2199756, -11704054, 15444560, -11003761, 7989037),
),
array(
array(31490452, 5568061, -2412803, 2182383, -32336847, 4531686, -32078269, 6200206, -19686113, -14800171),
array(-17308668, -15879940, -31522777, -2831, -32887382, 16375549, 8680158, -16371713, 28550068, -6857132),
array(-28126887, -5688091, 16837845, -1820458, -6850681, 12700016, -30039981, 4364038, 1155602, 5988841),
),
array(
array(21890435, -13272907, -12624011, 12154349, -7831873, 15300496, 23148983, -4470481, 24618407, 8283181),
array(-33136107, -10512751, 9975416, 6841041, -31559793, 16356536, 3070187, -7025928, 1466169, 10740210),
array(-1509399, -15488185, -13503385, -10655916, 32799044, 909394, -13938903, -5779719, -32164649, -15327040),
),
array(
array(3960823, -14267803, -28026090, -15918051, -19404858, 13146868, 15567327, 951507, -3260321, -573935),
array(24740841, 5052253, -30094131, 8961361, 25877428, 6165135, -24368180, 14397372, -7380369, -6144105),
array(-28888365, 3510803, -28103278, -1158478, -11238128, -10631454, -15441463, -14453128, -1625486, -6494814),
),
),
array(
array(
array(793299, -9230478, 8836302, -6235707, -27360908, -2369593, 33152843, -4885251, -9906200, -621852),
array(5666233, 525582, 20782575, -8038419, -24538499, 14657740, 16099374, 1468826, -6171428, -15186581),
array(-4859255, -3779343, -2917758, -6748019, 7778750, 11688288, -30404353, -9871238, -1558923, -9863646),
),
array(
array(10896332, -7719704, 824275, 472601, -19460308, 3009587, 25248958, 14783338, -30581476, -15757844),
array(10566929, 12612572, -31944212, 11118703, -12633376, 12362879, 21752402, 8822496, 24003793, 14264025),
array(27713862, -7355973, -11008240, 9227530, 27050101, 2504721, 23886875, -13117525, 13958495, -5732453),
),
array(
array(-23481610, 4867226, -27247128, 3900521, 29838369, -8212291, -31889399, -10041781, 7340521, -15410068),
array(4646514, -8011124, -22766023, -11532654, 23184553, 8566613, 31366726, -1381061, -15066784, -10375192),
array(-17270517, 12723032, -16993061, 14878794, 21619651, -6197576, 27584817, 3093888, -8843694, 3849921),
),
array(
array(-9064912, 2103172, 25561640, -15125738, -5239824, 9582958, 32477045, -9017955, 5002294, -15550259),
array(-12057553, -11177906, 21115585, -13365155, 8808712, -12030708, 16489530, 13378448, -25845716, 12741426),
array(-5946367, 10645103, -30911586, 15390284, -3286982, -7118677, 24306472, 15852464, 28834118, -7646072),
),
array(
array(-17335748, -9107057, -24531279, 9434953, -8472084, -583362, -13090771, 455841, 20461858, 5491305),
array(13669248, -16095482, -12481974, -10203039, -14569770, -11893198, -24995986, 11293807, -28588204, -9421832),
array(28497928, 6272777, -33022994, 14470570, 8906179, -1225630, 18504674, -14165166, 29867745, -8795943),
),
array(
array(-16207023, 13517196, -27799630, -13697798, 24009064, -6373891, -6367600, -13175392, 22853429, -4012011),
array(24191378, 16712145, -13931797, 15217831, 14542237, 1646131, 18603514, -11037887, 12876623, -2112447),
array(17902668, 4518229, -411702, -2829247, 26878217, 5258055, -12860753, 608397, 16031844, 3723494),
),
array(
array(-28632773, 12763728, -20446446, 7577504, 33001348, -13017745, 17558842, -7872890, 23896954, -4314245),
array(-20005381, -12011952, 31520464, 605201, 2543521, 5991821, -2945064, 7229064, -9919646, -8826859),
array(28816045, 298879, -28165016, -15920938, 19000928, -1665890, -12680833, -2949325, -18051778, -2082915),
),
array(
array(16000882, -344896, 3493092, -11447198, -29504595, -13159789, 12577740, 16041268, -19715240, 7847707),
array(10151868, 10572098, 27312476, 7922682, 14825339, 4723128, -32855931, -6519018, -10020567, 3852848),
array(-11430470, 15697596, -21121557, -4420647, 5386314, 15063598, 16514493, -15932110, 29330899, -15076224),
),
),
array(
array(
array(-25499735, -4378794, -15222908, -6901211, 16615731, 2051784, 3303702, 15490, -27548796, 12314391),
array(15683520, -6003043, 18109120, -9980648, 15337968, -5997823, -16717435, 15921866, 16103996, -3731215),
array(-23169824, -10781249, 13588192, -1628807, -3798557, -1074929, -19273607, 5402699, -29815713, -9841101),
),
array(
array(23190676, 2384583, -32714340, 3462154, -29903655, -1529132, -11266856, 8911517, -25205859, 2739713),
array(21374101, -3554250, -33524649, 9874411, 15377179, 11831242, -33529904, 6134907, 4931255, 11987849),
array(-7732, -2978858, -16223486, 7277597, 105524, -322051, -31480539, 13861388, -30076310, 10117930),
),
array(
array(-29501170, -10744872, -26163768, 13051539, -25625564, 5089643, -6325503, 6704079, 12890019, 15728940),
array(-21972360, -11771379, -951059, -4418840, 14704840, 2695116, 903376, -10428139, 12885167, 8311031),
array(-17516482, 5352194, 10384213, -13811658, 7506451, 13453191, 26423267, 4384730, 1888765, -5435404),
),
array(
array(-25817338, -3107312, -13494599, -3182506, 30896459, -13921729, -32251644, -12707869, -19464434, -3340243),
array(-23607977, -2665774, -526091, 4651136, 5765089, 4618330, 6092245, 14845197, 17151279, -9854116),
array(-24830458, -12733720, -15165978, 10367250, -29530908, -265356, 22825805, -7087279, -16866484, 16176525),
),
array(
array(-23583256, 6564961, 20063689, 3798228, -4740178, 7359225, 2006182, -10363426, -28746253, -10197509),
array(-10626600, -4486402, -13320562, -5125317, 3432136, -6393229, 23632037, -1940610, 32808310, 1099883),
array(15030977, 5768825, -27451236, -2887299, -6427378, -15361371, -15277896, -6809350, 2051441, -15225865),
),
array(
array(-3362323, -7239372, 7517890, 9824992, 23555850, 295369, 5148398, -14154188, -22686354, 16633660),
array(4577086, -16752288, 13249841, -15304328, 19958763, -14537274, 18559670, -10759549, 8402478, -9864273),
array(-28406330, -1051581, -26790155, -907698, -17212414, -11030789, 9453451, -14980072, 17983010, 9967138),
),
array(
array(-25762494, 6524722, 26585488, 9969270, 24709298, 1220360, -1677990, 7806337, 17507396, 3651560),
array(-10420457, -4118111, 14584639, 15971087, -15768321, 8861010, 26556809, -5574557, -18553322, -11357135),
array(2839101, 14284142, 4029895, 3472686, 14402957, 12689363, -26642121, 8459447, -5605463, -7621941),
),
array(
array(-4839289, -3535444, 9744961, 2871048, 25113978, 3187018, -25110813, -849066, 17258084, -7977739),
array(18164541, -10595176, -17154882, -1542417, 19237078, -9745295, 23357533, -15217008, 26908270, 12150756),
array(-30264870, -7647865, 5112249, -7036672, -1499807, -6974257, 43168, -5537701, -32302074, 16215819),
),
),
array(
array(
array(-6898905, 9824394, -12304779, -4401089, -31397141, -6276835, 32574489, 12532905, -7503072, -8675347),
array(-27343522, -16515468, -27151524, -10722951, 946346, 16291093, 254968, 7168080, 21676107, -1943028),
array(21260961, -8424752, -16831886, -11920822, -23677961, 3968121, -3651949, -6215466, -3556191, -7913075),
),
array(
array(16544754, 13250366, -16804428, 15546242, -4583003, 12757258, -2462308, -8680336, -18907032, -9662799),
array(-2415239, -15577728, 18312303, 4964443, -15272530, -12653564, 26820651, 16690659, 25459437, -4564609),
array(-25144690, 11425020, 28423002, -11020557, -6144921, -15826224, 9142795, -2391602, -6432418, -1644817),
),
array(
array(-23104652, 6253476, 16964147, -3768872, -25113972, -12296437, -27457225, -16344658, 6335692, 7249989),
array(-30333227, 13979675, 7503222, -12368314, -11956721, -4621693, -30272269, 2682242, 25993170, -12478523),
array(4364628, 5930691, 32304656, -10044554, -8054781, 15091131, 22857016, -10598955, 31820368, 15075278),
),
array(
array(31879134, -8918693, 17258761, 90626, -8041836, -4917709, 24162788, -9650886, -17970238, 12833045),
array(19073683, 14851414, -24403169, -11860168, 7625278, 11091125, -19619190, 2074449, -9413939, 14905377),
array(24483667, -11935567, -2518866, -11547418, -1553130, 15355506, -25282080, 9253129, 27628530, -7555480),
),
array(
array(17597607, 8340603, 19355617, 552187, 26198470, -3176583, 4593324, -9157582, -14110875, 15297016),
array(510886, 14337390, -31785257, 16638632, 6328095, 2713355, -20217417, -11864220, 8683221, 2921426),
array(18606791, 11874196, 27155355, -5281482, -24031742, 6265446, -25178240, -1278924, 4674690, 13890525),
),
array(
array(13609624, 13069022, -27372361, -13055908, 24360586, 9592974, 14977157, 9835105, 4389687, 288396),
array(9922506, -519394, 13613107, 5883594, -18758345, -434263, -12304062, 8317628, 23388070, 16052080),
array(12720016, 11937594, -31970060, -5028689, 26900120, 8561328, -20155687, -11632979, -14754271, -10812892),
),
array(
array(15961858, 14150409, 26716931, -665832, -22794328, 13603569, 11829573, 7467844, -28822128, 929275),
array(11038231, -11582396, -27310482, -7316562, -10498527, -16307831, -23479533, -9371869, -21393143, 2465074),
array(20017163, -4323226, 27915242, 1529148, 12396362, 15675764, 13817261, -9658066, 2463391, -4622140),
),
array(
array(-16358878, -12663911, -12065183, 4996454, -1256422, 1073572, 9583558, 12851107, 4003896, 12673717),
array(-1731589, -15155870, -3262930, 16143082, 19294135, 13385325, 14741514, -9103726, 7903886, 2348101),
array(24536016, -16515207, 12715592, -3862155, 1511293, 10047386, -3842346, -7129159, -28377538, 10048127),
),
),
array(
array(
array(-12622226, -6204820, 30718825, 2591312, -10617028, 12192840, 18873298, -7297090, -32297756, 15221632),
array(-26478122, -11103864, 11546244, -1852483, 9180880, 7656409, -21343950, 2095755, 29769758, 6593415),
array(-31994208, -2907461, 4176912, 3264766, 12538965, -868111, 26312345, -6118678, 30958054, 8292160),
),
array(
array(31429822, -13959116, 29173532, 15632448, 12174511, -2760094, 32808831, 3977186, 26143136, -3148876),
array(22648901, 1402143, -22799984, 13746059, 7936347, 365344, -8668633, -1674433, -3758243, -2304625),
array(-15491917, 8012313, -2514730, -12702462, -23965846, -10254029, -1612713, -1535569, -16664475, 8194478),
),
array(
array(27338066, -7507420, -7414224, 10140405, -19026427, -6589889, 27277191, 8855376, 28572286, 3005164),
array(26287124, 4821776, 25476601, -4145903, -3764513, -15788984, -18008582, 1182479, -26094821, -13079595),
array(-7171154, 3178080, 23970071, 6201893, -17195577, -4489192, -21876275, -13982627, 32208683, -1198248),
),
array(
array(-16657702, 2817643, -10286362, 14811298, 6024667, 13349505, -27315504, -10497842, -27672585, -11539858),
array(15941029, -9405932, -21367050, 8062055, 31876073, -238629, -15278393, -1444429, 15397331, -4130193),
array(8934485, -13485467, -23286397, -13423241, -32446090, 14047986, 31170398, -1441021, -27505566, 15087184),
),
array(
array(-18357243, -2156491, 24524913, -16677868, 15520427, -6360776, -15502406, 11461896, 16788528, -5868942),
array(-1947386, 16013773, 21750665, 3714552, -17401782, -16055433, -3770287, -10323320, 31322514, -11615635),
array(21426655, -5650218, -13648287, -5347537, -28812189, -4920970, -18275391, -14621414, 13040862, -12112948),
),
array(
array(11293895, 12478086, -27136401, 15083750, -29307421, 14748872, 14555558, -13417103, 1613711, 4896935),
array(-25894883, 15323294, -8489791, -8057900, 25967126, -13425460, 2825960, -4897045, -23971776, -11267415),
array(-15924766, -5229880, -17443532, 6410664, 3622847, 10243618, 20615400, 12405433, -23753030, -8436416),
),
array(
array(-7091295, 12556208, -20191352, 9025187, -17072479, 4333801, 4378436, 2432030, 23097949, -566018),
array(4565804, -16025654, 20084412, -7842817, 1724999, 189254, 24767264, 10103221, -18512313, 2424778),
array(366633, -11976806, 8173090, -6890119, 30788634, 5745705, -7168678, 1344109, -3642553, 12412659),
),
array(
array(-24001791, 7690286, 14929416, -168257, -32210835, -13412986, 24162697, -15326504, -3141501, 11179385),
array(18289522, -14724954, 8056945, 16430056, -21729724, 7842514, -6001441, -1486897, -18684645, -11443503),
array(476239, 6601091, -6152790, -9723375, 17503545, -4863900, 27672959, 13403813, 11052904, 5219329),
),
),
array(
array(
array(20678546, -8375738, -32671898, 8849123, -5009758, 14574752, 31186971, -3973730, 9014762, -8579056),
array(-13644050, -10350239, -15962508, 5075808, -1514661, -11534600, -33102500, 9160280, 8473550, -3256838),
array(24900749, 14435722, 17209120, -15292541, -22592275, 9878983, -7689309, -16335821, -24568481, 11788948),
),
array(
array(-3118155, -11395194, -13802089, 14797441, 9652448, -6845904, -20037437, 10410733, -24568470, -1458691),
array(-15659161, 16736706, -22467150, 10215878, -9097177, 7563911, 11871841, -12505194, -18513325, 8464118),
array(-23400612, 8348507, -14585951, -861714, -3950205, -6373419, 14325289, 8628612, 33313881, -8370517),
),
array(
array(-20186973, -4967935, 22367356, 5271547, -1097117, -4788838, -24805667, -10236854, -8940735, -5818269),
array(-6948785, -1795212, -32625683, -16021179, 32635414, -7374245, 15989197, -12838188, 28358192, -4253904),
array(-23561781, -2799059, -32351682, -1661963, -9147719, 10429267, -16637684, 4072016, -5351664, 5596589),
),
array(
array(-28236598, -3390048, 12312896, 6213178, 3117142, 16078565, 29266239, 2557221, 1768301, 15373193),
array(-7243358, -3246960, -4593467, -7553353, -127927, -912245, -1090902, -4504991, -24660491, 3442910),
array(-30210571, 5124043, 14181784, 8197961, 18964734, -11939093, 22597931, 7176455, -18585478, 13365930),
),
array(
array(-7877390, -1499958, 8324673, 4690079, 6261860, 890446, 24538107, -8570186, -9689599, -3031667),
array(25008904, -10771599, -4305031, -9638010, 16265036, 15721635, 683793, -11823784, 15723479, -15163481),
array(-9660625, 12374379, -27006999, -7026148, -7724114, -12314514, 11879682, 5400171, 519526, -1235876),
),
array(
array(22258397, -16332233, -7869817, 14613016, -22520255, -2950923, -20353881, 7315967, 16648397, 7605640),
array(-8081308, -8464597, -8223311, 9719710, 19259459, -15348212, 23994942, -5281555, -9468848, 4763278),
array(-21699244, 9220969, -15730624, 1084137, -25476107, -2852390, 31088447, -7764523, -11356529, 728112),
),
array(
array(26047220, -11751471, -6900323, -16521798, 24092068, 9158119, -4273545, -12555558, -29365436, -5498272),
array(17510331, -322857, 5854289, 8403524, 17133918, -3112612, -28111007, 12327945, 10750447, 10014012),
array(-10312768, 3936952, 9156313, -8897683, 16498692, -994647, -27481051, -666732, 3424691, 7540221),
),
array(
array(30322361, -6964110, 11361005, -4143317, 7433304, 4989748, -7071422, -16317219, -9244265, 15258046),
array(13054562, -2779497, 19155474, 469045, -12482797, 4566042, 5631406, 2711395, 1062915, -5136345),
array(-19240248, -11254599, -29509029, -7499965, -5835763, 13005411, -6066489, 12194497, 32960380, 1459310),
),
),
array(
array(
array(19852034, 7027924, 23669353, 10020366, 8586503, -6657907, 394197, -6101885, 18638003, -11174937),
array(31395534, 15098109, 26581030, 8030562, -16527914, -5007134, 9012486, -7584354, -6643087, -5442636),
array(-9192165, -2347377, -1997099, 4529534, 25766844, 607986, -13222, 9677543, -32294889, -6456008),
),
array(
array(-2444496, -149937, 29348902, 8186665, 1873760, 12489863, -30934579, -7839692, -7852844, -8138429),
array(-15236356, -15433509, 7766470, 746860, 26346930, -10221762, -27333451, 10754588, -9431476, 5203576),
array(31834314, 14135496, -770007, 5159118, 20917671, -16768096, -7467973, -7337524, 31809243, 7347066),
),
array(
array(-9606723, -11874240, 20414459, 13033986, 13716524, -11691881, 19797970, -12211255, 15192876, -2087490),
array(-12663563, -2181719, 1168162, -3804809, 26747877, -14138091, 10609330, 12694420, 33473243, -13382104),
array(33184999, 11180355, 15832085, -11385430, -1633671, 225884, 15089336, -11023903, -6135662, 14480053),
),
array(
array(31308717, -5619998, 31030840, -1897099, 15674547, -6582883, 5496208, 13685227, 27595050, 8737275),
array(-20318852, -15150239, 10933843, -16178022, 8335352, -7546022, -31008351, -12610604, 26498114, 66511),
array(22644454, -8761729, -16671776, 4884562, -3105614, -13559366, 30540766, -4286747, -13327787, -7515095),
),
array(
array(-28017847, 9834845, 18617207, -2681312, -3401956, -13307506, 8205540, 13585437, -17127465, 15115439),
array(23711543, -672915, 31206561, -8362711, 6164647, -9709987, -33535882, -1426096, 8236921, 16492939),
array(-23910559, -13515526, -26299483, -4503841, 25005590, -7687270, 19574902, 10071562, 6708380, -6222424),
),
array(
array(2101391, -4930054, 19702731, 2367575, -15427167, 1047675, 5301017, 9328700, 29955601, -11678310),
array(3096359, 9271816, -21620864, -15521844, -14847996, -7592937, -25892142, -12635595, -9917575, 6216608),
array(-32615849, 338663, -25195611, 2510422, -29213566, -13820213, 24822830, -6146567, -26767480, 7525079),
),
array(
array(-23066649, -13985623, 16133487, -7896178, -3389565, 778788, -910336, -2782495, -19386633, 11994101),
array(21691500, -13624626, -641331, -14367021, 3285881, -3483596, -25064666, 9718258, -7477437, 13381418),
array(18445390, -4202236, 14979846, 11622458, -1727110, -3582980, 23111648, -6375247, 28535282, 15779576),
),
array(
array(30098053, 3089662, -9234387, 16662135, -21306940, 11308411, -14068454, 12021730, 9955285, -16303356),
array(9734894, -14576830, -7473633, -9138735, 2060392, 11313496, -18426029, 9924399, 20194861, 13380996),
array(-26378102, -7965207, -22167821, 15789297, -18055342, -6168792, -1984914, 15707771, 26342023, 10146099),
),
),
array(
array(
array(-26016874, -219943, 21339191, -41388, 19745256, -2878700, -29637280, 2227040, 21612326, -545728),
array(-13077387, 1184228, 23562814, -5970442, -20351244, -6348714, 25764461, 12243797, -20856566, 11649658),
array(-10031494, 11262626, 27384172, 2271902, 26947504, -15997771, 39944, 6114064, 33514190, 2333242),
),
array(
array(-21433588, -12421821, 8119782, 7219913, -21830522, -9016134, -6679750, -12670638, 24350578, -13450001),
array(-4116307, -11271533, -23886186, 4843615, -30088339, 690623, -31536088, -10406836, 8317860, 12352766),
array(18200138, -14475911, -33087759, -2696619, -23702521, -9102511, -23552096, -2287550, 20712163, 6719373),
),
array(
array(26656208, 6075253, -7858556, 1886072, -28344043, 4262326, 11117530, -3763210, 26224235, -3297458),
array(-17168938, -14854097, -3395676, -16369877, -19954045, 14050420, 21728352, 9493610, 18620611, -16428628),
array(-13323321, 13325349, 11432106, 5964811, 18609221, 6062965, -5269471, -9725556, -30701573, -16479657),
),
array(
array(-23860538, -11233159, 26961357, 1640861, -32413112, -16737940, 12248509, -5240639, 13735342, 1934062),
array(25089769, 6742589, 17081145, -13406266, 21909293, -16067981, -15136294, -3765346, -21277997, 5473616),
array(31883677, -7961101, 1083432, -11572403, 22828471, 13290673, -7125085, 12469656, 29111212, -5451014),
),
array(
array(24244947, -15050407, -26262976, 2791540, -14997599, 16666678, 24367466, 6388839, -10295587, 452383),
array(-25640782, -3417841, 5217916, 16224624, 19987036, -4082269, -24236251, -5915248, 15766062, 8407814),
array(-20406999, 13990231, 15495425, 16395525, 5377168, 15166495, -8917023, -4388953, -8067909, 2276718),
),
array(
array(30157918, 12924066, -17712050, 9245753, 19895028, 3368142, -23827587, 5096219, 22740376, -7303417),
array(2041139, -14256350, 7783687, 13876377, -25946985, -13352459, 24051124, 13742383, -15637599, 13295222),
array(33338237, -8505733, 12532113, 7977527, 9106186, -1715251, -17720195, -4612972, -4451357, -14669444),
),
array(
array(-20045281, 5454097, -14346548, 6447146, 28862071, 1883651, -2469266, -4141880, 7770569, 9620597),
array(23208068, 7979712, 33071466, 8149229, 1758231, -10834995, 30945528, -1694323, -33502340, -14767970),
array(1439958, -16270480, -1079989, -793782, 4625402, 10647766, -5043801, 1220118, 30494170, -11440799),
),
array(
array(-5037580, -13028295, -2970559, -3061767, 15640974, -6701666, -26739026, 926050, -1684339, -13333647),
array(13908495, -3549272, 30919928, -6273825, -21521863, 7989039, 9021034, 9078865, 3353509, 4033511),
array(-29663431, -15113610, 32259991, -344482, 24295849, -12912123, 23161163, 8839127, 27485041, 7356032),
),
),
array(
array(
array(9661027, 705443, 11980065, -5370154, -1628543, 14661173, -6346142, 2625015, 28431036, -16771834),
array(-23839233, -8311415, -25945511, 7480958, -17681669, -8354183, -22545972, 14150565, 15970762, 4099461),
array(29262576, 16756590, 26350592, -8793563, 8529671, -11208050, 13617293, -9937143, 11465739, 8317062),
),
array(
array(-25493081, -6962928, 32500200, -9419051, -23038724, -2302222, 14898637, 3848455, 20969334, -5157516),
array(-20384450, -14347713, -18336405, 13884722, -33039454, 2842114, -21610826, -3649888, 11177095, 14989547),
array(-24496721, -11716016, 16959896, 2278463, 12066309, 10137771, 13515641, 2581286, -28487508, 9930240),
),
array(
array(-17751622, -2097826, 16544300, -13009300, -15914807, -14949081, 18345767, -13403753, 16291481, -5314038),
array(-33229194, 2553288, 32678213, 9875984, 8534129, 6889387, -9676774, 6957617, 4368891, 9788741),
array(16660756, 7281060, -10830758, 12911820, 20108584, -8101676, -21722536, -8613148, 16250552, -11111103),
),
array(
array(-19765507, 2390526, -16551031, 14161980, 1905286, 6414907, 4689584, 10604807, -30190403, 4782747),
array(-1354539, 14736941, -7367442, -13292886, 7710542, -14155590, -9981571, 4383045, 22546403, 437323),
array(31665577, -12180464, -16186830, 1491339, -18368625, 3294682, 27343084, 2786261, -30633590, -14097016),
),
array(
array(-14467279, -683715, -33374107, 7448552, 19294360, 14334329, -19690631, 2355319, -19284671, -6114373),
array(15121312, -15796162, 6377020, -6031361, -10798111, -12957845, 18952177, 15496498, -29380133, 11754228),
array(-2637277, -13483075, 8488727, -14303896, 12728761, -1622493, 7141596, 11724556, 22761615, -10134141),
),
array(
array(16918416, 11729663, -18083579, 3022987, -31015732, -13339659, -28741185, -12227393, 32851222, 11717399),
array(11166634, 7338049, -6722523, 4531520, -29468672, -7302055, 31474879, 3483633, -1193175, -4030831),
array(-185635, 9921305, 31456609, -13536438, -12013818, 13348923, 33142652, 6546660, -19985279, -3948376),
),
array(
array(-32460596, 11266712, -11197107, -7899103, 31703694, 3855903, -8537131, -12833048, -30772034, -15486313),
array(-18006477, 12709068, 3991746, -6479188, -21491523, -10550425, -31135347, -16049879, 10928917, 3011958),
array(-6957757, -15594337, 31696059, 334240, 29576716, 14796075, -30831056, -12805180, 18008031, 10258577),
),
array(
array(-22448644, 15655569, 7018479, -4410003, -30314266, -1201591, -1853465, 1367120, 25127874, 6671743),
array(29701166, -14373934, -10878120, 9279288, -17568, 13127210, 21382910, 11042292, 25838796, 4642684),
array(-20430234, 14955537, -24126347, 8124619, -5369288, -5990470, 30468147, -13900640, 18423289, 4177476),
),
)
);
/**
* See: libsodium's crypto_core/curve25519/ref10/base2.h
*
* @var array basically int[8][3]
*/
protected static $base2 = array(
array(
array(25967493, -14356035, 29566456, 3660896, -12694345, 4014787, 27544626, -11754271, -6079156, 2047605),
array(-12545711, 934262, -2722910, 3049990, -727428, 9406986, 12720692, 5043384, 19500929, -15469378),
array(-8738181, 4489570, 9688441, -14785194, 10184609, -12363380, 29287919, 11864899, -24514362, -4438546),
),
array(
array(15636291, -9688557, 24204773, -7912398, 616977, -16685262, 27787600, -14772189, 28944400, -1550024),
array(16568933, 4717097, -11556148, -1102322, 15682896, -11807043, 16354577, -11775962, 7689662, 11199574),
array(30464156, -5976125, -11779434, -15670865, 23220365, 15915852, 7512774, 10017326, -17749093, -9920357),
),
array(
array(10861363, 11473154, 27284546, 1981175, -30064349, 12577861, 32867885, 14515107, -15438304, 10819380),
array(4708026, 6336745, 20377586, 9066809, -11272109, 6594696, -25653668, 12483688, -12668491, 5581306),
array(19563160, 16186464, -29386857, 4097519, 10237984, -4348115, 28542350, 13850243, -23678021, -15815942),
),
array(
array(5153746, 9909285, 1723747, -2777874, 30523605, 5516873, 19480852, 5230134, -23952439, -15175766),
array(-30269007, -3463509, 7665486, 10083793, 28475525, 1649722, 20654025, 16520125, 30598449, 7715701),
array(28881845, 14381568, 9657904, 3680757, -20181635, 7843316, -31400660, 1370708, 29794553, -1409300),
),
array(
array(-22518993, -6692182, 14201702, -8745502, -23510406, 8844726, 18474211, -1361450, -13062696, 13821877),
array(-6455177, -7839871, 3374702, -4740862, -27098617, -10571707, 31655028, -7212327, 18853322, -14220951),
array(4566830, -12963868, -28974889, -12240689, -7602672, -2830569, -8514358, -10431137, 2207753, -3209784),
),
array(
array(-25154831, -4185821, 29681144, 7868801, -6854661, -9423865, -12437364, -663000, -31111463, -16132436),
array(25576264, -2703214, 7349804, -11814844, 16472782, 9300885, 3844789, 15725684, 171356, 6466918),
array(23103977, 13316479, 9739013, -16149481, 817875, -15038942, 8965339, -14088058, -30714912, 16193877),
),
array(
array(-33521811, 3180713, -2394130, 14003687, -16903474, -16270840, 17238398, 4729455, -18074513, 9256800),
array(-25182317, -4174131, 32336398, 5036987, -21236817, 11360617, 22616405, 9761698, -19827198, 630305),
array(-13720693, 2639453, -24237460, -7406481, 9494427, -5774029, -6554551, -15960994, -2449256, -14291300),
),
array(
array(-3151181, -5046075, 9282714, 6866145, -31907062, -863023, -18940575, 15033784, 25105118, -7894876),
array(-24326370, 15950226, -31801215, -14592823, -11662737, -5090925, 1573892, -2625887, 2198790, -15804619),
array(-3099351, 10324967, -2241613, 7453183, -5446979, -2735503, -13812022, -16236442, -32461234, -12290683),
)
);
/**
* 37095705934669439343138083508754565189542113879843219016388785533085940283555
*
* @var array<int, int>
*/
protected static $d = array(
-10913610,
13857413,
-15372611,
6949391,
114729,
-8787816,
-6275908,
-3247719,
-18696448,
-12055116
);
/**
* 2 * d = 16295367250680780974490674513165176452449235426866156013048779062215315747161
*
* @var array<int, int>
*/
protected static $d2 = array(
-21827239,
-5839606,
-30745221,
13898782,
229458,
15978800,
-12551817,
-6495438,
29715968,
9444199
);
/**
* sqrt(-1)
*
* @var array<int, int>
*/
protected static $sqrtm1 = array(
-32595792,
-7943725,
9377950,
3500415,
12389472,
-272473,
-25146209,
-2005654,
326686,
11406482
);
/**
* 1 / sqrt(a - d)
*
* @var array<int, int>
*/
protected static $invsqrtamd = array(
6111485,
4156064,
-27798727,
12243468,
-25904040,
120897,
20826367,
-7060776,
6093568,
-1986012
);
/**
* sqrt(ad - 1) with a = -1 (mod p)
*
* @var array<int, int>
*/
protected static $sqrtadm1 = array(
24849947,
-153582,
-23613485,
6347715,
-21072328,
-667138,
-25271143,
-15367704,
-870347,
14525639
);
/**
* 1 - d ^ 2
*
* @var array<int, int>
*/
protected static $onemsqd = array(
6275446,
-16617371,
-22938544,
-3773710,
11667077,
7397348,
-27922721,
1766195,
-24433858,
672203
);
/**
* (d - 1) ^ 2
* @var array<int, int>
*/
protected static $sqdmone = array(
15551795,
-11097455,
-13425098,
-10125071,
-11896535,
10178284,
-26634327,
4729244,
-5282110,
-10116402
);
/*
* 2^252+27742317777372353535851937790883648493
static const unsigned char L[] = {
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7,
0xa2, 0xde, 0xf9, 0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
};
*/
const L = "\xed\xd3\xf5\x5c\x1a\x63\x12\x58\xd6\x9c\xf7\xa2\xde\xf9\xde\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10";
}
Core/Curve25519/README.md 0000644 00000000332 15153427537 0010417 0 ustar 00 # Curve25519 Data Structures
These are PHP implementation of the [structs used in the ref10 curve25519 code](https://github.com/jedisct1/libsodium/blob/master/src/libsodium/include/sodium/private/curve25519_ref10.h).
Core/Curve25519.php 0000644 00000426446 15153427537 0007673 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Curve25519', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Curve25519
*
* Implements Curve25519 core functions
*
* Based on the ref10 curve25519 code provided by libsodium
*
* @ref https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_core/curve25519/ref10/curve25519_ref10.c
*/
abstract class ParagonIE_Sodium_Core_Curve25519 extends ParagonIE_Sodium_Core_Curve25519_H
{
/**
* Get a field element of size 10 with a value of 0
*
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_0()
{
return ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
);
}
/**
* Get a field element of size 10 with a value of 1
*
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_1()
{
return ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(1, 0, 0, 0, 0, 0, 0, 0, 0, 0)
);
}
/**
* Add two field elements.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core_Curve25519_Fe $g
* @return ParagonIE_Sodium_Core_Curve25519_Fe
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedOperand
*/
public static function fe_add(
ParagonIE_Sodium_Core_Curve25519_Fe $f,
ParagonIE_Sodium_Core_Curve25519_Fe $g
) {
/** @var array<int, int> $arr */
$arr = array();
for ($i = 0; $i < 10; ++$i) {
$arr[$i] = (int) ($f[$i] + $g[$i]);
}
return ParagonIE_Sodium_Core_Curve25519_Fe::fromArray($arr);
}
/**
* Constant-time conditional move.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core_Curve25519_Fe $g
* @param int $b
* @return ParagonIE_Sodium_Core_Curve25519_Fe
* @psalm-suppress MixedAssignment
*/
public static function fe_cmov(
ParagonIE_Sodium_Core_Curve25519_Fe $f,
ParagonIE_Sodium_Core_Curve25519_Fe $g,
$b = 0
) {
/** @var array<int, int> $h */
$h = array();
$b *= -1;
for ($i = 0; $i < 10; ++$i) {
$x = (($f[$i] ^ $g[$i]) & $b);
$h[$i] = ($f[$i]) ^ $x;
}
return ParagonIE_Sodium_Core_Curve25519_Fe::fromArray($h);
}
/**
* Create a copy of a field element.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_copy(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$h = clone $f;
return $h;
}
/**
* Give: 32-byte string.
* Receive: A field element object to use for internal calculations.
*
* @internal You should not use this directly from another application
*
* @param string $s
* @return ParagonIE_Sodium_Core_Curve25519_Fe
* @throws RangeException
* @throws TypeError
*/
public static function fe_frombytes($s)
{
if (self::strlen($s) !== 32) {
throw new RangeException('Expected a 32-byte string.');
}
$h0 = self::load_4($s);
$h1 = self::load_3(self::substr($s, 4, 3)) << 6;
$h2 = self::load_3(self::substr($s, 7, 3)) << 5;
$h3 = self::load_3(self::substr($s, 10, 3)) << 3;
$h4 = self::load_3(self::substr($s, 13, 3)) << 2;
$h5 = self::load_4(self::substr($s, 16, 4));
$h6 = self::load_3(self::substr($s, 20, 3)) << 7;
$h7 = self::load_3(self::substr($s, 23, 3)) << 5;
$h8 = self::load_3(self::substr($s, 26, 3)) << 4;
$h9 = (self::load_3(self::substr($s, 29, 3)) & 8388607) << 2;
$carry9 = ($h9 + (1 << 24)) >> 25;
$h0 += self::mul($carry9, 19, 5);
$h9 -= $carry9 << 25;
$carry1 = ($h1 + (1 << 24)) >> 25;
$h2 += $carry1;
$h1 -= $carry1 << 25;
$carry3 = ($h3 + (1 << 24)) >> 25;
$h4 += $carry3;
$h3 -= $carry3 << 25;
$carry5 = ($h5 + (1 << 24)) >> 25;
$h6 += $carry5;
$h5 -= $carry5 << 25;
$carry7 = ($h7 + (1 << 24)) >> 25;
$h8 += $carry7;
$h7 -= $carry7 << 25;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
$carry2 = ($h2 + (1 << 25)) >> 26;
$h3 += $carry2;
$h2 -= $carry2 << 26;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry6 = ($h6 + (1 << 25)) >> 26;
$h7 += $carry6;
$h6 -= $carry6 << 26;
$carry8 = ($h8 + (1 << 25)) >> 26;
$h9 += $carry8;
$h8 -= $carry8 << 26;
return ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(
(int) $h0,
(int) $h1,
(int) $h2,
(int) $h3,
(int) $h4,
(int) $h5,
(int) $h6,
(int) $h7,
(int) $h8,
(int) $h9
)
);
}
/**
* Convert a field element to a byte string.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $h
* @return string
*/
public static function fe_tobytes(ParagonIE_Sodium_Core_Curve25519_Fe $h)
{
$h0 = (int) $h[0];
$h1 = (int) $h[1];
$h2 = (int) $h[2];
$h3 = (int) $h[3];
$h4 = (int) $h[4];
$h5 = (int) $h[5];
$h6 = (int) $h[6];
$h7 = (int) $h[7];
$h8 = (int) $h[8];
$h9 = (int) $h[9];
$q = (self::mul($h9, 19, 5) + (1 << 24)) >> 25;
$q = ($h0 + $q) >> 26;
$q = ($h1 + $q) >> 25;
$q = ($h2 + $q) >> 26;
$q = ($h3 + $q) >> 25;
$q = ($h4 + $q) >> 26;
$q = ($h5 + $q) >> 25;
$q = ($h6 + $q) >> 26;
$q = ($h7 + $q) >> 25;
$q = ($h8 + $q) >> 26;
$q = ($h9 + $q) >> 25;
$h0 += self::mul($q, 19, 5);
$carry0 = $h0 >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
$carry1 = $h1 >> 25;
$h2 += $carry1;
$h1 -= $carry1 << 25;
$carry2 = $h2 >> 26;
$h3 += $carry2;
$h2 -= $carry2 << 26;
$carry3 = $h3 >> 25;
$h4 += $carry3;
$h3 -= $carry3 << 25;
$carry4 = $h4 >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry5 = $h5 >> 25;
$h6 += $carry5;
$h5 -= $carry5 << 25;
$carry6 = $h6 >> 26;
$h7 += $carry6;
$h6 -= $carry6 << 26;
$carry7 = $h7 >> 25;
$h8 += $carry7;
$h7 -= $carry7 << 25;
$carry8 = $h8 >> 26;
$h9 += $carry8;
$h8 -= $carry8 << 26;
$carry9 = $h9 >> 25;
$h9 -= $carry9 << 25;
/**
* @var array<int, int>
*/
$s = array(
(int) (($h0 >> 0) & 0xff),
(int) (($h0 >> 8) & 0xff),
(int) (($h0 >> 16) & 0xff),
(int) ((($h0 >> 24) | ($h1 << 2)) & 0xff),
(int) (($h1 >> 6) & 0xff),
(int) (($h1 >> 14) & 0xff),
(int) ((($h1 >> 22) | ($h2 << 3)) & 0xff),
(int) (($h2 >> 5) & 0xff),
(int) (($h2 >> 13) & 0xff),
(int) ((($h2 >> 21) | ($h3 << 5)) & 0xff),
(int) (($h3 >> 3) & 0xff),
(int) (($h3 >> 11) & 0xff),
(int) ((($h3 >> 19) | ($h4 << 6)) & 0xff),
(int) (($h4 >> 2) & 0xff),
(int) (($h4 >> 10) & 0xff),
(int) (($h4 >> 18) & 0xff),
(int) (($h5 >> 0) & 0xff),
(int) (($h5 >> 8) & 0xff),
(int) (($h5 >> 16) & 0xff),
(int) ((($h5 >> 24) | ($h6 << 1)) & 0xff),
(int) (($h6 >> 7) & 0xff),
(int) (($h6 >> 15) & 0xff),
(int) ((($h6 >> 23) | ($h7 << 3)) & 0xff),
(int) (($h7 >> 5) & 0xff),
(int) (($h7 >> 13) & 0xff),
(int) ((($h7 >> 21) | ($h8 << 4)) & 0xff),
(int) (($h8 >> 4) & 0xff),
(int) (($h8 >> 12) & 0xff),
(int) ((($h8 >> 20) | ($h9 << 6)) & 0xff),
(int) (($h9 >> 2) & 0xff),
(int) (($h9 >> 10) & 0xff),
(int) (($h9 >> 18) & 0xff)
);
return self::intArrayToString($s);
}
/**
* Is a field element negative? (1 = yes, 0 = no. Used in calculations.)
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return int
* @throws SodiumException
* @throws TypeError
*/
public static function fe_isnegative(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$str = self::fe_tobytes($f);
return (int) (self::chrToInt($str[0]) & 1);
}
/**
* Returns 0 if this field element results in all NUL bytes.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function fe_isnonzero(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
static $zero;
if ($zero === null) {
$zero = str_repeat("\x00", 32);
}
/** @var string $zero */
/** @var string $str */
$str = self::fe_tobytes($f);
return !self::verify_32($str, (string) $zero);
}
/**
* Multiply two field elements
*
* h = f * g
*
* @internal You should not use this directly from another application
*
* @security Is multiplication a source of timing leaks? If so, can we do
* anything to prevent that from happening?
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core_Curve25519_Fe $g
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_mul(
ParagonIE_Sodium_Core_Curve25519_Fe $f,
ParagonIE_Sodium_Core_Curve25519_Fe $g
) {
// Ensure limbs aren't oversized.
$f = self::fe_normalize($f);
$g = self::fe_normalize($g);
$f0 = $f[0];
$f1 = $f[1];
$f2 = $f[2];
$f3 = $f[3];
$f4 = $f[4];
$f5 = $f[5];
$f6 = $f[6];
$f7 = $f[7];
$f8 = $f[8];
$f9 = $f[9];
$g0 = $g[0];
$g1 = $g[1];
$g2 = $g[2];
$g3 = $g[3];
$g4 = $g[4];
$g5 = $g[5];
$g6 = $g[6];
$g7 = $g[7];
$g8 = $g[8];
$g9 = $g[9];
$g1_19 = self::mul($g1, 19, 5);
$g2_19 = self::mul($g2, 19, 5);
$g3_19 = self::mul($g3, 19, 5);
$g4_19 = self::mul($g4, 19, 5);
$g5_19 = self::mul($g5, 19, 5);
$g6_19 = self::mul($g6, 19, 5);
$g7_19 = self::mul($g7, 19, 5);
$g8_19 = self::mul($g8, 19, 5);
$g9_19 = self::mul($g9, 19, 5);
$f1_2 = $f1 << 1;
$f3_2 = $f3 << 1;
$f5_2 = $f5 << 1;
$f7_2 = $f7 << 1;
$f9_2 = $f9 << 1;
$f0g0 = self::mul($f0, $g0, 26);
$f0g1 = self::mul($f0, $g1, 25);
$f0g2 = self::mul($f0, $g2, 26);
$f0g3 = self::mul($f0, $g3, 25);
$f0g4 = self::mul($f0, $g4, 26);
$f0g5 = self::mul($f0, $g5, 25);
$f0g6 = self::mul($f0, $g6, 26);
$f0g7 = self::mul($f0, $g7, 25);
$f0g8 = self::mul($f0, $g8, 26);
$f0g9 = self::mul($f0, $g9, 26);
$f1g0 = self::mul($f1, $g0, 26);
$f1g1_2 = self::mul($f1_2, $g1, 25);
$f1g2 = self::mul($f1, $g2, 26);
$f1g3_2 = self::mul($f1_2, $g3, 25);
$f1g4 = self::mul($f1, $g4, 26);
$f1g5_2 = self::mul($f1_2, $g5, 25);
$f1g6 = self::mul($f1, $g6, 26);
$f1g7_2 = self::mul($f1_2, $g7, 25);
$f1g8 = self::mul($f1, $g8, 26);
$f1g9_38 = self::mul($g9_19, $f1_2, 26);
$f2g0 = self::mul($f2, $g0, 26);
$f2g1 = self::mul($f2, $g1, 25);
$f2g2 = self::mul($f2, $g2, 26);
$f2g3 = self::mul($f2, $g3, 25);
$f2g4 = self::mul($f2, $g4, 26);
$f2g5 = self::mul($f2, $g5, 25);
$f2g6 = self::mul($f2, $g6, 26);
$f2g7 = self::mul($f2, $g7, 25);
$f2g8_19 = self::mul($g8_19, $f2, 26);
$f2g9_19 = self::mul($g9_19, $f2, 26);
$f3g0 = self::mul($f3, $g0, 26);
$f3g1_2 = self::mul($f3_2, $g1, 25);
$f3g2 = self::mul($f3, $g2, 26);
$f3g3_2 = self::mul($f3_2, $g3, 25);
$f3g4 = self::mul($f3, $g4, 26);
$f3g5_2 = self::mul($f3_2, $g5, 25);
$f3g6 = self::mul($f3, $g6, 26);
$f3g7_38 = self::mul($g7_19, $f3_2, 26);
$f3g8_19 = self::mul($g8_19, $f3, 25);
$f3g9_38 = self::mul($g9_19, $f3_2, 26);
$f4g0 = self::mul($f4, $g0, 26);
$f4g1 = self::mul($f4, $g1, 25);
$f4g2 = self::mul($f4, $g2, 26);
$f4g3 = self::mul($f4, $g3, 25);
$f4g4 = self::mul($f4, $g4, 26);
$f4g5 = self::mul($f4, $g5, 25);
$f4g6_19 = self::mul($g6_19, $f4, 26);
$f4g7_19 = self::mul($g7_19, $f4, 26);
$f4g8_19 = self::mul($g8_19, $f4, 26);
$f4g9_19 = self::mul($g9_19, $f4, 26);
$f5g0 = self::mul($f5, $g0, 26);
$f5g1_2 = self::mul($f5_2, $g1, 25);
$f5g2 = self::mul($f5, $g2, 26);
$f5g3_2 = self::mul($f5_2, $g3, 25);
$f5g4 = self::mul($f5, $g4, 26);
$f5g5_38 = self::mul($g5_19, $f5_2, 26);
$f5g6_19 = self::mul($g6_19, $f5, 25);
$f5g7_38 = self::mul($g7_19, $f5_2, 26);
$f5g8_19 = self::mul($g8_19, $f5, 25);
$f5g9_38 = self::mul($g9_19, $f5_2, 26);
$f6g0 = self::mul($f6, $g0, 26);
$f6g1 = self::mul($f6, $g1, 25);
$f6g2 = self::mul($f6, $g2, 26);
$f6g3 = self::mul($f6, $g3, 25);
$f6g4_19 = self::mul($g4_19, $f6, 26);
$f6g5_19 = self::mul($g5_19, $f6, 26);
$f6g6_19 = self::mul($g6_19, $f6, 26);
$f6g7_19 = self::mul($g7_19, $f6, 26);
$f6g8_19 = self::mul($g8_19, $f6, 26);
$f6g9_19 = self::mul($g9_19, $f6, 26);
$f7g0 = self::mul($f7, $g0, 26);
$f7g1_2 = self::mul($f7_2, $g1, 25);
$f7g2 = self::mul($f7, $g2, 26);
$f7g3_38 = self::mul($g3_19, $f7_2, 26);
$f7g4_19 = self::mul($g4_19, $f7, 26);
$f7g5_38 = self::mul($g5_19, $f7_2, 26);
$f7g6_19 = self::mul($g6_19, $f7, 25);
$f7g7_38 = self::mul($g7_19, $f7_2, 26);
$f7g8_19 = self::mul($g8_19, $f7, 25);
$f7g9_38 = self::mul($g9_19,$f7_2, 26);
$f8g0 = self::mul($f8, $g0, 26);
$f8g1 = self::mul($f8, $g1, 25);
$f8g2_19 = self::mul($g2_19, $f8, 26);
$f8g3_19 = self::mul($g3_19, $f8, 26);
$f8g4_19 = self::mul($g4_19, $f8, 26);
$f8g5_19 = self::mul($g5_19, $f8, 26);
$f8g6_19 = self::mul($g6_19, $f8, 26);
$f8g7_19 = self::mul($g7_19, $f8, 26);
$f8g8_19 = self::mul($g8_19, $f8, 26);
$f8g9_19 = self::mul($g9_19, $f8, 26);
$f9g0 = self::mul($f9, $g0, 26);
$f9g1_38 = self::mul($g1_19, $f9_2, 26);
$f9g2_19 = self::mul($g2_19, $f9, 25);
$f9g3_38 = self::mul($g3_19, $f9_2, 26);
$f9g4_19 = self::mul($g4_19, $f9, 25);
$f9g5_38 = self::mul($g5_19, $f9_2, 26);
$f9g6_19 = self::mul($g6_19, $f9, 25);
$f9g7_38 = self::mul($g7_19, $f9_2, 26);
$f9g8_19 = self::mul($g8_19, $f9, 25);
$f9g9_38 = self::mul($g9_19, $f9_2, 26);
$h0 = $f0g0 + $f1g9_38 + $f2g8_19 + $f3g7_38 + $f4g6_19 + $f5g5_38 + $f6g4_19 + $f7g3_38 + $f8g2_19 + $f9g1_38;
$h1 = $f0g1 + $f1g0 + $f2g9_19 + $f3g8_19 + $f4g7_19 + $f5g6_19 + $f6g5_19 + $f7g4_19 + $f8g3_19 + $f9g2_19;
$h2 = $f0g2 + $f1g1_2 + $f2g0 + $f3g9_38 + $f4g8_19 + $f5g7_38 + $f6g6_19 + $f7g5_38 + $f8g4_19 + $f9g3_38;
$h3 = $f0g3 + $f1g2 + $f2g1 + $f3g0 + $f4g9_19 + $f5g8_19 + $f6g7_19 + $f7g6_19 + $f8g5_19 + $f9g4_19;
$h4 = $f0g4 + $f1g3_2 + $f2g2 + $f3g1_2 + $f4g0 + $f5g9_38 + $f6g8_19 + $f7g7_38 + $f8g6_19 + $f9g5_38;
$h5 = $f0g5 + $f1g4 + $f2g3 + $f3g2 + $f4g1 + $f5g0 + $f6g9_19 + $f7g8_19 + $f8g7_19 + $f9g6_19;
$h6 = $f0g6 + $f1g5_2 + $f2g4 + $f3g3_2 + $f4g2 + $f5g1_2 + $f6g0 + $f7g9_38 + $f8g8_19 + $f9g7_38;
$h7 = $f0g7 + $f1g6 + $f2g5 + $f3g4 + $f4g3 + $f5g2 + $f6g1 + $f7g0 + $f8g9_19 + $f9g8_19;
$h8 = $f0g8 + $f1g7_2 + $f2g6 + $f3g5_2 + $f4g4 + $f5g3_2 + $f6g2 + $f7g1_2 + $f8g0 + $f9g9_38;
$h9 = $f0g9 + $f1g8 + $f2g7 + $f3g6 + $f4g5 + $f5g4 + $f6g3 + $f7g2 + $f8g1 + $f9g0 ;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry1 = ($h1 + (1 << 24)) >> 25;
$h2 += $carry1;
$h1 -= $carry1 << 25;
$carry5 = ($h5 + (1 << 24)) >> 25;
$h6 += $carry5;
$h5 -= $carry5 << 25;
$carry2 = ($h2 + (1 << 25)) >> 26;
$h3 += $carry2;
$h2 -= $carry2 << 26;
$carry6 = ($h6 + (1 << 25)) >> 26;
$h7 += $carry6;
$h6 -= $carry6 << 26;
$carry3 = ($h3 + (1 << 24)) >> 25;
$h4 += $carry3;
$h3 -= $carry3 << 25;
$carry7 = ($h7 + (1 << 24)) >> 25;
$h8 += $carry7;
$h7 -= $carry7 << 25;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry8 = ($h8 + (1 << 25)) >> 26;
$h9 += $carry8;
$h8 -= $carry8 << 26;
$carry9 = ($h9 + (1 << 24)) >> 25;
$h0 += self::mul($carry9, 19, 5);
$h9 -= $carry9 << 25;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
return self::fe_normalize(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(
(int) $h0,
(int) $h1,
(int) $h2,
(int) $h3,
(int) $h4,
(int) $h5,
(int) $h6,
(int) $h7,
(int) $h8,
(int) $h9
)
)
);
}
/**
* Get the negative values for each piece of the field element.
*
* h = -f
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
* @psalm-suppress MixedAssignment
*/
public static function fe_neg(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$h = new ParagonIE_Sodium_Core_Curve25519_Fe();
for ($i = 0; $i < 10; ++$i) {
$h[$i] = -$f[$i];
}
return self::fe_normalize($h);
}
/**
* Square a field element
*
* h = f * f
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_sq(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$f = self::fe_normalize($f);
$f0 = (int) $f[0];
$f1 = (int) $f[1];
$f2 = (int) $f[2];
$f3 = (int) $f[3];
$f4 = (int) $f[4];
$f5 = (int) $f[5];
$f6 = (int) $f[6];
$f7 = (int) $f[7];
$f8 = (int) $f[8];
$f9 = (int) $f[9];
$f0_2 = $f0 << 1;
$f1_2 = $f1 << 1;
$f2_2 = $f2 << 1;
$f3_2 = $f3 << 1;
$f4_2 = $f4 << 1;
$f5_2 = $f5 << 1;
$f6_2 = $f6 << 1;
$f7_2 = $f7 << 1;
$f5_38 = self::mul($f5, 38, 6);
$f6_19 = self::mul($f6, 19, 5);
$f7_38 = self::mul($f7, 38, 6);
$f8_19 = self::mul($f8, 19, 5);
$f9_38 = self::mul($f9, 38, 6);
$f0f0 = self::mul($f0, $f0, 26);
$f0f1_2 = self::mul($f0_2, $f1, 26);
$f0f2_2 = self::mul($f0_2, $f2, 26);
$f0f3_2 = self::mul($f0_2, $f3, 26);
$f0f4_2 = self::mul($f0_2, $f4, 26);
$f0f5_2 = self::mul($f0_2, $f5, 26);
$f0f6_2 = self::mul($f0_2, $f6, 26);
$f0f7_2 = self::mul($f0_2, $f7, 26);
$f0f8_2 = self::mul($f0_2, $f8, 26);
$f0f9_2 = self::mul($f0_2, $f9, 26);
$f1f1_2 = self::mul($f1_2, $f1, 26);
$f1f2_2 = self::mul($f1_2, $f2, 26);
$f1f3_4 = self::mul($f1_2, $f3_2, 26);
$f1f4_2 = self::mul($f1_2, $f4, 26);
$f1f5_4 = self::mul($f1_2, $f5_2, 26);
$f1f6_2 = self::mul($f1_2, $f6, 26);
$f1f7_4 = self::mul($f1_2, $f7_2, 26);
$f1f8_2 = self::mul($f1_2, $f8, 26);
$f1f9_76 = self::mul($f9_38, $f1_2, 27);
$f2f2 = self::mul($f2, $f2, 27);
$f2f3_2 = self::mul($f2_2, $f3, 27);
$f2f4_2 = self::mul($f2_2, $f4, 27);
$f2f5_2 = self::mul($f2_2, $f5, 27);
$f2f6_2 = self::mul($f2_2, $f6, 27);
$f2f7_2 = self::mul($f2_2, $f7, 27);
$f2f8_38 = self::mul($f8_19, $f2_2, 27);
$f2f9_38 = self::mul($f9_38, $f2, 26);
$f3f3_2 = self::mul($f3_2, $f3, 26);
$f3f4_2 = self::mul($f3_2, $f4, 26);
$f3f5_4 = self::mul($f3_2, $f5_2, 26);
$f3f6_2 = self::mul($f3_2, $f6, 26);
$f3f7_76 = self::mul($f7_38, $f3_2, 26);
$f3f8_38 = self::mul($f8_19, $f3_2, 26);
$f3f9_76 = self::mul($f9_38, $f3_2, 26);
$f4f4 = self::mul($f4, $f4, 26);
$f4f5_2 = self::mul($f4_2, $f5, 26);
$f4f6_38 = self::mul($f6_19, $f4_2, 27);
$f4f7_38 = self::mul($f7_38, $f4, 26);
$f4f8_38 = self::mul($f8_19, $f4_2, 27);
$f4f9_38 = self::mul($f9_38, $f4, 26);
$f5f5_38 = self::mul($f5_38, $f5, 26);
$f5f6_38 = self::mul($f6_19, $f5_2, 26);
$f5f7_76 = self::mul($f7_38, $f5_2, 26);
$f5f8_38 = self::mul($f8_19, $f5_2, 26);
$f5f9_76 = self::mul($f9_38, $f5_2, 26);
$f6f6_19 = self::mul($f6_19, $f6, 26);
$f6f7_38 = self::mul($f7_38, $f6, 26);
$f6f8_38 = self::mul($f8_19, $f6_2, 27);
$f6f9_38 = self::mul($f9_38, $f6, 26);
$f7f7_38 = self::mul($f7_38, $f7, 26);
$f7f8_38 = self::mul($f8_19, $f7_2, 26);
$f7f9_76 = self::mul($f9_38, $f7_2, 26);
$f8f8_19 = self::mul($f8_19, $f8, 26);
$f8f9_38 = self::mul($f9_38, $f8, 26);
$f9f9_38 = self::mul($f9_38, $f9, 26);
$h0 = $f0f0 + $f1f9_76 + $f2f8_38 + $f3f7_76 + $f4f6_38 + $f5f5_38;
$h1 = $f0f1_2 + $f2f9_38 + $f3f8_38 + $f4f7_38 + $f5f6_38;
$h2 = $f0f2_2 + $f1f1_2 + $f3f9_76 + $f4f8_38 + $f5f7_76 + $f6f6_19;
$h3 = $f0f3_2 + $f1f2_2 + $f4f9_38 + $f5f8_38 + $f6f7_38;
$h4 = $f0f4_2 + $f1f3_4 + $f2f2 + $f5f9_76 + $f6f8_38 + $f7f7_38;
$h5 = $f0f5_2 + $f1f4_2 + $f2f3_2 + $f6f9_38 + $f7f8_38;
$h6 = $f0f6_2 + $f1f5_4 + $f2f4_2 + $f3f3_2 + $f7f9_76 + $f8f8_19;
$h7 = $f0f7_2 + $f1f6_2 + $f2f5_2 + $f3f4_2 + $f8f9_38;
$h8 = $f0f8_2 + $f1f7_4 + $f2f6_2 + $f3f5_4 + $f4f4 + $f9f9_38;
$h9 = $f0f9_2 + $f1f8_2 + $f2f7_2 + $f3f6_2 + $f4f5_2;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry1 = ($h1 + (1 << 24)) >> 25;
$h2 += $carry1;
$h1 -= $carry1 << 25;
$carry5 = ($h5 + (1 << 24)) >> 25;
$h6 += $carry5;
$h5 -= $carry5 << 25;
$carry2 = ($h2 + (1 << 25)) >> 26;
$h3 += $carry2;
$h2 -= $carry2 << 26;
$carry6 = ($h6 + (1 << 25)) >> 26;
$h7 += $carry6;
$h6 -= $carry6 << 26;
$carry3 = ($h3 + (1 << 24)) >> 25;
$h4 += $carry3;
$h3 -= $carry3 << 25;
$carry7 = ($h7 + (1 << 24)) >> 25;
$h8 += $carry7;
$h7 -= $carry7 << 25;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry8 = ($h8 + (1 << 25)) >> 26;
$h9 += $carry8;
$h8 -= $carry8 << 26;
$carry9 = ($h9 + (1 << 24)) >> 25;
$h0 += self::mul($carry9, 19, 5);
$h9 -= $carry9 << 25;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
return self::fe_normalize(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(
(int) $h0,
(int) $h1,
(int) $h2,
(int) $h3,
(int) $h4,
(int) $h5,
(int) $h6,
(int) $h7,
(int) $h8,
(int) $h9
)
)
);
}
/**
* Square and double a field element
*
* h = 2 * f * f
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_sq2(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$f = self::fe_normalize($f);
$f0 = (int) $f[0];
$f1 = (int) $f[1];
$f2 = (int) $f[2];
$f3 = (int) $f[3];
$f4 = (int) $f[4];
$f5 = (int) $f[5];
$f6 = (int) $f[6];
$f7 = (int) $f[7];
$f8 = (int) $f[8];
$f9 = (int) $f[9];
$f0_2 = $f0 << 1;
$f1_2 = $f1 << 1;
$f2_2 = $f2 << 1;
$f3_2 = $f3 << 1;
$f4_2 = $f4 << 1;
$f5_2 = $f5 << 1;
$f6_2 = $f6 << 1;
$f7_2 = $f7 << 1;
$f5_38 = self::mul($f5, 38, 6); /* 1.959375*2^30 */
$f6_19 = self::mul($f6, 19, 5); /* 1.959375*2^30 */
$f7_38 = self::mul($f7, 38, 6); /* 1.959375*2^30 */
$f8_19 = self::mul($f8, 19, 5); /* 1.959375*2^30 */
$f9_38 = self::mul($f9, 38, 6); /* 1.959375*2^30 */
$f0f0 = self::mul($f0, $f0, 24);
$f0f1_2 = self::mul($f0_2, $f1, 24);
$f0f2_2 = self::mul($f0_2, $f2, 24);
$f0f3_2 = self::mul($f0_2, $f3, 24);
$f0f4_2 = self::mul($f0_2, $f4, 24);
$f0f5_2 = self::mul($f0_2, $f5, 24);
$f0f6_2 = self::mul($f0_2, $f6, 24);
$f0f7_2 = self::mul($f0_2, $f7, 24);
$f0f8_2 = self::mul($f0_2, $f8, 24);
$f0f9_2 = self::mul($f0_2, $f9, 24);
$f1f1_2 = self::mul($f1_2, $f1, 24);
$f1f2_2 = self::mul($f1_2, $f2, 24);
$f1f3_4 = self::mul($f1_2, $f3_2, 24);
$f1f4_2 = self::mul($f1_2, $f4, 24);
$f1f5_4 = self::mul($f1_2, $f5_2, 24);
$f1f6_2 = self::mul($f1_2, $f6, 24);
$f1f7_4 = self::mul($f1_2, $f7_2, 24);
$f1f8_2 = self::mul($f1_2, $f8, 24);
$f1f9_76 = self::mul($f9_38, $f1_2, 24);
$f2f2 = self::mul($f2, $f2, 24);
$f2f3_2 = self::mul($f2_2, $f3, 24);
$f2f4_2 = self::mul($f2_2, $f4, 24);
$f2f5_2 = self::mul($f2_2, $f5, 24);
$f2f6_2 = self::mul($f2_2, $f6, 24);
$f2f7_2 = self::mul($f2_2, $f7, 24);
$f2f8_38 = self::mul($f8_19, $f2_2, 25);
$f2f9_38 = self::mul($f9_38, $f2, 24);
$f3f3_2 = self::mul($f3_2, $f3, 24);
$f3f4_2 = self::mul($f3_2, $f4, 24);
$f3f5_4 = self::mul($f3_2, $f5_2, 24);
$f3f6_2 = self::mul($f3_2, $f6, 24);
$f3f7_76 = self::mul($f7_38, $f3_2, 24);
$f3f8_38 = self::mul($f8_19, $f3_2, 24);
$f3f9_76 = self::mul($f9_38, $f3_2, 24);
$f4f4 = self::mul($f4, $f4, 24);
$f4f5_2 = self::mul($f4_2, $f5, 24);
$f4f6_38 = self::mul($f6_19, $f4_2, 25);
$f4f7_38 = self::mul($f7_38, $f4, 24);
$f4f8_38 = self::mul($f8_19, $f4_2, 25);
$f4f9_38 = self::mul($f9_38, $f4, 24);
$f5f5_38 = self::mul($f5_38, $f5, 24);
$f5f6_38 = self::mul($f6_19, $f5_2, 24);
$f5f7_76 = self::mul($f7_38, $f5_2, 24);
$f5f8_38 = self::mul($f8_19, $f5_2, 24);
$f5f9_76 = self::mul($f9_38, $f5_2, 24);
$f6f6_19 = self::mul($f6_19, $f6, 24);
$f6f7_38 = self::mul($f7_38, $f6, 24);
$f6f8_38 = self::mul($f8_19, $f6_2, 25);
$f6f9_38 = self::mul($f9_38, $f6, 24);
$f7f7_38 = self::mul($f7_38, $f7, 24);
$f7f8_38 = self::mul($f8_19, $f7_2, 24);
$f7f9_76 = self::mul($f9_38, $f7_2, 24);
$f8f8_19 = self::mul($f8_19, $f8, 24);
$f8f9_38 = self::mul($f9_38, $f8, 24);
$f9f9_38 = self::mul($f9_38, $f9, 24);
$h0 = (int) ($f0f0 + $f1f9_76 + $f2f8_38 + $f3f7_76 + $f4f6_38 + $f5f5_38) << 1;
$h1 = (int) ($f0f1_2 + $f2f9_38 + $f3f8_38 + $f4f7_38 + $f5f6_38) << 1;
$h2 = (int) ($f0f2_2 + $f1f1_2 + $f3f9_76 + $f4f8_38 + $f5f7_76 + $f6f6_19) << 1;
$h3 = (int) ($f0f3_2 + $f1f2_2 + $f4f9_38 + $f5f8_38 + $f6f7_38) << 1;
$h4 = (int) ($f0f4_2 + $f1f3_4 + $f2f2 + $f5f9_76 + $f6f8_38 + $f7f7_38) << 1;
$h5 = (int) ($f0f5_2 + $f1f4_2 + $f2f3_2 + $f6f9_38 + $f7f8_38) << 1;
$h6 = (int) ($f0f6_2 + $f1f5_4 + $f2f4_2 + $f3f3_2 + $f7f9_76 + $f8f8_19) << 1;
$h7 = (int) ($f0f7_2 + $f1f6_2 + $f2f5_2 + $f3f4_2 + $f8f9_38) << 1;
$h8 = (int) ($f0f8_2 + $f1f7_4 + $f2f6_2 + $f3f5_4 + $f4f4 + $f9f9_38) << 1;
$h9 = (int) ($f0f9_2 + $f1f8_2 + $f2f7_2 + $f3f6_2 + $f4f5_2) << 1;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry1 = ($h1 + (1 << 24)) >> 25;
$h2 += $carry1;
$h1 -= $carry1 << 25;
$carry5 = ($h5 + (1 << 24)) >> 25;
$h6 += $carry5;
$h5 -= $carry5 << 25;
$carry2 = ($h2 + (1 << 25)) >> 26;
$h3 += $carry2;
$h2 -= $carry2 << 26;
$carry6 = ($h6 + (1 << 25)) >> 26;
$h7 += $carry6;
$h6 -= $carry6 << 26;
$carry3 = ($h3 + (1 << 24)) >> 25;
$h4 += $carry3;
$h3 -= $carry3 << 25;
$carry7 = ($h7 + (1 << 24)) >> 25;
$h8 += $carry7;
$h7 -= $carry7 << 25;
$carry4 = ($h4 + (1 << 25)) >> 26;
$h5 += $carry4;
$h4 -= $carry4 << 26;
$carry8 = ($h8 + (1 << 25)) >> 26;
$h9 += $carry8;
$h8 -= $carry8 << 26;
$carry9 = ($h9 + (1 << 24)) >> 25;
$h0 += self::mul($carry9, 19, 5);
$h9 -= $carry9 << 25;
$carry0 = ($h0 + (1 << 25)) >> 26;
$h1 += $carry0;
$h0 -= $carry0 << 26;
return self::fe_normalize(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(
(int) $h0,
(int) $h1,
(int) $h2,
(int) $h3,
(int) $h4,
(int) $h5,
(int) $h6,
(int) $h7,
(int) $h8,
(int) $h9
)
)
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $Z
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_invert(ParagonIE_Sodium_Core_Curve25519_Fe $Z)
{
$z = clone $Z;
$t0 = self::fe_sq($z);
$t1 = self::fe_sq($t0);
$t1 = self::fe_sq($t1);
$t1 = self::fe_mul($z, $t1);
$t0 = self::fe_mul($t0, $t1);
$t2 = self::fe_sq($t0);
$t1 = self::fe_mul($t1, $t2);
$t2 = self::fe_sq($t1);
for ($i = 1; $i < 5; ++$i) {
$t2 = self::fe_sq($t2);
}
$t1 = self::fe_mul($t2, $t1);
$t2 = self::fe_sq($t1);
for ($i = 1; $i < 10; ++$i) {
$t2 = self::fe_sq($t2);
}
$t2 = self::fe_mul($t2, $t1);
$t3 = self::fe_sq($t2);
for ($i = 1; $i < 20; ++$i) {
$t3 = self::fe_sq($t3);
}
$t2 = self::fe_mul($t3, $t2);
$t2 = self::fe_sq($t2);
for ($i = 1; $i < 10; ++$i) {
$t2 = self::fe_sq($t2);
}
$t1 = self::fe_mul($t2, $t1);
$t2 = self::fe_sq($t1);
for ($i = 1; $i < 50; ++$i) {
$t2 = self::fe_sq($t2);
}
$t2 = self::fe_mul($t2, $t1);
$t3 = self::fe_sq($t2);
for ($i = 1; $i < 100; ++$i) {
$t3 = self::fe_sq($t3);
}
$t2 = self::fe_mul($t3, $t2);
$t2 = self::fe_sq($t2);
for ($i = 1; $i < 50; ++$i) {
$t2 = self::fe_sq($t2);
}
$t1 = self::fe_mul($t2, $t1);
$t1 = self::fe_sq($t1);
for ($i = 1; $i < 5; ++$i) {
$t1 = self::fe_sq($t1);
}
return self::fe_mul($t1, $t0);
}
/**
* @internal You should not use this directly from another application
*
* @ref https://github.com/jedisct1/libsodium/blob/68564326e1e9dc57ef03746f85734232d20ca6fb/src/libsodium/crypto_core/curve25519/ref10/curve25519_ref10.c#L1054-L1106
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $z
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_pow22523(ParagonIE_Sodium_Core_Curve25519_Fe $z)
{
$z = self::fe_normalize($z);
# fe_sq(t0, z);
# fe_sq(t1, t0);
# fe_sq(t1, t1);
# fe_mul(t1, z, t1);
# fe_mul(t0, t0, t1);
# fe_sq(t0, t0);
# fe_mul(t0, t1, t0);
# fe_sq(t1, t0);
$t0 = self::fe_sq($z);
$t1 = self::fe_sq($t0);
$t1 = self::fe_sq($t1);
$t1 = self::fe_mul($z, $t1);
$t0 = self::fe_mul($t0, $t1);
$t0 = self::fe_sq($t0);
$t0 = self::fe_mul($t1, $t0);
$t1 = self::fe_sq($t0);
# for (i = 1; i < 5; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 5; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t0, t1, t0);
# fe_sq(t1, t0);
$t0 = self::fe_mul($t1, $t0);
$t1 = self::fe_sq($t0);
# for (i = 1; i < 10; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 10; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t1, t1, t0);
# fe_sq(t2, t1);
$t1 = self::fe_mul($t1, $t0);
$t2 = self::fe_sq($t1);
# for (i = 1; i < 20; ++i) {
# fe_sq(t2, t2);
# }
for ($i = 1; $i < 20; ++$i) {
$t2 = self::fe_sq($t2);
}
# fe_mul(t1, t2, t1);
# fe_sq(t1, t1);
$t1 = self::fe_mul($t2, $t1);
$t1 = self::fe_sq($t1);
# for (i = 1; i < 10; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 10; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t0, t1, t0);
# fe_sq(t1, t0);
$t0 = self::fe_mul($t1, $t0);
$t1 = self::fe_sq($t0);
# for (i = 1; i < 50; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 50; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t1, t1, t0);
# fe_sq(t2, t1);
$t1 = self::fe_mul($t1, $t0);
$t2 = self::fe_sq($t1);
# for (i = 1; i < 100; ++i) {
# fe_sq(t2, t2);
# }
for ($i = 1; $i < 100; ++$i) {
$t2 = self::fe_sq($t2);
}
# fe_mul(t1, t2, t1);
# fe_sq(t1, t1);
$t1 = self::fe_mul($t2, $t1);
$t1 = self::fe_sq($t1);
# for (i = 1; i < 50; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 50; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t0, t1, t0);
# fe_sq(t0, t0);
# fe_sq(t0, t0);
# fe_mul(out, t0, z);
$t0 = self::fe_mul($t1, $t0);
$t0 = self::fe_sq($t0);
$t0 = self::fe_sq($t0);
return self::fe_mul($t0, $z);
}
/**
* Subtract two field elements.
*
* h = f - g
*
* Preconditions:
* |f| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc.
* |g| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc.
*
* Postconditions:
* |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core_Curve25519_Fe $g
* @return ParagonIE_Sodium_Core_Curve25519_Fe
* @psalm-suppress MixedOperand
*/
public static function fe_sub(ParagonIE_Sodium_Core_Curve25519_Fe $f, ParagonIE_Sodium_Core_Curve25519_Fe $g)
{
return self::fe_normalize(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(
array(
(int) ($f[0] - $g[0]),
(int) ($f[1] - $g[1]),
(int) ($f[2] - $g[2]),
(int) ($f[3] - $g[3]),
(int) ($f[4] - $g[4]),
(int) ($f[5] - $g[5]),
(int) ($f[6] - $g[6]),
(int) ($f[7] - $g[7]),
(int) ($f[8] - $g[8]),
(int) ($f[9] - $g[9])
)
)
);
}
/**
* Add two group elements.
*
* r = p + q
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Cached $q
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
public static function ge_add(
ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core_Curve25519_Ge_Cached $q
) {
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_P1p1();
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->YplusX);
$r->Y = self::fe_mul($r->Y, $q->YminusX);
$r->T = self::fe_mul($q->T2d, $p->T);
$r->X = self::fe_mul($p->Z, $q->Z);
$t0 = self::fe_add($r->X, $r->X);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_add($t0, $r->T);
$r->T = self::fe_sub($t0, $r->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @ref https://github.com/jedisct1/libsodium/blob/157c4a80c13b117608aeae12178b2d38825f9f8f/src/libsodium/crypto_core/curve25519/ref10/curve25519_ref10.c#L1185-L1215
* @param string $a
* @return array<int, mixed>
* @throws SodiumException
* @throws TypeError
*/
public static function slide($a)
{
if (self::strlen($a) < 256) {
if (self::strlen($a) < 16) {
$a = str_pad($a, 256, '0', STR_PAD_RIGHT);
}
}
/** @var array<int, int> $r */
$r = array();
/** @var int $i */
for ($i = 0; $i < 256; ++$i) {
$r[$i] = (int) (
1 & (
self::chrToInt($a[(int) ($i >> 3)])
>>
($i & 7)
)
);
}
for ($i = 0;$i < 256;++$i) {
if ($r[$i]) {
for ($b = 1;$b <= 6 && $i + $b < 256;++$b) {
if ($r[$i + $b]) {
if ($r[$i] + ($r[$i + $b] << $b) <= 15) {
$r[$i] += $r[$i + $b] << $b;
$r[$i + $b] = 0;
} elseif ($r[$i] - ($r[$i + $b] << $b) >= -15) {
$r[$i] -= $r[$i + $b] << $b;
for ($k = $i + $b; $k < 256; ++$k) {
if (!$r[$k]) {
$r[$k] = 1;
break;
}
$r[$k] = 0;
}
} else {
break;
}
}
}
}
}
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param string $s
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
*/
public static function ge_frombytes_negate_vartime($s)
{
static $d = null;
if (!$d) {
$d = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$d);
}
# fe_frombytes(h->Y,s);
# fe_1(h->Z);
$h = new ParagonIE_Sodium_Core_Curve25519_Ge_P3(
self::fe_0(),
self::fe_frombytes($s),
self::fe_1()
);
# fe_sq(u,h->Y);
# fe_mul(v,u,d);
# fe_sub(u,u,h->Z); /* u = y^2-1 */
# fe_add(v,v,h->Z); /* v = dy^2+1 */
$u = self::fe_sq($h->Y);
/** @var ParagonIE_Sodium_Core_Curve25519_Fe $d */
$v = self::fe_mul($u, $d);
$u = self::fe_sub($u, $h->Z); /* u = y^2 - 1 */
$v = self::fe_add($v, $h->Z); /* v = dy^2 + 1 */
# fe_sq(v3,v);
# fe_mul(v3,v3,v); /* v3 = v^3 */
# fe_sq(h->X,v3);
# fe_mul(h->X,h->X,v);
# fe_mul(h->X,h->X,u); /* x = uv^7 */
$v3 = self::fe_sq($v);
$v3 = self::fe_mul($v3, $v); /* v3 = v^3 */
$h->X = self::fe_sq($v3);
$h->X = self::fe_mul($h->X, $v);
$h->X = self::fe_mul($h->X, $u); /* x = uv^7 */
# fe_pow22523(h->X,h->X); /* x = (uv^7)^((q-5)/8) */
# fe_mul(h->X,h->X,v3);
# fe_mul(h->X,h->X,u); /* x = uv^3(uv^7)^((q-5)/8) */
$h->X = self::fe_pow22523($h->X); /* x = (uv^7)^((q-5)/8) */
$h->X = self::fe_mul($h->X, $v3);
$h->X = self::fe_mul($h->X, $u); /* x = uv^3(uv^7)^((q-5)/8) */
# fe_sq(vxx,h->X);
# fe_mul(vxx,vxx,v);
# fe_sub(check,vxx,u); /* vx^2-u */
$vxx = self::fe_sq($h->X);
$vxx = self::fe_mul($vxx, $v);
$check = self::fe_sub($vxx, $u); /* vx^2 - u */
# if (fe_isnonzero(check)) {
# fe_add(check,vxx,u); /* vx^2+u */
# if (fe_isnonzero(check)) {
# return -1;
# }
# fe_mul(h->X,h->X,sqrtm1);
# }
if (self::fe_isnonzero($check)) {
$check = self::fe_add($vxx, $u); /* vx^2 + u */
if (self::fe_isnonzero($check)) {
throw new RangeException('Internal check failed.');
}
$h->X = self::fe_mul(
$h->X,
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$sqrtm1)
);
}
# if (fe_isnegative(h->X) == (s[31] >> 7)) {
# fe_neg(h->X,h->X);
# }
$i = self::chrToInt($s[31]);
if (self::fe_isnegative($h->X) === ($i >> 7)) {
$h->X = self::fe_neg($h->X);
}
# fe_mul(h->T,h->X,h->Y);
$h->T = self::fe_mul($h->X, $h->Y);
return $h;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $R
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $q
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
public static function ge_madd(
ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $R,
ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $q
) {
$r = clone $R;
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->yplusx);
$r->Y = self::fe_mul($r->Y, $q->yminusx);
$r->T = self::fe_mul($q->xy2d, $p->T);
$t0 = self::fe_add(clone $p->Z, clone $p->Z);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_add($t0, $r->T);
$r->T = self::fe_sub($t0, $r->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $R
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $q
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
public static function ge_msub(
ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $R,
ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $q
) {
$r = clone $R;
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->yminusx);
$r->Y = self::fe_mul($r->Y, $q->yplusx);
$r->T = self::fe_mul($q->xy2d, $p->T);
$t0 = self::fe_add($p->Z, $p->Z);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_sub($t0, $r->T);
$r->T = self::fe_add($t0, $r->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P2
*/
public static function ge_p1p1_to_p2(ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $p)
{
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_P2();
$r->X = self::fe_mul($p->X, $p->T);
$r->Y = self::fe_mul($p->Y, $p->Z);
$r->Z = self::fe_mul($p->Z, $p->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
*/
public static function ge_p1p1_to_p3(ParagonIE_Sodium_Core_Curve25519_Ge_P1p1 $p)
{
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_P3();
$r->X = self::fe_mul($p->X, $p->T);
$r->Y = self::fe_mul($p->Y, $p->Z);
$r->Z = self::fe_mul($p->Z, $p->T);
$r->T = self::fe_mul($p->X, $p->Y);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P2
*/
public static function ge_p2_0()
{
return new ParagonIE_Sodium_Core_Curve25519_Ge_P2(
self::fe_0(),
self::fe_1(),
self::fe_1()
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P2 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
public static function ge_p2_dbl(ParagonIE_Sodium_Core_Curve25519_Ge_P2 $p)
{
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_P1p1();
$r->X = self::fe_sq($p->X);
$r->Z = self::fe_sq($p->Y);
$r->T = self::fe_sq2($p->Z);
$r->Y = self::fe_add($p->X, $p->Y);
$t0 = self::fe_sq($r->Y);
$r->Y = self::fe_add($r->Z, $r->X);
$r->Z = self::fe_sub($r->Z, $r->X);
$r->X = self::fe_sub($t0, $r->Y);
$r->T = self::fe_sub($r->T, $r->Z);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
*/
public static function ge_p3_0()
{
return new ParagonIE_Sodium_Core_Curve25519_Ge_P3(
self::fe_0(),
self::fe_1(),
self::fe_1(),
self::fe_0()
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_Cached
*/
public static function ge_p3_to_cached(ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p)
{
static $d2 = null;
if ($d2 === null) {
$d2 = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$d2);
}
/** @var ParagonIE_Sodium_Core_Curve25519_Fe $d2 */
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_Cached();
$r->YplusX = self::fe_add($p->Y, $p->X);
$r->YminusX = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_copy($p->Z);
$r->T2d = self::fe_mul($p->T, $d2);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P2
*/
public static function ge_p3_to_p2(ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p)
{
return new ParagonIE_Sodium_Core_Curve25519_Ge_P2(
self::fe_copy($p->X),
self::fe_copy($p->Y),
self::fe_copy($p->Z)
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $h
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p3_tobytes(ParagonIE_Sodium_Core_Curve25519_Ge_P3 $h)
{
$recip = self::fe_invert($h->Z);
$x = self::fe_mul($h->X, $recip);
$y = self::fe_mul($h->Y, $recip);
$s = self::fe_tobytes($y);
$s[31] = self::intToChr(
self::chrToInt($s[31]) ^ (self::fe_isnegative($x) << 7)
);
return $s;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
public static function ge_p3_dbl(ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p)
{
$q = self::ge_p3_to_p2($p);
return self::ge_p2_dbl($q);
}
/**
* @return ParagonIE_Sodium_Core_Curve25519_Ge_Precomp
*/
public static function ge_precomp_0()
{
return new ParagonIE_Sodium_Core_Curve25519_Ge_Precomp(
self::fe_1(),
self::fe_1(),
self::fe_0()
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $b
* @param int $c
* @return int
*/
public static function equal($b, $c)
{
return (int) ((($b ^ $c) - 1) >> 31) & 1;
}
/**
* @internal You should not use this directly from another application
*
* @param int|string $char
* @return int (1 = yes, 0 = no)
* @throws SodiumException
* @throws TypeError
*/
public static function negative($char)
{
if (is_int($char)) {
return ($char >> 63) & 1;
}
$x = self::chrToInt(self::substr($char, 0, 1));
return (int) ($x >> 63);
}
/**
* Conditional move
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $t
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $u
* @param int $b
* @return ParagonIE_Sodium_Core_Curve25519_Ge_Precomp
*/
public static function cmov(
ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $t,
ParagonIE_Sodium_Core_Curve25519_Ge_Precomp $u,
$b
) {
if (!is_int($b)) {
throw new InvalidArgumentException('Expected an integer.');
}
return new ParagonIE_Sodium_Core_Curve25519_Ge_Precomp(
self::fe_cmov($t->yplusx, $u->yplusx, $b),
self::fe_cmov($t->yminusx, $u->yminusx, $b),
self::fe_cmov($t->xy2d, $u->xy2d, $b)
);
}
/**
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Cached $t
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Cached $u
* @param int $b
* @return ParagonIE_Sodium_Core_Curve25519_Ge_Cached
*/
public static function ge_cmov_cached(
ParagonIE_Sodium_Core_Curve25519_Ge_Cached $t,
ParagonIE_Sodium_Core_Curve25519_Ge_Cached $u,
$b
) {
$b &= 1;
$ret = new ParagonIE_Sodium_Core_Curve25519_Ge_Cached();
$ret->YplusX = self::fe_cmov($t->YplusX, $u->YplusX, $b);
$ret->YminusX = self::fe_cmov($t->YminusX, $u->YminusX, $b);
$ret->Z = self::fe_cmov($t->Z, $u->Z, $b);
$ret->T2d = self::fe_cmov($t->T2d, $u->T2d, $b);
return $ret;
}
/**
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Cached[] $cached
* @param int $b
* @return ParagonIE_Sodium_Core_Curve25519_Ge_Cached
* @throws SodiumException
*/
public static function ge_cmov8_cached(array $cached, $b)
{
// const unsigned char bnegative = negative(b);
// const unsigned char babs = b - (((-bnegative) & b) * ((signed char) 1 << 1));
$bnegative = self::negative($b);
$babs = $b - (((-$bnegative) & $b) << 1);
// ge25519_cached_0(t);
$t = new ParagonIE_Sodium_Core_Curve25519_Ge_Cached(
self::fe_1(),
self::fe_1(),
self::fe_1(),
self::fe_0()
);
// ge25519_cmov_cached(t, &cached[0], equal(babs, 1));
// ge25519_cmov_cached(t, &cached[1], equal(babs, 2));
// ge25519_cmov_cached(t, &cached[2], equal(babs, 3));
// ge25519_cmov_cached(t, &cached[3], equal(babs, 4));
// ge25519_cmov_cached(t, &cached[4], equal(babs, 5));
// ge25519_cmov_cached(t, &cached[5], equal(babs, 6));
// ge25519_cmov_cached(t, &cached[6], equal(babs, 7));
// ge25519_cmov_cached(t, &cached[7], equal(babs, 8));
for ($x = 0; $x < 8; ++$x) {
$t = self::ge_cmov_cached($t, $cached[$x], self::equal($babs, $x + 1));
}
// fe25519_copy(minust.YplusX, t->YminusX);
// fe25519_copy(minust.YminusX, t->YplusX);
// fe25519_copy(minust.Z, t->Z);
// fe25519_neg(minust.T2d, t->T2d);
$minust = new ParagonIE_Sodium_Core_Curve25519_Ge_Cached(
self::fe_copy($t->YminusX),
self::fe_copy($t->YplusX),
self::fe_copy($t->Z),
self::fe_neg($t->T2d)
);
return self::ge_cmov_cached($t, $minust, $bnegative);
}
/**
* @internal You should not use this directly from another application
*
* @param int $pos
* @param int $b
* @return ParagonIE_Sodium_Core_Curve25519_Ge_Precomp
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayOffset
*/
public static function ge_select($pos = 0, $b = 0)
{
static $base = null;
if ($base === null) {
$base = array();
/** @var int $i */
foreach (self::$base as $i => $bas) {
for ($j = 0; $j < 8; ++$j) {
$base[$i][$j] = new ParagonIE_Sodium_Core_Curve25519_Ge_Precomp(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray($bas[$j][0]),
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray($bas[$j][1]),
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray($bas[$j][2])
);
}
}
}
/** @var array<int, array<int, ParagonIE_Sodium_Core_Curve25519_Ge_Precomp>> $base */
if (!is_int($pos)) {
throw new InvalidArgumentException('Position must be an integer');
}
if ($pos < 0 || $pos > 31) {
throw new RangeException('Position is out of range [0, 31]');
}
$bnegative = self::negative($b);
$babs = $b - (((-$bnegative) & $b) << 1);
$t = self::ge_precomp_0();
for ($i = 0; $i < 8; ++$i) {
$t = self::cmov(
$t,
$base[$pos][$i],
self::equal($babs, $i + 1)
);
}
$minusT = new ParagonIE_Sodium_Core_Curve25519_Ge_Precomp(
self::fe_copy($t->yminusx),
self::fe_copy($t->yplusx),
self::fe_neg($t->xy2d)
);
return self::cmov($t, $minusT, $bnegative);
}
/**
* Subtract two group elements.
*
* r = p - q
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core_Curve25519_Ge_Cached $q
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P1p1
*/
public static function ge_sub(
ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core_Curve25519_Ge_Cached $q
) {
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_P1p1();
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->YminusX);
$r->Y = self::fe_mul($r->Y, $q->YplusX);
$r->T = self::fe_mul($q->T2d, $p->T);
$r->X = self::fe_mul($p->Z, $q->Z);
$t0 = self::fe_add($r->X, $r->X);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_sub($t0, $r->T);
$r->T = self::fe_add($t0, $r->T);
return $r;
}
/**
* Convert a group element to a byte string.
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P2 $h
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ge_tobytes(ParagonIE_Sodium_Core_Curve25519_Ge_P2 $h)
{
$recip = self::fe_invert($h->Z);
$x = self::fe_mul($h->X, $recip);
$y = self::fe_mul($h->Y, $recip);
$s = self::fe_tobytes($y);
$s[31] = self::intToChr(
self::chrToInt($s[31]) ^ (self::fe_isnegative($x) << 7)
);
return $s;
}
/**
* @internal You should not use this directly from another application
*
* @param string $a
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $A
* @param string $b
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P2
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayAccess
*/
public static function ge_double_scalarmult_vartime(
$a,
ParagonIE_Sodium_Core_Curve25519_Ge_P3 $A,
$b
) {
/** @var array<int, ParagonIE_Sodium_Core_Curve25519_Ge_Cached> $Ai */
$Ai = array();
/** @var array<int, ParagonIE_Sodium_Core_Curve25519_Ge_Precomp> $Bi */
static $Bi = array();
if (!$Bi) {
for ($i = 0; $i < 8; ++$i) {
$Bi[$i] = new ParagonIE_Sodium_Core_Curve25519_Ge_Precomp(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$base2[$i][0]),
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$base2[$i][1]),
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$base2[$i][2])
);
}
}
for ($i = 0; $i < 8; ++$i) {
$Ai[$i] = new ParagonIE_Sodium_Core_Curve25519_Ge_Cached(
self::fe_0(),
self::fe_0(),
self::fe_0(),
self::fe_0()
);
}
# slide(aslide,a);
# slide(bslide,b);
/** @var array<int, int> $aslide */
$aslide = self::slide($a);
/** @var array<int, int> $bslide */
$bslide = self::slide($b);
# ge_p3_to_cached(&Ai[0],A);
# ge_p3_dbl(&t,A); ge_p1p1_to_p3(&A2,&t);
$Ai[0] = self::ge_p3_to_cached($A);
$t = self::ge_p3_dbl($A);
$A2 = self::ge_p1p1_to_p3($t);
# ge_add(&t,&A2,&Ai[0]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[1],&u);
# ge_add(&t,&A2,&Ai[1]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[2],&u);
# ge_add(&t,&A2,&Ai[2]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[3],&u);
# ge_add(&t,&A2,&Ai[3]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[4],&u);
# ge_add(&t,&A2,&Ai[4]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[5],&u);
# ge_add(&t,&A2,&Ai[5]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[6],&u);
# ge_add(&t,&A2,&Ai[6]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[7],&u);
for ($i = 0; $i < 7; ++$i) {
$t = self::ge_add($A2, $Ai[$i]);
$u = self::ge_p1p1_to_p3($t);
$Ai[$i + 1] = self::ge_p3_to_cached($u);
}
# ge_p2_0(r);
$r = self::ge_p2_0();
# for (i = 255;i >= 0;--i) {
# if (aslide[i] || bslide[i]) break;
# }
$i = 255;
for (; $i >= 0; --$i) {
if ($aslide[$i] || $bslide[$i]) {
break;
}
}
# for (;i >= 0;--i) {
for (; $i >= 0; --$i) {
# ge_p2_dbl(&t,r);
$t = self::ge_p2_dbl($r);
# if (aslide[i] > 0) {
if ($aslide[$i] > 0) {
# ge_p1p1_to_p3(&u,&t);
# ge_add(&t,&u,&Ai[aslide[i]/2]);
$u = self::ge_p1p1_to_p3($t);
$t = self::ge_add(
$u,
$Ai[(int) floor($aslide[$i] / 2)]
);
# } else if (aslide[i] < 0) {
} elseif ($aslide[$i] < 0) {
# ge_p1p1_to_p3(&u,&t);
# ge_sub(&t,&u,&Ai[(-aslide[i])/2]);
$u = self::ge_p1p1_to_p3($t);
$t = self::ge_sub(
$u,
$Ai[(int) floor(-$aslide[$i] / 2)]
);
}
# if (bslide[i] > 0) {
if ($bslide[$i] > 0) {
/** @var int $index */
$index = (int) floor($bslide[$i] / 2);
# ge_p1p1_to_p3(&u,&t);
# ge_madd(&t,&u,&Bi[bslide[i]/2]);
$u = self::ge_p1p1_to_p3($t);
$t = self::ge_madd($t, $u, $Bi[$index]);
# } else if (bslide[i] < 0) {
} elseif ($bslide[$i] < 0) {
/** @var int $index */
$index = (int) floor(-$bslide[$i] / 2);
# ge_p1p1_to_p3(&u,&t);
# ge_msub(&t,&u,&Bi[(-bslide[i])/2]);
$u = self::ge_p1p1_to_p3($t);
$t = self::ge_msub($t, $u, $Bi[$index]);
}
# ge_p1p1_to_p2(r,&t);
$r = self::ge_p1p1_to_p2($t);
}
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param string $a
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedOperand
*/
public static function ge_scalarmult($a, $p)
{
$e = array_fill(0, 64, 0);
/** @var ParagonIE_Sodium_Core_Curve25519_Ge_Cached[] $pi */
$pi = array();
// ge25519_p3_to_cached(&pi[1 - 1], p); /* p */
$pi[0] = self::ge_p3_to_cached($p);
// ge25519_p3_dbl(&t2, p);
// ge25519_p1p1_to_p3(&p2, &t2);
// ge25519_p3_to_cached(&pi[2 - 1], &p2); /* 2p = 2*p */
$t2 = self::ge_p3_dbl($p);
$p2 = self::ge_p1p1_to_p3($t2);
$pi[1] = self::ge_p3_to_cached($p2);
// ge25519_add_cached(&t3, p, &pi[2 - 1]);
// ge25519_p1p1_to_p3(&p3, &t3);
// ge25519_p3_to_cached(&pi[3 - 1], &p3); /* 3p = 2p+p */
$t3 = self::ge_add($p, $pi[1]);
$p3 = self::ge_p1p1_to_p3($t3);
$pi[2] = self::ge_p3_to_cached($p3);
// ge25519_p3_dbl(&t4, &p2);
// ge25519_p1p1_to_p3(&p4, &t4);
// ge25519_p3_to_cached(&pi[4 - 1], &p4); /* 4p = 2*2p */
$t4 = self::ge_p3_dbl($p2);
$p4 = self::ge_p1p1_to_p3($t4);
$pi[3] = self::ge_p3_to_cached($p4);
// ge25519_add_cached(&t5, p, &pi[4 - 1]);
// ge25519_p1p1_to_p3(&p5, &t5);
// ge25519_p3_to_cached(&pi[5 - 1], &p5); /* 5p = 4p+p */
$t5 = self::ge_add($p, $pi[3]);
$p5 = self::ge_p1p1_to_p3($t5);
$pi[4] = self::ge_p3_to_cached($p5);
// ge25519_p3_dbl(&t6, &p3);
// ge25519_p1p1_to_p3(&p6, &t6);
// ge25519_p3_to_cached(&pi[6 - 1], &p6); /* 6p = 2*3p */
$t6 = self::ge_p3_dbl($p3);
$p6 = self::ge_p1p1_to_p3($t6);
$pi[5] = self::ge_p3_to_cached($p6);
// ge25519_add_cached(&t7, p, &pi[6 - 1]);
// ge25519_p1p1_to_p3(&p7, &t7);
// ge25519_p3_to_cached(&pi[7 - 1], &p7); /* 7p = 6p+p */
$t7 = self::ge_add($p, $pi[5]);
$p7 = self::ge_p1p1_to_p3($t7);
$pi[6] = self::ge_p3_to_cached($p7);
// ge25519_p3_dbl(&t8, &p4);
// ge25519_p1p1_to_p3(&p8, &t8);
// ge25519_p3_to_cached(&pi[8 - 1], &p8); /* 8p = 2*4p */
$t8 = self::ge_p3_dbl($p4);
$p8 = self::ge_p1p1_to_p3($t8);
$pi[7] = self::ge_p3_to_cached($p8);
// for (i = 0; i < 32; ++i) {
// e[2 * i + 0] = (a[i] >> 0) & 15;
// e[2 * i + 1] = (a[i] >> 4) & 15;
// }
for ($i = 0; $i < 32; ++$i) {
$e[($i << 1) ] = self::chrToInt($a[$i]) & 15;
$e[($i << 1) + 1] = (self::chrToInt($a[$i]) >> 4) & 15;
}
// /* each e[i] is between 0 and 15 */
// /* e[63] is between 0 and 7 */
// carry = 0;
// for (i = 0; i < 63; ++i) {
// e[i] += carry;
// carry = e[i] + 8;
// carry >>= 4;
// e[i] -= carry * ((signed char) 1 << 4);
// }
$carry = 0;
for ($i = 0; $i < 63; ++$i) {
$e[$i] += $carry;
$carry = $e[$i] + 8;
$carry >>= 4;
$e[$i] -= $carry << 4;
}
// e[63] += carry;
// /* each e[i] is between -8 and 8 */
$e[63] += $carry;
// ge25519_p3_0(h);
$h = self::ge_p3_0();
// for (i = 63; i != 0; i--) {
for ($i = 63; $i != 0; --$i) {
// ge25519_cmov8_cached(&t, pi, e[i]);
$t = self::ge_cmov8_cached($pi, $e[$i]);
// ge25519_add_cached(&r, h, &t);
$r = self::ge_add($h, $t);
// ge25519_p1p1_to_p2(&s, &r);
// ge25519_p2_dbl(&r, &s);
// ge25519_p1p1_to_p2(&s, &r);
// ge25519_p2_dbl(&r, &s);
// ge25519_p1p1_to_p2(&s, &r);
// ge25519_p2_dbl(&r, &s);
// ge25519_p1p1_to_p2(&s, &r);
// ge25519_p2_dbl(&r, &s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
// ge25519_p1p1_to_p3(h, &r); /* *16 */
$h = self::ge_p1p1_to_p3($r); /* *16 */
}
// ge25519_cmov8_cached(&t, pi, e[i]);
// ge25519_add_cached(&r, h, &t);
// ge25519_p1p1_to_p3(h, &r);
$t = self::ge_cmov8_cached($pi, $e[0]);
$r = self::ge_add($h, $t);
return self::ge_p1p1_to_p3($r);
}
/**
* @internal You should not use this directly from another application
*
* @param string $a
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedOperand
*/
public static function ge_scalarmult_base($a)
{
/** @var array<int, int> $e */
$e = array();
$r = new ParagonIE_Sodium_Core_Curve25519_Ge_P1p1();
for ($i = 0; $i < 32; ++$i) {
$dbl = (int) $i << 1;
$e[$dbl] = (int) self::chrToInt($a[$i]) & 15;
$e[$dbl + 1] = (int) (self::chrToInt($a[$i]) >> 4) & 15;
}
$carry = 0;
for ($i = 0; $i < 63; ++$i) {
$e[$i] += $carry;
$carry = $e[$i] + 8;
$carry >>= 4;
$e[$i] -= $carry << 4;
}
$e[63] += (int) $carry;
$h = self::ge_p3_0();
for ($i = 1; $i < 64; $i += 2) {
$t = self::ge_select((int) floor($i / 2), (int) $e[$i]);
$r = self::ge_madd($r, $h, $t);
$h = self::ge_p1p1_to_p3($r);
}
$r = self::ge_p3_dbl($h);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$h = self::ge_p1p1_to_p3($r);
for ($i = 0; $i < 64; $i += 2) {
$t = self::ge_select($i >> 1, (int) $e[$i]);
$r = self::ge_madd($r, $h, $t);
$h = self::ge_p1p1_to_p3($r);
}
return $h;
}
/**
* Calculates (ab + c) mod l
* where l = 2^252 + 27742317777372353535851937790883648493
*
* @internal You should not use this directly from another application
*
* @param string $a
* @param string $b
* @param string $c
* @return string
* @throws TypeError
*/
public static function sc_muladd($a, $b, $c)
{
$a0 = 2097151 & self::load_3(self::substr($a, 0, 3));
$a1 = 2097151 & (self::load_4(self::substr($a, 2, 4)) >> 5);
$a2 = 2097151 & (self::load_3(self::substr($a, 5, 3)) >> 2);
$a3 = 2097151 & (self::load_4(self::substr($a, 7, 4)) >> 7);
$a4 = 2097151 & (self::load_4(self::substr($a, 10, 4)) >> 4);
$a5 = 2097151 & (self::load_3(self::substr($a, 13, 3)) >> 1);
$a6 = 2097151 & (self::load_4(self::substr($a, 15, 4)) >> 6);
$a7 = 2097151 & (self::load_3(self::substr($a, 18, 3)) >> 3);
$a8 = 2097151 & self::load_3(self::substr($a, 21, 3));
$a9 = 2097151 & (self::load_4(self::substr($a, 23, 4)) >> 5);
$a10 = 2097151 & (self::load_3(self::substr($a, 26, 3)) >> 2);
$a11 = (self::load_4(self::substr($a, 28, 4)) >> 7);
$b0 = 2097151 & self::load_3(self::substr($b, 0, 3));
$b1 = 2097151 & (self::load_4(self::substr($b, 2, 4)) >> 5);
$b2 = 2097151 & (self::load_3(self::substr($b, 5, 3)) >> 2);
$b3 = 2097151 & (self::load_4(self::substr($b, 7, 4)) >> 7);
$b4 = 2097151 & (self::load_4(self::substr($b, 10, 4)) >> 4);
$b5 = 2097151 & (self::load_3(self::substr($b, 13, 3)) >> 1);
$b6 = 2097151 & (self::load_4(self::substr($b, 15, 4)) >> 6);
$b7 = 2097151 & (self::load_3(self::substr($b, 18, 3)) >> 3);
$b8 = 2097151 & self::load_3(self::substr($b, 21, 3));
$b9 = 2097151 & (self::load_4(self::substr($b, 23, 4)) >> 5);
$b10 = 2097151 & (self::load_3(self::substr($b, 26, 3)) >> 2);
$b11 = (self::load_4(self::substr($b, 28, 4)) >> 7);
$c0 = 2097151 & self::load_3(self::substr($c, 0, 3));
$c1 = 2097151 & (self::load_4(self::substr($c, 2, 4)) >> 5);
$c2 = 2097151 & (self::load_3(self::substr($c, 5, 3)) >> 2);
$c3 = 2097151 & (self::load_4(self::substr($c, 7, 4)) >> 7);
$c4 = 2097151 & (self::load_4(self::substr($c, 10, 4)) >> 4);
$c5 = 2097151 & (self::load_3(self::substr($c, 13, 3)) >> 1);
$c6 = 2097151 & (self::load_4(self::substr($c, 15, 4)) >> 6);
$c7 = 2097151 & (self::load_3(self::substr($c, 18, 3)) >> 3);
$c8 = 2097151 & self::load_3(self::substr($c, 21, 3));
$c9 = 2097151 & (self::load_4(self::substr($c, 23, 4)) >> 5);
$c10 = 2097151 & (self::load_3(self::substr($c, 26, 3)) >> 2);
$c11 = (self::load_4(self::substr($c, 28, 4)) >> 7);
/* Can't really avoid the pyramid here: */
$s0 = $c0 + self::mul($a0, $b0, 24);
$s1 = $c1 + self::mul($a0, $b1, 24) + self::mul($a1, $b0, 24);
$s2 = $c2 + self::mul($a0, $b2, 24) + self::mul($a1, $b1, 24) + self::mul($a2, $b0, 24);
$s3 = $c3 + self::mul($a0, $b3, 24) + self::mul($a1, $b2, 24) + self::mul($a2, $b1, 24) + self::mul($a3, $b0, 24);
$s4 = $c4 + self::mul($a0, $b4, 24) + self::mul($a1, $b3, 24) + self::mul($a2, $b2, 24) + self::mul($a3, $b1, 24) +
self::mul($a4, $b0, 24);
$s5 = $c5 + self::mul($a0, $b5, 24) + self::mul($a1, $b4, 24) + self::mul($a2, $b3, 24) + self::mul($a3, $b2, 24) +
self::mul($a4, $b1, 24) + self::mul($a5, $b0, 24);
$s6 = $c6 + self::mul($a0, $b6, 24) + self::mul($a1, $b5, 24) + self::mul($a2, $b4, 24) + self::mul($a3, $b3, 24) +
self::mul($a4, $b2, 24) + self::mul($a5, $b1, 24) + self::mul($a6, $b0, 24);
$s7 = $c7 + self::mul($a0, $b7, 24) + self::mul($a1, $b6, 24) + self::mul($a2, $b5, 24) + self::mul($a3, $b4, 24) +
self::mul($a4, $b3, 24) + self::mul($a5, $b2, 24) + self::mul($a6, $b1, 24) + self::mul($a7, $b0, 24);
$s8 = $c8 + self::mul($a0, $b8, 24) + self::mul($a1, $b7, 24) + self::mul($a2, $b6, 24) + self::mul($a3, $b5, 24) +
self::mul($a4, $b4, 24) + self::mul($a5, $b3, 24) + self::mul($a6, $b2, 24) + self::mul($a7, $b1, 24) +
self::mul($a8, $b0, 24);
$s9 = $c9 + self::mul($a0, $b9, 24) + self::mul($a1, $b8, 24) + self::mul($a2, $b7, 24) + self::mul($a3, $b6, 24) +
self::mul($a4, $b5, 24) + self::mul($a5, $b4, 24) + self::mul($a6, $b3, 24) + self::mul($a7, $b2, 24) +
self::mul($a8, $b1, 24) + self::mul($a9, $b0, 24);
$s10 = $c10 + self::mul($a0, $b10, 24) + self::mul($a1, $b9, 24) + self::mul($a2, $b8, 24) + self::mul($a3, $b7, 24) +
self::mul($a4, $b6, 24) + self::mul($a5, $b5, 24) + self::mul($a6, $b4, 24) + self::mul($a7, $b3, 24) +
self::mul($a8, $b2, 24) + self::mul($a9, $b1, 24) + self::mul($a10, $b0, 24);
$s11 = $c11 + self::mul($a0, $b11, 24) + self::mul($a1, $b10, 24) + self::mul($a2, $b9, 24) + self::mul($a3, $b8, 24) +
self::mul($a4, $b7, 24) + self::mul($a5, $b6, 24) + self::mul($a6, $b5, 24) + self::mul($a7, $b4, 24) +
self::mul($a8, $b3, 24) + self::mul($a9, $b2, 24) + self::mul($a10, $b1, 24) + self::mul($a11, $b0, 24);
$s12 = self::mul($a1, $b11, 24) + self::mul($a2, $b10, 24) + self::mul($a3, $b9, 24) + self::mul($a4, $b8, 24) +
self::mul($a5, $b7, 24) + self::mul($a6, $b6, 24) + self::mul($a7, $b5, 24) + self::mul($a8, $b4, 24) +
self::mul($a9, $b3, 24) + self::mul($a10, $b2, 24) + self::mul($a11, $b1, 24);
$s13 = self::mul($a2, $b11, 24) + self::mul($a3, $b10, 24) + self::mul($a4, $b9, 24) + self::mul($a5, $b8, 24) +
self::mul($a6, $b7, 24) + self::mul($a7, $b6, 24) + self::mul($a8, $b5, 24) + self::mul($a9, $b4, 24) +
self::mul($a10, $b3, 24) + self::mul($a11, $b2, 24);
$s14 = self::mul($a3, $b11, 24) + self::mul($a4, $b10, 24) + self::mul($a5, $b9, 24) + self::mul($a6, $b8, 24) +
self::mul($a7, $b7, 24) + self::mul($a8, $b6, 24) + self::mul($a9, $b5, 24) + self::mul($a10, $b4, 24) +
self::mul($a11, $b3, 24);
$s15 = self::mul($a4, $b11, 24) + self::mul($a5, $b10, 24) + self::mul($a6, $b9, 24) + self::mul($a7, $b8, 24) +
self::mul($a8, $b7, 24) + self::mul($a9, $b6, 24) + self::mul($a10, $b5, 24) + self::mul($a11, $b4, 24);
$s16 = self::mul($a5, $b11, 24) + self::mul($a6, $b10, 24) + self::mul($a7, $b9, 24) + self::mul($a8, $b8, 24) +
self::mul($a9, $b7, 24) + self::mul($a10, $b6, 24) + self::mul($a11, $b5, 24);
$s17 = self::mul($a6, $b11, 24) + self::mul($a7, $b10, 24) + self::mul($a8, $b9, 24) + self::mul($a9, $b8, 24) +
self::mul($a10, $b7, 24) + self::mul($a11, $b6, 24);
$s18 = self::mul($a7, $b11, 24) + self::mul($a8, $b10, 24) + self::mul($a9, $b9, 24) + self::mul($a10, $b8, 24) +
self::mul($a11, $b7, 24);
$s19 = self::mul($a8, $b11, 24) + self::mul($a9, $b10, 24) + self::mul($a10, $b9, 24) + self::mul($a11, $b8, 24);
$s20 = self::mul($a9, $b11, 24) + self::mul($a10, $b10, 24) + self::mul($a11, $b9, 24);
$s21 = self::mul($a10, $b11, 24) + self::mul($a11, $b10, 24);
$s22 = self::mul($a11, $b11, 24);
$s23 = 0;
$carry0 = ($s0 + (1 << 20)) >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry2 = ($s2 + (1 << 20)) >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry4 = ($s4 + (1 << 20)) >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry12 = ($s12 + (1 << 20)) >> 21;
$s13 += $carry12;
$s12 -= $carry12 << 21;
$carry14 = ($s14 + (1 << 20)) >> 21;
$s15 += $carry14;
$s14 -= $carry14 << 21;
$carry16 = ($s16 + (1 << 20)) >> 21;
$s17 += $carry16;
$s16 -= $carry16 << 21;
$carry18 = ($s18 + (1 << 20)) >> 21;
$s19 += $carry18;
$s18 -= $carry18 << 21;
$carry20 = ($s20 + (1 << 20)) >> 21;
$s21 += $carry20;
$s20 -= $carry20 << 21;
$carry22 = ($s22 + (1 << 20)) >> 21;
$s23 += $carry22;
$s22 -= $carry22 << 21;
$carry1 = ($s1 + (1 << 20)) >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry3 = ($s3 + (1 << 20)) >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry5 = ($s5 + (1 << 20)) >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$carry13 = ($s13 + (1 << 20)) >> 21;
$s14 += $carry13;
$s13 -= $carry13 << 21;
$carry15 = ($s15 + (1 << 20)) >> 21;
$s16 += $carry15;
$s15 -= $carry15 << 21;
$carry17 = ($s17 + (1 << 20)) >> 21;
$s18 += $carry17;
$s17 -= $carry17 << 21;
$carry19 = ($s19 + (1 << 20)) >> 21;
$s20 += $carry19;
$s19 -= $carry19 << 21;
$carry21 = ($s21 + (1 << 20)) >> 21;
$s22 += $carry21;
$s21 -= $carry21 << 21;
$s11 += self::mul($s23, 666643, 20);
$s12 += self::mul($s23, 470296, 19);
$s13 += self::mul($s23, 654183, 20);
$s14 -= self::mul($s23, 997805, 20);
$s15 += self::mul($s23, 136657, 18);
$s16 -= self::mul($s23, 683901, 20);
$s10 += self::mul($s22, 666643, 20);
$s11 += self::mul($s22, 470296, 19);
$s12 += self::mul($s22, 654183, 20);
$s13 -= self::mul($s22, 997805, 20);
$s14 += self::mul($s22, 136657, 18);
$s15 -= self::mul($s22, 683901, 20);
$s9 += self::mul($s21, 666643, 20);
$s10 += self::mul($s21, 470296, 19);
$s11 += self::mul($s21, 654183, 20);
$s12 -= self::mul($s21, 997805, 20);
$s13 += self::mul($s21, 136657, 18);
$s14 -= self::mul($s21, 683901, 20);
$s8 += self::mul($s20, 666643, 20);
$s9 += self::mul($s20, 470296, 19);
$s10 += self::mul($s20, 654183, 20);
$s11 -= self::mul($s20, 997805, 20);
$s12 += self::mul($s20, 136657, 18);
$s13 -= self::mul($s20, 683901, 20);
$s7 += self::mul($s19, 666643, 20);
$s8 += self::mul($s19, 470296, 19);
$s9 += self::mul($s19, 654183, 20);
$s10 -= self::mul($s19, 997805, 20);
$s11 += self::mul($s19, 136657, 18);
$s12 -= self::mul($s19, 683901, 20);
$s6 += self::mul($s18, 666643, 20);
$s7 += self::mul($s18, 470296, 19);
$s8 += self::mul($s18, 654183, 20);
$s9 -= self::mul($s18, 997805, 20);
$s10 += self::mul($s18, 136657, 18);
$s11 -= self::mul($s18, 683901, 20);
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry12 = ($s12 + (1 << 20)) >> 21;
$s13 += $carry12;
$s12 -= $carry12 << 21;
$carry14 = ($s14 + (1 << 20)) >> 21;
$s15 += $carry14;
$s14 -= $carry14 << 21;
$carry16 = ($s16 + (1 << 20)) >> 21;
$s17 += $carry16;
$s16 -= $carry16 << 21;
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$carry13 = ($s13 + (1 << 20)) >> 21;
$s14 += $carry13;
$s13 -= $carry13 << 21;
$carry15 = ($s15 + (1 << 20)) >> 21;
$s16 += $carry15;
$s15 -= $carry15 << 21;
$s5 += self::mul($s17, 666643, 20);
$s6 += self::mul($s17, 470296, 19);
$s7 += self::mul($s17, 654183, 20);
$s8 -= self::mul($s17, 997805, 20);
$s9 += self::mul($s17, 136657, 18);
$s10 -= self::mul($s17, 683901, 20);
$s4 += self::mul($s16, 666643, 20);
$s5 += self::mul($s16, 470296, 19);
$s6 += self::mul($s16, 654183, 20);
$s7 -= self::mul($s16, 997805, 20);
$s8 += self::mul($s16, 136657, 18);
$s9 -= self::mul($s16, 683901, 20);
$s3 += self::mul($s15, 666643, 20);
$s4 += self::mul($s15, 470296, 19);
$s5 += self::mul($s15, 654183, 20);
$s6 -= self::mul($s15, 997805, 20);
$s7 += self::mul($s15, 136657, 18);
$s8 -= self::mul($s15, 683901, 20);
$s2 += self::mul($s14, 666643, 20);
$s3 += self::mul($s14, 470296, 19);
$s4 += self::mul($s14, 654183, 20);
$s5 -= self::mul($s14, 997805, 20);
$s6 += self::mul($s14, 136657, 18);
$s7 -= self::mul($s14, 683901, 20);
$s1 += self::mul($s13, 666643, 20);
$s2 += self::mul($s13, 470296, 19);
$s3 += self::mul($s13, 654183, 20);
$s4 -= self::mul($s13, 997805, 20);
$s5 += self::mul($s13, 136657, 18);
$s6 -= self::mul($s13, 683901, 20);
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$s12 = 0;
$carry0 = ($s0 + (1 << 20)) >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry2 = ($s2 + (1 << 20)) >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry4 = ($s4 + (1 << 20)) >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry1 = ($s1 + (1 << 20)) >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry3 = ($s3 + (1 << 20)) >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry5 = ($s5 + (1 << 20)) >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$s12 = 0;
$carry0 = $s0 >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry1 = $s1 >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry2 = $s2 >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry3 = $s3 >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry4 = $s4 >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry5 = $s5 >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry6 = $s6 >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry7 = $s7 >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry8 = $s8 >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry9 = $s9 >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry10 = $s10 >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry11 = $s11 >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$carry0 = $s0 >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry1 = $s1 >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry2 = $s2 >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry3 = $s3 >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry4 = $s4 >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry5 = $s5 >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry6 = $s6 >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry7 = $s7 >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry8 = $s8 >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry9 = $s9 >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry10 = $s10 >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
/**
* @var array<int, int>
*/
$arr = array(
(int) (0xff & ($s0 >> 0)),
(int) (0xff & ($s0 >> 8)),
(int) (0xff & (($s0 >> 16) | $s1 << 5)),
(int) (0xff & ($s1 >> 3)),
(int) (0xff & ($s1 >> 11)),
(int) (0xff & (($s1 >> 19) | $s2 << 2)),
(int) (0xff & ($s2 >> 6)),
(int) (0xff & (($s2 >> 14) | $s3 << 7)),
(int) (0xff & ($s3 >> 1)),
(int) (0xff & ($s3 >> 9)),
(int) (0xff & (($s3 >> 17) | $s4 << 4)),
(int) (0xff & ($s4 >> 4)),
(int) (0xff & ($s4 >> 12)),
(int) (0xff & (($s4 >> 20) | $s5 << 1)),
(int) (0xff & ($s5 >> 7)),
(int) (0xff & (($s5 >> 15) | $s6 << 6)),
(int) (0xff & ($s6 >> 2)),
(int) (0xff & ($s6 >> 10)),
(int) (0xff & (($s6 >> 18) | $s7 << 3)),
(int) (0xff & ($s7 >> 5)),
(int) (0xff & ($s7 >> 13)),
(int) (0xff & ($s8 >> 0)),
(int) (0xff & ($s8 >> 8)),
(int) (0xff & (($s8 >> 16) | $s9 << 5)),
(int) (0xff & ($s9 >> 3)),
(int) (0xff & ($s9 >> 11)),
(int) (0xff & (($s9 >> 19) | $s10 << 2)),
(int) (0xff & ($s10 >> 6)),
(int) (0xff & (($s10 >> 14) | $s11 << 7)),
(int) (0xff & ($s11 >> 1)),
(int) (0xff & ($s11 >> 9)),
0xff & ($s11 >> 17)
);
return self::intArrayToString($arr);
}
/**
* @internal You should not use this directly from another application
*
* @param string $s
* @return string
* @throws TypeError
*/
public static function sc_reduce($s)
{
$s0 = 2097151 & self::load_3(self::substr($s, 0, 3));
$s1 = 2097151 & (self::load_4(self::substr($s, 2, 4)) >> 5);
$s2 = 2097151 & (self::load_3(self::substr($s, 5, 3)) >> 2);
$s3 = 2097151 & (self::load_4(self::substr($s, 7, 4)) >> 7);
$s4 = 2097151 & (self::load_4(self::substr($s, 10, 4)) >> 4);
$s5 = 2097151 & (self::load_3(self::substr($s, 13, 3)) >> 1);
$s6 = 2097151 & (self::load_4(self::substr($s, 15, 4)) >> 6);
$s7 = 2097151 & (self::load_3(self::substr($s, 18, 4)) >> 3);
$s8 = 2097151 & self::load_3(self::substr($s, 21, 3));
$s9 = 2097151 & (self::load_4(self::substr($s, 23, 4)) >> 5);
$s10 = 2097151 & (self::load_3(self::substr($s, 26, 3)) >> 2);
$s11 = 2097151 & (self::load_4(self::substr($s, 28, 4)) >> 7);
$s12 = 2097151 & (self::load_4(self::substr($s, 31, 4)) >> 4);
$s13 = 2097151 & (self::load_3(self::substr($s, 34, 3)) >> 1);
$s14 = 2097151 & (self::load_4(self::substr($s, 36, 4)) >> 6);
$s15 = 2097151 & (self::load_3(self::substr($s, 39, 4)) >> 3);
$s16 = 2097151 & self::load_3(self::substr($s, 42, 3));
$s17 = 2097151 & (self::load_4(self::substr($s, 44, 4)) >> 5);
$s18 = 2097151 & (self::load_3(self::substr($s, 47, 3)) >> 2);
$s19 = 2097151 & (self::load_4(self::substr($s, 49, 4)) >> 7);
$s20 = 2097151 & (self::load_4(self::substr($s, 52, 4)) >> 4);
$s21 = 2097151 & (self::load_3(self::substr($s, 55, 3)) >> 1);
$s22 = 2097151 & (self::load_4(self::substr($s, 57, 4)) >> 6);
$s23 = 0x1fffffff & (self::load_4(self::substr($s, 60, 4)) >> 3);
$s11 += self::mul($s23, 666643, 20);
$s12 += self::mul($s23, 470296, 19);
$s13 += self::mul($s23, 654183, 20);
$s14 -= self::mul($s23, 997805, 20);
$s15 += self::mul($s23, 136657, 18);
$s16 -= self::mul($s23, 683901, 20);
$s10 += self::mul($s22, 666643, 20);
$s11 += self::mul($s22, 470296, 19);
$s12 += self::mul($s22, 654183, 20);
$s13 -= self::mul($s22, 997805, 20);
$s14 += self::mul($s22, 136657, 18);
$s15 -= self::mul($s22, 683901, 20);
$s9 += self::mul($s21, 666643, 20);
$s10 += self::mul($s21, 470296, 19);
$s11 += self::mul($s21, 654183, 20);
$s12 -= self::mul($s21, 997805, 20);
$s13 += self::mul($s21, 136657, 18);
$s14 -= self::mul($s21, 683901, 20);
$s8 += self::mul($s20, 666643, 20);
$s9 += self::mul($s20, 470296, 19);
$s10 += self::mul($s20, 654183, 20);
$s11 -= self::mul($s20, 997805, 20);
$s12 += self::mul($s20, 136657, 18);
$s13 -= self::mul($s20, 683901, 20);
$s7 += self::mul($s19, 666643, 20);
$s8 += self::mul($s19, 470296, 19);
$s9 += self::mul($s19, 654183, 20);
$s10 -= self::mul($s19, 997805, 20);
$s11 += self::mul($s19, 136657, 18);
$s12 -= self::mul($s19, 683901, 20);
$s6 += self::mul($s18, 666643, 20);
$s7 += self::mul($s18, 470296, 19);
$s8 += self::mul($s18, 654183, 20);
$s9 -= self::mul($s18, 997805, 20);
$s10 += self::mul($s18, 136657, 18);
$s11 -= self::mul($s18, 683901, 20);
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry12 = ($s12 + (1 << 20)) >> 21;
$s13 += $carry12;
$s12 -= $carry12 << 21;
$carry14 = ($s14 + (1 << 20)) >> 21;
$s15 += $carry14;
$s14 -= $carry14 << 21;
$carry16 = ($s16 + (1 << 20)) >> 21;
$s17 += $carry16;
$s16 -= $carry16 << 21;
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$carry13 = ($s13 + (1 << 20)) >> 21;
$s14 += $carry13;
$s13 -= $carry13 << 21;
$carry15 = ($s15 + (1 << 20)) >> 21;
$s16 += $carry15;
$s15 -= $carry15 << 21;
$s5 += self::mul($s17, 666643, 20);
$s6 += self::mul($s17, 470296, 19);
$s7 += self::mul($s17, 654183, 20);
$s8 -= self::mul($s17, 997805, 20);
$s9 += self::mul($s17, 136657, 18);
$s10 -= self::mul($s17, 683901, 20);
$s4 += self::mul($s16, 666643, 20);
$s5 += self::mul($s16, 470296, 19);
$s6 += self::mul($s16, 654183, 20);
$s7 -= self::mul($s16, 997805, 20);
$s8 += self::mul($s16, 136657, 18);
$s9 -= self::mul($s16, 683901, 20);
$s3 += self::mul($s15, 666643, 20);
$s4 += self::mul($s15, 470296, 19);
$s5 += self::mul($s15, 654183, 20);
$s6 -= self::mul($s15, 997805, 20);
$s7 += self::mul($s15, 136657, 18);
$s8 -= self::mul($s15, 683901, 20);
$s2 += self::mul($s14, 666643, 20);
$s3 += self::mul($s14, 470296, 19);
$s4 += self::mul($s14, 654183, 20);
$s5 -= self::mul($s14, 997805, 20);
$s6 += self::mul($s14, 136657, 18);
$s7 -= self::mul($s14, 683901, 20);
$s1 += self::mul($s13, 666643, 20);
$s2 += self::mul($s13, 470296, 19);
$s3 += self::mul($s13, 654183, 20);
$s4 -= self::mul($s13, 997805, 20);
$s5 += self::mul($s13, 136657, 18);
$s6 -= self::mul($s13, 683901, 20);
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$s12 = 0;
$carry0 = ($s0 + (1 << 20)) >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry2 = ($s2 + (1 << 20)) >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry4 = ($s4 + (1 << 20)) >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry1 = ($s1 + (1 << 20)) >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry3 = ($s3 + (1 << 20)) >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry5 = ($s5 + (1 << 20)) >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$s12 = 0;
$carry0 = $s0 >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry1 = $s1 >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry2 = $s2 >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry3 = $s3 >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry4 = $s4 >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry5 = $s5 >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry6 = $s6 >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry7 = $s7 >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry8 = $s8 >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry9 = $s9 >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry10 = $s10 >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$carry11 = $s11 >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$carry0 = $s0 >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
$carry1 = $s1 >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
$carry2 = $s2 >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
$carry3 = $s3 >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
$carry4 = $s4 >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
$carry5 = $s5 >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
$carry6 = $s6 >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
$carry7 = $s7 >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
$carry8 = $s8 >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
$carry9 = $s9 >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
$carry10 = $s10 >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
/**
* @var array<int, int>
*/
$arr = array(
(int) ($s0 >> 0),
(int) ($s0 >> 8),
(int) (($s0 >> 16) | $s1 << 5),
(int) ($s1 >> 3),
(int) ($s1 >> 11),
(int) (($s1 >> 19) | $s2 << 2),
(int) ($s2 >> 6),
(int) (($s2 >> 14) | $s3 << 7),
(int) ($s3 >> 1),
(int) ($s3 >> 9),
(int) (($s3 >> 17) | $s4 << 4),
(int) ($s4 >> 4),
(int) ($s4 >> 12),
(int) (($s4 >> 20) | $s5 << 1),
(int) ($s5 >> 7),
(int) (($s5 >> 15) | $s6 << 6),
(int) ($s6 >> 2),
(int) ($s6 >> 10),
(int) (($s6 >> 18) | $s7 << 3),
(int) ($s7 >> 5),
(int) ($s7 >> 13),
(int) ($s8 >> 0),
(int) ($s8 >> 8),
(int) (($s8 >> 16) | $s9 << 5),
(int) ($s9 >> 3),
(int) ($s9 >> 11),
(int) (($s9 >> 19) | $s10 << 2),
(int) ($s10 >> 6),
(int) (($s10 >> 14) | $s11 << 7),
(int) ($s11 >> 1),
(int) ($s11 >> 9),
(int) $s11 >> 17
);
return self::intArrayToString($arr);
}
/**
* multiply by the order of the main subgroup l = 2^252+27742317777372353535851937790883648493
*
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $A
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
*/
public static function ge_mul_l(ParagonIE_Sodium_Core_Curve25519_Ge_P3 $A)
{
$aslide = array(
13, 0, 0, 0, 0, -1, 0, 0, 0, 0, -11, 0, 0, 0, 0, 0, 0, -5, 0, 0, 0,
0, 0, 0, -3, 0, 0, 0, 0, -13, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 3, 0,
0, 0, 0, -13, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0,
0, 0, 11, 0, 0, 0, 0, -13, 0, 0, 0, 0, 0, 0, -3, 0, 0, 0, 0, 0, -1,
0, 0, 0, 0, 3, 0, 0, 0, 0, -11, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0,
0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 7, 0, 0, 0, 0, 5, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
);
/** @var array<int, ParagonIE_Sodium_Core_Curve25519_Ge_Cached> $Ai size 8 */
$Ai = array();
# ge_p3_to_cached(&Ai[0], A);
$Ai[0] = self::ge_p3_to_cached($A);
# ge_p3_dbl(&t, A);
$t = self::ge_p3_dbl($A);
# ge_p1p1_to_p3(&A2, &t);
$A2 = self::ge_p1p1_to_p3($t);
for ($i = 1; $i < 8; ++$i) {
# ge_add(&t, &A2, &Ai[0]);
$t = self::ge_add($A2, $Ai[$i - 1]);
# ge_p1p1_to_p3(&u, &t);
$u = self::ge_p1p1_to_p3($t);
# ge_p3_to_cached(&Ai[i], &u);
$Ai[$i] = self::ge_p3_to_cached($u);
}
$r = self::ge_p3_0();
for ($i = 252; $i >= 0; --$i) {
$t = self::ge_p3_dbl($r);
if ($aslide[$i] > 0) {
# ge_p1p1_to_p3(&u, &t);
$u = self::ge_p1p1_to_p3($t);
# ge_add(&t, &u, &Ai[aslide[i] / 2]);
$t = self::ge_add($u, $Ai[(int)($aslide[$i] / 2)]);
} elseif ($aslide[$i] < 0) {
# ge_p1p1_to_p3(&u, &t);
$u = self::ge_p1p1_to_p3($t);
# ge_sub(&t, &u, &Ai[(-aslide[i]) / 2]);
$t = self::ge_sub($u, $Ai[(int)(-$aslide[$i] / 2)]);
}
}
# ge_p1p1_to_p3(r, &t);
return self::ge_p1p1_to_p3($t);
}
/**
* @param string $a
* @param string $b
* @return string
*/
public static function sc25519_mul($a, $b)
{
// int64_t a0 = 2097151 & load_3(a);
// int64_t a1 = 2097151 & (load_4(a + 2) >> 5);
// int64_t a2 = 2097151 & (load_3(a + 5) >> 2);
// int64_t a3 = 2097151 & (load_4(a + 7) >> 7);
// int64_t a4 = 2097151 & (load_4(a + 10) >> 4);
// int64_t a5 = 2097151 & (load_3(a + 13) >> 1);
// int64_t a6 = 2097151 & (load_4(a + 15) >> 6);
// int64_t a7 = 2097151 & (load_3(a + 18) >> 3);
// int64_t a8 = 2097151 & load_3(a + 21);
// int64_t a9 = 2097151 & (load_4(a + 23) >> 5);
// int64_t a10 = 2097151 & (load_3(a + 26) >> 2);
// int64_t a11 = (load_4(a + 28) >> 7);
$a0 = 2097151 & self::load_3(self::substr($a, 0, 3));
$a1 = 2097151 & (self::load_4(self::substr($a, 2, 4)) >> 5);
$a2 = 2097151 & (self::load_3(self::substr($a, 5, 3)) >> 2);
$a3 = 2097151 & (self::load_4(self::substr($a, 7, 4)) >> 7);
$a4 = 2097151 & (self::load_4(self::substr($a, 10, 4)) >> 4);
$a5 = 2097151 & (self::load_3(self::substr($a, 13, 3)) >> 1);
$a6 = 2097151 & (self::load_4(self::substr($a, 15, 4)) >> 6);
$a7 = 2097151 & (self::load_3(self::substr($a, 18, 3)) >> 3);
$a8 = 2097151 & self::load_3(self::substr($a, 21, 3));
$a9 = 2097151 & (self::load_4(self::substr($a, 23, 4)) >> 5);
$a10 = 2097151 & (self::load_3(self::substr($a, 26, 3)) >> 2);
$a11 = (self::load_4(self::substr($a, 28, 4)) >> 7);
// int64_t b0 = 2097151 & load_3(b);
// int64_t b1 = 2097151 & (load_4(b + 2) >> 5);
// int64_t b2 = 2097151 & (load_3(b + 5) >> 2);
// int64_t b3 = 2097151 & (load_4(b + 7) >> 7);
// int64_t b4 = 2097151 & (load_4(b + 10) >> 4);
// int64_t b5 = 2097151 & (load_3(b + 13) >> 1);
// int64_t b6 = 2097151 & (load_4(b + 15) >> 6);
// int64_t b7 = 2097151 & (load_3(b + 18) >> 3);
// int64_t b8 = 2097151 & load_3(b + 21);
// int64_t b9 = 2097151 & (load_4(b + 23) >> 5);
// int64_t b10 = 2097151 & (load_3(b + 26) >> 2);
// int64_t b11 = (load_4(b + 28) >> 7);
$b0 = 2097151 & self::load_3(self::substr($b, 0, 3));
$b1 = 2097151 & (self::load_4(self::substr($b, 2, 4)) >> 5);
$b2 = 2097151 & (self::load_3(self::substr($b, 5, 3)) >> 2);
$b3 = 2097151 & (self::load_4(self::substr($b, 7, 4)) >> 7);
$b4 = 2097151 & (self::load_4(self::substr($b, 10, 4)) >> 4);
$b5 = 2097151 & (self::load_3(self::substr($b, 13, 3)) >> 1);
$b6 = 2097151 & (self::load_4(self::substr($b, 15, 4)) >> 6);
$b7 = 2097151 & (self::load_3(self::substr($b, 18, 3)) >> 3);
$b8 = 2097151 & self::load_3(self::substr($b, 21, 3));
$b9 = 2097151 & (self::load_4(self::substr($b, 23, 4)) >> 5);
$b10 = 2097151 & (self::load_3(self::substr($b, 26, 3)) >> 2);
$b11 = (self::load_4(self::substr($b, 28, 4)) >> 7);
// s0 = a0 * b0;
// s1 = a0 * b1 + a1 * b0;
// s2 = a0 * b2 + a1 * b1 + a2 * b0;
// s3 = a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0;
// s4 = a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0;
// s5 = a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0;
// s6 = a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0;
// s7 = a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 +
// a6 * b1 + a7 * b0;
// s8 = a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 +
// a6 * b2 + a7 * b1 + a8 * b0;
// s9 = a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 +
// a6 * b3 + a7 * b2 + a8 * b1 + a9 * b0;
// s10 = a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 +
// a6 * b4 + a7 * b3 + a8 * b2 + a9 * b1 + a10 * b0;
// s11 = a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 +
// a6 * b5 + a7 * b4 + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0;
// s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 +
// a7 * b5 + a8 * b4 + a9 * b3 + a10 * b2 + a11 * b1;
// s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 +
// a8 * b5 + a9 * b4 + a10 * b3 + a11 * b2;
// s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 +
// a9 * b5 + a10 * b4 + a11 * b3;
// s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 +
// a10 * b5 + a11 * b4;
// s16 =
// a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5;
// s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6;
// s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7;
// s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8;
// s20 = a9 * b11 + a10 * b10 + a11 * b9;
// s21 = a10 * b11 + a11 * b10;
// s22 = a11 * b11;
// s23 = 0;
$s0 = self::mul($a0, $b0, 22);
$s1 = self::mul($a0, $b1, 22) + self::mul($a1, $b0, 22);
$s2 = self::mul($a0, $b2, 22) + self::mul($a1, $b1, 22) + self::mul($a2, $b0, 22);
$s3 = self::mul($a0, $b3, 22) + self::mul($a1, $b2, 22) + self::mul($a2, $b1, 22) + self::mul($a3, $b0, 22);
$s4 = self::mul($a0, $b4, 22) + self::mul($a1, $b3, 22) + self::mul($a2, $b2, 22) + self::mul($a3, $b1, 22) +
self::mul($a4, $b0, 22);
$s5 = self::mul($a0, $b5, 22) + self::mul($a1, $b4, 22) + self::mul($a2, $b3, 22) + self::mul($a3, $b2, 22) +
self::mul($a4, $b1, 22) + self::mul($a5, $b0, 22);
$s6 = self::mul($a0, $b6, 22) + self::mul($a1, $b5, 22) + self::mul($a2, $b4, 22) + self::mul($a3, $b3, 22) +
self::mul($a4, $b2, 22) + self::mul($a5, $b1, 22) + self::mul($a6, $b0, 22);
$s7 = self::mul($a0, $b7, 22) + self::mul($a1, $b6, 22) + self::mul($a2, $b5, 22) + self::mul($a3, $b4, 22) +
self::mul($a4, $b3, 22) + self::mul($a5, $b2, 22) + self::mul($a6, $b1, 22) + self::mul($a7, $b0, 22);
$s8 = self::mul($a0, $b8, 22) + self::mul($a1, $b7, 22) + self::mul($a2, $b6, 22) + self::mul($a3, $b5, 22) +
self::mul($a4, $b4, 22) + self::mul($a5, $b3, 22) + self::mul($a6, $b2, 22) + self::mul($a7, $b1, 22) +
self::mul($a8, $b0, 22);
$s9 = self::mul($a0, $b9, 22) + self::mul($a1, $b8, 22) + self::mul($a2, $b7, 22) + self::mul($a3, $b6, 22) +
self::mul($a4, $b5, 22) + self::mul($a5, $b4, 22) + self::mul($a6, $b3, 22) + self::mul($a7, $b2, 22) +
self::mul($a8, $b1, 22) + self::mul($a9, $b0, 22);
$s10 = self::mul($a0, $b10, 22) + self::mul($a1, $b9, 22) + self::mul($a2, $b8, 22) + self::mul($a3, $b7, 22) +
self::mul($a4, $b6, 22) + self::mul($a5, $b5, 22) + self::mul($a6, $b4, 22) + self::mul($a7, $b3, 22) +
self::mul($a8, $b2, 22) + self::mul($a9, $b1, 22) + self::mul($a10, $b0, 22);
$s11 = self::mul($a0, $b11, 22) + self::mul($a1, $b10, 22) + self::mul($a2, $b9, 22) + self::mul($a3, $b8, 22) +
self::mul($a4, $b7, 22) + self::mul($a5, $b6, 22) + self::mul($a6, $b5, 22) + self::mul($a7, $b4, 22) +
self::mul($a8, $b3, 22) + self::mul($a9, $b2, 22) + self::mul($a10, $b1, 22) + self::mul($a11, $b0, 22);
$s12 = self::mul($a1, $b11, 22) + self::mul($a2, $b10, 22) + self::mul($a3, $b9, 22) + self::mul($a4, $b8, 22) +
self::mul($a5, $b7, 22) + self::mul($a6, $b6, 22) + self::mul($a7, $b5, 22) + self::mul($a8, $b4, 22) +
self::mul($a9, $b3, 22) + self::mul($a10, $b2, 22) + self::mul($a11, $b1, 22);
$s13 = self::mul($a2, $b11, 22) + self::mul($a3, $b10, 22) + self::mul($a4, $b9, 22) + self::mul($a5, $b8, 22) +
self::mul($a6, $b7, 22) + self::mul($a7, $b6, 22) + self::mul($a8, $b5, 22) + self::mul($a9, $b4, 22) +
self::mul($a10, $b3, 22) + self::mul($a11, $b2, 22);
$s14 = self::mul($a3, $b11, 22) + self::mul($a4, $b10, 22) + self::mul($a5, $b9, 22) + self::mul($a6, $b8, 22) +
self::mul($a7, $b7, 22) + self::mul($a8, $b6, 22) + self::mul($a9, $b5, 22) + self::mul($a10, $b4, 22) +
self::mul($a11, $b3, 22);
$s15 = self::mul($a4, $b11, 22) + self::mul($a5, $b10, 22) + self::mul($a6, $b9, 22) + self::mul($a7, $b8, 22) +
self::mul($a8, $b7, 22) + self::mul($a9, $b6, 22) + self::mul($a10, $b5, 22) + self::mul($a11, $b4, 22);
$s16 =
self::mul($a5, $b11, 22) + self::mul($a6, $b10, 22) + self::mul($a7, $b9, 22) + self::mul($a8, $b8, 22) +
self::mul($a9, $b7, 22) + self::mul($a10, $b6, 22) + self::mul($a11, $b5, 22);
$s17 = self::mul($a6, $b11, 22) + self::mul($a7, $b10, 22) + self::mul($a8, $b9, 22) + self::mul($a9, $b8, 22) +
self::mul($a10, $b7, 22) + self::mul($a11, $b6, 22);
$s18 = self::mul($a7, $b11, 22) + self::mul($a8, $b10, 22) + self::mul($a9, $b9, 22) + self::mul($a10, $b8, 22)
+ self::mul($a11, $b7, 22);
$s19 = self::mul($a8, $b11, 22) + self::mul($a9, $b10, 22) + self::mul($a10, $b9, 22) +
self::mul($a11, $b8, 22);
$s20 = self::mul($a9, $b11, 22) + self::mul($a10, $b10, 22) + self::mul($a11, $b9, 22);
$s21 = self::mul($a10, $b11, 22) + self::mul($a11, $b10, 22);
$s22 = self::mul($a11, $b11, 22);
$s23 = 0;
// carry0 = (s0 + (int64_t) (1L << 20)) >> 21;
// s1 += carry0;
// s0 -= carry0 * ((uint64_t) 1L << 21);
$carry0 = ($s0 + (1 << 20)) >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
// carry2 = (s2 + (int64_t) (1L << 20)) >> 21;
// s3 += carry2;
// s2 -= carry2 * ((uint64_t) 1L << 21);
$carry2 = ($s2 + (1 << 20)) >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
// carry4 = (s4 + (int64_t) (1L << 20)) >> 21;
// s5 += carry4;
// s4 -= carry4 * ((uint64_t) 1L << 21);
$carry4 = ($s4 + (1 << 20)) >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
// carry6 = (s6 + (int64_t) (1L << 20)) >> 21;
// s7 += carry6;
// s6 -= carry6 * ((uint64_t) 1L << 21);
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
// carry8 = (s8 + (int64_t) (1L << 20)) >> 21;
// s9 += carry8;
// s8 -= carry8 * ((uint64_t) 1L << 21);
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
// carry10 = (s10 + (int64_t) (1L << 20)) >> 21;
// s11 += carry10;
// s10 -= carry10 * ((uint64_t) 1L << 21);
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
// carry12 = (s12 + (int64_t) (1L << 20)) >> 21;
// s13 += carry12;
// s12 -= carry12 * ((uint64_t) 1L << 21);
$carry12 = ($s12 + (1 << 20)) >> 21;
$s13 += $carry12;
$s12 -= $carry12 << 21;
// carry14 = (s14 + (int64_t) (1L << 20)) >> 21;
// s15 += carry14;
// s14 -= carry14 * ((uint64_t) 1L << 21);
$carry14 = ($s14 + (1 << 20)) >> 21;
$s15 += $carry14;
$s14 -= $carry14 << 21;
// carry16 = (s16 + (int64_t) (1L << 20)) >> 21;
// s17 += carry16;
// s16 -= carry16 * ((uint64_t) 1L << 21);
$carry16 = ($s16 + (1 << 20)) >> 21;
$s17 += $carry16;
$s16 -= $carry16 << 21;
// carry18 = (s18 + (int64_t) (1L << 20)) >> 21;
// s19 += carry18;
// s18 -= carry18 * ((uint64_t) 1L << 21);
$carry18 = ($s18 + (1 << 20)) >> 21;
$s19 += $carry18;
$s18 -= $carry18 << 21;
// carry20 = (s20 + (int64_t) (1L << 20)) >> 21;
// s21 += carry20;
// s20 -= carry20 * ((uint64_t) 1L << 21);
$carry20 = ($s20 + (1 << 20)) >> 21;
$s21 += $carry20;
$s20 -= $carry20 << 21;
// carry22 = (s22 + (int64_t) (1L << 20)) >> 21;
// s23 += carry22;
// s22 -= carry22 * ((uint64_t) 1L << 21);
$carry22 = ($s22 + (1 << 20)) >> 21;
$s23 += $carry22;
$s22 -= $carry22 << 21;
// carry1 = (s1 + (int64_t) (1L << 20)) >> 21;
// s2 += carry1;
// s1 -= carry1 * ((uint64_t) 1L << 21);
$carry1 = ($s1 + (1 << 20)) >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
// carry3 = (s3 + (int64_t) (1L << 20)) >> 21;
// s4 += carry3;
// s3 -= carry3 * ((uint64_t) 1L << 21);
$carry3 = ($s3 + (1 << 20)) >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
// carry5 = (s5 + (int64_t) (1L << 20)) >> 21;
// s6 += carry5;
// s5 -= carry5 * ((uint64_t) 1L << 21);
$carry5 = ($s5 + (1 << 20)) >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
// carry7 = (s7 + (int64_t) (1L << 20)) >> 21;
// s8 += carry7;
// s7 -= carry7 * ((uint64_t) 1L << 21);
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
// carry9 = (s9 + (int64_t) (1L << 20)) >> 21;
// s10 += carry9;
// s9 -= carry9 * ((uint64_t) 1L << 21);
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
// carry11 = (s11 + (int64_t) (1L << 20)) >> 21;
// s12 += carry11;
// s11 -= carry11 * ((uint64_t) 1L << 21);
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
// carry13 = (s13 + (int64_t) (1L << 20)) >> 21;
// s14 += carry13;
// s13 -= carry13 * ((uint64_t) 1L << 21);
$carry13 = ($s13 + (1 << 20)) >> 21;
$s14 += $carry13;
$s13 -= $carry13 << 21;
// carry15 = (s15 + (int64_t) (1L << 20)) >> 21;
// s16 += carry15;
// s15 -= carry15 * ((uint64_t) 1L << 21);
$carry15 = ($s15 + (1 << 20)) >> 21;
$s16 += $carry15;
$s15 -= $carry15 << 21;
// carry17 = (s17 + (int64_t) (1L << 20)) >> 21;
// s18 += carry17;
// s17 -= carry17 * ((uint64_t) 1L << 21);
$carry17 = ($s17 + (1 << 20)) >> 21;
$s18 += $carry17;
$s17 -= $carry17 << 21;
// carry19 = (s19 + (int64_t) (1L << 20)) >> 21;
// s20 += carry19;
// s19 -= carry19 * ((uint64_t) 1L << 21);
$carry19 = ($s19 + (1 << 20)) >> 21;
$s20 += $carry19;
$s19 -= $carry19 << 21;
// carry21 = (s21 + (int64_t) (1L << 20)) >> 21;
// s22 += carry21;
// s21 -= carry21 * ((uint64_t) 1L << 21);
$carry21 = ($s21 + (1 << 20)) >> 21;
$s22 += $carry21;
$s21 -= $carry21 << 21;
// s11 += s23 * 666643;
// s12 += s23 * 470296;
// s13 += s23 * 654183;
// s14 -= s23 * 997805;
// s15 += s23 * 136657;
// s16 -= s23 * 683901;
$s11 += self::mul($s23, 666643, 20);
$s12 += self::mul($s23, 470296, 19);
$s13 += self::mul($s23, 654183, 20);
$s14 -= self::mul($s23, 997805, 20);
$s15 += self::mul($s23, 136657, 18);
$s16 -= self::mul($s23, 683901, 20);
// s10 += s22 * 666643;
// s11 += s22 * 470296;
// s12 += s22 * 654183;
// s13 -= s22 * 997805;
// s14 += s22 * 136657;
// s15 -= s22 * 683901;
$s10 += self::mul($s22, 666643, 20);
$s11 += self::mul($s22, 470296, 19);
$s12 += self::mul($s22, 654183, 20);
$s13 -= self::mul($s22, 997805, 20);
$s14 += self::mul($s22, 136657, 18);
$s15 -= self::mul($s22, 683901, 20);
// s9 += s21 * 666643;
// s10 += s21 * 470296;
// s11 += s21 * 654183;
// s12 -= s21 * 997805;
// s13 += s21 * 136657;
// s14 -= s21 * 683901;
$s9 += self::mul($s21, 666643, 20);
$s10 += self::mul($s21, 470296, 19);
$s11 += self::mul($s21, 654183, 20);
$s12 -= self::mul($s21, 997805, 20);
$s13 += self::mul($s21, 136657, 18);
$s14 -= self::mul($s21, 683901, 20);
// s8 += s20 * 666643;
// s9 += s20 * 470296;
// s10 += s20 * 654183;
// s11 -= s20 * 997805;
// s12 += s20 * 136657;
// s13 -= s20 * 683901;
$s8 += self::mul($s20, 666643, 20);
$s9 += self::mul($s20, 470296, 19);
$s10 += self::mul($s20, 654183, 20);
$s11 -= self::mul($s20, 997805, 20);
$s12 += self::mul($s20, 136657, 18);
$s13 -= self::mul($s20, 683901, 20);
// s7 += s19 * 666643;
// s8 += s19 * 470296;
// s9 += s19 * 654183;
// s10 -= s19 * 997805;
// s11 += s19 * 136657;
// s12 -= s19 * 683901;
$s7 += self::mul($s19, 666643, 20);
$s8 += self::mul($s19, 470296, 19);
$s9 += self::mul($s19, 654183, 20);
$s10 -= self::mul($s19, 997805, 20);
$s11 += self::mul($s19, 136657, 18);
$s12 -= self::mul($s19, 683901, 20);
// s6 += s18 * 666643;
// s7 += s18 * 470296;
// s8 += s18 * 654183;
// s9 -= s18 * 997805;
// s10 += s18 * 136657;
// s11 -= s18 * 683901;
$s6 += self::mul($s18, 666643, 20);
$s7 += self::mul($s18, 470296, 19);
$s8 += self::mul($s18, 654183, 20);
$s9 -= self::mul($s18, 997805, 20);
$s10 += self::mul($s18, 136657, 18);
$s11 -= self::mul($s18, 683901, 20);
// carry6 = (s6 + (int64_t) (1L << 20)) >> 21;
// s7 += carry6;
// s6 -= carry6 * ((uint64_t) 1L << 21);
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
// carry8 = (s8 + (int64_t) (1L << 20)) >> 21;
// s9 += carry8;
// s8 -= carry8 * ((uint64_t) 1L << 21);
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
// carry10 = (s10 + (int64_t) (1L << 20)) >> 21;
// s11 += carry10;
// s10 -= carry10 * ((uint64_t) 1L << 21);
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
// carry12 = (s12 + (int64_t) (1L << 20)) >> 21;
// s13 += carry12;
// s12 -= carry12 * ((uint64_t) 1L << 21);
$carry12 = ($s12 + (1 << 20)) >> 21;
$s13 += $carry12;
$s12 -= $carry12 << 21;
// carry14 = (s14 + (int64_t) (1L << 20)) >> 21;
// s15 += carry14;
// s14 -= carry14 * ((uint64_t) 1L << 21);
$carry14 = ($s14 + (1 << 20)) >> 21;
$s15 += $carry14;
$s14 -= $carry14 << 21;
// carry16 = (s16 + (int64_t) (1L << 20)) >> 21;
// s17 += carry16;
// s16 -= carry16 * ((uint64_t) 1L << 21);
$carry16 = ($s16 + (1 << 20)) >> 21;
$s17 += $carry16;
$s16 -= $carry16 << 21;
// carry7 = (s7 + (int64_t) (1L << 20)) >> 21;
// s8 += carry7;
// s7 -= carry7 * ((uint64_t) 1L << 21);
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
// carry9 = (s9 + (int64_t) (1L << 20)) >> 21;
// s10 += carry9;
// s9 -= carry9 * ((uint64_t) 1L << 21);
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
// carry11 = (s11 + (int64_t) (1L << 20)) >> 21;
// s12 += carry11;
// s11 -= carry11 * ((uint64_t) 1L << 21);
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
// carry13 = (s13 + (int64_t) (1L << 20)) >> 21;
// s14 += carry13;
// s13 -= carry13 * ((uint64_t) 1L << 21);
$carry13 = ($s13 + (1 << 20)) >> 21;
$s14 += $carry13;
$s13 -= $carry13 << 21;
// carry15 = (s15 + (int64_t) (1L << 20)) >> 21;
// s16 += carry15;
// s15 -= carry15 * ((uint64_t) 1L << 21);
$carry15 = ($s15 + (1 << 20)) >> 21;
$s16 += $carry15;
$s15 -= $carry15 << 21;
// s5 += s17 * 666643;
// s6 += s17 * 470296;
// s7 += s17 * 654183;
// s8 -= s17 * 997805;
// s9 += s17 * 136657;
// s10 -= s17 * 683901;
$s5 += self::mul($s17, 666643, 20);
$s6 += self::mul($s17, 470296, 19);
$s7 += self::mul($s17, 654183, 20);
$s8 -= self::mul($s17, 997805, 20);
$s9 += self::mul($s17, 136657, 18);
$s10 -= self::mul($s17, 683901, 20);
// s4 += s16 * 666643;
// s5 += s16 * 470296;
// s6 += s16 * 654183;
// s7 -= s16 * 997805;
// s8 += s16 * 136657;
// s9 -= s16 * 683901;
$s4 += self::mul($s16, 666643, 20);
$s5 += self::mul($s16, 470296, 19);
$s6 += self::mul($s16, 654183, 20);
$s7 -= self::mul($s16, 997805, 20);
$s8 += self::mul($s16, 136657, 18);
$s9 -= self::mul($s16, 683901, 20);
// s3 += s15 * 666643;
// s4 += s15 * 470296;
// s5 += s15 * 654183;
// s6 -= s15 * 997805;
// s7 += s15 * 136657;
// s8 -= s15 * 683901;
$s3 += self::mul($s15, 666643, 20);
$s4 += self::mul($s15, 470296, 19);
$s5 += self::mul($s15, 654183, 20);
$s6 -= self::mul($s15, 997805, 20);
$s7 += self::mul($s15, 136657, 18);
$s8 -= self::mul($s15, 683901, 20);
// s2 += s14 * 666643;
// s3 += s14 * 470296;
// s4 += s14 * 654183;
// s5 -= s14 * 997805;
// s6 += s14 * 136657;
// s7 -= s14 * 683901;
$s2 += self::mul($s14, 666643, 20);
$s3 += self::mul($s14, 470296, 19);
$s4 += self::mul($s14, 654183, 20);
$s5 -= self::mul($s14, 997805, 20);
$s6 += self::mul($s14, 136657, 18);
$s7 -= self::mul($s14, 683901, 20);
// s1 += s13 * 666643;
// s2 += s13 * 470296;
// s3 += s13 * 654183;
// s4 -= s13 * 997805;
// s5 += s13 * 136657;
// s6 -= s13 * 683901;
$s1 += self::mul($s13, 666643, 20);
$s2 += self::mul($s13, 470296, 19);
$s3 += self::mul($s13, 654183, 20);
$s4 -= self::mul($s13, 997805, 20);
$s5 += self::mul($s13, 136657, 18);
$s6 -= self::mul($s13, 683901, 20);
// s0 += s12 * 666643;
// s1 += s12 * 470296;
// s2 += s12 * 654183;
// s3 -= s12 * 997805;
// s4 += s12 * 136657;
// s5 -= s12 * 683901;
// s12 = 0;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$s12 = 0;
// carry0 = (s0 + (int64_t) (1L << 20)) >> 21;
// s1 += carry0;
// s0 -= carry0 * ((uint64_t) 1L << 21);
$carry0 = ($s0 + (1 << 20)) >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
// carry2 = (s2 + (int64_t) (1L << 20)) >> 21;
// s3 += carry2;
// s2 -= carry2 * ((uint64_t) 1L << 21);
$carry2 = ($s2 + (1 << 20)) >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
// carry4 = (s4 + (int64_t) (1L << 20)) >> 21;
// s5 += carry4;
// s4 -= carry4 * ((uint64_t) 1L << 21);
$carry4 = ($s4 + (1 << 20)) >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
// carry6 = (s6 + (int64_t) (1L << 20)) >> 21;
// s7 += carry6;
// s6 -= carry6 * ((uint64_t) 1L << 21);
$carry6 = ($s6 + (1 << 20)) >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
// carry8 = (s8 + (int64_t) (1L << 20)) >> 21;
// s9 += carry8;
// s8 -= carry8 * ((uint64_t) 1L << 21);
$carry8 = ($s8 + (1 << 20)) >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
// carry10 = (s10 + (int64_t) (1L << 20)) >> 21;
// s11 += carry10;
// s10 -= carry10 * ((uint64_t) 1L << 21);
$carry10 = ($s10 + (1 << 20)) >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
// carry1 = (s1 + (int64_t) (1L << 20)) >> 21;
// s2 += carry1;
// s1 -= carry1 * ((uint64_t) 1L << 21);
$carry1 = ($s1 + (1 << 20)) >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
// carry3 = (s3 + (int64_t) (1L << 20)) >> 21;
// s4 += carry3;
// s3 -= carry3 * ((uint64_t) 1L << 21);
$carry3 = ($s3 + (1 << 20)) >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
// carry5 = (s5 + (int64_t) (1L << 20)) >> 21;
// s6 += carry5;
// s5 -= carry5 * ((uint64_t) 1L << 21);
$carry5 = ($s5 + (1 << 20)) >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
// carry7 = (s7 + (int64_t) (1L << 20)) >> 21;
// s8 += carry7;
// s7 -= carry7 * ((uint64_t) 1L << 21);
$carry7 = ($s7 + (1 << 20)) >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
// carry9 = (s9 + (int64_t) (1L << 20)) >> 21;
// s10 += carry9;
// s9 -= carry9 * ((uint64_t) 1L << 21);
$carry9 = ($s9 + (1 << 20)) >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
// carry11 = (s11 + (int64_t) (1L << 20)) >> 21;
// s12 += carry11;
// s11 -= carry11 * ((uint64_t) 1L << 21);
$carry11 = ($s11 + (1 << 20)) >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
// s0 += s12 * 666643;
// s1 += s12 * 470296;
// s2 += s12 * 654183;
// s3 -= s12 * 997805;
// s4 += s12 * 136657;
// s5 -= s12 * 683901;
// s12 = 0;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
$s12 = 0;
// carry0 = s0 >> 21;
// s1 += carry0;
// s0 -= carry0 * ((uint64_t) 1L << 21);
$carry0 = $s0 >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
// carry1 = s1 >> 21;
// s2 += carry1;
// s1 -= carry1 * ((uint64_t) 1L << 21);
$carry1 = $s1 >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
// carry2 = s2 >> 21;
// s3 += carry2;
// s2 -= carry2 * ((uint64_t) 1L << 21);
$carry2 = $s2 >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
// carry3 = s3 >> 21;
// s4 += carry3;
// s3 -= carry3 * ((uint64_t) 1L << 21);
$carry3 = $s3 >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
// carry4 = s4 >> 21;
// s5 += carry4;
// s4 -= carry4 * ((uint64_t) 1L << 21);
$carry4 = $s4 >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
// carry5 = s5 >> 21;
// s6 += carry5;
// s5 -= carry5 * ((uint64_t) 1L << 21);
$carry5 = $s5 >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
// carry6 = s6 >> 21;
// s7 += carry6;
// s6 -= carry6 * ((uint64_t) 1L << 21);
$carry6 = $s6 >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
// carry7 = s7 >> 21;
// s8 += carry7;
// s7 -= carry7 * ((uint64_t) 1L << 21);
$carry7 = $s7 >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
// carry8 = s8 >> 21;
// s9 += carry8;
// s8 -= carry8 * ((uint64_t) 1L << 21);
$carry8 = $s8 >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
// carry9 = s9 >> 21;
// s10 += carry9;
// s9 -= carry9 * ((uint64_t) 1L << 21);
$carry9 = $s9 >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
// carry10 = s10 >> 21;
// s11 += carry10;
// s10 -= carry10 * ((uint64_t) 1L << 21);
$carry10 = $s10 >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
// carry11 = s11 >> 21;
// s12 += carry11;
// s11 -= carry11 * ((uint64_t) 1L << 21);
$carry11 = $s11 >> 21;
$s12 += $carry11;
$s11 -= $carry11 << 21;
// s0 += s12 * 666643;
// s1 += s12 * 470296;
// s2 += s12 * 654183;
// s3 -= s12 * 997805;
// s4 += s12 * 136657;
// s5 -= s12 * 683901;
$s0 += self::mul($s12, 666643, 20);
$s1 += self::mul($s12, 470296, 19);
$s2 += self::mul($s12, 654183, 20);
$s3 -= self::mul($s12, 997805, 20);
$s4 += self::mul($s12, 136657, 18);
$s5 -= self::mul($s12, 683901, 20);
// carry0 = s0 >> 21;
// s1 += carry0;
// s0 -= carry0 * ((uint64_t) 1L << 21);
$carry0 = $s0 >> 21;
$s1 += $carry0;
$s0 -= $carry0 << 21;
// carry1 = s1 >> 21;
// s2 += carry1;
// s1 -= carry1 * ((uint64_t) 1L << 21);
$carry1 = $s1 >> 21;
$s2 += $carry1;
$s1 -= $carry1 << 21;
// carry2 = s2 >> 21;
// s3 += carry2;
// s2 -= carry2 * ((uint64_t) 1L << 21);
$carry2 = $s2 >> 21;
$s3 += $carry2;
$s2 -= $carry2 << 21;
// carry3 = s3 >> 21;
// s4 += carry3;
// s3 -= carry3 * ((uint64_t) 1L << 21);
$carry3 = $s3 >> 21;
$s4 += $carry3;
$s3 -= $carry3 << 21;
// carry4 = s4 >> 21;
// s5 += carry4;
// s4 -= carry4 * ((uint64_t) 1L << 21);
$carry4 = $s4 >> 21;
$s5 += $carry4;
$s4 -= $carry4 << 21;
// carry5 = s5 >> 21;
// s6 += carry5;
// s5 -= carry5 * ((uint64_t) 1L << 21);
$carry5 = $s5 >> 21;
$s6 += $carry5;
$s5 -= $carry5 << 21;
// carry6 = s6 >> 21;
// s7 += carry6;
// s6 -= carry6 * ((uint64_t) 1L << 21);
$carry6 = $s6 >> 21;
$s7 += $carry6;
$s6 -= $carry6 << 21;
// carry7 = s7 >> 21;
// s8 += carry7;
// s7 -= carry7 * ((uint64_t) 1L << 21);
$carry7 = $s7 >> 21;
$s8 += $carry7;
$s7 -= $carry7 << 21;
// carry8 = s8 >> 21;
// s9 += carry8;
// s8 -= carry8 * ((uint64_t) 1L << 21);
$carry8 = $s8 >> 21;
$s9 += $carry8;
$s8 -= $carry8 << 21;
// carry9 = s9 >> 21;
// s10 += carry9;
// s9 -= carry9 * ((uint64_t) 1L << 21);
$carry9 = $s9 >> 21;
$s10 += $carry9;
$s9 -= $carry9 << 21;
// carry10 = s10 >> 21;
// s11 += carry10;
// s10 -= carry10 * ((uint64_t) 1L << 21);
$carry10 = $s10 >> 21;
$s11 += $carry10;
$s10 -= $carry10 << 21;
$s = array_fill(0, 32, 0);
// s[0] = s0 >> 0;
$s[0] = $s0 >> 0;
// s[1] = s0 >> 8;
$s[1] = $s0 >> 8;
// s[2] = (s0 >> 16) | (s1 * ((uint64_t) 1 << 5));
$s[2] = ($s0 >> 16) | ($s1 << 5);
// s[3] = s1 >> 3;
$s[3] = $s1 >> 3;
// s[4] = s1 >> 11;
$s[4] = $s1 >> 11;
// s[5] = (s1 >> 19) | (s2 * ((uint64_t) 1 << 2));
$s[5] = ($s1 >> 19) | ($s2 << 2);
// s[6] = s2 >> 6;
$s[6] = $s2 >> 6;
// s[7] = (s2 >> 14) | (s3 * ((uint64_t) 1 << 7));
$s[7] = ($s2 >> 14) | ($s3 << 7);
// s[8] = s3 >> 1;
$s[8] = $s3 >> 1;
// s[9] = s3 >> 9;
$s[9] = $s3 >> 9;
// s[10] = (s3 >> 17) | (s4 * ((uint64_t) 1 << 4));
$s[10] = ($s3 >> 17) | ($s4 << 4);
// s[11] = s4 >> 4;
$s[11] = $s4 >> 4;
// s[12] = s4 >> 12;
$s[12] = $s4 >> 12;
// s[13] = (s4 >> 20) | (s5 * ((uint64_t) 1 << 1));
$s[13] = ($s4 >> 20) | ($s5 << 1);
// s[14] = s5 >> 7;
$s[14] = $s5 >> 7;
// s[15] = (s5 >> 15) | (s6 * ((uint64_t) 1 << 6));
$s[15] = ($s5 >> 15) | ($s6 << 6);
// s[16] = s6 >> 2;
$s[16] = $s6 >> 2;
// s[17] = s6 >> 10;
$s[17] = $s6 >> 10;
// s[18] = (s6 >> 18) | (s7 * ((uint64_t) 1 << 3));
$s[18] = ($s6 >> 18) | ($s7 << 3);
// s[19] = s7 >> 5;
$s[19] = $s7 >> 5;
// s[20] = s7 >> 13;
$s[20] = $s7 >> 13;
// s[21] = s8 >> 0;
$s[21] = $s8 >> 0;
// s[22] = s8 >> 8;
$s[22] = $s8 >> 8;
// s[23] = (s8 >> 16) | (s9 * ((uint64_t) 1 << 5));
$s[23] = ($s8 >> 16) | ($s9 << 5);
// s[24] = s9 >> 3;
$s[24] = $s9 >> 3;
// s[25] = s9 >> 11;
$s[25] = $s9 >> 11;
// s[26] = (s9 >> 19) | (s10 * ((uint64_t) 1 << 2));
$s[26] = ($s9 >> 19) | ($s10 << 2);
// s[27] = s10 >> 6;
$s[27] = $s10 >> 6;
// s[28] = (s10 >> 14) | (s11 * ((uint64_t) 1 << 7));
$s[28] = ($s10 >> 14) | ($s11 << 7);
// s[29] = s11 >> 1;
$s[29] = $s11 >> 1;
// s[30] = s11 >> 9;
$s[30] = $s11 >> 9;
// s[31] = s11 >> 17;
$s[31] = $s11 >> 17;
return self::intArrayToString($s);
}
/**
* @param string $s
* @return string
*/
public static function sc25519_sq($s)
{
return self::sc25519_mul($s, $s);
}
/**
* @param string $s
* @param int $n
* @param string $a
* @return string
*/
public static function sc25519_sqmul($s, $n, $a)
{
for ($i = 0; $i < $n; ++$i) {
$s = self::sc25519_sq($s);
}
return self::sc25519_mul($s, $a);
}
/**
* @param string $s
* @return string
*/
public static function sc25519_invert($s)
{
$_10 = self::sc25519_sq($s);
$_11 = self::sc25519_mul($s, $_10);
$_100 = self::sc25519_mul($s, $_11);
$_1000 = self::sc25519_sq($_100);
$_1010 = self::sc25519_mul($_10, $_1000);
$_1011 = self::sc25519_mul($s, $_1010);
$_10000 = self::sc25519_sq($_1000);
$_10110 = self::sc25519_sq($_1011);
$_100000 = self::sc25519_mul($_1010, $_10110);
$_100110 = self::sc25519_mul($_10000, $_10110);
$_1000000 = self::sc25519_sq($_100000);
$_1010000 = self::sc25519_mul($_10000, $_1000000);
$_1010011 = self::sc25519_mul($_11, $_1010000);
$_1100011 = self::sc25519_mul($_10000, $_1010011);
$_1100111 = self::sc25519_mul($_100, $_1100011);
$_1101011 = self::sc25519_mul($_100, $_1100111);
$_10010011 = self::sc25519_mul($_1000000, $_1010011);
$_10010111 = self::sc25519_mul($_100, $_10010011);
$_10111101 = self::sc25519_mul($_100110, $_10010111);
$_11010011 = self::sc25519_mul($_10110, $_10111101);
$_11100111 = self::sc25519_mul($_1010000, $_10010111);
$_11101011 = self::sc25519_mul($_100, $_11100111);
$_11110101 = self::sc25519_mul($_1010, $_11101011);
$recip = self::sc25519_mul($_1011, $_11110101);
$recip = self::sc25519_sqmul($recip, 126, $_1010011);
$recip = self::sc25519_sqmul($recip, 9, $_10);
$recip = self::sc25519_mul($recip, $_11110101);
$recip = self::sc25519_sqmul($recip, 7, $_1100111);
$recip = self::sc25519_sqmul($recip, 9, $_11110101);
$recip = self::sc25519_sqmul($recip, 11, $_10111101);
$recip = self::sc25519_sqmul($recip, 8, $_11100111);
$recip = self::sc25519_sqmul($recip, 9, $_1101011);
$recip = self::sc25519_sqmul($recip, 6, $_1011);
$recip = self::sc25519_sqmul($recip, 14, $_10010011);
$recip = self::sc25519_sqmul($recip, 10, $_1100011);
$recip = self::sc25519_sqmul($recip, 9, $_10010111);
$recip = self::sc25519_sqmul($recip, 10, $_11110101);
$recip = self::sc25519_sqmul($recip, 8, $_11010011);
return self::sc25519_sqmul($recip, 8, $_11101011);
}
/**
* @param string $s
* @return string
*/
public static function clamp($s)
{
$s_ = self::stringToIntArray($s);
$s_[0] &= 248;
$s_[31] |= 64;
$s_[31] &= 128;
return self::intArrayToString($s_);
}
/**
* Ensure limbs are less than 28 bits long to prevent float promotion.
*
* This uses a constant-time conditional swap under the hood.
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_normalize(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$x = (PHP_INT_SIZE << 3) - 1; // 31 or 63
$g = self::fe_copy($f);
for ($i = 0; $i < 10; ++$i) {
$mask = -(($g[$i] >> $x) & 1);
/*
* Get two candidate normalized values for $g[$i], depending on the sign of $g[$i]:
*/
$a = $g[$i] & 0x7ffffff;
$b = -((-$g[$i]) & 0x7ffffff);
/*
* Return the appropriate candidate value, based on the sign of the original input:
*
* The following is equivalent to this ternary:
*
* $g[$i] = (($g[$i] >> $x) & 1) ? $a : $b;
*
* Except what's written doesn't contain timing leaks.
*/
$g[$i] = ($a ^ (($a ^ $b) & $mask));
}
return $g;
}
}
Core/Ed25519.php 0000644 00000042114 15153427537 0007121 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Ed25519', false)) {
return;
}
if (!class_exists('ParagonIE_Sodium_Core_Curve25519', false)) {
require_once dirname(__FILE__) . '/Curve25519.php';
}
/**
* Class ParagonIE_Sodium_Core_Ed25519
*/
abstract class ParagonIE_Sodium_Core_Ed25519 extends ParagonIE_Sodium_Core_Curve25519
{
const KEYPAIR_BYTES = 96;
const SEED_BYTES = 32;
const SCALAR_BYTES = 32;
/**
* @internal You should not use this directly from another application
*
* @return string (96 bytes)
* @throws Exception
* @throws SodiumException
* @throws TypeError
*/
public static function keypair()
{
$seed = random_bytes(self::SEED_BYTES);
$pk = '';
$sk = '';
self::seed_keypair($pk, $sk, $seed);
return $sk . $pk;
}
/**
* @internal You should not use this directly from another application
*
* @param string $pk
* @param string $sk
* @param string $seed
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function seed_keypair(&$pk, &$sk, $seed)
{
if (self::strlen($seed) !== self::SEED_BYTES) {
throw new RangeException('crypto_sign keypair seed must be 32 bytes long');
}
/** @var string $pk */
$pk = self::publickey_from_secretkey($seed);
$sk = $seed . $pk;
return $sk;
}
/**
* @internal You should not use this directly from another application
*
* @param string $keypair
* @return string
* @throws TypeError
*/
public static function secretkey($keypair)
{
if (self::strlen($keypair) !== self::KEYPAIR_BYTES) {
throw new RangeException('crypto_sign keypair must be 96 bytes long');
}
return self::substr($keypair, 0, 64);
}
/**
* @internal You should not use this directly from another application
*
* @param string $keypair
* @return string
* @throws TypeError
*/
public static function publickey($keypair)
{
if (self::strlen($keypair) !== self::KEYPAIR_BYTES) {
throw new RangeException('crypto_sign keypair must be 96 bytes long');
}
return self::substr($keypair, 64, 32);
}
/**
* @internal You should not use this directly from another application
*
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function publickey_from_secretkey($sk)
{
/** @var string $sk */
$sk = hash('sha512', self::substr($sk, 0, 32), true);
$sk[0] = self::intToChr(
self::chrToInt($sk[0]) & 248
);
$sk[31] = self::intToChr(
(self::chrToInt($sk[31]) & 63) | 64
);
return self::sk_to_pk($sk);
}
/**
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function pk_to_curve25519($pk)
{
if (self::small_order($pk)) {
throw new SodiumException('Public key is on a small order');
}
$A = self::ge_frombytes_negate_vartime(self::substr($pk, 0, 32));
$p1 = self::ge_mul_l($A);
if (!self::fe_isnonzero($p1->X)) {
throw new SodiumException('Unexpected zero result');
}
# fe_1(one_minus_y);
# fe_sub(one_minus_y, one_minus_y, A.Y);
# fe_invert(one_minus_y, one_minus_y);
$one_minux_y = self::fe_invert(
self::fe_sub(
self::fe_1(),
$A->Y
)
);
# fe_1(x);
# fe_add(x, x, A.Y);
# fe_mul(x, x, one_minus_y);
$x = self::fe_mul(
self::fe_add(self::fe_1(), $A->Y),
$one_minux_y
);
# fe_tobytes(curve25519_pk, x);
return self::fe_tobytes($x);
}
/**
* @internal You should not use this directly from another application
*
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sk_to_pk($sk)
{
return self::ge_p3_tobytes(
self::ge_scalarmult_base(
self::substr($sk, 0, 32)
)
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign($message, $sk)
{
/** @var string $signature */
$signature = self::sign_detached($message, $sk);
return $signature . $message;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message A signed message
* @param string $pk Public key
* @return string Message (without signature)
* @throws SodiumException
* @throws TypeError
*/
public static function sign_open($message, $pk)
{
/** @var string $signature */
$signature = self::substr($message, 0, 64);
/** @var string $message */
$message = self::substr($message, 64);
if (self::verify_detached($signature, $message, $pk)) {
return $message;
}
throw new SodiumException('Invalid signature');
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign_detached($message, $sk)
{
# crypto_hash_sha512(az, sk, 32);
$az = hash('sha512', self::substr($sk, 0, 32), true);
# az[0] &= 248;
# az[31] &= 63;
# az[31] |= 64;
$az[0] = self::intToChr(self::chrToInt($az[0]) & 248);
$az[31] = self::intToChr((self::chrToInt($az[31]) & 63) | 64);
# crypto_hash_sha512_init(&hs);
# crypto_hash_sha512_update(&hs, az + 32, 32);
# crypto_hash_sha512_update(&hs, m, mlen);
# crypto_hash_sha512_final(&hs, nonce);
$hs = hash_init('sha512');
hash_update($hs, self::substr($az, 32, 32));
hash_update($hs, $message);
$nonceHash = hash_final($hs, true);
# memmove(sig + 32, sk + 32, 32);
$pk = self::substr($sk, 32, 32);
# sc_reduce(nonce);
# ge_scalarmult_base(&R, nonce);
# ge_p3_tobytes(sig, &R);
$nonce = self::sc_reduce($nonceHash) . self::substr($nonceHash, 32);
$sig = self::ge_p3_tobytes(
self::ge_scalarmult_base($nonce)
);
# crypto_hash_sha512_init(&hs);
# crypto_hash_sha512_update(&hs, sig, 64);
# crypto_hash_sha512_update(&hs, m, mlen);
# crypto_hash_sha512_final(&hs, hram);
$hs = hash_init('sha512');
hash_update($hs, self::substr($sig, 0, 32));
hash_update($hs, self::substr($pk, 0, 32));
hash_update($hs, $message);
$hramHash = hash_final($hs, true);
# sc_reduce(hram);
# sc_muladd(sig + 32, hram, az, nonce);
$hram = self::sc_reduce($hramHash);
$sigAfter = self::sc_muladd($hram, $az, $nonce);
$sig = self::substr($sig, 0, 32) . self::substr($sigAfter, 0, 32);
try {
ParagonIE_Sodium_Compat::memzero($az);
} catch (SodiumException $ex) {
$az = null;
}
return $sig;
}
/**
* @internal You should not use this directly from another application
*
* @param string $sig
* @param string $message
* @param string $pk
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function verify_detached($sig, $message, $pk)
{
if (self::strlen($sig) < 64) {
throw new SodiumException('Signature is too short');
}
if ((self::chrToInt($sig[63]) & 240) && self::check_S_lt_L(self::substr($sig, 32, 32))) {
throw new SodiumException('S < L - Invalid signature');
}
if (self::small_order($sig)) {
throw new SodiumException('Signature is on too small of an order');
}
if ((self::chrToInt($sig[63]) & 224) !== 0) {
throw new SodiumException('Invalid signature');
}
$d = 0;
for ($i = 0; $i < 32; ++$i) {
$d |= self::chrToInt($pk[$i]);
}
if ($d === 0) {
throw new SodiumException('All zero public key');
}
/** @var bool The original value of ParagonIE_Sodium_Compat::$fastMult */
$orig = ParagonIE_Sodium_Compat::$fastMult;
// Set ParagonIE_Sodium_Compat::$fastMult to true to speed up verification.
ParagonIE_Sodium_Compat::$fastMult = true;
/** @var ParagonIE_Sodium_Core_Curve25519_Ge_P3 $A */
$A = self::ge_frombytes_negate_vartime($pk);
/** @var string $hDigest */
$hDigest = hash(
'sha512',
self::substr($sig, 0, 32) .
self::substr($pk, 0, 32) .
$message,
true
);
/** @var string $h */
$h = self::sc_reduce($hDigest) . self::substr($hDigest, 32);
/** @var ParagonIE_Sodium_Core_Curve25519_Ge_P2 $R */
$R = self::ge_double_scalarmult_vartime(
$h,
$A,
self::substr($sig, 32)
);
/** @var string $rcheck */
$rcheck = self::ge_tobytes($R);
// Reset ParagonIE_Sodium_Compat::$fastMult to what it was before.
ParagonIE_Sodium_Compat::$fastMult = $orig;
return self::verify_32($rcheck, self::substr($sig, 0, 32));
}
/**
* @internal You should not use this directly from another application
*
* @param string $S
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function check_S_lt_L($S)
{
if (self::strlen($S) < 32) {
throw new SodiumException('Signature must be 32 bytes');
}
$L = array(
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
);
$c = 0;
$n = 1;
$i = 32;
/** @var array<int, int> $L */
do {
--$i;
$x = self::chrToInt($S[$i]);
$c |= (
(($x - $L[$i]) >> 8) & $n
);
$n &= (
(($x ^ $L[$i]) - 1) >> 8
);
} while ($i !== 0);
return $c === 0;
}
/**
* @param string $R
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function small_order($R)
{
/** @var array<int, array<int, int>> $blocklist */
$blocklist = array(
/* 0 (order 4) */
array(
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
),
/* 1 (order 1) */
array(
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
),
/* 2707385501144840649318225287225658788936804267575313519463743609750303402022 (order 8) */
array(
0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0,
0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, 0xf0,
0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39,
0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, 0xfc, 0x05
),
/* 55188659117513257062467267217118295137698188065244968500265048394206261417927 (order 8) */
array(
0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f,
0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, 0x0f,
0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6,
0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, 0x03, 0x7a
),
/* p-1 (order 2) */
array(
0x13, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0,
0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, 0xf0,
0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39,
0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, 0xfc, 0x85
),
/* p (order 4) */
array(
0xb4, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f,
0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, 0x0f,
0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6,
0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, 0x03, 0xfa
),
/* p+1 (order 1) */
array(
0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
),
/* p+2707385501144840649318225287225658788936804267575313519463743609750303402022 (order 8) */
array(
0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
),
/* p+55188659117513257062467267217118295137698188065244968500265048394206261417927 (order 8) */
array(
0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
),
/* 2p-1 (order 2) */
array(
0xd9, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
),
/* 2p (order 4) */
array(
0xda, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
),
/* 2p+1 (order 1) */
array(
0xdb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
)
);
/** @var int $countBlocklist */
$countBlocklist = count($blocklist);
for ($i = 0; $i < $countBlocklist; ++$i) {
$c = 0;
for ($j = 0; $j < 32; ++$j) {
$c |= self::chrToInt($R[$j]) ^ (int) $blocklist[$i][$j];
}
if ($c === 0) {
return true;
}
}
return false;
}
/**
* @param string $s
* @return string
* @throws SodiumException
*/
public static function scalar_complement($s)
{
$t_ = self::L . str_repeat("\x00", 32);
sodium_increment($t_);
$s_ = $s . str_repeat("\x00", 32);
ParagonIE_Sodium_Compat::sub($t_, $s_);
return self::sc_reduce($t_);
}
/**
* @return string
* @throws SodiumException
*/
public static function scalar_random()
{
do {
$r = ParagonIE_Sodium_Compat::randombytes_buf(self::SCALAR_BYTES);
$r[self::SCALAR_BYTES - 1] = self::intToChr(
self::chrToInt($r[self::SCALAR_BYTES - 1]) & 0x1f
);
} while (
!self::check_S_lt_L($r) || ParagonIE_Sodium_Compat::is_zero($r)
);
return $r;
}
/**
* @param string $s
* @return string
* @throws SodiumException
*/
public static function scalar_negate($s)
{
$t_ = self::L . str_repeat("\x00", 32) ;
$s_ = $s . str_repeat("\x00", 32) ;
ParagonIE_Sodium_Compat::sub($t_, $s_);
return self::sc_reduce($t_);
}
/**
* @param string $a
* @param string $b
* @return string
* @throws SodiumException
*/
public static function scalar_add($a, $b)
{
$a_ = $a . str_repeat("\x00", 32);
$b_ = $b . str_repeat("\x00", 32);
ParagonIE_Sodium_Compat::add($a_, $b_);
return self::sc_reduce($a_);
}
/**
* @param string $x
* @param string $y
* @return string
* @throws SodiumException
*/
public static function scalar_sub($x, $y)
{
$yn = self::scalar_negate($y);
return self::scalar_add($x, $yn);
}
}
Core/HChaCha20.php 0000644 00000007437 15153427537 0007555 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_HChaCha20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_HChaCha20
*/
class ParagonIE_Sodium_Core_HChaCha20 extends ParagonIE_Sodium_Core_ChaCha20
{
/**
* @param string $in
* @param string $key
* @param string|null $c
* @return string
* @throws TypeError
*/
public static function hChaCha20($in = '', $key = '', $c = null)
{
$ctx = array();
if ($c === null) {
$ctx[0] = 0x61707865;
$ctx[1] = 0x3320646e;
$ctx[2] = 0x79622d32;
$ctx[3] = 0x6b206574;
} else {
$ctx[0] = self::load_4(self::substr($c, 0, 4));
$ctx[1] = self::load_4(self::substr($c, 4, 4));
$ctx[2] = self::load_4(self::substr($c, 8, 4));
$ctx[3] = self::load_4(self::substr($c, 12, 4));
}
$ctx[4] = self::load_4(self::substr($key, 0, 4));
$ctx[5] = self::load_4(self::substr($key, 4, 4));
$ctx[6] = self::load_4(self::substr($key, 8, 4));
$ctx[7] = self::load_4(self::substr($key, 12, 4));
$ctx[8] = self::load_4(self::substr($key, 16, 4));
$ctx[9] = self::load_4(self::substr($key, 20, 4));
$ctx[10] = self::load_4(self::substr($key, 24, 4));
$ctx[11] = self::load_4(self::substr($key, 28, 4));
$ctx[12] = self::load_4(self::substr($in, 0, 4));
$ctx[13] = self::load_4(self::substr($in, 4, 4));
$ctx[14] = self::load_4(self::substr($in, 8, 4));
$ctx[15] = self::load_4(self::substr($in, 12, 4));
return self::hChaCha20Bytes($ctx);
}
/**
* @param array $ctx
* @return string
* @throws TypeError
*/
protected static function hChaCha20Bytes(array $ctx)
{
$x0 = (int) $ctx[0];
$x1 = (int) $ctx[1];
$x2 = (int) $ctx[2];
$x3 = (int) $ctx[3];
$x4 = (int) $ctx[4];
$x5 = (int) $ctx[5];
$x6 = (int) $ctx[6];
$x7 = (int) $ctx[7];
$x8 = (int) $ctx[8];
$x9 = (int) $ctx[9];
$x10 = (int) $ctx[10];
$x11 = (int) $ctx[11];
$x12 = (int) $ctx[12];
$x13 = (int) $ctx[13];
$x14 = (int) $ctx[14];
$x15 = (int) $ctx[15];
for ($i = 0; $i < 10; ++$i) {
# QUARTERROUND( x0, x4, x8, x12)
list($x0, $x4, $x8, $x12) = self::quarterRound($x0, $x4, $x8, $x12);
# QUARTERROUND( x1, x5, x9, x13)
list($x1, $x5, $x9, $x13) = self::quarterRound($x1, $x5, $x9, $x13);
# QUARTERROUND( x2, x6, x10, x14)
list($x2, $x6, $x10, $x14) = self::quarterRound($x2, $x6, $x10, $x14);
# QUARTERROUND( x3, x7, x11, x15)
list($x3, $x7, $x11, $x15) = self::quarterRound($x3, $x7, $x11, $x15);
# QUARTERROUND( x0, x5, x10, x15)
list($x0, $x5, $x10, $x15) = self::quarterRound($x0, $x5, $x10, $x15);
# QUARTERROUND( x1, x6, x11, x12)
list($x1, $x6, $x11, $x12) = self::quarterRound($x1, $x6, $x11, $x12);
# QUARTERROUND( x2, x7, x8, x13)
list($x2, $x7, $x8, $x13) = self::quarterRound($x2, $x7, $x8, $x13);
# QUARTERROUND( x3, x4, x9, x14)
list($x3, $x4, $x9, $x14) = self::quarterRound($x3, $x4, $x9, $x14);
}
return self::store32_le((int) ($x0 & 0xffffffff)) .
self::store32_le((int) ($x1 & 0xffffffff)) .
self::store32_le((int) ($x2 & 0xffffffff)) .
self::store32_le((int) ($x3 & 0xffffffff)) .
self::store32_le((int) ($x12 & 0xffffffff)) .
self::store32_le((int) ($x13 & 0xffffffff)) .
self::store32_le((int) ($x14 & 0xffffffff)) .
self::store32_le((int) ($x15 & 0xffffffff));
}
}
Core/HSalsa20.php 0000644 00000007131 15153427537 0007500 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_HSalsa20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_HSalsa20
*/
abstract class ParagonIE_Sodium_Core_HSalsa20 extends ParagonIE_Sodium_Core_Salsa20
{
/**
* Calculate an hsalsa20 hash of a single block
*
* HSalsa20 doesn't have a counter and will never be used for more than
* one block (used to derive a subkey for xsalsa20).
*
* @internal You should not use this directly from another application
*
* @param string $in
* @param string $k
* @param string|null $c
* @return string
* @throws TypeError
*/
public static function hsalsa20($in, $k, $c = null)
{
if ($c === null) {
$x0 = 0x61707865;
$x5 = 0x3320646e;
$x10 = 0x79622d32;
$x15 = 0x6b206574;
} else {
$x0 = self::load_4(self::substr($c, 0, 4));
$x5 = self::load_4(self::substr($c, 4, 4));
$x10 = self::load_4(self::substr($c, 8, 4));
$x15 = self::load_4(self::substr($c, 12, 4));
}
$x1 = self::load_4(self::substr($k, 0, 4));
$x2 = self::load_4(self::substr($k, 4, 4));
$x3 = self::load_4(self::substr($k, 8, 4));
$x4 = self::load_4(self::substr($k, 12, 4));
$x11 = self::load_4(self::substr($k, 16, 4));
$x12 = self::load_4(self::substr($k, 20, 4));
$x13 = self::load_4(self::substr($k, 24, 4));
$x14 = self::load_4(self::substr($k, 28, 4));
$x6 = self::load_4(self::substr($in, 0, 4));
$x7 = self::load_4(self::substr($in, 4, 4));
$x8 = self::load_4(self::substr($in, 8, 4));
$x9 = self::load_4(self::substr($in, 12, 4));
for ($i = self::ROUNDS; $i > 0; $i -= 2) {
$x4 ^= self::rotate($x0 + $x12, 7);
$x8 ^= self::rotate($x4 + $x0, 9);
$x12 ^= self::rotate($x8 + $x4, 13);
$x0 ^= self::rotate($x12 + $x8, 18);
$x9 ^= self::rotate($x5 + $x1, 7);
$x13 ^= self::rotate($x9 + $x5, 9);
$x1 ^= self::rotate($x13 + $x9, 13);
$x5 ^= self::rotate($x1 + $x13, 18);
$x14 ^= self::rotate($x10 + $x6, 7);
$x2 ^= self::rotate($x14 + $x10, 9);
$x6 ^= self::rotate($x2 + $x14, 13);
$x10 ^= self::rotate($x6 + $x2, 18);
$x3 ^= self::rotate($x15 + $x11, 7);
$x7 ^= self::rotate($x3 + $x15, 9);
$x11 ^= self::rotate($x7 + $x3, 13);
$x15 ^= self::rotate($x11 + $x7, 18);
$x1 ^= self::rotate($x0 + $x3, 7);
$x2 ^= self::rotate($x1 + $x0, 9);
$x3 ^= self::rotate($x2 + $x1, 13);
$x0 ^= self::rotate($x3 + $x2, 18);
$x6 ^= self::rotate($x5 + $x4, 7);
$x7 ^= self::rotate($x6 + $x5, 9);
$x4 ^= self::rotate($x7 + $x6, 13);
$x5 ^= self::rotate($x4 + $x7, 18);
$x11 ^= self::rotate($x10 + $x9, 7);
$x8 ^= self::rotate($x11 + $x10, 9);
$x9 ^= self::rotate($x8 + $x11, 13);
$x10 ^= self::rotate($x9 + $x8, 18);
$x12 ^= self::rotate($x15 + $x14, 7);
$x13 ^= self::rotate($x12 + $x15, 9);
$x14 ^= self::rotate($x13 + $x12, 13);
$x15 ^= self::rotate($x14 + $x13, 18);
}
return self::store32_le($x0) .
self::store32_le($x5) .
self::store32_le($x10) .
self::store32_le($x15) .
self::store32_le($x6) .
self::store32_le($x7) .
self::store32_le($x8) .
self::store32_le($x9);
}
}
Core/Poly1305/State.php 0000644 00000031160 15153427537 0010476 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Poly1305_State', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Poly1305_State
*/
class ParagonIE_Sodium_Core_Poly1305_State extends ParagonIE_Sodium_Core_Util
{
/**
* @var array<int, int>
*/
protected $buffer = array();
/**
* @var bool
*/
protected $final = false;
/**
* @var array<int, int>
*/
public $h;
/**
* @var int
*/
protected $leftover = 0;
/**
* @var int[]
*/
public $r;
/**
* @var int[]
*/
public $pad;
/**
* ParagonIE_Sodium_Core_Poly1305_State constructor.
*
* @internal You should not use this directly from another application
*
* @param string $key
* @throws InvalidArgumentException
* @throws TypeError
*/
public function __construct($key = '')
{
if (self::strlen($key) < 32) {
throw new InvalidArgumentException(
'Poly1305 requires a 32-byte key'
);
}
/* r &= 0xffffffc0ffffffc0ffffffc0fffffff */
$this->r = array(
(int) ((self::load_4(self::substr($key, 0, 4))) & 0x3ffffff),
(int) ((self::load_4(self::substr($key, 3, 4)) >> 2) & 0x3ffff03),
(int) ((self::load_4(self::substr($key, 6, 4)) >> 4) & 0x3ffc0ff),
(int) ((self::load_4(self::substr($key, 9, 4)) >> 6) & 0x3f03fff),
(int) ((self::load_4(self::substr($key, 12, 4)) >> 8) & 0x00fffff)
);
/* h = 0 */
$this->h = array(0, 0, 0, 0, 0);
/* save pad for later */
$this->pad = array(
self::load_4(self::substr($key, 16, 4)),
self::load_4(self::substr($key, 20, 4)),
self::load_4(self::substr($key, 24, 4)),
self::load_4(self::substr($key, 28, 4)),
);
$this->leftover = 0;
$this->final = false;
}
/**
* Zero internal buffer upon destruction
*/
public function __destruct()
{
$this->r[0] ^= $this->r[0];
$this->r[1] ^= $this->r[1];
$this->r[2] ^= $this->r[2];
$this->r[3] ^= $this->r[3];
$this->r[4] ^= $this->r[4];
$this->h[0] ^= $this->h[0];
$this->h[1] ^= $this->h[1];
$this->h[2] ^= $this->h[2];
$this->h[3] ^= $this->h[3];
$this->h[4] ^= $this->h[4];
$this->pad[0] ^= $this->pad[0];
$this->pad[1] ^= $this->pad[1];
$this->pad[2] ^= $this->pad[2];
$this->pad[3] ^= $this->pad[3];
$this->leftover = 0;
$this->final = true;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @return self
* @throws SodiumException
* @throws TypeError
*/
public function update($message = '')
{
$bytes = self::strlen($message);
if ($bytes < 1) {
return $this;
}
/* handle leftover */
if ($this->leftover) {
$want = ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE - $this->leftover;
if ($want > $bytes) {
$want = $bytes;
}
for ($i = 0; $i < $want; ++$i) {
$mi = self::chrToInt($message[$i]);
$this->buffer[$this->leftover + $i] = $mi;
}
// We snip off the leftmost bytes.
$message = self::substr($message, $want);
$bytes = self::strlen($message);
$this->leftover += $want;
if ($this->leftover < ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE) {
// We still don't have enough to run $this->blocks()
return $this;
}
$this->blocks(
self::intArrayToString($this->buffer),
ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE
);
$this->leftover = 0;
}
/* process full blocks */
if ($bytes >= ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE) {
/** @var int $want */
$want = $bytes & ~(ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE - 1);
if ($want >= ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE) {
$block = self::substr($message, 0, $want);
if (self::strlen($block) >= ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE) {
$this->blocks($block, $want);
$message = self::substr($message, $want);
$bytes = self::strlen($message);
}
}
}
/* store leftover */
if ($bytes) {
for ($i = 0; $i < $bytes; ++$i) {
$mi = self::chrToInt($message[$i]);
$this->buffer[$this->leftover + $i] = $mi;
}
$this->leftover = (int) $this->leftover + $bytes;
}
return $this;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param int $bytes
* @return self
* @throws TypeError
*/
public function blocks($message, $bytes)
{
if (self::strlen($message) < 16) {
$message = str_pad($message, 16, "\x00", STR_PAD_RIGHT);
}
/** @var int $hibit */
$hibit = $this->final ? 0 : 1 << 24; /* 1 << 128 */
$r0 = (int) $this->r[0];
$r1 = (int) $this->r[1];
$r2 = (int) $this->r[2];
$r3 = (int) $this->r[3];
$r4 = (int) $this->r[4];
$s1 = self::mul($r1, 5, 3);
$s2 = self::mul($r2, 5, 3);
$s3 = self::mul($r3, 5, 3);
$s4 = self::mul($r4, 5, 3);
$h0 = $this->h[0];
$h1 = $this->h[1];
$h2 = $this->h[2];
$h3 = $this->h[3];
$h4 = $this->h[4];
while ($bytes >= ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE) {
/* h += m[i] */
$h0 += self::load_4(self::substr($message, 0, 4)) & 0x3ffffff;
$h1 += (self::load_4(self::substr($message, 3, 4)) >> 2) & 0x3ffffff;
$h2 += (self::load_4(self::substr($message, 6, 4)) >> 4) & 0x3ffffff;
$h3 += (self::load_4(self::substr($message, 9, 4)) >> 6) & 0x3ffffff;
$h4 += (self::load_4(self::substr($message, 12, 4)) >> 8) | $hibit;
/* h *= r */
$d0 = (
self::mul($h0, $r0, 27) +
self::mul($s4, $h1, 27) +
self::mul($s3, $h2, 27) +
self::mul($s2, $h3, 27) +
self::mul($s1, $h4, 27)
);
$d1 = (
self::mul($h0, $r1, 27) +
self::mul($h1, $r0, 27) +
self::mul($s4, $h2, 27) +
self::mul($s3, $h3, 27) +
self::mul($s2, $h4, 27)
);
$d2 = (
self::mul($h0, $r2, 27) +
self::mul($h1, $r1, 27) +
self::mul($h2, $r0, 27) +
self::mul($s4, $h3, 27) +
self::mul($s3, $h4, 27)
);
$d3 = (
self::mul($h0, $r3, 27) +
self::mul($h1, $r2, 27) +
self::mul($h2, $r1, 27) +
self::mul($h3, $r0, 27) +
self::mul($s4, $h4, 27)
);
$d4 = (
self::mul($h0, $r4, 27) +
self::mul($h1, $r3, 27) +
self::mul($h2, $r2, 27) +
self::mul($h3, $r1, 27) +
self::mul($h4, $r0, 27)
);
/* (partial) h %= p */
/** @var int $c */
$c = $d0 >> 26;
/** @var int $h0 */
$h0 = $d0 & 0x3ffffff;
$d1 += $c;
/** @var int $c */
$c = $d1 >> 26;
/** @var int $h1 */
$h1 = $d1 & 0x3ffffff;
$d2 += $c;
/** @var int $c */
$c = $d2 >> 26;
/** @var int $h2 */
$h2 = $d2 & 0x3ffffff;
$d3 += $c;
/** @var int $c */
$c = $d3 >> 26;
/** @var int $h3 */
$h3 = $d3 & 0x3ffffff;
$d4 += $c;
/** @var int $c */
$c = $d4 >> 26;
/** @var int $h4 */
$h4 = $d4 & 0x3ffffff;
$h0 += (int) self::mul($c, 5, 3);
/** @var int $c */
$c = $h0 >> 26;
/** @var int $h0 */
$h0 &= 0x3ffffff;
$h1 += $c;
// Chop off the left 32 bytes.
$message = self::substr(
$message,
ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE
);
$bytes -= ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE;
}
$this->h = array(
(int) ($h0 & 0xffffffff),
(int) ($h1 & 0xffffffff),
(int) ($h2 & 0xffffffff),
(int) ($h3 & 0xffffffff),
(int) ($h4 & 0xffffffff)
);
return $this;
}
/**
* @internal You should not use this directly from another application
*
* @return string
* @throws TypeError
*/
public function finish()
{
/* process the remaining block */
if ($this->leftover) {
$i = $this->leftover;
$this->buffer[$i++] = 1;
for (; $i < ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE; ++$i) {
$this->buffer[$i] = 0;
}
$this->final = true;
$this->blocks(
self::substr(
self::intArrayToString($this->buffer),
0,
ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE
),
ParagonIE_Sodium_Core_Poly1305::BLOCK_SIZE
);
}
$h0 = (int) $this->h[0];
$h1 = (int) $this->h[1];
$h2 = (int) $this->h[2];
$h3 = (int) $this->h[3];
$h4 = (int) $this->h[4];
/** @var int $c */
$c = $h1 >> 26;
/** @var int $h1 */
$h1 &= 0x3ffffff;
/** @var int $h2 */
$h2 += $c;
/** @var int $c */
$c = $h2 >> 26;
/** @var int $h2 */
$h2 &= 0x3ffffff;
$h3 += $c;
/** @var int $c */
$c = $h3 >> 26;
$h3 &= 0x3ffffff;
$h4 += $c;
/** @var int $c */
$c = $h4 >> 26;
$h4 &= 0x3ffffff;
/** @var int $h0 */
$h0 += self::mul($c, 5, 3);
/** @var int $c */
$c = $h0 >> 26;
/** @var int $h0 */
$h0 &= 0x3ffffff;
/** @var int $h1 */
$h1 += $c;
/* compute h + -p */
/** @var int $g0 */
$g0 = $h0 + 5;
/** @var int $c */
$c = $g0 >> 26;
/** @var int $g0 */
$g0 &= 0x3ffffff;
/** @var int $g1 */
$g1 = $h1 + $c;
/** @var int $c */
$c = $g1 >> 26;
$g1 &= 0x3ffffff;
/** @var int $g2 */
$g2 = $h2 + $c;
/** @var int $c */
$c = $g2 >> 26;
/** @var int $g2 */
$g2 &= 0x3ffffff;
/** @var int $g3 */
$g3 = $h3 + $c;
/** @var int $c */
$c = $g3 >> 26;
/** @var int $g3 */
$g3 &= 0x3ffffff;
/** @var int $g4 */
$g4 = ($h4 + $c - (1 << 26)) & 0xffffffff;
/* select h if h < p, or h + -p if h >= p */
/** @var int $mask */
$mask = ($g4 >> 31) - 1;
$g0 &= $mask;
$g1 &= $mask;
$g2 &= $mask;
$g3 &= $mask;
$g4 &= $mask;
/** @var int $mask */
$mask = ~$mask & 0xffffffff;
/** @var int $h0 */
$h0 = ($h0 & $mask) | $g0;
/** @var int $h1 */
$h1 = ($h1 & $mask) | $g1;
/** @var int $h2 */
$h2 = ($h2 & $mask) | $g2;
/** @var int $h3 */
$h3 = ($h3 & $mask) | $g3;
/** @var int $h4 */
$h4 = ($h4 & $mask) | $g4;
/* h = h % (2^128) */
/** @var int $h0 */
$h0 = (($h0) | ($h1 << 26)) & 0xffffffff;
/** @var int $h1 */
$h1 = (($h1 >> 6) | ($h2 << 20)) & 0xffffffff;
/** @var int $h2 */
$h2 = (($h2 >> 12) | ($h3 << 14)) & 0xffffffff;
/** @var int $h3 */
$h3 = (($h3 >> 18) | ($h4 << 8)) & 0xffffffff;
/* mac = (h + pad) % (2^128) */
$f = (int) ($h0 + $this->pad[0]);
$h0 = (int) $f;
$f = (int) ($h1 + $this->pad[1] + ($f >> 32));
$h1 = (int) $f;
$f = (int) ($h2 + $this->pad[2] + ($f >> 32));
$h2 = (int) $f;
$f = (int) ($h3 + $this->pad[3] + ($f >> 32));
$h3 = (int) $f;
return self::store32_le($h0 & 0xffffffff) .
self::store32_le($h1 & 0xffffffff) .
self::store32_le($h2 & 0xffffffff) .
self::store32_le($h3 & 0xffffffff);
}
}
Core/Poly1305.php 0000644 00000003046 15153427537 0007420 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Poly1305', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Poly1305
*/
abstract class ParagonIE_Sodium_Core_Poly1305 extends ParagonIE_Sodium_Core_Util
{
const BLOCK_SIZE = 16;
/**
* @internal You should not use this directly from another application
*
* @param string $m
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function onetimeauth($m, $key)
{
if (self::strlen($key) < 32) {
throw new InvalidArgumentException(
'Key must be 32 bytes long.'
);
}
$state = new ParagonIE_Sodium_Core_Poly1305_State(
self::substr($key, 0, 32)
);
return $state
->update($m)
->finish();
}
/**
* @internal You should not use this directly from another application
*
* @param string $mac
* @param string $m
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function onetimeauth_verify($mac, $m, $key)
{
if (self::strlen($key) < 32) {
throw new InvalidArgumentException(
'Key must be 32 bytes long.'
);
}
$state = new ParagonIE_Sodium_Core_Poly1305_State(
self::substr($key, 0, 32)
);
$calc = $state
->update($m)
->finish();
return self::verify_16($calc, $mac);
}
}
Core/Ristretto255.php 0000644 00000052574 15153427537 0010431 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core_Ristretto255
*/
class ParagonIE_Sodium_Core_Ristretto255 extends ParagonIE_Sodium_Core_Ed25519
{
const crypto_core_ristretto255_HASHBYTES = 64;
const HASH_SC_L = 48;
const CORE_H2C_SHA256 = 1;
const CORE_H2C_SHA512 = 2;
/**
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @param int $b
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_cneg(ParagonIE_Sodium_Core_Curve25519_Fe $f, $b)
{
$negf = self::fe_neg($f);
return self::fe_cmov($f, $negf, $b);
}
/**
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
* @throws SodiumException
*/
public static function fe_abs(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
return self::fe_cneg($f, self::fe_isnegative($f));
}
/**
* Returns 0 if this field element results in all NUL bytes.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return int
* @throws SodiumException
*/
public static function fe_iszero(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
static $zero;
if ($zero === null) {
$zero = str_repeat("\x00", 32);
}
/** @var string $zero */
$str = self::fe_tobytes($f);
$d = 0;
for ($i = 0; $i < 32; ++$i) {
$d |= self::chrToInt($str[$i]);
}
return (($d - 1) >> 31) & 1;
}
/**
* @param ParagonIE_Sodium_Core_Curve25519_Fe $u
* @param ParagonIE_Sodium_Core_Curve25519_Fe $v
* @return array{x: ParagonIE_Sodium_Core_Curve25519_Fe, nonsquare: int}
*
* @throws SodiumException
*/
public static function ristretto255_sqrt_ratio_m1(
ParagonIE_Sodium_Core_Curve25519_Fe $u,
ParagonIE_Sodium_Core_Curve25519_Fe $v
) {
$sqrtm1 = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$sqrtm1);
$v3 = self::fe_mul(
self::fe_sq($v),
$v
); /* v3 = v^3 */
$x = self::fe_mul(
self::fe_mul(
self::fe_sq($v3),
$u
),
$v
); /* x = uv^7 */
$x = self::fe_mul(
self::fe_mul(
self::fe_pow22523($x), /* x = (uv^7)^((q-5)/8) */
$v3
),
$u
); /* x = uv^3(uv^7)^((q-5)/8) */
$vxx = self::fe_mul(
self::fe_sq($x),
$v
); /* vx^2 */
$m_root_check = self::fe_sub($vxx, $u); /* vx^2-u */
$p_root_check = self::fe_add($vxx, $u); /* vx^2+u */
$f_root_check = self::fe_mul($u, $sqrtm1); /* u*sqrt(-1) */
$f_root_check = self::fe_add($vxx, $f_root_check); /* vx^2+u*sqrt(-1) */
$has_m_root = self::fe_iszero($m_root_check);
$has_p_root = self::fe_iszero($p_root_check);
$has_f_root = self::fe_iszero($f_root_check);
$x_sqrtm1 = self::fe_mul($x, $sqrtm1); /* x*sqrt(-1) */
$x = self::fe_abs(
self::fe_cmov($x, $x_sqrtm1, $has_p_root | $has_f_root)
);
return array(
'x' => $x,
'nonsquare' => $has_m_root | $has_p_root
);
}
/**
* @param string $s
* @return int
* @throws SodiumException
*/
public static function ristretto255_point_is_canonical($s)
{
$c = (self::chrToInt($s[31]) & 0x7f) ^ 0x7f;
for ($i = 30; $i > 0; --$i) {
$c |= self::chrToInt($s[$i]) ^ 0xff;
}
$c = ($c - 1) >> 8;
$d = (0xed - 1 - self::chrToInt($s[0])) >> 8;
$e = self::chrToInt($s[31]) >> 7;
return 1 - ((($c & $d) | $e | self::chrToInt($s[0])) & 1);
}
/**
* @param string $s
* @param bool $skipCanonicalCheck
* @return array{h: ParagonIE_Sodium_Core_Curve25519_Ge_P3, res: int}
* @throws SodiumException
*/
public static function ristretto255_frombytes($s, $skipCanonicalCheck = false)
{
if (!$skipCanonicalCheck) {
if (!self::ristretto255_point_is_canonical($s)) {
throw new SodiumException('S is not canonical');
}
}
$s_ = self::fe_frombytes($s);
$ss = self::fe_sq($s_); /* ss = s^2 */
$u1 = self::fe_sub(self::fe_1(), $ss); /* u1 = 1-ss */
$u1u1 = self::fe_sq($u1); /* u1u1 = u1^2 */
$u2 = self::fe_add(self::fe_1(), $ss); /* u2 = 1+ss */
$u2u2 = self::fe_sq($u2); /* u2u2 = u2^2 */
$v = self::fe_mul(
ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$d),
$u1u1
); /* v = d*u1^2 */
$v = self::fe_neg($v); /* v = -d*u1^2 */
$v = self::fe_sub($v, $u2u2); /* v = -(d*u1^2)-u2^2 */
$v_u2u2 = self::fe_mul($v, $u2u2); /* v_u2u2 = v*u2^2 */
// fe25519_1(one);
// notsquare = ristretto255_sqrt_ratio_m1(inv_sqrt, one, v_u2u2);
$one = self::fe_1();
$result = self::ristretto255_sqrt_ratio_m1($one, $v_u2u2);
$inv_sqrt = $result['x'];
$notsquare = $result['nonsquare'];
$h = new ParagonIE_Sodium_Core_Curve25519_Ge_P3();
$h->X = self::fe_mul($inv_sqrt, $u2);
$h->Y = self::fe_mul(self::fe_mul($inv_sqrt, $h->X), $v);
$h->X = self::fe_mul($h->X, $s_);
$h->X = self::fe_abs(
self::fe_add($h->X, $h->X)
);
$h->Y = self::fe_mul($u1, $h->Y);
$h->Z = self::fe_1();
$h->T = self::fe_mul($h->X, $h->Y);
$res = - ((1 - $notsquare) | self::fe_isnegative($h->T) | self::fe_iszero($h->Y));
return array('h' => $h, 'res' => $res);
}
/**
* @param ParagonIE_Sodium_Core_Curve25519_Ge_P3 $h
* @return string
* @throws SodiumException
*/
public static function ristretto255_p3_tobytes(ParagonIE_Sodium_Core_Curve25519_Ge_P3 $h)
{
$sqrtm1 = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$sqrtm1);
$invsqrtamd = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$invsqrtamd);
$u1 = self::fe_add($h->Z, $h->Y); /* u1 = Z+Y */
$zmy = self::fe_sub($h->Z, $h->Y); /* zmy = Z-Y */
$u1 = self::fe_mul($u1, $zmy); /* u1 = (Z+Y)*(Z-Y) */
$u2 = self::fe_mul($h->X, $h->Y); /* u2 = X*Y */
$u1_u2u2 = self::fe_mul(self::fe_sq($u2), $u1); /* u1_u2u2 = u1*u2^2 */
$one = self::fe_1();
// fe25519_1(one);
// (void) ristretto255_sqrt_ratio_m1(inv_sqrt, one, u1_u2u2);
$result = self::ristretto255_sqrt_ratio_m1($one, $u1_u2u2);
$inv_sqrt = $result['x'];
$den1 = self::fe_mul($inv_sqrt, $u1); /* den1 = inv_sqrt*u1 */
$den2 = self::fe_mul($inv_sqrt, $u2); /* den2 = inv_sqrt*u2 */
$z_inv = self::fe_mul($h->T, self::fe_mul($den1, $den2)); /* z_inv = den1*den2*T */
$ix = self::fe_mul($h->X, $sqrtm1); /* ix = X*sqrt(-1) */
$iy = self::fe_mul($h->Y, $sqrtm1); /* iy = Y*sqrt(-1) */
$eden = self::fe_mul($den1, $invsqrtamd);
$t_z_inv = self::fe_mul($h->T, $z_inv); /* t_z_inv = T*z_inv */
$rotate = self::fe_isnegative($t_z_inv);
$x_ = self::fe_copy($h->X);
$y_ = self::fe_copy($h->Y);
$den_inv = self::fe_copy($den2);
$x_ = self::fe_cmov($x_, $iy, $rotate);
$y_ = self::fe_cmov($y_, $ix, $rotate);
$den_inv = self::fe_cmov($den_inv, $eden, $rotate);
$x_z_inv = self::fe_mul($x_, $z_inv);
$y_ = self::fe_cneg($y_, self::fe_isnegative($x_z_inv));
// fe25519_sub(s_, h->Z, y_);
// fe25519_mul(s_, den_inv, s_);
// fe25519_abs(s_, s_);
// fe25519_tobytes(s, s_);
return self::fe_tobytes(
self::fe_abs(
self::fe_mul(
$den_inv,
self::fe_sub($h->Z, $y_)
)
)
);
}
/**
* @param ParagonIE_Sodium_Core_Curve25519_Fe $t
* @return ParagonIE_Sodium_Core_Curve25519_Ge_P3
*
* @throws SodiumException
*/
public static function ristretto255_elligator(ParagonIE_Sodium_Core_Curve25519_Fe $t)
{
$sqrtm1 = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$sqrtm1);
$onemsqd = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$onemsqd);
$d = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$d);
$sqdmone = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$sqdmone);
$sqrtadm1 = ParagonIE_Sodium_Core_Curve25519_Fe::fromArray(self::$sqrtadm1);
$one = self::fe_1();
$r = self::fe_mul($sqrtm1, self::fe_sq($t)); /* r = sqrt(-1)*t^2 */
$u = self::fe_mul(self::fe_add($r, $one), $onemsqd); /* u = (r+1)*(1-d^2) */
$c = self::fe_neg(self::fe_1()); /* c = -1 */
$rpd = self::fe_add($r, $d); /* rpd = r+d */
$v = self::fe_mul(
self::fe_sub(
$c,
self::fe_mul($r, $d)
),
$rpd
); /* v = (c-r*d)*(r+d) */
$result = self::ristretto255_sqrt_ratio_m1($u, $v);
$s = $result['x'];
$wasnt_square = 1 - $result['nonsquare'];
$s_prime = self::fe_neg(
self::fe_abs(
self::fe_mul($s, $t)
)
); /* s_prime = -|s*t| */
$s = self::fe_cmov($s, $s_prime, $wasnt_square);
$c = self::fe_cmov($c, $r, $wasnt_square);
// fe25519_sub(n, r, one); /* n = r-1 */
// fe25519_mul(n, n, c); /* n = c*(r-1) */
// fe25519_mul(n, n, ed25519_sqdmone); /* n = c*(r-1)*(d-1)^2 */
// fe25519_sub(n, n, v); /* n = c*(r-1)*(d-1)^2-v */
$n = self::fe_sub(
self::fe_mul(
self::fe_mul(
self::fe_sub($r, $one),
$c
),
$sqdmone
),
$v
); /* n = c*(r-1)*(d-1)^2-v */
$w0 = self::fe_mul(
self::fe_add($s, $s),
$v
); /* w0 = 2s*v */
$w1 = self::fe_mul($n, $sqrtadm1); /* w1 = n*sqrt(ad-1) */
$ss = self::fe_sq($s); /* ss = s^2 */
$w2 = self::fe_sub($one, $ss); /* w2 = 1-s^2 */
$w3 = self::fe_add($one, $ss); /* w3 = 1+s^2 */
return new ParagonIE_Sodium_Core_Curve25519_Ge_P3(
self::fe_mul($w0, $w3),
self::fe_mul($w2, $w1),
self::fe_mul($w1, $w3),
self::fe_mul($w0, $w2)
);
}
/**
* @param string $h
* @return string
* @throws SodiumException
*/
public static function ristretto255_from_hash($h)
{
if (self::strlen($h) !== 64) {
throw new SodiumException('Hash must be 64 bytes');
}
//fe25519_frombytes(r0, h);
//fe25519_frombytes(r1, h + 32);
$r0 = self::fe_frombytes(self::substr($h, 0, 32));
$r1 = self::fe_frombytes(self::substr($h, 32, 32));
//ristretto255_elligator(&p0, r0);
//ristretto255_elligator(&p1, r1);
$p0 = self::ristretto255_elligator($r0);
$p1 = self::ristretto255_elligator($r1);
//ge25519_p3_to_cached(&p1_cached, &p1);
//ge25519_add_cached(&p_p1p1, &p0, &p1_cached);
$p_p1p1 = self::ge_add(
$p0,
self::ge_p3_to_cached($p1)
);
//ge25519_p1p1_to_p3(&p, &p_p1p1);
//ristretto255_p3_tobytes(s, &p);
return self::ristretto255_p3_tobytes(
self::ge_p1p1_to_p3($p_p1p1)
);
}
/**
* @param string $p
* @return int
* @throws SodiumException
*/
public static function is_valid_point($p)
{
$result = self::ristretto255_frombytes($p);
if ($result['res'] !== 0) {
return 0;
}
return 1;
}
/**
* @param string $p
* @param string $q
* @return string
* @throws SodiumException
*/
public static function ristretto255_add($p, $q)
{
$p_res = self::ristretto255_frombytes($p);
$q_res = self::ristretto255_frombytes($q);
if ($p_res['res'] !== 0 || $q_res['res'] !== 0) {
throw new SodiumException('Could not add points');
}
$p_p3 = $p_res['h'];
$q_p3 = $q_res['h'];
$q_cached = self::ge_p3_to_cached($q_p3);
$r_p1p1 = self::ge_add($p_p3, $q_cached);
$r_p3 = self::ge_p1p1_to_p3($r_p1p1);
return self::ristretto255_p3_tobytes($r_p3);
}
/**
* @param string $p
* @param string $q
* @return string
* @throws SodiumException
*/
public static function ristretto255_sub($p, $q)
{
$p_res = self::ristretto255_frombytes($p);
$q_res = self::ristretto255_frombytes($q);
if ($p_res['res'] !== 0 || $q_res['res'] !== 0) {
throw new SodiumException('Could not add points');
}
$p_p3 = $p_res['h'];
$q_p3 = $q_res['h'];
$q_cached = self::ge_p3_to_cached($q_p3);
$r_p1p1 = self::ge_sub($p_p3, $q_cached);
$r_p3 = self::ge_p1p1_to_p3($r_p1p1);
return self::ristretto255_p3_tobytes($r_p3);
}
/**
* @param int $hLen
* @param ?string $ctx
* @param string $msg
* @return string
* @throws SodiumException
* @psalm-suppress PossiblyInvalidArgument hash API
*/
protected static function h2c_string_to_hash_sha256($hLen, $ctx, $msg)
{
$h = array_fill(0, $hLen, 0);
$ctx_len = !is_null($ctx) ? self::strlen($ctx) : 0;
if ($hLen > 0xff) {
throw new SodiumException('Hash must be less than 256 bytes');
}
if ($ctx_len > 0xff) {
$st = hash_init('sha256');
self::hash_update($st, "H2C-OVERSIZE-DST-");
self::hash_update($st, $ctx);
$ctx = hash_final($st, true);
$ctx_len = 32;
}
$t = array(0, $hLen, 0);
$ux = str_repeat("\0", 64);
$st = hash_init('sha256');
self::hash_update($st, $ux);
self::hash_update($st, $msg);
self::hash_update($st, self::intArrayToString($t));
self::hash_update($st, $ctx);
self::hash_update($st, self::intToChr($ctx_len));
$u0 = hash_final($st, true);
for ($i = 0; $i < $hLen; $i += 64) {
$ux = self::xorStrings($ux, $u0);
++$t[2];
$st = hash_init('sha256');
self::hash_update($st, $ux);
self::hash_update($st, self::intToChr($t[2]));
self::hash_update($st, $ctx);
self::hash_update($st, self::intToChr($ctx_len));
$ux = hash_final($st, true);
$amount = min($hLen - $i, 64);
for ($j = 0; $j < $amount; ++$j) {
$h[$i + $j] = self::chrToInt($ux[$i]);
}
}
return self::intArrayToString(array_slice($h, 0, $hLen));
}
/**
* @param int $hLen
* @param ?string $ctx
* @param string $msg
* @return string
* @throws SodiumException
* @psalm-suppress PossiblyInvalidArgument hash API
*/
protected static function h2c_string_to_hash_sha512($hLen, $ctx, $msg)
{
$h = array_fill(0, $hLen, 0);
$ctx_len = !is_null($ctx) ? self::strlen($ctx) : 0;
if ($hLen > 0xff) {
throw new SodiumException('Hash must be less than 256 bytes');
}
if ($ctx_len > 0xff) {
$st = hash_init('sha256');
self::hash_update($st, "H2C-OVERSIZE-DST-");
self::hash_update($st, $ctx);
$ctx = hash_final($st, true);
$ctx_len = 32;
}
$t = array(0, $hLen, 0);
$ux = str_repeat("\0", 128);
$st = hash_init('sha512');
self::hash_update($st, $ux);
self::hash_update($st, $msg);
self::hash_update($st, self::intArrayToString($t));
self::hash_update($st, $ctx);
self::hash_update($st, self::intToChr($ctx_len));
$u0 = hash_final($st, true);
for ($i = 0; $i < $hLen; $i += 128) {
$ux = self::xorStrings($ux, $u0);
++$t[2];
$st = hash_init('sha512');
self::hash_update($st, $ux);
self::hash_update($st, self::intToChr($t[2]));
self::hash_update($st, $ctx);
self::hash_update($st, self::intToChr($ctx_len));
$ux = hash_final($st, true);
$amount = min($hLen - $i, 128);
for ($j = 0; $j < $amount; ++$j) {
$h[$i + $j] = self::chrToInt($ux[$i]);
}
}
return self::intArrayToString(array_slice($h, 0, $hLen));
}
/**
* @param int $hLen
* @param ?string $ctx
* @param string $msg
* @param int $hash_alg
* @return string
* @throws SodiumException
*/
public static function h2c_string_to_hash($hLen, $ctx, $msg, $hash_alg)
{
switch ($hash_alg) {
case self::CORE_H2C_SHA256:
return self::h2c_string_to_hash_sha256($hLen, $ctx, $msg);
case self::CORE_H2C_SHA512:
return self::h2c_string_to_hash_sha512($hLen, $ctx, $msg);
default:
throw new SodiumException('Invalid H2C hash algorithm');
}
}
/**
* @param ?string $ctx
* @param string $msg
* @param int $hash_alg
* @return string
* @throws SodiumException
*/
protected static function _string_to_element($ctx, $msg, $hash_alg)
{
return self::ristretto255_from_hash(
self::h2c_string_to_hash(self::crypto_core_ristretto255_HASHBYTES, $ctx, $msg, $hash_alg)
);
}
/**
* @return string
* @throws SodiumException
* @throws Exception
*/
public static function ristretto255_random()
{
return self::ristretto255_from_hash(
ParagonIE_Sodium_Compat::randombytes_buf(self::crypto_core_ristretto255_HASHBYTES)
);
}
/**
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_random()
{
return self::scalar_random();
}
/**
* @param string $s
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_complement($s)
{
return self::scalar_complement($s);
}
/**
* @param string $s
* @return string
*/
public static function ristretto255_scalar_invert($s)
{
return self::sc25519_invert($s);
}
/**
* @param string $s
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_negate($s)
{
return self::scalar_negate($s);
}
/**
* @param string $x
* @param string $y
* @return string
*/
public static function ristretto255_scalar_add($x, $y)
{
return self::scalar_add($x, $y);
}
/**
* @param string $x
* @param string $y
* @return string
*/
public static function ristretto255_scalar_sub($x, $y)
{
return self::scalar_sub($x, $y);
}
/**
* @param string $x
* @param string $y
* @return string
*/
public static function ristretto255_scalar_mul($x, $y)
{
return self::sc25519_mul($x, $y);
}
/**
* @param string $ctx
* @param string $msg
* @param int $hash_alg
* @return string
* @throws SodiumException
*/
public static function ristretto255_scalar_from_string($ctx, $msg, $hash_alg)
{
$h = array_fill(0, 64, 0);
$h_be = self::stringToIntArray(
self::h2c_string_to_hash(
self::HASH_SC_L, $ctx, $msg, $hash_alg
)
);
for ($i = 0; $i < self::HASH_SC_L; ++$i) {
$h[$i] = $h_be[self::HASH_SC_L - 1 - $i];
}
return self::ristretto255_scalar_reduce(self::intArrayToString($h));
}
/**
* @param string $s
* @return string
*/
public static function ristretto255_scalar_reduce($s)
{
return self::sc_reduce($s);
}
/**
* @param string $n
* @param string $p
* @return string
* @throws SodiumException
*/
public static function scalarmult_ristretto255($n, $p)
{
if (self::strlen($n) !== 32) {
throw new SodiumException('Scalar must be 32 bytes, ' . self::strlen($p) . ' given.');
}
if (self::strlen($p) !== 32) {
throw new SodiumException('Point must be 32 bytes, ' . self::strlen($p) . ' given.');
}
$result = self::ristretto255_frombytes($p);
if ($result['res'] !== 0) {
throw new SodiumException('Could not multiply points');
}
$P = $result['h'];
$t = self::stringToIntArray($n);
$t[31] &= 0x7f;
$Q = self::ge_scalarmult(self::intArrayToString($t), $P);
$q = self::ristretto255_p3_tobytes($Q);
if (ParagonIE_Sodium_Compat::is_zero($q)) {
throw new SodiumException('An unknown error has occurred');
}
return $q;
}
/**
* @param string $n
* @return string
* @throws SodiumException
*/
public static function scalarmult_ristretto255_base($n)
{
$t = self::stringToIntArray($n);
$t[31] &= 0x7f;
$Q = self::ge_scalarmult_base(self::intArrayToString($t));
$q = self::ristretto255_p3_tobytes($Q);
if (ParagonIE_Sodium_Compat::is_zero($q)) {
throw new SodiumException('An unknown error has occurred');
}
return $q;
}
}
Core/Salsa20.php 0000644 00000020051 15153427537 0007364 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Salsa20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Salsa20
*/
abstract class ParagonIE_Sodium_Core_Salsa20 extends ParagonIE_Sodium_Core_Util
{
const ROUNDS = 20;
/**
* Calculate an salsa20 hash of a single block
*
* @internal You should not use this directly from another application
*
* @param string $in
* @param string $k
* @param string|null $c
* @return string
* @throws TypeError
*/
public static function core_salsa20($in, $k, $c = null)
{
if (self::strlen($k) < 32) {
throw new RangeException('Key must be 32 bytes long');
}
if ($c === null) {
$j0 = $x0 = 0x61707865;
$j5 = $x5 = 0x3320646e;
$j10 = $x10 = 0x79622d32;
$j15 = $x15 = 0x6b206574;
} else {
$j0 = $x0 = self::load_4(self::substr($c, 0, 4));
$j5 = $x5 = self::load_4(self::substr($c, 4, 4));
$j10 = $x10 = self::load_4(self::substr($c, 8, 4));
$j15 = $x15 = self::load_4(self::substr($c, 12, 4));
}
$j1 = $x1 = self::load_4(self::substr($k, 0, 4));
$j2 = $x2 = self::load_4(self::substr($k, 4, 4));
$j3 = $x3 = self::load_4(self::substr($k, 8, 4));
$j4 = $x4 = self::load_4(self::substr($k, 12, 4));
$j6 = $x6 = self::load_4(self::substr($in, 0, 4));
$j7 = $x7 = self::load_4(self::substr($in, 4, 4));
$j8 = $x8 = self::load_4(self::substr($in, 8, 4));
$j9 = $x9 = self::load_4(self::substr($in, 12, 4));
$j11 = $x11 = self::load_4(self::substr($k, 16, 4));
$j12 = $x12 = self::load_4(self::substr($k, 20, 4));
$j13 = $x13 = self::load_4(self::substr($k, 24, 4));
$j14 = $x14 = self::load_4(self::substr($k, 28, 4));
for ($i = self::ROUNDS; $i > 0; $i -= 2) {
$x4 ^= self::rotate($x0 + $x12, 7);
$x8 ^= self::rotate($x4 + $x0, 9);
$x12 ^= self::rotate($x8 + $x4, 13);
$x0 ^= self::rotate($x12 + $x8, 18);
$x9 ^= self::rotate($x5 + $x1, 7);
$x13 ^= self::rotate($x9 + $x5, 9);
$x1 ^= self::rotate($x13 + $x9, 13);
$x5 ^= self::rotate($x1 + $x13, 18);
$x14 ^= self::rotate($x10 + $x6, 7);
$x2 ^= self::rotate($x14 + $x10, 9);
$x6 ^= self::rotate($x2 + $x14, 13);
$x10 ^= self::rotate($x6 + $x2, 18);
$x3 ^= self::rotate($x15 + $x11, 7);
$x7 ^= self::rotate($x3 + $x15, 9);
$x11 ^= self::rotate($x7 + $x3, 13);
$x15 ^= self::rotate($x11 + $x7, 18);
$x1 ^= self::rotate($x0 + $x3, 7);
$x2 ^= self::rotate($x1 + $x0, 9);
$x3 ^= self::rotate($x2 + $x1, 13);
$x0 ^= self::rotate($x3 + $x2, 18);
$x6 ^= self::rotate($x5 + $x4, 7);
$x7 ^= self::rotate($x6 + $x5, 9);
$x4 ^= self::rotate($x7 + $x6, 13);
$x5 ^= self::rotate($x4 + $x7, 18);
$x11 ^= self::rotate($x10 + $x9, 7);
$x8 ^= self::rotate($x11 + $x10, 9);
$x9 ^= self::rotate($x8 + $x11, 13);
$x10 ^= self::rotate($x9 + $x8, 18);
$x12 ^= self::rotate($x15 + $x14, 7);
$x13 ^= self::rotate($x12 + $x15, 9);
$x14 ^= self::rotate($x13 + $x12, 13);
$x15 ^= self::rotate($x14 + $x13, 18);
}
$x0 += $j0;
$x1 += $j1;
$x2 += $j2;
$x3 += $j3;
$x4 += $j4;
$x5 += $j5;
$x6 += $j6;
$x7 += $j7;
$x8 += $j8;
$x9 += $j9;
$x10 += $j10;
$x11 += $j11;
$x12 += $j12;
$x13 += $j13;
$x14 += $j14;
$x15 += $j15;
return self::store32_le($x0) .
self::store32_le($x1) .
self::store32_le($x2) .
self::store32_le($x3) .
self::store32_le($x4) .
self::store32_le($x5) .
self::store32_le($x6) .
self::store32_le($x7) .
self::store32_le($x8) .
self::store32_le($x9) .
self::store32_le($x10) .
self::store32_le($x11) .
self::store32_le($x12) .
self::store32_le($x13) .
self::store32_le($x14) .
self::store32_le($x15);
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function salsa20($len, $nonce, $key)
{
if (self::strlen($key) !== 32) {
throw new RangeException('Key must be 32 bytes long');
}
$kcopy = '' . $key;
$in = self::substr($nonce, 0, 8) . str_repeat("\0", 8);
$c = '';
while ($len >= 64) {
$c .= self::core_salsa20($in, $kcopy, null);
$u = 1;
// Internal counter.
for ($i = 8; $i < 16; ++$i) {
$u += self::chrToInt($in[$i]);
$in[$i] = self::intToChr($u & 0xff);
$u >>= 8;
}
$len -= 64;
}
if ($len > 0) {
$c .= self::substr(
self::core_salsa20($in, $kcopy, null),
0,
$len
);
}
try {
ParagonIE_Sodium_Compat::memzero($kcopy);
} catch (SodiumException $ex) {
$kcopy = null;
}
return $c;
}
/**
* @internal You should not use this directly from another application
*
* @param string $m
* @param string $n
* @param int $ic
* @param string $k
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function salsa20_xor_ic($m, $n, $ic, $k)
{
$mlen = self::strlen($m);
if ($mlen < 1) {
return '';
}
$kcopy = self::substr($k, 0, 32);
$in = self::substr($n, 0, 8);
// Initialize the counter
$in .= ParagonIE_Sodium_Core_Util::store64_le($ic);
$c = '';
while ($mlen >= 64) {
$block = self::core_salsa20($in, $kcopy, null);
$c .= self::xorStrings(
self::substr($m, 0, 64),
self::substr($block, 0, 64)
);
$u = 1;
for ($i = 8; $i < 16; ++$i) {
$u += self::chrToInt($in[$i]);
$in[$i] = self::intToChr($u & 0xff);
$u >>= 8;
}
$mlen -= 64;
$m = self::substr($m, 64);
}
if ($mlen) {
$block = self::core_salsa20($in, $kcopy, null);
$c .= self::xorStrings(
self::substr($m, 0, $mlen),
self::substr($block, 0, $mlen)
);
}
try {
ParagonIE_Sodium_Compat::memzero($block);
ParagonIE_Sodium_Compat::memzero($kcopy);
} catch (SodiumException $ex) {
$block = null;
$kcopy = null;
}
return $c;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function salsa20_xor($message, $nonce, $key)
{
return self::xorStrings(
$message,
self::salsa20(
self::strlen($message),
$nonce,
$key
)
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $u
* @param int $c
* @return int
*/
public static function rotate($u, $c)
{
$u &= 0xffffffff;
$c %= 32;
return (int) (0xffffffff & (
($u << $c)
|
($u >> (32 - $c))
)
);
}
}
Core/SecretStream/State.php 0000644 00000007050 15153427537 0011644 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core_SecretStream_State
*/
class ParagonIE_Sodium_Core_SecretStream_State
{
/** @var string $key */
protected $key;
/** @var int $counter */
protected $counter;
/** @var string $nonce */
protected $nonce;
/** @var string $_pad */
protected $_pad;
/**
* ParagonIE_Sodium_Core_SecretStream_State constructor.
* @param string $key
* @param string|null $nonce
*/
public function __construct($key, $nonce = null)
{
$this->key = $key;
$this->counter = 1;
if (is_null($nonce)) {
$nonce = str_repeat("\0", 12);
}
$this->nonce = str_pad($nonce, 12, "\0", STR_PAD_RIGHT);;
$this->_pad = str_repeat("\0", 4);
}
/**
* @return self
*/
public function counterReset()
{
$this->counter = 1;
$this->_pad = str_repeat("\0", 4);
return $this;
}
/**
* @return string
*/
public function getKey()
{
return $this->key;
}
/**
* @return string
*/
public function getCounter()
{
return ParagonIE_Sodium_Core_Util::store32_le($this->counter);
}
/**
* @return string
*/
public function getNonce()
{
if (!is_string($this->nonce)) {
$this->nonce = str_repeat("\0", 12);
}
if (ParagonIE_Sodium_Core_Util::strlen($this->nonce) !== 12) {
$this->nonce = str_pad($this->nonce, 12, "\0", STR_PAD_RIGHT);
}
return $this->nonce;
}
/**
* @return string
*/
public function getCombinedNonce()
{
return $this->getCounter() .
ParagonIE_Sodium_Core_Util::substr($this->getNonce(), 0, 8);
}
/**
* @return self
*/
public function incrementCounter()
{
++$this->counter;
return $this;
}
/**
* @return bool
*/
public function needsRekey()
{
return ($this->counter & 0xffff) === 0;
}
/**
* @param string $newKeyAndNonce
* @return self
*/
public function rekey($newKeyAndNonce)
{
$this->key = ParagonIE_Sodium_Core_Util::substr($newKeyAndNonce, 0, 32);
$this->nonce = str_pad(
ParagonIE_Sodium_Core_Util::substr($newKeyAndNonce, 32),
12,
"\0",
STR_PAD_RIGHT
);
return $this;
}
/**
* @param string $str
* @return self
*/
public function xorNonce($str)
{
$this->nonce = ParagonIE_Sodium_Core_Util::xorStrings(
$this->getNonce(),
str_pad(
ParagonIE_Sodium_Core_Util::substr($str, 0, 8),
12,
"\0",
STR_PAD_RIGHT
)
);
return $this;
}
/**
* @param string $string
* @return self
*/
public static function fromString($string)
{
$state = new ParagonIE_Sodium_Core_SecretStream_State(
ParagonIE_Sodium_Core_Util::substr($string, 0, 32)
);
$state->counter = ParagonIE_Sodium_Core_Util::load_4(
ParagonIE_Sodium_Core_Util::substr($string, 32, 4)
);
$state->nonce = ParagonIE_Sodium_Core_Util::substr($string, 36, 12);
$state->_pad = ParagonIE_Sodium_Core_Util::substr($string, 48, 8);
return $state;
}
/**
* @return string
*/
public function toString()
{
return $this->key .
$this->getCounter() .
$this->nonce .
$this->_pad;
}
}
Core/SipHash.php 0000644 00000020051 15153427537 0007516 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_SipHash', false)) {
return;
}
/**
* Class ParagonIE_SodiumCompat_Core_SipHash
*
* Only uses 32-bit arithmetic, while the original SipHash used 64-bit integers
*/
class ParagonIE_Sodium_Core_SipHash extends ParagonIE_Sodium_Core_Util
{
/**
* @internal You should not use this directly from another application
*
* @param int[] $v
* @return int[]
*
*/
public static function sipRound(array $v)
{
# v0 += v1;
list($v[0], $v[1]) = self::add(
array($v[0], $v[1]),
array($v[2], $v[3])
);
# v1=ROTL(v1,13);
list($v[2], $v[3]) = self::rotl_64((int) $v[2], (int) $v[3], 13);
# v1 ^= v0;
$v[2] = (int) $v[2] ^ (int) $v[0];
$v[3] = (int) $v[3] ^ (int) $v[1];
# v0=ROTL(v0,32);
list($v[0], $v[1]) = self::rotl_64((int) $v[0], (int) $v[1], 32);
# v2 += v3;
list($v[4], $v[5]) = self::add(
array((int) $v[4], (int) $v[5]),
array((int) $v[6], (int) $v[7])
);
# v3=ROTL(v3,16);
list($v[6], $v[7]) = self::rotl_64((int) $v[6], (int) $v[7], 16);
# v3 ^= v2;
$v[6] = (int) $v[6] ^ (int) $v[4];
$v[7] = (int) $v[7] ^ (int) $v[5];
# v0 += v3;
list($v[0], $v[1]) = self::add(
array((int) $v[0], (int) $v[1]),
array((int) $v[6], (int) $v[7])
);
# v3=ROTL(v3,21);
list($v[6], $v[7]) = self::rotl_64((int) $v[6], (int) $v[7], 21);
# v3 ^= v0;
$v[6] = (int) $v[6] ^ (int) $v[0];
$v[7] = (int) $v[7] ^ (int) $v[1];
# v2 += v1;
list($v[4], $v[5]) = self::add(
array((int) $v[4], (int) $v[5]),
array((int) $v[2], (int) $v[3])
);
# v1=ROTL(v1,17);
list($v[2], $v[3]) = self::rotl_64((int) $v[2], (int) $v[3], 17);
# v1 ^= v2;;
$v[2] = (int) $v[2] ^ (int) $v[4];
$v[3] = (int) $v[3] ^ (int) $v[5];
# v2=ROTL(v2,32)
list($v[4], $v[5]) = self::rotl_64((int) $v[4], (int) $v[5], 32);
return $v;
}
/**
* Add two 32 bit integers representing a 64-bit integer.
*
* @internal You should not use this directly from another application
*
* @param int[] $a
* @param int[] $b
* @return array<int, mixed>
*/
public static function add(array $a, array $b)
{
/** @var int $x1 */
$x1 = $a[1] + $b[1];
/** @var int $c */
$c = $x1 >> 32; // Carry if ($a + $b) > 0xffffffff
/** @var int $x0 */
$x0 = $a[0] + $b[0] + $c;
return array(
$x0 & 0xffffffff,
$x1 & 0xffffffff
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $int0
* @param int $int1
* @param int $c
* @return array<int, mixed>
*/
public static function rotl_64($int0, $int1, $c)
{
$int0 &= 0xffffffff;
$int1 &= 0xffffffff;
$c &= 63;
if ($c === 32) {
return array($int1, $int0);
}
if ($c > 31) {
$tmp = $int1;
$int1 = $int0;
$int0 = $tmp;
$c &= 31;
}
if ($c === 0) {
return array($int0, $int1);
}
return array(
0xffffffff & (
($int0 << $c)
|
($int1 >> (32 - $c))
),
0xffffffff & (
($int1 << $c)
|
($int0 >> (32 - $c))
),
);
}
/**
* Implements Siphash-2-4 using only 32-bit numbers.
*
* When we split an int into two, the higher bits go to the lower index.
* e.g. 0xDEADBEEFAB10C92D becomes [
* 0 => 0xDEADBEEF,
* 1 => 0xAB10C92D
* ].
*
* @internal You should not use this directly from another application
*
* @param string $in
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sipHash24($in, $key)
{
$inlen = self::strlen($in);
# /* "somepseudorandomlygeneratedbytes" */
# u64 v0 = 0x736f6d6570736575ULL;
# u64 v1 = 0x646f72616e646f6dULL;
# u64 v2 = 0x6c7967656e657261ULL;
# u64 v3 = 0x7465646279746573ULL;
$v = array(
0x736f6d65, // 0
0x70736575, // 1
0x646f7261, // 2
0x6e646f6d, // 3
0x6c796765, // 4
0x6e657261, // 5
0x74656462, // 6
0x79746573 // 7
);
// v0 => $v[0], $v[1]
// v1 => $v[2], $v[3]
// v2 => $v[4], $v[5]
// v3 => $v[6], $v[7]
# u64 k0 = LOAD64_LE( k );
# u64 k1 = LOAD64_LE( k + 8 );
$k = array(
self::load_4(self::substr($key, 4, 4)),
self::load_4(self::substr($key, 0, 4)),
self::load_4(self::substr($key, 12, 4)),
self::load_4(self::substr($key, 8, 4))
);
// k0 => $k[0], $k[1]
// k1 => $k[2], $k[3]
# b = ( ( u64 )inlen ) << 56;
$b = array(
$inlen << 24,
0
);
// See docblock for why the 0th index gets the higher bits.
# v3 ^= k1;
$v[6] ^= $k[2];
$v[7] ^= $k[3];
# v2 ^= k0;
$v[4] ^= $k[0];
$v[5] ^= $k[1];
# v1 ^= k1;
$v[2] ^= $k[2];
$v[3] ^= $k[3];
# v0 ^= k0;
$v[0] ^= $k[0];
$v[1] ^= $k[1];
$left = $inlen;
# for ( ; in != end; in += 8 )
while ($left >= 8) {
# m = LOAD64_LE( in );
$m = array(
self::load_4(self::substr($in, 4, 4)),
self::load_4(self::substr($in, 0, 4))
);
# v3 ^= m;
$v[6] ^= $m[0];
$v[7] ^= $m[1];
# SIPROUND;
# SIPROUND;
$v = self::sipRound($v);
$v = self::sipRound($v);
# v0 ^= m;
$v[0] ^= $m[0];
$v[1] ^= $m[1];
$in = self::substr($in, 8);
$left -= 8;
}
# switch( left )
# {
# case 7: b |= ( ( u64 )in[ 6] ) << 48;
# case 6: b |= ( ( u64 )in[ 5] ) << 40;
# case 5: b |= ( ( u64 )in[ 4] ) << 32;
# case 4: b |= ( ( u64 )in[ 3] ) << 24;
# case 3: b |= ( ( u64 )in[ 2] ) << 16;
# case 2: b |= ( ( u64 )in[ 1] ) << 8;
# case 1: b |= ( ( u64 )in[ 0] ); break;
# case 0: break;
# }
switch ($left) {
case 7:
$b[0] |= self::chrToInt($in[6]) << 16;
case 6:
$b[0] |= self::chrToInt($in[5]) << 8;
case 5:
$b[0] |= self::chrToInt($in[4]);
case 4:
$b[1] |= self::chrToInt($in[3]) << 24;
case 3:
$b[1] |= self::chrToInt($in[2]) << 16;
case 2:
$b[1] |= self::chrToInt($in[1]) << 8;
case 1:
$b[1] |= self::chrToInt($in[0]);
case 0:
break;
}
// See docblock for why the 0th index gets the higher bits.
# v3 ^= b;
$v[6] ^= $b[0];
$v[7] ^= $b[1];
# SIPROUND;
# SIPROUND;
$v = self::sipRound($v);
$v = self::sipRound($v);
# v0 ^= b;
$v[0] ^= $b[0];
$v[1] ^= $b[1];
// Flip the lower 8 bits of v2 which is ($v[4], $v[5]) in our implementation
# v2 ^= 0xff;
$v[5] ^= 0xff;
# SIPROUND;
# SIPROUND;
# SIPROUND;
# SIPROUND;
$v = self::sipRound($v);
$v = self::sipRound($v);
$v = self::sipRound($v);
$v = self::sipRound($v);
# b = v0 ^ v1 ^ v2 ^ v3;
# STORE64_LE( out, b );
return self::store32_le($v[1] ^ $v[3] ^ $v[5] ^ $v[7]) .
self::store32_le($v[0] ^ $v[2] ^ $v[4] ^ $v[6]);
}
}
Core/Util.php 0000644 00000067150 15153427537 0007107 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_Util', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Util
*/
abstract class ParagonIE_Sodium_Core_Util
{
/**
* @param int $integer
* @param int $size (16, 32, 64)
* @return int
*/
public static function abs($integer, $size = 0)
{
/** @var int $realSize */
$realSize = (PHP_INT_SIZE << 3) - 1;
if ($size) {
--$size;
} else {
/** @var int $size */
$size = $realSize;
}
$negative = -(($integer >> $size) & 1);
return (int) (
($integer ^ $negative)
+
(($negative >> $realSize) & 1)
);
}
/**
* Convert a binary string into a hexadecimal string without cache-timing
* leaks
*
* @internal You should not use this directly from another application
*
* @param string $binaryString (raw binary)
* @return string
* @throws TypeError
*/
public static function bin2hex($binaryString)
{
/* Type checks: */
if (!is_string($binaryString)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($binaryString) . ' given.');
}
$hex = '';
$len = self::strlen($binaryString);
for ($i = 0; $i < $len; ++$i) {
/** @var array<int, int> $chunk */
$chunk = unpack('C', $binaryString[$i]);
/** @var int $c */
$c = $chunk[1] & 0xf;
/** @var int $b */
$b = $chunk[1] >> 4;
$hex .= pack(
'CC',
(87 + $b + ((($b - 10) >> 8) & ~38)),
(87 + $c + ((($c - 10) >> 8) & ~38))
);
}
return $hex;
}
/**
* Convert a binary string into a hexadecimal string without cache-timing
* leaks, returning uppercase letters (as per RFC 4648)
*
* @internal You should not use this directly from another application
*
* @param string $bin_string (raw binary)
* @return string
* @throws TypeError
*/
public static function bin2hexUpper($bin_string)
{
$hex = '';
$len = self::strlen($bin_string);
for ($i = 0; $i < $len; ++$i) {
/** @var array<int, int> $chunk */
$chunk = unpack('C', $bin_string[$i]);
/**
* Lower 16 bits
*
* @var int $c
*/
$c = $chunk[1] & 0xf;
/**
* Upper 16 bits
* @var int $b
*/
$b = $chunk[1] >> 4;
/**
* Use pack() and binary operators to turn the two integers
* into hexadecimal characters. We don't use chr() here, because
* it uses a lookup table internally and we want to avoid
* cache-timing side-channels.
*/
$hex .= pack(
'CC',
(55 + $b + ((($b - 10) >> 8) & ~6)),
(55 + $c + ((($c - 10) >> 8) & ~6))
);
}
return $hex;
}
/**
* Cache-timing-safe variant of ord()
*
* @internal You should not use this directly from another application
*
* @param string $chr
* @return int
* @throws SodiumException
* @throws TypeError
*/
public static function chrToInt($chr)
{
/* Type checks: */
if (!is_string($chr)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($chr) . ' given.');
}
if (self::strlen($chr) !== 1) {
throw new SodiumException('chrToInt() expects a string that is exactly 1 character long');
}
/** @var array<int, int> $chunk */
$chunk = unpack('C', $chr);
return (int) ($chunk[1]);
}
/**
* Compares two strings.
*
* @internal You should not use this directly from another application
*
* @param string $left
* @param string $right
* @param int $len
* @return int
* @throws SodiumException
* @throws TypeError
*/
public static function compare($left, $right, $len = null)
{
$leftLen = self::strlen($left);
$rightLen = self::strlen($right);
if ($len === null) {
$len = max($leftLen, $rightLen);
$left = str_pad($left, $len, "\x00", STR_PAD_RIGHT);
$right = str_pad($right, $len, "\x00", STR_PAD_RIGHT);
}
$gt = 0;
$eq = 1;
$i = $len;
while ($i !== 0) {
--$i;
$gt |= ((self::chrToInt($right[$i]) - self::chrToInt($left[$i])) >> 8) & $eq;
$eq &= ((self::chrToInt($right[$i]) ^ self::chrToInt($left[$i])) - 1) >> 8;
}
return ($gt + $gt + $eq) - 1;
}
/**
* If a variable does not match a given type, throw a TypeError.
*
* @param mixed $mixedVar
* @param string $type
* @param int $argumentIndex
* @throws TypeError
* @throws SodiumException
* @return void
*/
public static function declareScalarType(&$mixedVar = null, $type = 'void', $argumentIndex = 0)
{
if (func_num_args() === 0) {
/* Tautology, by default */
return;
}
if (func_num_args() === 1) {
throw new TypeError('Declared void, but passed a variable');
}
$realType = strtolower(gettype($mixedVar));
$type = strtolower($type);
switch ($type) {
case 'null':
if ($mixedVar !== null) {
throw new TypeError('Argument ' . $argumentIndex . ' must be null, ' . $realType . ' given.');
}
break;
case 'integer':
case 'int':
$allow = array('int', 'integer');
if (!in_array($type, $allow)) {
throw new TypeError('Argument ' . $argumentIndex . ' must be an integer, ' . $realType . ' given.');
}
$mixedVar = (int) $mixedVar;
break;
case 'boolean':
case 'bool':
$allow = array('bool', 'boolean');
if (!in_array($type, $allow)) {
throw new TypeError('Argument ' . $argumentIndex . ' must be a boolean, ' . $realType . ' given.');
}
$mixedVar = (bool) $mixedVar;
break;
case 'string':
if (!is_string($mixedVar)) {
throw new TypeError('Argument ' . $argumentIndex . ' must be a string, ' . $realType . ' given.');
}
$mixedVar = (string) $mixedVar;
break;
case 'decimal':
case 'double':
case 'float':
$allow = array('decimal', 'double', 'float');
if (!in_array($type, $allow)) {
throw new TypeError('Argument ' . $argumentIndex . ' must be a float, ' . $realType . ' given.');
}
$mixedVar = (float) $mixedVar;
break;
case 'object':
if (!is_object($mixedVar)) {
throw new TypeError('Argument ' . $argumentIndex . ' must be an object, ' . $realType . ' given.');
}
break;
case 'array':
if (!is_array($mixedVar)) {
if (is_object($mixedVar)) {
if ($mixedVar instanceof ArrayAccess) {
return;
}
}
throw new TypeError('Argument ' . $argumentIndex . ' must be an array, ' . $realType . ' given.');
}
break;
default:
throw new SodiumException('Unknown type (' . $realType .') does not match expect type (' . $type . ')');
}
}
/**
* Evaluate whether or not two strings are equal (in constant-time)
*
* @param string $left
* @param string $right
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function hashEquals($left, $right)
{
/* Type checks: */
if (!is_string($left)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($left) . ' given.');
}
if (!is_string($right)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($right) . ' given.');
}
if (is_callable('hash_equals')) {
return hash_equals($left, $right);
}
$d = 0;
/** @var int $len */
$len = self::strlen($left);
if ($len !== self::strlen($right)) {
return false;
}
for ($i = 0; $i < $len; ++$i) {
$d |= self::chrToInt($left[$i]) ^ self::chrToInt($right[$i]);
}
if ($d !== 0) {
return false;
}
return $left === $right;
}
/**
* Catch hash_update() failures and throw instead of silently proceeding
*
* @param HashContext|resource &$hs
* @param string $data
* @return void
* @throws SodiumException
* @psalm-suppress PossiblyInvalidArgument
*/
protected static function hash_update(&$hs, $data)
{
if (!hash_update($hs, $data)) {
throw new SodiumException('hash_update() failed');
}
}
/**
* Convert a hexadecimal string into a binary string without cache-timing
* leaks
*
* @internal You should not use this directly from another application
*
* @param string $hexString
* @param string $ignore
* @param bool $strictPadding
* @return string (raw binary)
* @throws RangeException
* @throws TypeError
*/
public static function hex2bin($hexString, $ignore = '', $strictPadding = false)
{
/* Type checks: */
if (!is_string($hexString)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($hexString) . ' given.');
}
if (!is_string($ignore)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($hexString) . ' given.');
}
$hex_pos = 0;
$bin = '';
$c_acc = 0;
$hex_len = self::strlen($hexString);
$state = 0;
if (($hex_len & 1) !== 0) {
if ($strictPadding) {
throw new RangeException(
'Expected an even number of hexadecimal characters'
);
} else {
$hexString = '0' . $hexString;
++$hex_len;
}
}
$chunk = unpack('C*', $hexString);
while ($hex_pos < $hex_len) {
++$hex_pos;
/** @var int $c */
$c = $chunk[$hex_pos];
$c_num = $c ^ 48;
$c_num0 = ($c_num - 10) >> 8;
$c_alpha = ($c & ~32) - 55;
$c_alpha0 = (($c_alpha - 10) ^ ($c_alpha - 16)) >> 8;
if (($c_num0 | $c_alpha0) === 0) {
if ($ignore && $state === 0 && strpos($ignore, self::intToChr($c)) !== false) {
continue;
}
throw new RangeException(
'hex2bin() only expects hexadecimal characters'
);
}
$c_val = ($c_num0 & $c_num) | ($c_alpha & $c_alpha0);
if ($state === 0) {
$c_acc = $c_val * 16;
} else {
$bin .= pack('C', $c_acc | $c_val);
}
$state ^= 1;
}
return $bin;
}
/**
* Turn an array of integers into a string
*
* @internal You should not use this directly from another application
*
* @param array<int, int> $ints
* @return string
*/
public static function intArrayToString(array $ints)
{
$args = $ints;
foreach ($args as $i => $v) {
$args[$i] = (int) ($v & 0xff);
}
array_unshift($args, str_repeat('C', count($ints)));
return (string) (call_user_func_array('pack', $args));
}
/**
* Cache-timing-safe variant of ord()
*
* @internal You should not use this directly from another application
*
* @param int $int
* @return string
* @throws TypeError
*/
public static function intToChr($int)
{
return pack('C', $int);
}
/**
* Load a 3 character substring into an integer
*
* @internal You should not use this directly from another application
*
* @param string $string
* @return int
* @throws RangeException
* @throws TypeError
*/
public static function load_3($string)
{
/* Type checks: */
if (!is_string($string)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($string) . ' given.');
}
/* Input validation: */
if (self::strlen($string) < 3) {
throw new RangeException(
'String must be 3 bytes or more; ' . self::strlen($string) . ' given.'
);
}
/** @var array<int, int> $unpacked */
$unpacked = unpack('V', $string . "\0");
return (int) ($unpacked[1] & 0xffffff);
}
/**
* Load a 4 character substring into an integer
*
* @internal You should not use this directly from another application
*
* @param string $string
* @return int
* @throws RangeException
* @throws TypeError
*/
public static function load_4($string)
{
/* Type checks: */
if (!is_string($string)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($string) . ' given.');
}
/* Input validation: */
if (self::strlen($string) < 4) {
throw new RangeException(
'String must be 4 bytes or more; ' . self::strlen($string) . ' given.'
);
}
/** @var array<int, int> $unpacked */
$unpacked = unpack('V', $string);
return (int) $unpacked[1];
}
/**
* Load a 8 character substring into an integer
*
* @internal You should not use this directly from another application
*
* @param string $string
* @return int
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function load64_le($string)
{
/* Type checks: */
if (!is_string($string)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($string) . ' given.');
}
/* Input validation: */
if (self::strlen($string) < 4) {
throw new RangeException(
'String must be 4 bytes or more; ' . self::strlen($string) . ' given.'
);
}
if (PHP_VERSION_ID >= 50603 && PHP_INT_SIZE === 8) {
/** @var array<int, int> $unpacked */
$unpacked = unpack('P', $string);
return (int) $unpacked[1];
}
/** @var int $result */
$result = (self::chrToInt($string[0]) & 0xff);
$result |= (self::chrToInt($string[1]) & 0xff) << 8;
$result |= (self::chrToInt($string[2]) & 0xff) << 16;
$result |= (self::chrToInt($string[3]) & 0xff) << 24;
$result |= (self::chrToInt($string[4]) & 0xff) << 32;
$result |= (self::chrToInt($string[5]) & 0xff) << 40;
$result |= (self::chrToInt($string[6]) & 0xff) << 48;
$result |= (self::chrToInt($string[7]) & 0xff) << 56;
return (int) $result;
}
/**
* @internal You should not use this directly from another application
*
* @param string $left
* @param string $right
* @return int
* @throws SodiumException
* @throws TypeError
*/
public static function memcmp($left, $right)
{
if (self::hashEquals($left, $right)) {
return 0;
}
return -1;
}
/**
* Multiply two integers in constant-time
*
* Micro-architecture timing side-channels caused by how your CPU
* implements multiplication are best prevented by never using the
* multiplication operators and ensuring that our code always takes
* the same number of operations to complete, regardless of the values
* of $a and $b.
*
* @internal You should not use this directly from another application
*
* @param int $a
* @param int $b
* @param int $size Limits the number of operations (useful for small,
* constant operands)
* @return int
*/
public static function mul($a, $b, $size = 0)
{
if (ParagonIE_Sodium_Compat::$fastMult) {
return (int) ($a * $b);
}
static $defaultSize = null;
/** @var int $defaultSize */
if (!$defaultSize) {
/** @var int $defaultSize */
$defaultSize = (PHP_INT_SIZE << 3) - 1;
}
if ($size < 1) {
/** @var int $size */
$size = $defaultSize;
}
/** @var int $size */
$c = 0;
/**
* Mask is either -1 or 0.
*
* -1 in binary looks like 0x1111 ... 1111
* 0 in binary looks like 0x0000 ... 0000
*
* @var int
*/
$mask = -(($b >> ((int) $defaultSize)) & 1);
/**
* Ensure $b is a positive integer, without creating
* a branching side-channel
*
* @var int $b
*/
$b = ($b & ~$mask) | ($mask & -$b);
/**
* Unless $size is provided:
*
* This loop always runs 32 times when PHP_INT_SIZE is 4.
* This loop always runs 64 times when PHP_INT_SIZE is 8.
*/
for ($i = $size; $i >= 0; --$i) {
$c += (int) ($a & -($b & 1));
$a <<= 1;
$b >>= 1;
}
$c = (int) @($c & -1);
/**
* If $b was negative, we then apply the same value to $c here.
* It doesn't matter much if $a was negative; the $c += above would
* have produced a negative integer to begin with. But a negative $b
* makes $b >>= 1 never return 0, so we would end up with incorrect
* results.
*
* The end result is what we'd expect from integer multiplication.
*/
return (int) (($c & ~$mask) | ($mask & -$c));
}
/**
* Convert any arbitrary numbers into two 32-bit integers that represent
* a 64-bit integer.
*
* @internal You should not use this directly from another application
*
* @param int|float $num
* @return array<int, int>
*/
public static function numericTo64BitInteger($num)
{
$high = 0;
/** @var int $low */
if (PHP_INT_SIZE === 4) {
$low = (int) $num;
} else {
$low = $num & 0xffffffff;
}
if ((+(abs($num))) >= 1) {
if ($num > 0) {
/** @var int $high */
$high = min((+(floor($num/4294967296))), 4294967295);
} else {
/** @var int $high */
$high = ~~((+(ceil(($num - (+((~~($num)))))/4294967296))));
}
}
return array((int) $high, (int) $low);
}
/**
* Store a 24-bit integer into a string, treating it as big-endian.
*
* @internal You should not use this directly from another application
*
* @param int $int
* @return string
* @throws TypeError
*/
public static function store_3($int)
{
/* Type checks: */
if (!is_int($int)) {
if (is_numeric($int)) {
$int = (int) $int;
} else {
throw new TypeError('Argument 1 must be an integer, ' . gettype($int) . ' given.');
}
}
/** @var string $packed */
$packed = pack('N', $int);
return self::substr($packed, 1, 3);
}
/**
* Store a 32-bit integer into a string, treating it as little-endian.
*
* @internal You should not use this directly from another application
*
* @param int $int
* @return string
* @throws TypeError
*/
public static function store32_le($int)
{
/* Type checks: */
if (!is_int($int)) {
if (is_numeric($int)) {
$int = (int) $int;
} else {
throw new TypeError('Argument 1 must be an integer, ' . gettype($int) . ' given.');
}
}
/** @var string $packed */
$packed = pack('V', $int);
return $packed;
}
/**
* Store a 32-bit integer into a string, treating it as big-endian.
*
* @internal You should not use this directly from another application
*
* @param int $int
* @return string
* @throws TypeError
*/
public static function store_4($int)
{
/* Type checks: */
if (!is_int($int)) {
if (is_numeric($int)) {
$int = (int) $int;
} else {
throw new TypeError('Argument 1 must be an integer, ' . gettype($int) . ' given.');
}
}
/** @var string $packed */
$packed = pack('N', $int);
return $packed;
}
/**
* Stores a 64-bit integer as an string, treating it as little-endian.
*
* @internal You should not use this directly from another application
*
* @param int $int
* @return string
* @throws TypeError
*/
public static function store64_le($int)
{
/* Type checks: */
if (!is_int($int)) {
if (is_numeric($int)) {
$int = (int) $int;
} else {
throw new TypeError('Argument 1 must be an integer, ' . gettype($int) . ' given.');
}
}
if (PHP_INT_SIZE === 8) {
if (PHP_VERSION_ID >= 50603) {
/** @var string $packed */
$packed = pack('P', $int);
return $packed;
}
return self::intToChr($int & 0xff) .
self::intToChr(($int >> 8) & 0xff) .
self::intToChr(($int >> 16) & 0xff) .
self::intToChr(($int >> 24) & 0xff) .
self::intToChr(($int >> 32) & 0xff) .
self::intToChr(($int >> 40) & 0xff) .
self::intToChr(($int >> 48) & 0xff) .
self::intToChr(($int >> 56) & 0xff);
}
if ($int > PHP_INT_MAX) {
list($hiB, $int) = self::numericTo64BitInteger($int);
} else {
$hiB = 0;
}
return
self::intToChr(($int ) & 0xff) .
self::intToChr(($int >> 8) & 0xff) .
self::intToChr(($int >> 16) & 0xff) .
self::intToChr(($int >> 24) & 0xff) .
self::intToChr($hiB & 0xff) .
self::intToChr(($hiB >> 8) & 0xff) .
self::intToChr(($hiB >> 16) & 0xff) .
self::intToChr(($hiB >> 24) & 0xff);
}
/**
* Safe string length
*
* @internal You should not use this directly from another application
*
* @ref mbstring.func_overload
*
* @param string $str
* @return int
* @throws TypeError
*/
public static function strlen($str)
{
/* Type checks: */
if (!is_string($str)) {
throw new TypeError('String expected');
}
return (int) (
self::isMbStringOverride()
? mb_strlen($str, '8bit')
: strlen($str)
);
}
/**
* Turn a string into an array of integers
*
* @internal You should not use this directly from another application
*
* @param string $string
* @return array<int, int>
* @throws TypeError
*/
public static function stringToIntArray($string)
{
if (!is_string($string)) {
throw new TypeError('String expected');
}
/**
* @var array<int, int>
*/
$values = array_values(
unpack('C*', $string)
);
return $values;
}
/**
* Safe substring
*
* @internal You should not use this directly from another application
*
* @ref mbstring.func_overload
*
* @param string $str
* @param int $start
* @param int $length
* @return string
* @throws TypeError
*/
public static function substr($str, $start = 0, $length = null)
{
/* Type checks: */
if (!is_string($str)) {
throw new TypeError('String expected');
}
if ($length === 0) {
return '';
}
if (self::isMbStringOverride()) {
if (PHP_VERSION_ID < 50400 && $length === null) {
$length = self::strlen($str);
}
$sub = (string) mb_substr($str, $start, $length, '8bit');
} elseif ($length === null) {
$sub = (string) substr($str, $start);
} else {
$sub = (string) substr($str, $start, $length);
}
if ($sub !== '') {
return $sub;
}
return '';
}
/**
* Compare a 16-character byte string in constant time.
*
* @internal You should not use this directly from another application
*
* @param string $a
* @param string $b
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function verify_16($a, $b)
{
/* Type checks: */
if (!is_string($a)) {
throw new TypeError('String expected');
}
if (!is_string($b)) {
throw new TypeError('String expected');
}
return self::hashEquals(
self::substr($a, 0, 16),
self::substr($b, 0, 16)
);
}
/**
* Compare a 32-character byte string in constant time.
*
* @internal You should not use this directly from another application
*
* @param string $a
* @param string $b
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function verify_32($a, $b)
{
/* Type checks: */
if (!is_string($a)) {
throw new TypeError('String expected');
}
if (!is_string($b)) {
throw new TypeError('String expected');
}
return self::hashEquals(
self::substr($a, 0, 32),
self::substr($b, 0, 32)
);
}
/**
* Calculate $a ^ $b for two strings.
*
* @internal You should not use this directly from another application
*
* @param string $a
* @param string $b
* @return string
* @throws TypeError
*/
public static function xorStrings($a, $b)
{
/* Type checks: */
if (!is_string($a)) {
throw new TypeError('Argument 1 must be a string');
}
if (!is_string($b)) {
throw new TypeError('Argument 2 must be a string');
}
return (string) ($a ^ $b);
}
/**
* Returns whether or not mbstring.func_overload is in effect.
*
* @internal You should not use this directly from another application
*
* Note: MB_OVERLOAD_STRING === 2, but we don't reference the constant
* (for nuisance-free PHP 8 support)
*
* @return bool
*/
protected static function isMbStringOverride()
{
static $mbstring = null;
if ($mbstring === null) {
if (!defined('MB_OVERLOAD_STRING')) {
$mbstring = false;
return $mbstring;
}
$mbstring = extension_loaded('mbstring')
&& defined('MB_OVERLOAD_STRING')
&&
((int) (ini_get('mbstring.func_overload')) & 2);
// MB_OVERLOAD_STRING === 2
}
/** @var bool $mbstring */
return $mbstring;
}
}
Core/X25519.php 0000644 00000022352 15153427537 0007002 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_X25519', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_X25519
*/
abstract class ParagonIE_Sodium_Core_X25519 extends ParagonIE_Sodium_Core_Curve25519
{
/**
* Alters the objects passed to this method in place.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core_Curve25519_Fe $g
* @param int $b
* @return void
* @psalm-suppress MixedAssignment
*/
public static function fe_cswap(
ParagonIE_Sodium_Core_Curve25519_Fe $f,
ParagonIE_Sodium_Core_Curve25519_Fe $g,
$b = 0
) {
$f0 = (int) $f[0];
$f1 = (int) $f[1];
$f2 = (int) $f[2];
$f3 = (int) $f[3];
$f4 = (int) $f[4];
$f5 = (int) $f[5];
$f6 = (int) $f[6];
$f7 = (int) $f[7];
$f8 = (int) $f[8];
$f9 = (int) $f[9];
$g0 = (int) $g[0];
$g1 = (int) $g[1];
$g2 = (int) $g[2];
$g3 = (int) $g[3];
$g4 = (int) $g[4];
$g5 = (int) $g[5];
$g6 = (int) $g[6];
$g7 = (int) $g[7];
$g8 = (int) $g[8];
$g9 = (int) $g[9];
$b = -$b;
$x0 = ($f0 ^ $g0) & $b;
$x1 = ($f1 ^ $g1) & $b;
$x2 = ($f2 ^ $g2) & $b;
$x3 = ($f3 ^ $g3) & $b;
$x4 = ($f4 ^ $g4) & $b;
$x5 = ($f5 ^ $g5) & $b;
$x6 = ($f6 ^ $g6) & $b;
$x7 = ($f7 ^ $g7) & $b;
$x8 = ($f8 ^ $g8) & $b;
$x9 = ($f9 ^ $g9) & $b;
$f[0] = $f0 ^ $x0;
$f[1] = $f1 ^ $x1;
$f[2] = $f2 ^ $x2;
$f[3] = $f3 ^ $x3;
$f[4] = $f4 ^ $x4;
$f[5] = $f5 ^ $x5;
$f[6] = $f6 ^ $x6;
$f[7] = $f7 ^ $x7;
$f[8] = $f8 ^ $x8;
$f[9] = $f9 ^ $x9;
$g[0] = $g0 ^ $x0;
$g[1] = $g1 ^ $x1;
$g[2] = $g2 ^ $x2;
$g[3] = $g3 ^ $x3;
$g[4] = $g4 ^ $x4;
$g[5] = $g5 ^ $x5;
$g[6] = $g6 ^ $x6;
$g[7] = $g7 ^ $x7;
$g[8] = $g8 ^ $x8;
$g[9] = $g9 ^ $x9;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function fe_mul121666(ParagonIE_Sodium_Core_Curve25519_Fe $f)
{
$h = array(
self::mul((int) $f[0], 121666, 17),
self::mul((int) $f[1], 121666, 17),
self::mul((int) $f[2], 121666, 17),
self::mul((int) $f[3], 121666, 17),
self::mul((int) $f[4], 121666, 17),
self::mul((int) $f[5], 121666, 17),
self::mul((int) $f[6], 121666, 17),
self::mul((int) $f[7], 121666, 17),
self::mul((int) $f[8], 121666, 17),
self::mul((int) $f[9], 121666, 17)
);
/** @var int $carry9 */
$carry9 = ($h[9] + (1 << 24)) >> 25;
$h[0] += self::mul($carry9, 19, 5);
$h[9] -= $carry9 << 25;
/** @var int $carry1 */
$carry1 = ($h[1] + (1 << 24)) >> 25;
$h[2] += $carry1;
$h[1] -= $carry1 << 25;
/** @var int $carry3 */
$carry3 = ($h[3] + (1 << 24)) >> 25;
$h[4] += $carry3;
$h[3] -= $carry3 << 25;
/** @var int $carry5 */
$carry5 = ($h[5] + (1 << 24)) >> 25;
$h[6] += $carry5;
$h[5] -= $carry5 << 25;
/** @var int $carry7 */
$carry7 = ($h[7] + (1 << 24)) >> 25;
$h[8] += $carry7;
$h[7] -= $carry7 << 25;
/** @var int $carry0 */
$carry0 = ($h[0] + (1 << 25)) >> 26;
$h[1] += $carry0;
$h[0] -= $carry0 << 26;
/** @var int $carry2 */
$carry2 = ($h[2] + (1 << 25)) >> 26;
$h[3] += $carry2;
$h[2] -= $carry2 << 26;
/** @var int $carry4 */
$carry4 = ($h[4] + (1 << 25)) >> 26;
$h[5] += $carry4;
$h[4] -= $carry4 << 26;
/** @var int $carry6 */
$carry6 = ($h[6] + (1 << 25)) >> 26;
$h[7] += $carry6;
$h[6] -= $carry6 << 26;
/** @var int $carry8 */
$carry8 = ($h[8] + (1 << 25)) >> 26;
$h[9] += $carry8;
$h[8] -= $carry8 << 26;
foreach ($h as $i => $value) {
$h[$i] = (int) $value;
}
return ParagonIE_Sodium_Core_Curve25519_Fe::fromArray($h);
}
/**
* @internal You should not use this directly from another application
*
* Inline comments preceded by # are from libsodium's ref10 code.
*
* @param string $n
* @param string $p
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_scalarmult_curve25519_ref10($n, $p)
{
# for (i = 0;i < 32;++i) e[i] = n[i];
$e = '' . $n;
# e[0] &= 248;
$e[0] = self::intToChr(
self::chrToInt($e[0]) & 248
);
# e[31] &= 127;
# e[31] |= 64;
$e[31] = self::intToChr(
(self::chrToInt($e[31]) & 127) | 64
);
# fe_frombytes(x1,p);
$x1 = self::fe_frombytes($p);
# fe_1(x2);
$x2 = self::fe_1();
# fe_0(z2);
$z2 = self::fe_0();
# fe_copy(x3,x1);
$x3 = self::fe_copy($x1);
# fe_1(z3);
$z3 = self::fe_1();
# swap = 0;
/** @var int $swap */
$swap = 0;
# for (pos = 254;pos >= 0;--pos) {
for ($pos = 254; $pos >= 0; --$pos) {
# b = e[pos / 8] >> (pos & 7);
/** @var int $b */
$b = self::chrToInt(
$e[(int) floor($pos / 8)]
) >> ($pos & 7);
# b &= 1;
$b &= 1;
# swap ^= b;
$swap ^= $b;
# fe_cswap(x2,x3,swap);
self::fe_cswap($x2, $x3, $swap);
# fe_cswap(z2,z3,swap);
self::fe_cswap($z2, $z3, $swap);
# swap = b;
$swap = $b;
# fe_sub(tmp0,x3,z3);
$tmp0 = self::fe_sub($x3, $z3);
# fe_sub(tmp1,x2,z2);
$tmp1 = self::fe_sub($x2, $z2);
# fe_add(x2,x2,z2);
$x2 = self::fe_add($x2, $z2);
# fe_add(z2,x3,z3);
$z2 = self::fe_add($x3, $z3);
# fe_mul(z3,tmp0,x2);
$z3 = self::fe_mul($tmp0, $x2);
# fe_mul(z2,z2,tmp1);
$z2 = self::fe_mul($z2, $tmp1);
# fe_sq(tmp0,tmp1);
$tmp0 = self::fe_sq($tmp1);
# fe_sq(tmp1,x2);
$tmp1 = self::fe_sq($x2);
# fe_add(x3,z3,z2);
$x3 = self::fe_add($z3, $z2);
# fe_sub(z2,z3,z2);
$z2 = self::fe_sub($z3, $z2);
# fe_mul(x2,tmp1,tmp0);
$x2 = self::fe_mul($tmp1, $tmp0);
# fe_sub(tmp1,tmp1,tmp0);
$tmp1 = self::fe_sub($tmp1, $tmp0);
# fe_sq(z2,z2);
$z2 = self::fe_sq($z2);
# fe_mul121666(z3,tmp1);
$z3 = self::fe_mul121666($tmp1);
# fe_sq(x3,x3);
$x3 = self::fe_sq($x3);
# fe_add(tmp0,tmp0,z3);
$tmp0 = self::fe_add($tmp0, $z3);
# fe_mul(z3,x1,z2);
$z3 = self::fe_mul($x1, $z2);
# fe_mul(z2,tmp1,tmp0);
$z2 = self::fe_mul($tmp1, $tmp0);
}
# fe_cswap(x2,x3,swap);
self::fe_cswap($x2, $x3, $swap);
# fe_cswap(z2,z3,swap);
self::fe_cswap($z2, $z3, $swap);
# fe_invert(z2,z2);
$z2 = self::fe_invert($z2);
# fe_mul(x2,x2,z2);
$x2 = self::fe_mul($x2, $z2);
# fe_tobytes(q,x2);
return self::fe_tobytes($x2);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core_Curve25519_Fe $edwardsY
* @param ParagonIE_Sodium_Core_Curve25519_Fe $edwardsZ
* @return ParagonIE_Sodium_Core_Curve25519_Fe
*/
public static function edwards_to_montgomery(
ParagonIE_Sodium_Core_Curve25519_Fe $edwardsY,
ParagonIE_Sodium_Core_Curve25519_Fe $edwardsZ
) {
$tempX = self::fe_add($edwardsZ, $edwardsY);
$tempZ = self::fe_sub($edwardsZ, $edwardsY);
$tempZ = self::fe_invert($tempZ);
return self::fe_mul($tempX, $tempZ);
}
/**
* @internal You should not use this directly from another application
*
* @param string $n
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_scalarmult_curve25519_ref10_base($n)
{
# for (i = 0;i < 32;++i) e[i] = n[i];
$e = '' . $n;
# e[0] &= 248;
$e[0] = self::intToChr(
self::chrToInt($e[0]) & 248
);
# e[31] &= 127;
# e[31] |= 64;
$e[31] = self::intToChr(
(self::chrToInt($e[31]) & 127) | 64
);
$A = self::ge_scalarmult_base($e);
if (
!($A->Y instanceof ParagonIE_Sodium_Core_Curve25519_Fe)
||
!($A->Z instanceof ParagonIE_Sodium_Core_Curve25519_Fe)
) {
throw new TypeError('Null points encountered');
}
$pk = self::edwards_to_montgomery($A->Y, $A->Z);
return self::fe_tobytes($pk);
}
}
Core/XChaCha20.php 0000644 00000006452 15153427537 0007571 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_XChaCha20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_XChaCha20
*/
class ParagonIE_Sodium_Core_XChaCha20 extends ParagonIE_Sodium_Core_HChaCha20
{
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function stream($len = 64, $nonce = '', $key = '')
{
if (self::strlen($nonce) !== 24) {
throw new SodiumException('Nonce must be 24 bytes long');
}
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_Ctx(
self::hChaCha20(
self::substr($nonce, 0, 16),
$key
),
self::substr($nonce, 16, 8)
),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStream($len = 64, $nonce = '', $key = '')
{
if (self::strlen($nonce) !== 24) {
throw new SodiumException('Nonce must be 24 bytes long');
}
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_IetfCtx(
self::hChaCha20(
self::substr($nonce, 0, 16),
$key
),
"\x00\x00\x00\x00" . self::substr($nonce, 16, 8)
),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function streamXorIc($message, $nonce = '', $key = '', $ic = '')
{
if (self::strlen($nonce) !== 24) {
throw new SodiumException('Nonce must be 24 bytes long');
}
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_Ctx(
self::hChaCha20(self::substr($nonce, 0, 16), $key),
self::substr($nonce, 16, 8),
$ic
),
$message
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStreamXorIc($message, $nonce = '', $key = '', $ic = '')
{
if (self::strlen($nonce) !== 24) {
throw new SodiumException('Nonce must be 24 bytes long');
}
return self::encryptBytes(
new ParagonIE_Sodium_Core_ChaCha20_IetfCtx(
self::hChaCha20(self::substr($nonce, 0, 16), $key),
"\x00\x00\x00\x00" . self::substr($nonce, 16, 8),
$ic
),
$message
);
}
}
Core/XSalsa20.php 0000644 00000002533 15153427537 0007521 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_XSalsa20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_XSalsa20
*/
abstract class ParagonIE_Sodium_Core_XSalsa20 extends ParagonIE_Sodium_Core_HSalsa20
{
/**
* Expand a key and nonce into an xsalsa20 keystream.
*
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function xsalsa20($len, $nonce, $key)
{
$ret = self::salsa20(
$len,
self::substr($nonce, 16, 8),
self::hsalsa20($nonce, $key)
);
return $ret;
}
/**
* Encrypt a string with XSalsa20. Doesn't provide integrity.
*
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function xsalsa20_xor($message, $nonce, $key)
{
return self::xorStrings(
$message,
self::xsalsa20(
self::strlen($message),
$nonce,
$key
)
);
}
}
Core32/BLAKE2b.php 0000644 00000053464 15153427537 0007404 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_BLAKE2b', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_BLAKE2b
*
* Based on the work of Devi Mandiri in devi/salt.
*/
abstract class ParagonIE_Sodium_Core32_BLAKE2b extends ParagonIE_Sodium_Core_Util
{
/**
* @var SplFixedArray
*/
public static $iv;
/**
* @var array<int, array<int, int>>
*/
public static $sigma = array(
array( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
array( 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3),
array( 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4),
array( 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8),
array( 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13),
array( 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9),
array( 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11),
array( 13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10),
array( 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5),
array( 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13 , 0),
array( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
array( 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3)
);
const BLOCKBYTES = 128;
const OUTBYTES = 64;
const KEYBYTES = 64;
/**
* Turn two 32-bit integers into a fixed array representing a 64-bit integer.
*
* @internal You should not use this directly from another application
*
* @param int $high
* @param int $low
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public static function new64($high, $low)
{
return ParagonIE_Sodium_Core32_Int64::fromInts($low, $high);
}
/**
* Convert an arbitrary number into an SplFixedArray of two 32-bit integers
* that represents a 64-bit integer.
*
* @internal You should not use this directly from another application
*
* @param int $num
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
protected static function to64($num)
{
list($hi, $lo) = self::numericTo64BitInteger($num);
return self::new64($hi, $lo);
}
/**
* Adds two 64-bit integers together, returning their sum as a SplFixedArray
* containing two 32-bit integers (representing a 64-bit integer).
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Int64 $x
* @param ParagonIE_Sodium_Core32_Int64 $y
* @return ParagonIE_Sodium_Core32_Int64
*/
protected static function add64($x, $y)
{
return $x->addInt64($y);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Int64 $x
* @param ParagonIE_Sodium_Core32_Int64 $y
* @param ParagonIE_Sodium_Core32_Int64 $z
* @return ParagonIE_Sodium_Core32_Int64
*/
public static function add364($x, $y, $z)
{
return $x->addInt64($y)->addInt64($z);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Int64 $x
* @param ParagonIE_Sodium_Core32_Int64 $y
* @return ParagonIE_Sodium_Core32_Int64
* @throws TypeError
*/
public static function xor64(ParagonIE_Sodium_Core32_Int64 $x, ParagonIE_Sodium_Core32_Int64 $y)
{
return $x->xorInt64($y);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Int64 $x
* @param int $c
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public static function rotr64(ParagonIE_Sodium_Core32_Int64 $x, $c)
{
return $x->rotateRight($c);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param int $i
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public static function load64($x, $i)
{
/** @var int $l */
$l = (int) ($x[$i])
| ((int) ($x[$i+1]) << 8)
| ((int) ($x[$i+2]) << 16)
| ((int) ($x[$i+3]) << 24);
/** @var int $h */
$h = (int) ($x[$i+4])
| ((int) ($x[$i+5]) << 8)
| ((int) ($x[$i+6]) << 16)
| ((int) ($x[$i+7]) << 24);
return self::new64($h, $l);
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $x
* @param int $i
* @param ParagonIE_Sodium_Core32_Int64 $u
* @return void
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
*/
public static function store64(SplFixedArray $x, $i, ParagonIE_Sodium_Core32_Int64 $u)
{
$v = clone $u;
$maxLength = $x->getSize() - 1;
for ($j = 0; $j < 8; ++$j) {
$k = 3 - ($j >> 1);
$x[$i] = $v->limbs[$k] & 0xff;
if (++$i > $maxLength) {
return;
}
$v->limbs[$k] >>= 8;
}
}
/**
* This just sets the $iv static variable.
*
* @internal You should not use this directly from another application
*
* @return void
* @throws SodiumException
* @throws TypeError
*/
public static function pseudoConstructor()
{
static $called = false;
if ($called) {
return;
}
self::$iv = new SplFixedArray(8);
self::$iv[0] = self::new64(0x6a09e667, 0xf3bcc908);
self::$iv[1] = self::new64(0xbb67ae85, 0x84caa73b);
self::$iv[2] = self::new64(0x3c6ef372, 0xfe94f82b);
self::$iv[3] = self::new64(0xa54ff53a, 0x5f1d36f1);
self::$iv[4] = self::new64(0x510e527f, 0xade682d1);
self::$iv[5] = self::new64(0x9b05688c, 0x2b3e6c1f);
self::$iv[6] = self::new64(0x1f83d9ab, 0xfb41bd6b);
self::$iv[7] = self::new64(0x5be0cd19, 0x137e2179);
$called = true;
}
/**
* Returns a fresh BLAKE2 context.
*
* @internal You should not use this directly from another application
*
* @return SplFixedArray
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
* @throws SodiumException
* @throws TypeError
*/
protected static function context()
{
$ctx = new SplFixedArray(6);
$ctx[0] = new SplFixedArray(8); // h
$ctx[1] = new SplFixedArray(2); // t
$ctx[2] = new SplFixedArray(2); // f
$ctx[3] = new SplFixedArray(256); // buf
$ctx[4] = 0; // buflen
$ctx[5] = 0; // last_node (uint8_t)
for ($i = 8; $i--;) {
$ctx[0][$i] = self::$iv[$i];
}
for ($i = 256; $i--;) {
$ctx[3][$i] = 0;
}
$zero = self::new64(0, 0);
$ctx[1][0] = $zero;
$ctx[1][1] = $zero;
$ctx[2][0] = $zero;
$ctx[2][1] = $zero;
return $ctx;
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param SplFixedArray $buf
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedAssignment
*/
protected static function compress(SplFixedArray $ctx, SplFixedArray $buf)
{
$m = new SplFixedArray(16);
$v = new SplFixedArray(16);
for ($i = 16; $i--;) {
$m[$i] = self::load64($buf, $i << 3);
}
for ($i = 8; $i--;) {
$v[$i] = $ctx[0][$i];
}
$v[ 8] = self::$iv[0];
$v[ 9] = self::$iv[1];
$v[10] = self::$iv[2];
$v[11] = self::$iv[3];
$v[12] = self::xor64($ctx[1][0], self::$iv[4]);
$v[13] = self::xor64($ctx[1][1], self::$iv[5]);
$v[14] = self::xor64($ctx[2][0], self::$iv[6]);
$v[15] = self::xor64($ctx[2][1], self::$iv[7]);
for ($r = 0; $r < 12; ++$r) {
$v = self::G($r, 0, 0, 4, 8, 12, $v, $m);
$v = self::G($r, 1, 1, 5, 9, 13, $v, $m);
$v = self::G($r, 2, 2, 6, 10, 14, $v, $m);
$v = self::G($r, 3, 3, 7, 11, 15, $v, $m);
$v = self::G($r, 4, 0, 5, 10, 15, $v, $m);
$v = self::G($r, 5, 1, 6, 11, 12, $v, $m);
$v = self::G($r, 6, 2, 7, 8, 13, $v, $m);
$v = self::G($r, 7, 3, 4, 9, 14, $v, $m);
}
for ($i = 8; $i--;) {
$ctx[0][$i] = self::xor64(
$ctx[0][$i], self::xor64($v[$i], $v[$i+8])
);
}
}
/**
* @internal You should not use this directly from another application
*
* @param int $r
* @param int $i
* @param int $a
* @param int $b
* @param int $c
* @param int $d
* @param SplFixedArray $v
* @param SplFixedArray $m
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayOffset
*/
public static function G($r, $i, $a, $b, $c, $d, SplFixedArray $v, SplFixedArray $m)
{
$v[$a] = self::add364($v[$a], $v[$b], $m[self::$sigma[$r][$i << 1]]);
$v[$d] = self::rotr64(self::xor64($v[$d], $v[$a]), 32);
$v[$c] = self::add64($v[$c], $v[$d]);
$v[$b] = self::rotr64(self::xor64($v[$b], $v[$c]), 24);
$v[$a] = self::add364($v[$a], $v[$b], $m[self::$sigma[$r][($i << 1) + 1]]);
$v[$d] = self::rotr64(self::xor64($v[$d], $v[$a]), 16);
$v[$c] = self::add64($v[$c], $v[$d]);
$v[$b] = self::rotr64(self::xor64($v[$b], $v[$c]), 63);
return $v;
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param int $inc
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
*/
public static function increment_counter($ctx, $inc)
{
if ($inc < 0) {
throw new SodiumException('Increasing by a negative number makes no sense.');
}
$t = self::to64($inc);
# S->t is $ctx[1] in our implementation
# S->t[0] = ( uint64_t )( t >> 0 );
$ctx[1][0] = self::add64($ctx[1][0], $t);
# S->t[1] += ( S->t[0] < inc );
if (!($ctx[1][0] instanceof ParagonIE_Sodium_Core32_Int64)) {
throw new TypeError('Not an int64');
}
/** @var ParagonIE_Sodium_Core32_Int64 $c*/
$c = $ctx[1][0];
if ($c->isLessThanInt($inc)) {
$ctx[1][1] = self::add64($ctx[1][1], self::to64(1));
}
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param SplFixedArray $p
* @param int $plen
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
* @psalm-suppress MixedMethodCall
* @psalm-suppress MixedOperand
*/
public static function update(SplFixedArray $ctx, SplFixedArray $p, $plen)
{
self::pseudoConstructor();
$offset = 0;
while ($plen > 0) {
$left = $ctx[4];
$fill = 256 - $left;
if ($plen > $fill) {
# memcpy( S->buf + left, in, fill ); /* Fill buffer */
for ($i = $fill; $i--;) {
$ctx[3][$i + $left] = $p[$i + $offset];
}
# S->buflen += fill;
$ctx[4] += $fill;
# blake2b_increment_counter( S, BLAKE2B_BLOCKBYTES );
self::increment_counter($ctx, 128);
# blake2b_compress( S, S->buf ); /* Compress */
self::compress($ctx, $ctx[3]);
# memcpy( S->buf, S->buf + BLAKE2B_BLOCKBYTES, BLAKE2B_BLOCKBYTES ); /* Shift buffer left */
for ($i = 128; $i--;) {
$ctx[3][$i] = $ctx[3][$i + 128];
}
# S->buflen -= BLAKE2B_BLOCKBYTES;
$ctx[4] -= 128;
# in += fill;
$offset += $fill;
# inlen -= fill;
$plen -= $fill;
} else {
for ($i = $plen; $i--;) {
$ctx[3][$i + $left] = $p[$i + $offset];
}
$ctx[4] += $plen;
$offset += $plen;
$plen -= $plen;
}
}
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @param SplFixedArray $out
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedArrayOffset
* @psalm-suppress MixedMethodCall
* @psalm-suppress MixedOperand
*/
public static function finish(SplFixedArray $ctx, SplFixedArray $out)
{
self::pseudoConstructor();
if ($ctx[4] > 128) {
self::increment_counter($ctx, 128);
self::compress($ctx, $ctx[3]);
$ctx[4] -= 128;
if ($ctx[4] > 128) {
throw new SodiumException('Failed to assert that buflen <= 128 bytes');
}
for ($i = $ctx[4]; $i--;) {
$ctx[3][$i] = $ctx[3][$i + 128];
}
}
self::increment_counter($ctx, $ctx[4]);
$ctx[2][0] = self::new64(0xffffffff, 0xffffffff);
for ($i = 256 - $ctx[4]; $i--;) {
/** @var int $i */
$ctx[3][$i + $ctx[4]] = 0;
}
self::compress($ctx, $ctx[3]);
$i = (int) (($out->getSize() - 1) / 8);
for (; $i >= 0; --$i) {
self::store64($out, $i << 3, $ctx[0][$i]);
}
return $out;
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray|null $key
* @param int $outlen
* @param SplFixedArray|null $salt
* @param SplFixedArray|null $personal
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedMethodCall
*/
public static function init(
$key = null,
$outlen = 64,
$salt = null,
$personal = null
) {
self::pseudoConstructor();
$klen = 0;
if ($key !== null) {
if (count($key) > 64) {
throw new SodiumException('Invalid key size');
}
$klen = count($key);
}
if ($outlen > 64) {
throw new SodiumException('Invalid output size');
}
$ctx = self::context();
$p = new SplFixedArray(64);
// Zero our param buffer...
for ($i = 64; --$i;) {
$p[$i] = 0;
}
$p[0] = $outlen; // digest_length
$p[1] = $klen; // key_length
$p[2] = 1; // fanout
$p[3] = 1; // depth
if ($salt instanceof SplFixedArray) {
// salt: [32] through [47]
for ($i = 0; $i < 16; ++$i) {
$p[32 + $i] = (int) $salt[$i];
}
}
if ($personal instanceof SplFixedArray) {
// personal: [48] through [63]
for ($i = 0; $i < 16; ++$i) {
$p[48 + $i] = (int) $personal[$i];
}
}
$ctx[0][0] = self::xor64(
$ctx[0][0],
self::load64($p, 0)
);
if ($salt instanceof SplFixedArray || $personal instanceof SplFixedArray) {
// We need to do what blake2b_init_param() does:
for ($i = 1; $i < 8; ++$i) {
$ctx[0][$i] = self::xor64(
$ctx[0][$i],
self::load64($p, $i << 3)
);
}
}
if ($klen > 0 && $key instanceof SplFixedArray) {
$block = new SplFixedArray(128);
for ($i = 128; $i--;) {
$block[$i] = 0;
}
for ($i = $klen; $i--;) {
$block[$i] = $key[$i];
}
self::update($ctx, $block, 128);
$ctx[4] = 128;
}
return $ctx;
}
/**
* Convert a string into an SplFixedArray of integers
*
* @internal You should not use this directly from another application
*
* @param string $str
* @return SplFixedArray
* @psalm-suppress MixedArgumentTypeCoercion
*/
public static function stringToSplFixedArray($str = '')
{
$values = unpack('C*', $str);
return SplFixedArray::fromArray(array_values($values));
}
/**
* Convert an SplFixedArray of integers into a string
*
* @internal You should not use this directly from another application
*
* @param SplFixedArray $a
* @return string
*/
public static function SplFixedArrayToString(SplFixedArray $a)
{
/**
* @var array<int, string|int>
*/
$arr = $a->toArray();
$c = $a->count();
array_unshift($arr, str_repeat('C', $c));
return (string) (call_user_func_array('pack', $arr));
}
/**
* @internal You should not use this directly from another application
*
* @param SplFixedArray $ctx
* @return string
* @throws TypeError
* @psalm-suppress MixedArgument
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
* @psalm-suppress MixedMethodCall
*/
public static function contextToString(SplFixedArray $ctx)
{
$str = '';
/** @var array<int, ParagonIE_Sodium_Core32_Int64> $ctxA */
$ctxA = $ctx[0]->toArray();
# uint64_t h[8];
for ($i = 0; $i < 8; ++$i) {
if (!($ctxA[$i] instanceof ParagonIE_Sodium_Core32_Int64)) {
throw new TypeError('Not an instance of Int64');
}
/** @var ParagonIE_Sodium_Core32_Int64 $ctxAi */
$ctxAi = $ctxA[$i];
$str .= $ctxAi->toReverseString();
}
# uint64_t t[2];
# uint64_t f[2];
for ($i = 1; $i < 3; ++$i) {
/** @var array<int, ParagonIE_Sodium_Core32_Int64> $ctxA */
$ctxA = $ctx[$i]->toArray();
/** @var ParagonIE_Sodium_Core32_Int64 $ctxA1 */
$ctxA1 = $ctxA[0];
/** @var ParagonIE_Sodium_Core32_Int64 $ctxA2 */
$ctxA2 = $ctxA[1];
$str .= $ctxA1->toReverseString();
$str .= $ctxA2->toReverseString();
}
# uint8_t buf[2 * 128];
$str .= self::SplFixedArrayToString($ctx[3]);
/** @var int $ctx4 */
$ctx4 = $ctx[4];
# size_t buflen;
$str .= implode('', array(
self::intToChr($ctx4 & 0xff),
self::intToChr(($ctx4 >> 8) & 0xff),
self::intToChr(($ctx4 >> 16) & 0xff),
self::intToChr(($ctx4 >> 24) & 0xff),
"\x00\x00\x00\x00"
/*
self::intToChr(($ctx4 >> 32) & 0xff),
self::intToChr(($ctx4 >> 40) & 0xff),
self::intToChr(($ctx4 >> 48) & 0xff),
self::intToChr(($ctx4 >> 56) & 0xff)
*/
));
# uint8_t last_node;
return $str . self::intToChr($ctx[5]) . str_repeat("\x00", 23);
}
/**
* Creates an SplFixedArray containing other SplFixedArray elements, from
* a string (compatible with \Sodium\crypto_generichash_{init, update, final})
*
* @internal You should not use this directly from another application
*
* @param string $string
* @return SplFixedArray
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayAssignment
*/
public static function stringToContext($string)
{
$ctx = self::context();
# uint64_t h[8];
for ($i = 0; $i < 8; ++$i) {
$ctx[0][$i] = ParagonIE_Sodium_Core32_Int64::fromReverseString(
self::substr($string, (($i << 3) + 0), 8)
);
}
# uint64_t t[2];
# uint64_t f[2];
for ($i = 1; $i < 3; ++$i) {
$ctx[$i][1] = ParagonIE_Sodium_Core32_Int64::fromReverseString(
self::substr($string, 72 + (($i - 1) << 4), 8)
);
$ctx[$i][0] = ParagonIE_Sodium_Core32_Int64::fromReverseString(
self::substr($string, 64 + (($i - 1) << 4), 8)
);
}
# uint8_t buf[2 * 128];
$ctx[3] = self::stringToSplFixedArray(self::substr($string, 96, 256));
# uint8_t buf[2 * 128];
$int = 0;
for ($i = 0; $i < 8; ++$i) {
$int |= self::chrToInt($string[352 + $i]) << ($i << 3);
}
$ctx[4] = $int;
return $ctx;
}
}
Core32/ChaCha20/Ctx.php 0000644 00000011450 15153427537 0010336 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_ChaCha20_Ctx', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_ChaCha20_Ctx
*/
class ParagonIE_Sodium_Core32_ChaCha20_Ctx extends ParagonIE_Sodium_Core32_Util implements ArrayAccess
{
/**
* @var SplFixedArray internally, <int, ParagonIE_Sodium_Core32_Int32>
*/
protected $container;
/**
* ParagonIE_Sodium_Core_ChaCha20_Ctx constructor.
*
* @internal You should not use this directly from another application
*
* @param string $key ChaCha20 key.
* @param string $iv Initialization Vector (a.k.a. nonce).
* @param string $counter The initial counter value.
* Defaults to 8 0x00 bytes.
* @throws InvalidArgumentException
* @throws SodiumException
* @throws TypeError
*/
public function __construct($key = '', $iv = '', $counter = '')
{
if (self::strlen($key) !== 32) {
throw new InvalidArgumentException('ChaCha20 expects a 256-bit key.');
}
if (self::strlen($iv) !== 8) {
throw new InvalidArgumentException('ChaCha20 expects a 64-bit nonce.');
}
$this->container = new SplFixedArray(16);
/* "expand 32-byte k" as per ChaCha20 spec */
$this->container[0] = new ParagonIE_Sodium_Core32_Int32(array(0x6170, 0x7865));
$this->container[1] = new ParagonIE_Sodium_Core32_Int32(array(0x3320, 0x646e));
$this->container[2] = new ParagonIE_Sodium_Core32_Int32(array(0x7962, 0x2d32));
$this->container[3] = new ParagonIE_Sodium_Core32_Int32(array(0x6b20, 0x6574));
$this->container[4] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 0, 4));
$this->container[5] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 4, 4));
$this->container[6] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 8, 4));
$this->container[7] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 12, 4));
$this->container[8] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 16, 4));
$this->container[9] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 20, 4));
$this->container[10] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 24, 4));
$this->container[11] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 28, 4));
if (empty($counter)) {
$this->container[12] = new ParagonIE_Sodium_Core32_Int32();
$this->container[13] = new ParagonIE_Sodium_Core32_Int32();
} else {
$this->container[12] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($counter, 0, 4));
$this->container[13] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($counter, 4, 4));
}
$this->container[14] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($iv, 0, 4));
$this->container[15] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($iv, 4, 4));
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @param int|ParagonIE_Sodium_Core32_Int32 $value
* @return void
*/
#[ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if (!is_int($offset)) {
throw new InvalidArgumentException('Expected an integer');
}
if ($value instanceof ParagonIE_Sodium_Core32_Int32) {
/*
} elseif (is_int($value)) {
$value = ParagonIE_Sodium_Core32_Int32::fromInt($value);
*/
} else {
throw new InvalidArgumentException('Expected an integer');
}
$this->container[$offset] = $value;
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return bool
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return void
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param int $offset
* @return mixed|null
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetGet($offset)
{
return isset($this->container[$offset])
? $this->container[$offset]
: null;
}
}
Core32/ChaCha20/IetfCtx.php 0000644 00000002737 15153427537 0011156 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core_ChaCha20_IetfCtx', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_ChaCha20_IetfCtx
*/
class ParagonIE_Sodium_Core32_ChaCha20_IetfCtx extends ParagonIE_Sodium_Core32_ChaCha20_Ctx
{
/**
* ParagonIE_Sodium_Core_ChaCha20_IetfCtx constructor.
*
* @internal You should not use this directly from another application
*
* @param string $key ChaCha20 key.
* @param string $iv Initialization Vector (a.k.a. nonce).
* @param string $counter The initial counter value.
* Defaults to 4 0x00 bytes.
* @throws InvalidArgumentException
* @throws SodiumException
* @throws TypeError
*/
public function __construct($key = '', $iv = '', $counter = '')
{
if (self::strlen($iv) !== 12) {
throw new InvalidArgumentException('ChaCha20 expects a 96-bit nonce in IETF mode.');
}
parent::__construct($key, self::substr($iv, 0, 8), $counter);
if (!empty($counter)) {
$this->container[12] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($counter, 0, 4));
}
$this->container[13] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($iv, 0, 4));
$this->container[14] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($iv, 4, 4));
$this->container[15] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($iv, 8, 4));
}
}
Core32/ChaCha20.php 0000644 00000034257 15153427537 0007612 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_ChaCha20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_ChaCha20
*/
class ParagonIE_Sodium_Core32_ChaCha20 extends ParagonIE_Sodium_Core32_Util
{
/**
* The ChaCha20 quarter round function. Works on four 32-bit integers.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Int32 $a
* @param ParagonIE_Sodium_Core32_Int32 $b
* @param ParagonIE_Sodium_Core32_Int32 $c
* @param ParagonIE_Sodium_Core32_Int32 $d
* @return array<int, ParagonIE_Sodium_Core32_Int32>
* @throws SodiumException
* @throws TypeError
*/
protected static function quarterRound(
ParagonIE_Sodium_Core32_Int32 $a,
ParagonIE_Sodium_Core32_Int32 $b,
ParagonIE_Sodium_Core32_Int32 $c,
ParagonIE_Sodium_Core32_Int32 $d
) {
/** @var ParagonIE_Sodium_Core32_Int32 $a */
/** @var ParagonIE_Sodium_Core32_Int32 $b */
/** @var ParagonIE_Sodium_Core32_Int32 $c */
/** @var ParagonIE_Sodium_Core32_Int32 $d */
# a = PLUS(a,b); d = ROTATE(XOR(d,a),16);
$a = $a->addInt32($b);
$d = $d->xorInt32($a)->rotateLeft(16);
# c = PLUS(c,d); b = ROTATE(XOR(b,c),12);
$c = $c->addInt32($d);
$b = $b->xorInt32($c)->rotateLeft(12);
# a = PLUS(a,b); d = ROTATE(XOR(d,a), 8);
$a = $a->addInt32($b);
$d = $d->xorInt32($a)->rotateLeft(8);
# c = PLUS(c,d); b = ROTATE(XOR(b,c), 7);
$c = $c->addInt32($d);
$b = $b->xorInt32($c)->rotateLeft(7);
return array($a, $b, $c, $d);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_ChaCha20_Ctx $ctx
* @param string $message
*
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function encryptBytes(
ParagonIE_Sodium_Core32_ChaCha20_Ctx $ctx,
$message = ''
) {
$bytes = self::strlen($message);
/** @var ParagonIE_Sodium_Core32_Int32 $x0 */
/** @var ParagonIE_Sodium_Core32_Int32 $x1 */
/** @var ParagonIE_Sodium_Core32_Int32 $x2 */
/** @var ParagonIE_Sodium_Core32_Int32 $x3 */
/** @var ParagonIE_Sodium_Core32_Int32 $x4 */
/** @var ParagonIE_Sodium_Core32_Int32 $x5 */
/** @var ParagonIE_Sodium_Core32_Int32 $x6 */
/** @var ParagonIE_Sodium_Core32_Int32 $x7 */
/** @var ParagonIE_Sodium_Core32_Int32 $x8 */
/** @var ParagonIE_Sodium_Core32_Int32 $x9 */
/** @var ParagonIE_Sodium_Core32_Int32 $x10 */
/** @var ParagonIE_Sodium_Core32_Int32 $x11 */
/** @var ParagonIE_Sodium_Core32_Int32 $x12 */
/** @var ParagonIE_Sodium_Core32_Int32 $x13 */
/** @var ParagonIE_Sodium_Core32_Int32 $x14 */
/** @var ParagonIE_Sodium_Core32_Int32 $x15 */
/*
j0 = ctx->input[0];
j1 = ctx->input[1];
j2 = ctx->input[2];
j3 = ctx->input[3];
j4 = ctx->input[4];
j5 = ctx->input[5];
j6 = ctx->input[6];
j7 = ctx->input[7];
j8 = ctx->input[8];
j9 = ctx->input[9];
j10 = ctx->input[10];
j11 = ctx->input[11];
j12 = ctx->input[12];
j13 = ctx->input[13];
j14 = ctx->input[14];
j15 = ctx->input[15];
*/
/** @var ParagonIE_Sodium_Core32_Int32 $j0 */
$j0 = $ctx[0];
/** @var ParagonIE_Sodium_Core32_Int32 $j1 */
$j1 = $ctx[1];
/** @var ParagonIE_Sodium_Core32_Int32 $j2 */
$j2 = $ctx[2];
/** @var ParagonIE_Sodium_Core32_Int32 $j3 */
$j3 = $ctx[3];
/** @var ParagonIE_Sodium_Core32_Int32 $j4 */
$j4 = $ctx[4];
/** @var ParagonIE_Sodium_Core32_Int32 $j5 */
$j5 = $ctx[5];
/** @var ParagonIE_Sodium_Core32_Int32 $j6 */
$j6 = $ctx[6];
/** @var ParagonIE_Sodium_Core32_Int32 $j7 */
$j7 = $ctx[7];
/** @var ParagonIE_Sodium_Core32_Int32 $j8 */
$j8 = $ctx[8];
/** @var ParagonIE_Sodium_Core32_Int32 $j9 */
$j9 = $ctx[9];
/** @var ParagonIE_Sodium_Core32_Int32 $j10 */
$j10 = $ctx[10];
/** @var ParagonIE_Sodium_Core32_Int32 $j11 */
$j11 = $ctx[11];
/** @var ParagonIE_Sodium_Core32_Int32 $j12 */
$j12 = $ctx[12];
/** @var ParagonIE_Sodium_Core32_Int32 $j13 */
$j13 = $ctx[13];
/** @var ParagonIE_Sodium_Core32_Int32 $j14 */
$j14 = $ctx[14];
/** @var ParagonIE_Sodium_Core32_Int32 $j15 */
$j15 = $ctx[15];
$c = '';
for (;;) {
if ($bytes < 64) {
$message .= str_repeat("\x00", 64 - $bytes);
}
$x0 = clone $j0;
$x1 = clone $j1;
$x2 = clone $j2;
$x3 = clone $j3;
$x4 = clone $j4;
$x5 = clone $j5;
$x6 = clone $j6;
$x7 = clone $j7;
$x8 = clone $j8;
$x9 = clone $j9;
$x10 = clone $j10;
$x11 = clone $j11;
$x12 = clone $j12;
$x13 = clone $j13;
$x14 = clone $j14;
$x15 = clone $j15;
# for (i = 20; i > 0; i -= 2) {
for ($i = 20; $i > 0; $i -= 2) {
# QUARTERROUND( x0, x4, x8, x12)
list($x0, $x4, $x8, $x12) = self::quarterRound($x0, $x4, $x8, $x12);
# QUARTERROUND( x1, x5, x9, x13)
list($x1, $x5, $x9, $x13) = self::quarterRound($x1, $x5, $x9, $x13);
# QUARTERROUND( x2, x6, x10, x14)
list($x2, $x6, $x10, $x14) = self::quarterRound($x2, $x6, $x10, $x14);
# QUARTERROUND( x3, x7, x11, x15)
list($x3, $x7, $x11, $x15) = self::quarterRound($x3, $x7, $x11, $x15);
# QUARTERROUND( x0, x5, x10, x15)
list($x0, $x5, $x10, $x15) = self::quarterRound($x0, $x5, $x10, $x15);
# QUARTERROUND( x1, x6, x11, x12)
list($x1, $x6, $x11, $x12) = self::quarterRound($x1, $x6, $x11, $x12);
# QUARTERROUND( x2, x7, x8, x13)
list($x2, $x7, $x8, $x13) = self::quarterRound($x2, $x7, $x8, $x13);
# QUARTERROUND( x3, x4, x9, x14)
list($x3, $x4, $x9, $x14) = self::quarterRound($x3, $x4, $x9, $x14);
}
/*
x0 = PLUS(x0, j0);
x1 = PLUS(x1, j1);
x2 = PLUS(x2, j2);
x3 = PLUS(x3, j3);
x4 = PLUS(x4, j4);
x5 = PLUS(x5, j5);
x6 = PLUS(x6, j6);
x7 = PLUS(x7, j7);
x8 = PLUS(x8, j8);
x9 = PLUS(x9, j9);
x10 = PLUS(x10, j10);
x11 = PLUS(x11, j11);
x12 = PLUS(x12, j12);
x13 = PLUS(x13, j13);
x14 = PLUS(x14, j14);
x15 = PLUS(x15, j15);
*/
$x0 = $x0->addInt32($j0);
$x1 = $x1->addInt32($j1);
$x2 = $x2->addInt32($j2);
$x3 = $x3->addInt32($j3);
$x4 = $x4->addInt32($j4);
$x5 = $x5->addInt32($j5);
$x6 = $x6->addInt32($j6);
$x7 = $x7->addInt32($j7);
$x8 = $x8->addInt32($j8);
$x9 = $x9->addInt32($j9);
$x10 = $x10->addInt32($j10);
$x11 = $x11->addInt32($j11);
$x12 = $x12->addInt32($j12);
$x13 = $x13->addInt32($j13);
$x14 = $x14->addInt32($j14);
$x15 = $x15->addInt32($j15);
/*
x0 = XOR(x0, LOAD32_LE(m + 0));
x1 = XOR(x1, LOAD32_LE(m + 4));
x2 = XOR(x2, LOAD32_LE(m + 8));
x3 = XOR(x3, LOAD32_LE(m + 12));
x4 = XOR(x4, LOAD32_LE(m + 16));
x5 = XOR(x5, LOAD32_LE(m + 20));
x6 = XOR(x6, LOAD32_LE(m + 24));
x7 = XOR(x7, LOAD32_LE(m + 28));
x8 = XOR(x8, LOAD32_LE(m + 32));
x9 = XOR(x9, LOAD32_LE(m + 36));
x10 = XOR(x10, LOAD32_LE(m + 40));
x11 = XOR(x11, LOAD32_LE(m + 44));
x12 = XOR(x12, LOAD32_LE(m + 48));
x13 = XOR(x13, LOAD32_LE(m + 52));
x14 = XOR(x14, LOAD32_LE(m + 56));
x15 = XOR(x15, LOAD32_LE(m + 60));
*/
$x0 = $x0->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 0, 4)));
$x1 = $x1->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 4, 4)));
$x2 = $x2->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 8, 4)));
$x3 = $x3->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 12, 4)));
$x4 = $x4->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 16, 4)));
$x5 = $x5->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 20, 4)));
$x6 = $x6->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 24, 4)));
$x7 = $x7->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 28, 4)));
$x8 = $x8->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 32, 4)));
$x9 = $x9->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 36, 4)));
$x10 = $x10->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 40, 4)));
$x11 = $x11->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 44, 4)));
$x12 = $x12->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 48, 4)));
$x13 = $x13->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 52, 4)));
$x14 = $x14->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 56, 4)));
$x15 = $x15->xorInt32(ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 60, 4)));
/*
j12 = PLUSONE(j12);
if (!j12) {
j13 = PLUSONE(j13);
}
*/
/** @var ParagonIE_Sodium_Core32_Int32 $j12 */
$j12 = $j12->addInt(1);
if ($j12->limbs[0] === 0 && $j12->limbs[1] === 0) {
$j13 = $j13->addInt(1);
}
/*
STORE32_LE(c + 0, x0);
STORE32_LE(c + 4, x1);
STORE32_LE(c + 8, x2);
STORE32_LE(c + 12, x3);
STORE32_LE(c + 16, x4);
STORE32_LE(c + 20, x5);
STORE32_LE(c + 24, x6);
STORE32_LE(c + 28, x7);
STORE32_LE(c + 32, x8);
STORE32_LE(c + 36, x9);
STORE32_LE(c + 40, x10);
STORE32_LE(c + 44, x11);
STORE32_LE(c + 48, x12);
STORE32_LE(c + 52, x13);
STORE32_LE(c + 56, x14);
STORE32_LE(c + 60, x15);
*/
$block = $x0->toReverseString() .
$x1->toReverseString() .
$x2->toReverseString() .
$x3->toReverseString() .
$x4->toReverseString() .
$x5->toReverseString() .
$x6->toReverseString() .
$x7->toReverseString() .
$x8->toReverseString() .
$x9->toReverseString() .
$x10->toReverseString() .
$x11->toReverseString() .
$x12->toReverseString() .
$x13->toReverseString() .
$x14->toReverseString() .
$x15->toReverseString();
/* Partial block */
if ($bytes < 64) {
$c .= self::substr($block, 0, $bytes);
break;
}
/* Full block */
$c .= $block;
$bytes -= 64;
if ($bytes <= 0) {
break;
}
$message = self::substr($message, 64);
}
/* end for(;;) loop */
$ctx[12] = $j12;
$ctx[13] = $j13;
return $c;
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function stream($len = 64, $nonce = '', $key = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_Ctx($key, $nonce),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStream($len, $nonce = '', $key = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_IetfCtx($key, $nonce),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStreamXorIc($message, $nonce = '', $key = '', $ic = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_IetfCtx($key, $nonce, $ic),
$message
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function streamXorIc($message, $nonce = '', $key = '', $ic = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_Ctx($key, $nonce, $ic),
$message
);
}
}
Core32/Curve25519/Fe.php 0000644 00000012572 15153427537 0010361 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_Fe', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_Fe
*
* This represents a Field Element
*/
class ParagonIE_Sodium_Core32_Curve25519_Fe implements ArrayAccess
{
/**
* @var array<int, ParagonIE_Sodium_Core32_Int32>
*/
protected $container = array();
/**
* @var int
*/
protected $size = 10;
/**
* @internal You should not use this directly from another application
*
* @param array<int, ParagonIE_Sodium_Core32_Int32> $array
* @param bool $save_indexes
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromArray($array, $save_indexes = null)
{
$count = count($array);
if ($save_indexes) {
$keys = array_keys($array);
} else {
$keys = range(0, $count - 1);
}
$array = array_values($array);
$obj = new ParagonIE_Sodium_Core32_Curve25519_Fe();
if ($save_indexes) {
for ($i = 0; $i < $count; ++$i) {
$array[$i]->overflow = 0;
$obj->offsetSet($keys[$i], $array[$i]);
}
} else {
for ($i = 0; $i < $count; ++$i) {
if (!($array[$i] instanceof ParagonIE_Sodium_Core32_Int32)) {
throw new TypeError('Expected ParagonIE_Sodium_Core32_Int32');
}
$array[$i]->overflow = 0;
$obj->offsetSet($i, $array[$i]);
}
}
return $obj;
}
/**
* @internal You should not use this directly from another application
*
* @param array<int, int> $array
* @param bool $save_indexes
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromIntArray($array, $save_indexes = null)
{
$count = count($array);
if ($save_indexes) {
$keys = array_keys($array);
} else {
$keys = range(0, $count - 1);
}
$array = array_values($array);
$set = array();
/** @var int $i */
/** @var int $v */
foreach ($array as $i => $v) {
$set[$i] = ParagonIE_Sodium_Core32_Int32::fromInt($v);
}
$obj = new ParagonIE_Sodium_Core32_Curve25519_Fe();
if ($save_indexes) {
for ($i = 0; $i < $count; ++$i) {
$set[$i]->overflow = 0;
$obj->offsetSet($keys[$i], $set[$i]);
}
} else {
for ($i = 0; $i < $count; ++$i) {
$set[$i]->overflow = 0;
$obj->offsetSet($i, $set[$i]);
}
}
return $obj;
}
/**
* @internal You should not use this directly from another application
*
* @param mixed $offset
* @param mixed $value
* @return void
* @throws SodiumException
* @throws TypeError
*/
#[ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if (!($value instanceof ParagonIE_Sodium_Core32_Int32)) {
throw new InvalidArgumentException('Expected an instance of ParagonIE_Sodium_Core32_Int32');
}
if (is_null($offset)) {
$this->container[] = $value;
} else {
ParagonIE_Sodium_Core32_Util::declareScalarType($offset, 'int', 1);
$this->container[(int) $offset] = $value;
}
}
/**
* @internal You should not use this directly from another application
*
* @param mixed $offset
* @return bool
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param mixed $offset
* @return void
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->container[$offset]);
}
/**
* @internal You should not use this directly from another application
*
* @param mixed $offset
* @return ParagonIE_Sodium_Core32_Int32
* @psalm-suppress MixedArrayOffset
*/
#[ReturnTypeWillChange]
public function offsetGet($offset)
{
if (!isset($this->container[$offset])) {
$this->container[(int) $offset] = new ParagonIE_Sodium_Core32_Int32();
}
/** @var ParagonIE_Sodium_Core32_Int32 $get */
$get = $this->container[$offset];
return $get;
}
/**
* @internal You should not use this directly from another application
*
* @return array
*/
public function __debugInfo()
{
if (empty($this->container)) {
return array();
}
$c = array(
(int) ($this->container[0]->toInt()),
(int) ($this->container[1]->toInt()),
(int) ($this->container[2]->toInt()),
(int) ($this->container[3]->toInt()),
(int) ($this->container[4]->toInt()),
(int) ($this->container[5]->toInt()),
(int) ($this->container[6]->toInt()),
(int) ($this->container[7]->toInt()),
(int) ($this->container[8]->toInt()),
(int) ($this->container[9]->toInt())
);
return array(implode(', ', $c));
}
}
Core32/Curve25519/Ge/Cached.php 0000644 00000003415 15153427537 0011525 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_Ge_Cached', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_Ge_Cached
*/
class ParagonIE_Sodium_Core32_Curve25519_Ge_Cached
{
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $YplusX;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $YminusX;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Z;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $T2d;
/**
* ParagonIE_Sodium_Core32_Curve25519_Ge_Cached constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $YplusX
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $YminusX
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $Z
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $T2d
*/
public function __construct(
ParagonIE_Sodium_Core32_Curve25519_Fe $YplusX = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $YminusX = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $Z = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $T2d = null
) {
if ($YplusX === null) {
$YplusX = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->YplusX = $YplusX;
if ($YminusX === null) {
$YminusX = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->YminusX = $YminusX;
if ($Z === null) {
$Z = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->Z = $Z;
if ($T2d === null) {
$T2d = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->T2d = $T2d;
}
}
Core32/Curve25519/Ge/P1p1.php 0000644 00000003344 15153427537 0011100 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
*/
class ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
{
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $X;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Y;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Z;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $T;
/**
* ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $x
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $y
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $z
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $t
*
* @throws SodiumException
* @throws TypeError
*/
public function __construct(
ParagonIE_Sodium_Core32_Curve25519_Fe $x = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $y = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $z = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $t = null
) {
if ($x === null) {
$x = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->X = $x;
if ($y === null) {
$y = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->Y = $y;
if ($z === null) {
$z = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->Z = $z;
if ($t === null) {
$t = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->T = $t;
}
}
Core32/Curve25519/Ge/P2.php 0000644 00000002541 15153427537 0010636 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_Ge_P2', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_Ge_P2
*/
class ParagonIE_Sodium_Core32_Curve25519_Ge_P2
{
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $X;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Y;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Z;
/**
* ParagonIE_Sodium_Core32_Curve25519_Ge_P2 constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $x
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $y
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $z
*/
public function __construct(
ParagonIE_Sodium_Core32_Curve25519_Fe $x = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $y = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $z = null
) {
if ($x === null) {
$x = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->X = $x;
if ($y === null) {
$y = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->Y = $y;
if ($z === null) {
$z = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->Z = $z;
}
}
Core32/Curve25519/Ge/P3.php 0000644 00000003242 15153427537 0010636 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_Ge_P3', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_Ge_P3
*/
class ParagonIE_Sodium_Core32_Curve25519_Ge_P3
{
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $X;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Y;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $Z;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $T;
/**
* ParagonIE_Sodium_Core32_Curve25519_Ge_P3 constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $x
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $y
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $z
* @param ParagonIE_Sodium_Core32_Curve25519_Fe|null $t
*/
public function __construct(
ParagonIE_Sodium_Core32_Curve25519_Fe $x = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $y = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $z = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $t = null
) {
if ($x === null) {
$x = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->X = $x;
if ($y === null) {
$y = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->Y = $y;
if ($z === null) {
$z = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->Z = $z;
if ($t === null) {
$t = new ParagonIE_Sodium_Core32_Curve25519_Fe();
}
$this->T = $t;
}
}
Core32/Curve25519/Ge/Precomp.php 0000644 00000002775 15153427537 0011773 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp
*/
class ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp
{
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $yplusx;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $yminusx;
/**
* @var ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public $xy2d;
/**
* ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp constructor.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $yplusx
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $yminusx
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $xy2d
* @throws SodiumException
* @throws TypeError
*/
public function __construct(
ParagonIE_Sodium_Core32_Curve25519_Fe $yplusx = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $yminusx = null,
ParagonIE_Sodium_Core32_Curve25519_Fe $xy2d = null
) {
if ($yplusx === null) {
$yplusx = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->yplusx = $yplusx;
if ($yminusx === null) {
$yminusx = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->yminusx = $yminusx;
if ($xy2d === null) {
$xy2d = ParagonIE_Sodium_Core32_Curve25519::fe_0();
}
$this->xy2d = $xy2d;
}
}
Core32/Curve25519/H.php 0000644 00000324375 15153427537 0010225 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519_H', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519_H
*
* This just contains the constants in the ref10/base.h file
*/
class ParagonIE_Sodium_Core32_Curve25519_H extends ParagonIE_Sodium_Core32_Util
{
/**
* See: libsodium's crypto_core/curve25519/ref10/base.h
*
* @var array<int, array<int, array<int, array<int, int>>>> Basically, int[32][8][3][10]
*/
protected static $base = array(
array(
array(
array(25967493, -14356035, 29566456, 3660896, -12694345, 4014787, 27544626, -11754271, -6079156, 2047605),
array(-12545711, 934262, -2722910, 3049990, -727428, 9406986, 12720692, 5043384, 19500929, -15469378),
array(-8738181, 4489570, 9688441, -14785194, 10184609, -12363380, 29287919, 11864899, -24514362, -4438546),
),
array(
array(-12815894, -12976347, -21581243, 11784320, -25355658, -2750717, -11717903, -3814571, -358445, -10211303),
array(-21703237, 6903825, 27185491, 6451973, -29577724, -9554005, -15616551, 11189268, -26829678, -5319081),
array(26966642, 11152617, 32442495, 15396054, 14353839, -12752335, -3128826, -9541118, -15472047, -4166697),
),
array(
array(15636291, -9688557, 24204773, -7912398, 616977, -16685262, 27787600, -14772189, 28944400, -1550024),
array(16568933, 4717097, -11556148, -1102322, 15682896, -11807043, 16354577, -11775962, 7689662, 11199574),
array(30464156, -5976125, -11779434, -15670865, 23220365, 15915852, 7512774, 10017326, -17749093, -9920357),
),
array(
array(-17036878, 13921892, 10945806, -6033431, 27105052, -16084379, -28926210, 15006023, 3284568, -6276540),
array(23599295, -8306047, -11193664, -7687416, 13236774, 10506355, 7464579, 9656445, 13059162, 10374397),
array(7798556, 16710257, 3033922, 2874086, 28997861, 2835604, 32406664, -3839045, -641708, -101325),
),
array(
array(10861363, 11473154, 27284546, 1981175, -30064349, 12577861, 32867885, 14515107, -15438304, 10819380),
array(4708026, 6336745, 20377586, 9066809, -11272109, 6594696, -25653668, 12483688, -12668491, 5581306),
array(19563160, 16186464, -29386857, 4097519, 10237984, -4348115, 28542350, 13850243, -23678021, -15815942),
),
array(
array(-15371964, -12862754, 32573250, 4720197, -26436522, 5875511, -19188627, -15224819, -9818940, -12085777),
array(-8549212, 109983, 15149363, 2178705, 22900618, 4543417, 3044240, -15689887, 1762328, 14866737),
array(-18199695, -15951423, -10473290, 1707278, -17185920, 3916101, -28236412, 3959421, 27914454, 4383652),
),
array(
array(5153746, 9909285, 1723747, -2777874, 30523605, 5516873, 19480852, 5230134, -23952439, -15175766),
array(-30269007, -3463509, 7665486, 10083793, 28475525, 1649722, 20654025, 16520125, 30598449, 7715701),
array(28881845, 14381568, 9657904, 3680757, -20181635, 7843316, -31400660, 1370708, 29794553, -1409300),
),
array(
array(14499471, -2729599, -33191113, -4254652, 28494862, 14271267, 30290735, 10876454, -33154098, 2381726),
array(-7195431, -2655363, -14730155, 462251, -27724326, 3941372, -6236617, 3696005, -32300832, 15351955),
array(27431194, 8222322, 16448760, -3907995, -18707002, 11938355, -32961401, -2970515, 29551813, 10109425),
),
),
array(
array(
array(-13657040, -13155431, -31283750, 11777098, 21447386, 6519384, -2378284, -1627556, 10092783, -4764171),
array(27939166, 14210322, 4677035, 16277044, -22964462, -12398139, -32508754, 12005538, -17810127, 12803510),
array(17228999, -15661624, -1233527, 300140, -1224870, -11714777, 30364213, -9038194, 18016357, 4397660),
),
array(
array(-10958843, -7690207, 4776341, -14954238, 27850028, -15602212, -26619106, 14544525, -17477504, 982639),
array(29253598, 15796703, -2863982, -9908884, 10057023, 3163536, 7332899, -4120128, -21047696, 9934963),
array(5793303, 16271923, -24131614, -10116404, 29188560, 1206517, -14747930, 4559895, -30123922, -10897950),
),
array(
array(-27643952, -11493006, 16282657, -11036493, 28414021, -15012264, 24191034, 4541697, -13338309, 5500568),
array(12650548, -1497113, 9052871, 11355358, -17680037, -8400164, -17430592, 12264343, 10874051, 13524335),
array(25556948, -3045990, 714651, 2510400, 23394682, -10415330, 33119038, 5080568, -22528059, 5376628),
),
array(
array(-26088264, -4011052, -17013699, -3537628, -6726793, 1920897, -22321305, -9447443, 4535768, 1569007),
array(-2255422, 14606630, -21692440, -8039818, 28430649, 8775819, -30494562, 3044290, 31848280, 12543772),
array(-22028579, 2943893, -31857513, 6777306, 13784462, -4292203, -27377195, -2062731, 7718482, 14474653),
),
array(
array(2385315, 2454213, -22631320, 46603, -4437935, -15680415, 656965, -7236665, 24316168, -5253567),
array(13741529, 10911568, -33233417, -8603737, -20177830, -1033297, 33040651, -13424532, -20729456, 8321686),
array(21060490, -2212744, 15712757, -4336099, 1639040, 10656336, 23845965, -11874838, -9984458, 608372),
),
array(
array(-13672732, -15087586, -10889693, -7557059, -6036909, 11305547, 1123968, -6780577, 27229399, 23887),
array(-23244140, -294205, -11744728, 14712571, -29465699, -2029617, 12797024, -6440308, -1633405, 16678954),
array(-29500620, 4770662, -16054387, 14001338, 7830047, 9564805, -1508144, -4795045, -17169265, 4904953),
),
array(
array(24059557, 14617003, 19037157, -15039908, 19766093, -14906429, 5169211, 16191880, 2128236, -4326833),
array(-16981152, 4124966, -8540610, -10653797, 30336522, -14105247, -29806336, 916033, -6882542, -2986532),
array(-22630907, 12419372, -7134229, -7473371, -16478904, 16739175, 285431, 2763829, 15736322, 4143876),
),
array(
array(2379352, 11839345, -4110402, -5988665, 11274298, 794957, 212801, -14594663, 23527084, -16458268),
array(33431127, -11130478, -17838966, -15626900, 8909499, 8376530, -32625340, 4087881, -15188911, -14416214),
array(1767683, 7197987, -13205226, -2022635, -13091350, 448826, 5799055, 4357868, -4774191, -16323038),
),
),
array(
array(
array(6721966, 13833823, -23523388, -1551314, 26354293, -11863321, 23365147, -3949732, 7390890, 2759800),
array(4409041, 2052381, 23373853, 10530217, 7676779, -12885954, 21302353, -4264057, 1244380, -12919645),
array(-4421239, 7169619, 4982368, -2957590, 30256825, -2777540, 14086413, 9208236, 15886429, 16489664),
),
array(
array(1996075, 10375649, 14346367, 13311202, -6874135, -16438411, -13693198, 398369, -30606455, -712933),
array(-25307465, 9795880, -2777414, 14878809, -33531835, 14780363, 13348553, 12076947, -30836462, 5113182),
array(-17770784, 11797796, 31950843, 13929123, -25888302, 12288344, -30341101, -7336386, 13847711, 5387222),
),
array(
array(-18582163, -3416217, 17824843, -2340966, 22744343, -10442611, 8763061, 3617786, -19600662, 10370991),
array(20246567, -14369378, 22358229, -543712, 18507283, -10413996, 14554437, -8746092, 32232924, 16763880),
array(9648505, 10094563, 26416693, 14745928, -30374318, -6472621, 11094161, 15689506, 3140038, -16510092),
),
array(
array(-16160072, 5472695, 31895588, 4744994, 8823515, 10365685, -27224800, 9448613, -28774454, 366295),
array(19153450, 11523972, -11096490, -6503142, -24647631, 5420647, 28344573, 8041113, 719605, 11671788),
array(8678025, 2694440, -6808014, 2517372, 4964326, 11152271, -15432916, -15266516, 27000813, -10195553),
),
array(
array(-15157904, 7134312, 8639287, -2814877, -7235688, 10421742, 564065, 5336097, 6750977, -14521026),
array(11836410, -3979488, 26297894, 16080799, 23455045, 15735944, 1695823, -8819122, 8169720, 16220347),
array(-18115838, 8653647, 17578566, -6092619, -8025777, -16012763, -11144307, -2627664, -5990708, -14166033),
),
array(
array(-23308498, -10968312, 15213228, -10081214, -30853605, -11050004, 27884329, 2847284, 2655861, 1738395),
array(-27537433, -14253021, -25336301, -8002780, -9370762, 8129821, 21651608, -3239336, -19087449, -11005278),
array(1533110, 3437855, 23735889, 459276, 29970501, 11335377, 26030092, 5821408, 10478196, 8544890),
),
array(
array(32173121, -16129311, 24896207, 3921497, 22579056, -3410854, 19270449, 12217473, 17789017, -3395995),
array(-30552961, -2228401, -15578829, -10147201, 13243889, 517024, 15479401, -3853233, 30460520, 1052596),
array(-11614875, 13323618, 32618793, 8175907, -15230173, 12596687, 27491595, -4612359, 3179268, -9478891),
),
array(
array(31947069, -14366651, -4640583, -15339921, -15125977, -6039709, -14756777, -16411740, 19072640, -9511060),
array(11685058, 11822410, 3158003, -13952594, 33402194, -4165066, 5977896, -5215017, 473099, 5040608),
array(-20290863, 8198642, -27410132, 11602123, 1290375, -2799760, 28326862, 1721092, -19558642, -3131606),
),
),
array(
array(
array(7881532, 10687937, 7578723, 7738378, -18951012, -2553952, 21820786, 8076149, -27868496, 11538389),
array(-19935666, 3899861, 18283497, -6801568, -15728660, -11249211, 8754525, 7446702, -5676054, 5797016),
array(-11295600, -3793569, -15782110, -7964573, 12708869, -8456199, 2014099, -9050574, -2369172, -5877341),
),
array(
array(-22472376, -11568741, -27682020, 1146375, 18956691, 16640559, 1192730, -3714199, 15123619, 10811505),
array(14352098, -3419715, -18942044, 10822655, 32750596, 4699007, -70363, 15776356, -28886779, -11974553),
array(-28241164, -8072475, -4978962, -5315317, 29416931, 1847569, -20654173, -16484855, 4714547, -9600655),
),
array(
array(15200332, 8368572, 19679101, 15970074, -31872674, 1959451, 24611599, -4543832, -11745876, 12340220),
array(12876937, -10480056, 33134381, 6590940, -6307776, 14872440, 9613953, 8241152, 15370987, 9608631),
array(-4143277, -12014408, 8446281, -391603, 4407738, 13629032, -7724868, 15866074, -28210621, -8814099),
),
array(
array(26660628, -15677655, 8393734, 358047, -7401291, 992988, -23904233, 858697, 20571223, 8420556),
array(14620715, 13067227, -15447274, 8264467, 14106269, 15080814, 33531827, 12516406, -21574435, -12476749),
array(236881, 10476226, 57258, -14677024, 6472998, 2466984, 17258519, 7256740, 8791136, 15069930),
),
array(
array(1276410, -9371918, 22949635, -16322807, -23493039, -5702186, 14711875, 4874229, -30663140, -2331391),
array(5855666, 4990204, -13711848, 7294284, -7804282, 1924647, -1423175, -7912378, -33069337, 9234253),
array(20590503, -9018988, 31529744, -7352666, -2706834, 10650548, 31559055, -11609587, 18979186, 13396066),
),
array(
array(24474287, 4968103, 22267082, 4407354, 24063882, -8325180, -18816887, 13594782, 33514650, 7021958),
array(-11566906, -6565505, -21365085, 15928892, -26158305, 4315421, -25948728, -3916677, -21480480, 12868082),
array(-28635013, 13504661, 19988037, -2132761, 21078225, 6443208, -21446107, 2244500, -12455797, -8089383),
),
array(
array(-30595528, 13793479, -5852820, 319136, -25723172, -6263899, 33086546, 8957937, -15233648, 5540521),
array(-11630176, -11503902, -8119500, -7643073, 2620056, 1022908, -23710744, -1568984, -16128528, -14962807),
array(23152971, 775386, 27395463, 14006635, -9701118, 4649512, 1689819, 892185, -11513277, -15205948),
),
array(
array(9770129, 9586738, 26496094, 4324120, 1556511, -3550024, 27453819, 4763127, -19179614, 5867134),
array(-32765025, 1927590, 31726409, -4753295, 23962434, -16019500, 27846559, 5931263, -29749703, -16108455),
array(27461885, -2977536, 22380810, 1815854, -23033753, -3031938, 7283490, -15148073, -19526700, 7734629),
),
),
array(
array(
array(-8010264, -9590817, -11120403, 6196038, 29344158, -13430885, 7585295, -3176626, 18549497, 15302069),
array(-32658337, -6171222, -7672793, -11051681, 6258878, 13504381, 10458790, -6418461, -8872242, 8424746),
array(24687205, 8613276, -30667046, -3233545, 1863892, -1830544, 19206234, 7134917, -11284482, -828919),
),
array(
array(11334899, -9218022, 8025293, 12707519, 17523892, -10476071, 10243738, -14685461, -5066034, 16498837),
array(8911542, 6887158, -9584260, -6958590, 11145641, -9543680, 17303925, -14124238, 6536641, 10543906),
array(-28946384, 15479763, -17466835, 568876, -1497683, 11223454, -2669190, -16625574, -27235709, 8876771),
),
array(
array(-25742899, -12566864, -15649966, -846607, -33026686, -796288, -33481822, 15824474, -604426, -9039817),
array(10330056, 70051, 7957388, -9002667, 9764902, 15609756, 27698697, -4890037, 1657394, 3084098),
array(10477963, -7470260, 12119566, -13250805, 29016247, -5365589, 31280319, 14396151, -30233575, 15272409),
),
array(
array(-12288309, 3169463, 28813183, 16658753, 25116432, -5630466, -25173957, -12636138, -25014757, 1950504),
array(-26180358, 9489187, 11053416, -14746161, -31053720, 5825630, -8384306, -8767532, 15341279, 8373727),
array(28685821, 7759505, -14378516, -12002860, -31971820, 4079242, 298136, -10232602, -2878207, 15190420),
),
array(
array(-32932876, 13806336, -14337485, -15794431, -24004620, 10940928, 8669718, 2742393, -26033313, -6875003),
array(-1580388, -11729417, -25979658, -11445023, -17411874, -10912854, 9291594, -16247779, -12154742, 6048605),
array(-30305315, 14843444, 1539301, 11864366, 20201677, 1900163, 13934231, 5128323, 11213262, 9168384),
),
array(
array(-26280513, 11007847, 19408960, -940758, -18592965, -4328580, -5088060, -11105150, 20470157, -16398701),
array(-23136053, 9282192, 14855179, -15390078, -7362815, -14408560, -22783952, 14461608, 14042978, 5230683),
array(29969567, -2741594, -16711867, -8552442, 9175486, -2468974, 21556951, 3506042, -5933891, -12449708),
),
array(
array(-3144746, 8744661, 19704003, 4581278, -20430686, 6830683, -21284170, 8971513, -28539189, 15326563),
array(-19464629, 10110288, -17262528, -3503892, -23500387, 1355669, -15523050, 15300988, -20514118, 9168260),
array(-5353335, 4488613, -23803248, 16314347, 7780487, -15638939, -28948358, 9601605, 33087103, -9011387),
),
array(
array(-19443170, -15512900, -20797467, -12445323, -29824447, 10229461, -27444329, -15000531, -5996870, 15664672),
array(23294591, -16632613, -22650781, -8470978, 27844204, 11461195, 13099750, -2460356, 18151676, 13417686),
array(-24722913, -4176517, -31150679, 5988919, -26858785, 6685065, 1661597, -12551441, 15271676, -15452665),
),
),
array(
array(
array(11433042, -13228665, 8239631, -5279517, -1985436, -725718, -18698764, 2167544, -6921301, -13440182),
array(-31436171, 15575146, 30436815, 12192228, -22463353, 9395379, -9917708, -8638997, 12215110, 12028277),
array(14098400, 6555944, 23007258, 5757252, -15427832, -12950502, 30123440, 4617780, -16900089, -655628),
),
array(
array(-4026201, -15240835, 11893168, 13718664, -14809462, 1847385, -15819999, 10154009, 23973261, -12684474),
array(-26531820, -3695990, -1908898, 2534301, -31870557, -16550355, 18341390, -11419951, 32013174, -10103539),
array(-25479301, 10876443, -11771086, -14625140, -12369567, 1838104, 21911214, 6354752, 4425632, -837822),
),
array(
array(-10433389, -14612966, 22229858, -3091047, -13191166, 776729, -17415375, -12020462, 4725005, 14044970),
array(19268650, -7304421, 1555349, 8692754, -21474059, -9910664, 6347390, -1411784, -19522291, -16109756),
array(-24864089, 12986008, -10898878, -5558584, -11312371, -148526, 19541418, 8180106, 9282262, 10282508),
),
array(
array(-26205082, 4428547, -8661196, -13194263, 4098402, -14165257, 15522535, 8372215, 5542595, -10702683),
array(-10562541, 14895633, 26814552, -16673850, -17480754, -2489360, -2781891, 6993761, -18093885, 10114655),
array(-20107055, -929418, 31422704, 10427861, -7110749, 6150669, -29091755, -11529146, 25953725, -106158),
),
array(
array(-4234397, -8039292, -9119125, 3046000, 2101609, -12607294, 19390020, 6094296, -3315279, 12831125),
array(-15998678, 7578152, 5310217, 14408357, -33548620, -224739, 31575954, 6326196, 7381791, -2421839),
array(-20902779, 3296811, 24736065, -16328389, 18374254, 7318640, 6295303, 8082724, -15362489, 12339664),
),
array(
array(27724736, 2291157, 6088201, -14184798, 1792727, 5857634, 13848414, 15768922, 25091167, 14856294),
array(-18866652, 8331043, 24373479, 8541013, -701998, -9269457, 12927300, -12695493, -22182473, -9012899),
array(-11423429, -5421590, 11632845, 3405020, 30536730, -11674039, -27260765, 13866390, 30146206, 9142070),
),
array(
array(3924129, -15307516, -13817122, -10054960, 12291820, -668366, -27702774, 9326384, -8237858, 4171294),
array(-15921940, 16037937, 6713787, 16606682, -21612135, 2790944, 26396185, 3731949, 345228, -5462949),
array(-21327538, 13448259, 25284571, 1143661, 20614966, -8849387, 2031539, -12391231, -16253183, -13582083),
),
array(
array(31016211, -16722429, 26371392, -14451233, -5027349, 14854137, 17477601, 3842657, 28012650, -16405420),
array(-5075835, 9368966, -8562079, -4600902, -15249953, 6970560, -9189873, 16292057, -8867157, 3507940),
array(29439664, 3537914, 23333589, 6997794, -17555561, -11018068, -15209202, -15051267, -9164929, 6580396),
),
),
array(
array(
array(-12185861, -7679788, 16438269, 10826160, -8696817, -6235611, 17860444, -9273846, -2095802, 9304567),
array(20714564, -4336911, 29088195, 7406487, 11426967, -5095705, 14792667, -14608617, 5289421, -477127),
array(-16665533, -10650790, -6160345, -13305760, 9192020, -1802462, 17271490, 12349094, 26939669, -3752294),
),
array(
array(-12889898, 9373458, 31595848, 16374215, 21471720, 13221525, -27283495, -12348559, -3698806, 117887),
array(22263325, -6560050, 3984570, -11174646, -15114008, -566785, 28311253, 5358056, -23319780, 541964),
array(16259219, 3261970, 2309254, -15534474, -16885711, -4581916, 24134070, -16705829, -13337066, -13552195),
),
array(
array(9378160, -13140186, -22845982, -12745264, 28198281, -7244098, -2399684, -717351, 690426, 14876244),
array(24977353, -314384, -8223969, -13465086, 28432343, -1176353, -13068804, -12297348, -22380984, 6618999),
array(-1538174, 11685646, 12944378, 13682314, -24389511, -14413193, 8044829, -13817328, 32239829, -5652762),
),
array(
array(-18603066, 4762990, -926250, 8885304, -28412480, -3187315, 9781647, -10350059, 32779359, 5095274),
array(-33008130, -5214506, -32264887, -3685216, 9460461, -9327423, -24601656, 14506724, 21639561, -2630236),
array(-16400943, -13112215, 25239338, 15531969, 3987758, -4499318, -1289502, -6863535, 17874574, 558605),
),
array(
array(-13600129, 10240081, 9171883, 16131053, -20869254, 9599700, 33499487, 5080151, 2085892, 5119761),
array(-22205145, -2519528, -16381601, 414691, -25019550, 2170430, 30634760, -8363614, -31999993, -5759884),
array(-6845704, 15791202, 8550074, -1312654, 29928809, -12092256, 27534430, -7192145, -22351378, 12961482),
),
array(
array(-24492060, -9570771, 10368194, 11582341, -23397293, -2245287, 16533930, 8206996, -30194652, -5159638),
array(-11121496, -3382234, 2307366, 6362031, -135455, 8868177, -16835630, 7031275, 7589640, 8945490),
array(-32152748, 8917967, 6661220, -11677616, -1192060, -15793393, 7251489, -11182180, 24099109, -14456170),
),
array(
array(5019558, -7907470, 4244127, -14714356, -26933272, 6453165, -19118182, -13289025, -6231896, -10280736),
array(10853594, 10721687, 26480089, 5861829, -22995819, 1972175, -1866647, -10557898, -3363451, -6441124),
array(-17002408, 5906790, 221599, -6563147, 7828208, -13248918, 24362661, -2008168, -13866408, 7421392),
),
array(
array(8139927, -6546497, 32257646, -5890546, 30375719, 1886181, -21175108, 15441252, 28826358, -4123029),
array(6267086, 9695052, 7709135, -16603597, -32869068, -1886135, 14795160, -7840124, 13746021, -1742048),
array(28584902, 7787108, -6732942, -15050729, 22846041, -7571236, -3181936, -363524, 4771362, -8419958),
),
),
array(
array(
array(24949256, 6376279, -27466481, -8174608, -18646154, -9930606, 33543569, -12141695, 3569627, 11342593),
array(26514989, 4740088, 27912651, 3697550, 19331575, -11472339, 6809886, 4608608, 7325975, -14801071),
array(-11618399, -14554430, -24321212, 7655128, -1369274, 5214312, -27400540, 10258390, -17646694, -8186692),
),
array(
array(11431204, 15823007, 26570245, 14329124, 18029990, 4796082, -31446179, 15580664, 9280358, -3973687),
array(-160783, -10326257, -22855316, -4304997, -20861367, -13621002, -32810901, -11181622, -15545091, 4387441),
array(-20799378, 12194512, 3937617, -5805892, -27154820, 9340370, -24513992, 8548137, 20617071, -7482001),
),
array(
array(-938825, -3930586, -8714311, 16124718, 24603125, -6225393, -13775352, -11875822, 24345683, 10325460),
array(-19855277, -1568885, -22202708, 8714034, 14007766, 6928528, 16318175, -1010689, 4766743, 3552007),
array(-21751364, -16730916, 1351763, -803421, -4009670, 3950935, 3217514, 14481909, 10988822, -3994762),
),
array(
array(15564307, -14311570, 3101243, 5684148, 30446780, -8051356, 12677127, -6505343, -8295852, 13296005),
array(-9442290, 6624296, -30298964, -11913677, -4670981, -2057379, 31521204, 9614054, -30000824, 12074674),
array(4771191, -135239, 14290749, -13089852, 27992298, 14998318, -1413936, -1556716, 29832613, -16391035),
),
array(
array(7064884, -7541174, -19161962, -5067537, -18891269, -2912736, 25825242, 5293297, -27122660, 13101590),
array(-2298563, 2439670, -7466610, 1719965, -27267541, -16328445, 32512469, -5317593, -30356070, -4190957),
array(-30006540, 10162316, -33180176, 3981723, -16482138, -13070044, 14413974, 9515896, 19568978, 9628812),
),
array(
array(33053803, 199357, 15894591, 1583059, 27380243, -4580435, -17838894, -6106839, -6291786, 3437740),
array(-18978877, 3884493, 19469877, 12726490, 15913552, 13614290, -22961733, 70104, 7463304, 4176122),
array(-27124001, 10659917, 11482427, -16070381, 12771467, -6635117, -32719404, -5322751, 24216882, 5944158),
),
array(
array(8894125, 7450974, -2664149, -9765752, -28080517, -12389115, 19345746, 14680796, 11632993, 5847885),
array(26942781, -2315317, 9129564, -4906607, 26024105, 11769399, -11518837, 6367194, -9727230, 4782140),
array(19916461, -4828410, -22910704, -11414391, 25606324, -5972441, 33253853, 8220911, 6358847, -1873857),
),
array(
array(801428, -2081702, 16569428, 11065167, 29875704, 96627, 7908388, -4480480, -13538503, 1387155),
array(19646058, 5720633, -11416706, 12814209, 11607948, 12749789, 14147075, 15156355, -21866831, 11835260),
array(19299512, 1155910, 28703737, 14890794, 2925026, 7269399, 26121523, 15467869, -26560550, 5052483),
),
),
array(
array(
array(-3017432, 10058206, 1980837, 3964243, 22160966, 12322533, -6431123, -12618185, 12228557, -7003677),
array(32944382, 14922211, -22844894, 5188528, 21913450, -8719943, 4001465, 13238564, -6114803, 8653815),
array(22865569, -4652735, 27603668, -12545395, 14348958, 8234005, 24808405, 5719875, 28483275, 2841751),
),
array(
array(-16420968, -1113305, -327719, -12107856, 21886282, -15552774, -1887966, -315658, 19932058, -12739203),
array(-11656086, 10087521, -8864888, -5536143, -19278573, -3055912, 3999228, 13239134, -4777469, -13910208),
array(1382174, -11694719, 17266790, 9194690, -13324356, 9720081, 20403944, 11284705, -14013818, 3093230),
),
array(
array(16650921, -11037932, -1064178, 1570629, -8329746, 7352753, -302424, 16271225, -24049421, -6691850),
array(-21911077, -5927941, -4611316, -5560156, -31744103, -10785293, 24123614, 15193618, -21652117, -16739389),
array(-9935934, -4289447, -25279823, 4372842, 2087473, 10399484, 31870908, 14690798, 17361620, 11864968),
),
array(
array(-11307610, 6210372, 13206574, 5806320, -29017692, -13967200, -12331205, -7486601, -25578460, -16240689),
array(14668462, -12270235, 26039039, 15305210, 25515617, 4542480, 10453892, 6577524, 9145645, -6443880),
array(5974874, 3053895, -9433049, -10385191, -31865124, 3225009, -7972642, 3936128, -5652273, -3050304),
),
array(
array(30625386, -4729400, -25555961, -12792866, -20484575, 7695099, 17097188, -16303496, -27999779, 1803632),
array(-3553091, 9865099, -5228566, 4272701, -5673832, -16689700, 14911344, 12196514, -21405489, 7047412),
array(20093277, 9920966, -11138194, -5343857, 13161587, 12044805, -32856851, 4124601, -32343828, -10257566),
),
array(
array(-20788824, 14084654, -13531713, 7842147, 19119038, -13822605, 4752377, -8714640, -21679658, 2288038),
array(-26819236, -3283715, 29965059, 3039786, -14473765, 2540457, 29457502, 14625692, -24819617, 12570232),
array(-1063558, -11551823, 16920318, 12494842, 1278292, -5869109, -21159943, -3498680, -11974704, 4724943),
),
array(
array(17960970, -11775534, -4140968, -9702530, -8876562, -1410617, -12907383, -8659932, -29576300, 1903856),
array(23134274, -14279132, -10681997, -1611936, 20684485, 15770816, -12989750, 3190296, 26955097, 14109738),
array(15308788, 5320727, -30113809, -14318877, 22902008, 7767164, 29425325, -11277562, 31960942, 11934971),
),
array(
array(-27395711, 8435796, 4109644, 12222639, -24627868, 14818669, 20638173, 4875028, 10491392, 1379718),
array(-13159415, 9197841, 3875503, -8936108, -1383712, -5879801, 33518459, 16176658, 21432314, 12180697),
array(-11787308, 11500838, 13787581, -13832590, -22430679, 10140205, 1465425, 12689540, -10301319, -13872883),
),
),
array(
array(
array(5414091, -15386041, -21007664, 9643570, 12834970, 1186149, -2622916, -1342231, 26128231, 6032912),
array(-26337395, -13766162, 32496025, -13653919, 17847801, -12669156, 3604025, 8316894, -25875034, -10437358),
array(3296484, 6223048, 24680646, -12246460, -23052020, 5903205, -8862297, -4639164, 12376617, 3188849),
),
array(
array(29190488, -14659046, 27549113, -1183516, 3520066, -10697301, 32049515, -7309113, -16109234, -9852307),
array(-14744486, -9309156, 735818, -598978, -20407687, -5057904, 25246078, -15795669, 18640741, -960977),
array(-6928835, -16430795, 10361374, 5642961, 4910474, 12345252, -31638386, -494430, 10530747, 1053335),
),
array(
array(-29265967, -14186805, -13538216, -12117373, -19457059, -10655384, -31462369, -2948985, 24018831, 15026644),
array(-22592535, -3145277, -2289276, 5953843, -13440189, 9425631, 25310643, 13003497, -2314791, -15145616),
array(-27419985, -603321, -8043984, -1669117, -26092265, 13987819, -27297622, 187899, -23166419, -2531735),
),
array(
array(-21744398, -13810475, 1844840, 5021428, -10434399, -15911473, 9716667, 16266922, -5070217, 726099),
array(29370922, -6053998, 7334071, -15342259, 9385287, 2247707, -13661962, -4839461, 30007388, -15823341),
array(-936379, 16086691, 23751945, -543318, -1167538, -5189036, 9137109, 730663, 9835848, 4555336),
),
array(
array(-23376435, 1410446, -22253753, -12899614, 30867635, 15826977, 17693930, 544696, -11985298, 12422646),
array(31117226, -12215734, -13502838, 6561947, -9876867, -12757670, -5118685, -4096706, 29120153, 13924425),
array(-17400879, -14233209, 19675799, -2734756, -11006962, -5858820, -9383939, -11317700, 7240931, -237388),
),
array(
array(-31361739, -11346780, -15007447, -5856218, -22453340, -12152771, 1222336, 4389483, 3293637, -15551743),
array(-16684801, -14444245, 11038544, 11054958, -13801175, -3338533, -24319580, 7733547, 12796905, -6335822),
array(-8759414, -10817836, -25418864, 10783769, -30615557, -9746811, -28253339, 3647836, 3222231, -11160462),
),
array(
array(18606113, 1693100, -25448386, -15170272, 4112353, 10045021, 23603893, -2048234, -7550776, 2484985),
array(9255317, -3131197, -12156162, -1004256, 13098013, -9214866, 16377220, -2102812, -19802075, -3034702),
array(-22729289, 7496160, -5742199, 11329249, 19991973, -3347502, -31718148, 9936966, -30097688, -10618797),
),
array(
array(21878590, -5001297, 4338336, 13643897, -3036865, 13160960, 19708896, 5415497, -7360503, -4109293),
array(27736861, 10103576, 12500508, 8502413, -3413016, -9633558, 10436918, -1550276, -23659143, -8132100),
array(19492550, -12104365, -29681976, -852630, -3208171, 12403437, 30066266, 8367329, 13243957, 8709688),
),
),
array(
array(
array(12015105, 2801261, 28198131, 10151021, 24818120, -4743133, -11194191, -5645734, 5150968, 7274186),
array(2831366, -12492146, 1478975, 6122054, 23825128, -12733586, 31097299, 6083058, 31021603, -9793610),
array(-2529932, -2229646, 445613, 10720828, -13849527, -11505937, -23507731, 16354465, 15067285, -14147707),
),
array(
array(7840942, 14037873, -33364863, 15934016, -728213, -3642706, 21403988, 1057586, -19379462, -12403220),
array(915865, -16469274, 15608285, -8789130, -24357026, 6060030, -17371319, 8410997, -7220461, 16527025),
array(32922597, -556987, 20336074, -16184568, 10903705, -5384487, 16957574, 52992, 23834301, 6588044),
),
array(
array(32752030, 11232950, 3381995, -8714866, 22652988, -10744103, 17159699, 16689107, -20314580, -1305992),
array(-4689649, 9166776, -25710296, -10847306, 11576752, 12733943, 7924251, -2752281, 1976123, -7249027),
array(21251222, 16309901, -2983015, -6783122, 30810597, 12967303, 156041, -3371252, 12331345, -8237197),
),
array(
array(8651614, -4477032, -16085636, -4996994, 13002507, 2950805, 29054427, -5106970, 10008136, -4667901),
array(31486080, 15114593, -14261250, 12951354, 14369431, -7387845, 16347321, -13662089, 8684155, -10532952),
array(19443825, 11385320, 24468943, -9659068, -23919258, 2187569, -26263207, -6086921, 31316348, 14219878),
),
array(
array(-28594490, 1193785, 32245219, 11392485, 31092169, 15722801, 27146014, 6992409, 29126555, 9207390),
array(32382935, 1110093, 18477781, 11028262, -27411763, -7548111, -4980517, 10843782, -7957600, -14435730),
array(2814918, 7836403, 27519878, -7868156, -20894015, -11553689, -21494559, 8550130, 28346258, 1994730),
),
array(
array(-19578299, 8085545, -14000519, -3948622, 2785838, -16231307, -19516951, 7174894, 22628102, 8115180),
array(-30405132, 955511, -11133838, -15078069, -32447087, -13278079, -25651578, 3317160, -9943017, 930272),
array(-15303681, -6833769, 28856490, 1357446, 23421993, 1057177, 24091212, -1388970, -22765376, -10650715),
),
array(
array(-22751231, -5303997, -12907607, -12768866, -15811511, -7797053, -14839018, -16554220, -1867018, 8398970),
array(-31969310, 2106403, -4736360, 1362501, 12813763, 16200670, 22981545, -6291273, 18009408, -15772772),
array(-17220923, -9545221, -27784654, 14166835, 29815394, 7444469, 29551787, -3727419, 19288549, 1325865),
),
array(
array(15100157, -15835752, -23923978, -1005098, -26450192, 15509408, 12376730, -3479146, 33166107, -8042750),
array(20909231, 13023121, -9209752, 16251778, -5778415, -8094914, 12412151, 10018715, 2213263, -13878373),
array(32529814, -11074689, 30361439, -16689753, -9135940, 1513226, 22922121, 6382134, -5766928, 8371348),
),
),
array(
array(
array(9923462, 11271500, 12616794, 3544722, -29998368, -1721626, 12891687, -8193132, -26442943, 10486144),
array(-22597207, -7012665, 8587003, -8257861, 4084309, -12970062, 361726, 2610596, -23921530, -11455195),
array(5408411, -1136691, -4969122, 10561668, 24145918, 14240566, 31319731, -4235541, 19985175, -3436086),
),
array(
array(-13994457, 16616821, 14549246, 3341099, 32155958, 13648976, -17577068, 8849297, 65030, 8370684),
array(-8320926, -12049626, 31204563, 5839400, -20627288, -1057277, -19442942, 6922164, 12743482, -9800518),
array(-2361371, 12678785, 28815050, 4759974, -23893047, 4884717, 23783145, 11038569, 18800704, 255233),
),
array(
array(-5269658, -1773886, 13957886, 7990715, 23132995, 728773, 13393847, 9066957, 19258688, -14753793),
array(-2936654, -10827535, -10432089, 14516793, -3640786, 4372541, -31934921, 2209390, -1524053, 2055794),
array(580882, 16705327, 5468415, -2683018, -30926419, -14696000, -7203346, -8994389, -30021019, 7394435),
),
array(
array(23838809, 1822728, -15738443, 15242727, 8318092, -3733104, -21672180, -3492205, -4821741, 14799921),
array(13345610, 9759151, 3371034, -16137791, 16353039, 8577942, 31129804, 13496856, -9056018, 7402518),
array(2286874, -4435931, -20042458, -2008336, -13696227, 5038122, 11006906, -15760352, 8205061, 1607563),
),
array(
array(14414086, -8002132, 3331830, -3208217, 22249151, -5594188, 18364661, -2906958, 30019587, -9029278),
array(-27688051, 1585953, -10775053, 931069, -29120221, -11002319, -14410829, 12029093, 9944378, 8024),
array(4368715, -3709630, 29874200, -15022983, -20230386, -11410704, -16114594, -999085, -8142388, 5640030),
),
array(
array(10299610, 13746483, 11661824, 16234854, 7630238, 5998374, 9809887, -16694564, 15219798, -14327783),
array(27425505, -5719081, 3055006, 10660664, 23458024, 595578, -15398605, -1173195, -18342183, 9742717),
array(6744077, 2427284, 26042789, 2720740, -847906, 1118974, 32324614, 7406442, 12420155, 1994844),
),
array(
array(14012521, -5024720, -18384453, -9578469, -26485342, -3936439, -13033478, -10909803, 24319929, -6446333),
array(16412690, -4507367, 10772641, 15929391, -17068788, -4658621, 10555945, -10484049, -30102368, -4739048),
array(22397382, -7767684, -9293161, -12792868, 17166287, -9755136, -27333065, 6199366, 21880021, -12250760),
),
array(
array(-4283307, 5368523, -31117018, 8163389, -30323063, 3209128, 16557151, 8890729, 8840445, 4957760),
array(-15447727, 709327, -6919446, -10870178, -29777922, 6522332, -21720181, 12130072, -14796503, 5005757),
array(-2114751, -14308128, 23019042, 15765735, -25269683, 6002752, 10183197, -13239326, -16395286, -2176112),
),
),
array(
array(
array(-19025756, 1632005, 13466291, -7995100, -23640451, 16573537, -32013908, -3057104, 22208662, 2000468),
array(3065073, -1412761, -25598674, -361432, -17683065, -5703415, -8164212, 11248527, -3691214, -7414184),
array(10379208, -6045554, 8877319, 1473647, -29291284, -12507580, 16690915, 2553332, -3132688, 16400289),
),
array(
array(15716668, 1254266, -18472690, 7446274, -8448918, 6344164, -22097271, -7285580, 26894937, 9132066),
array(24158887, 12938817, 11085297, -8177598, -28063478, -4457083, -30576463, 64452, -6817084, -2692882),
array(13488534, 7794716, 22236231, 5989356, 25426474, -12578208, 2350710, -3418511, -4688006, 2364226),
),
array(
array(16335052, 9132434, 25640582, 6678888, 1725628, 8517937, -11807024, -11697457, 15445875, -7798101),
array(29004207, -7867081, 28661402, -640412, -12794003, -7943086, 31863255, -4135540, -278050, -15759279),
array(-6122061, -14866665, -28614905, 14569919, -10857999, -3591829, 10343412, -6976290, -29828287, -10815811),
),
array(
array(27081650, 3463984, 14099042, -4517604, 1616303, -6205604, 29542636, 15372179, 17293797, 960709),
array(20263915, 11434237, -5765435, 11236810, 13505955, -10857102, -16111345, 6493122, -19384511, 7639714),
array(-2830798, -14839232, 25403038, -8215196, -8317012, -16173699, 18006287, -16043750, 29994677, -15808121),
),
array(
array(9769828, 5202651, -24157398, -13631392, -28051003, -11561624, -24613141, -13860782, -31184575, 709464),
array(12286395, 13076066, -21775189, -1176622, -25003198, 4057652, -32018128, -8890874, 16102007, 13205847),
array(13733362, 5599946, 10557076, 3195751, -5557991, 8536970, -25540170, 8525972, 10151379, 10394400),
),
array(
array(4024660, -16137551, 22436262, 12276534, -9099015, -2686099, 19698229, 11743039, -33302334, 8934414),
array(-15879800, -4525240, -8580747, -2934061, 14634845, -698278, -9449077, 3137094, -11536886, 11721158),
array(17555939, -5013938, 8268606, 2331751, -22738815, 9761013, 9319229, 8835153, -9205489, -1280045),
),
array(
array(-461409, -7830014, 20614118, 16688288, -7514766, -4807119, 22300304, 505429, 6108462, -6183415),
array(-5070281, 12367917, -30663534, 3234473, 32617080, -8422642, 29880583, -13483331, -26898490, -7867459),
array(-31975283, 5726539, 26934134, 10237677, -3173717, -605053, 24199304, 3795095, 7592688, -14992079),
),
array(
array(21594432, -14964228, 17466408, -4077222, 32537084, 2739898, 6407723, 12018833, -28256052, 4298412),
array(-20650503, -11961496, -27236275, 570498, 3767144, -1717540, 13891942, -1569194, 13717174, 10805743),
array(-14676630, -15644296, 15287174, 11927123, 24177847, -8175568, -796431, 14860609, -26938930, -5863836),
),
),
array(
array(
array(12962541, 5311799, -10060768, 11658280, 18855286, -7954201, 13286263, -12808704, -4381056, 9882022),
array(18512079, 11319350, -20123124, 15090309, 18818594, 5271736, -22727904, 3666879, -23967430, -3299429),
array(-6789020, -3146043, 16192429, 13241070, 15898607, -14206114, -10084880, -6661110, -2403099, 5276065),
),
array(
array(30169808, -5317648, 26306206, -11750859, 27814964, 7069267, 7152851, 3684982, 1449224, 13082861),
array(10342826, 3098505, 2119311, 193222, 25702612, 12233820, 23697382, 15056736, -21016438, -8202000),
array(-33150110, 3261608, 22745853, 7948688, 19370557, -15177665, -26171976, 6482814, -10300080, -11060101),
),
array(
array(32869458, -5408545, 25609743, 15678670, -10687769, -15471071, 26112421, 2521008, -22664288, 6904815),
array(29506923, 4457497, 3377935, -9796444, -30510046, 12935080, 1561737, 3841096, -29003639, -6657642),
array(10340844, -6630377, -18656632, -2278430, 12621151, -13339055, 30878497, -11824370, -25584551, 5181966),
),
array(
array(25940115, -12658025, 17324188, -10307374, -8671468, 15029094, 24396252, -16450922, -2322852, -12388574),
array(-21765684, 9916823, -1300409, 4079498, -1028346, 11909559, 1782390, 12641087, 20603771, -6561742),
array(-18882287, -11673380, 24849422, 11501709, 13161720, -4768874, 1925523, 11914390, 4662781, 7820689),
),
array(
array(12241050, -425982, 8132691, 9393934, 32846760, -1599620, 29749456, 12172924, 16136752, 15264020),
array(-10349955, -14680563, -8211979, 2330220, -17662549, -14545780, 10658213, 6671822, 19012087, 3772772),
array(3753511, -3421066, 10617074, 2028709, 14841030, -6721664, 28718732, -15762884, 20527771, 12988982),
),
array(
array(-14822485, -5797269, -3707987, 12689773, -898983, -10914866, -24183046, -10564943, 3299665, -12424953),
array(-16777703, -15253301, -9642417, 4978983, 3308785, 8755439, 6943197, 6461331, -25583147, 8991218),
array(-17226263, 1816362, -1673288, -6086439, 31783888, -8175991, -32948145, 7417950, -30242287, 1507265),
),
array(
array(29692663, 6829891, -10498800, 4334896, 20945975, -11906496, -28887608, 8209391, 14606362, -10647073),
array(-3481570, 8707081, 32188102, 5672294, 22096700, 1711240, -33020695, 9761487, 4170404, -2085325),
array(-11587470, 14855945, -4127778, -1531857, -26649089, 15084046, 22186522, 16002000, -14276837, -8400798),
),
array(
array(-4811456, 13761029, -31703877, -2483919, -3312471, 7869047, -7113572, -9620092, 13240845, 10965870),
array(-7742563, -8256762, -14768334, -13656260, -23232383, 12387166, 4498947, 14147411, 29514390, 4302863),
array(-13413405, -12407859, 20757302, -13801832, 14785143, 8976368, -5061276, -2144373, 17846988, -13971927),
),
),
array(
array(
array(-2244452, -754728, -4597030, -1066309, -6247172, 1455299, -21647728, -9214789, -5222701, 12650267),
array(-9906797, -16070310, 21134160, 12198166, -27064575, 708126, 387813, 13770293, -19134326, 10958663),
array(22470984, 12369526, 23446014, -5441109, -21520802, -9698723, -11772496, -11574455, -25083830, 4271862),
),
array(
array(-25169565, -10053642, -19909332, 15361595, -5984358, 2159192, 75375, -4278529, -32526221, 8469673),
array(15854970, 4148314, -8893890, 7259002, 11666551, 13824734, -30531198, 2697372, 24154791, -9460943),
array(15446137, -15806644, 29759747, 14019369, 30811221, -9610191, -31582008, 12840104, 24913809, 9815020),
),
array(
array(-4709286, -5614269, -31841498, -12288893, -14443537, 10799414, -9103676, 13438769, 18735128, 9466238),
array(11933045, 9281483, 5081055, -5183824, -2628162, -4905629, -7727821, -10896103, -22728655, 16199064),
array(14576810, 379472, -26786533, -8317236, -29426508, -10812974, -102766, 1876699, 30801119, 2164795),
),
array(
array(15995086, 3199873, 13672555, 13712240, -19378835, -4647646, -13081610, -15496269, -13492807, 1268052),
array(-10290614, -3659039, -3286592, 10948818, 23037027, 3794475, -3470338, -12600221, -17055369, 3565904),
array(29210088, -9419337, -5919792, -4952785, 10834811, -13327726, -16512102, -10820713, -27162222, -14030531),
),
array(
array(-13161890, 15508588, 16663704, -8156150, -28349942, 9019123, -29183421, -3769423, 2244111, -14001979),
array(-5152875, -3800936, -9306475, -6071583, 16243069, 14684434, -25673088, -16180800, 13491506, 4641841),
array(10813417, 643330, -19188515, -728916, 30292062, -16600078, 27548447, -7721242, 14476989, -12767431),
),
array(
array(10292079, 9984945, 6481436, 8279905, -7251514, 7032743, 27282937, -1644259, -27912810, 12651324),
array(-31185513, -813383, 22271204, 11835308, 10201545, 15351028, 17099662, 3988035, 21721536, -3148940),
array(10202177, -6545839, -31373232, -9574638, -32150642, -8119683, -12906320, 3852694, 13216206, 14842320),
),
array(
array(-15815640, -10601066, -6538952, -7258995, -6984659, -6581778, -31500847, 13765824, -27434397, 9900184),
array(14465505, -13833331, -32133984, -14738873, -27443187, 12990492, 33046193, 15796406, -7051866, -8040114),
array(30924417, -8279620, 6359016, -12816335, 16508377, 9071735, -25488601, 15413635, 9524356, -7018878),
),
array(
array(12274201, -13175547, 32627641, -1785326, 6736625, 13267305, 5237659, -5109483, 15663516, 4035784),
array(-2951309, 8903985, 17349946, 601635, -16432815, -4612556, -13732739, -15889334, -22258478, 4659091),
array(-16916263, -4952973, -30393711, -15158821, 20774812, 15897498, 5736189, 15026997, -2178256, -13455585),
),
),
array(
array(
array(-8858980, -2219056, 28571666, -10155518, -474467, -10105698, -3801496, 278095, 23440562, -290208),
array(10226241, -5928702, 15139956, 120818, -14867693, 5218603, 32937275, 11551483, -16571960, -7442864),
array(17932739, -12437276, -24039557, 10749060, 11316803, 7535897, 22503767, 5561594, -3646624, 3898661),
),
array(
array(7749907, -969567, -16339731, -16464, -25018111, 15122143, -1573531, 7152530, 21831162, 1245233),
array(26958459, -14658026, 4314586, 8346991, -5677764, 11960072, -32589295, -620035, -30402091, -16716212),
array(-12165896, 9166947, 33491384, 13673479, 29787085, 13096535, 6280834, 14587357, -22338025, 13987525),
),
array(
array(-24349909, 7778775, 21116000, 15572597, -4833266, -5357778, -4300898, -5124639, -7469781, -2858068),
array(9681908, -6737123, -31951644, 13591838, -6883821, 386950, 31622781, 6439245, -14581012, 4091397),
array(-8426427, 1470727, -28109679, -1596990, 3978627, -5123623, -19622683, 12092163, 29077877, -14741988),
),
array(
array(5269168, -6859726, -13230211, -8020715, 25932563, 1763552, -5606110, -5505881, -20017847, 2357889),
array(32264008, -15407652, -5387735, -1160093, -2091322, -3946900, 23104804, -12869908, 5727338, 189038),
array(14609123, -8954470, -6000566, -16622781, -14577387, -7743898, -26745169, 10942115, -25888931, -14884697),
),
array(
array(20513500, 5557931, -15604613, 7829531, 26413943, -2019404, -21378968, 7471781, 13913677, -5137875),
array(-25574376, 11967826, 29233242, 12948236, -6754465, 4713227, -8940970, 14059180, 12878652, 8511905),
array(-25656801, 3393631, -2955415, -7075526, -2250709, 9366908, -30223418, 6812974, 5568676, -3127656),
),
array(
array(11630004, 12144454, 2116339, 13606037, 27378885, 15676917, -17408753, -13504373, -14395196, 8070818),
array(27117696, -10007378, -31282771, -5570088, 1127282, 12772488, -29845906, 10483306, -11552749, -1028714),
array(10637467, -5688064, 5674781, 1072708, -26343588, -6982302, -1683975, 9177853, -27493162, 15431203),
),
array(
array(20525145, 10892566, -12742472, 12779443, -29493034, 16150075, -28240519, 14943142, -15056790, -7935931),
array(-30024462, 5626926, -551567, -9981087, 753598, 11981191, 25244767, -3239766, -3356550, 9594024),
array(-23752644, 2636870, -5163910, -10103818, 585134, 7877383, 11345683, -6492290, 13352335, -10977084),
),
array(
array(-1931799, -5407458, 3304649, -12884869, 17015806, -4877091, -29783850, -7752482, -13215537, -319204),
array(20239939, 6607058, 6203985, 3483793, -18386976, -779229, -20723742, 15077870, -22750759, 14523817),
array(27406042, -6041657, 27423596, -4497394, 4996214, 10002360, -28842031, -4545494, -30172742, -4805667),
),
),
array(
array(
array(11374242, 12660715, 17861383, -12540833, 10935568, 1099227, -13886076, -9091740, -27727044, 11358504),
array(-12730809, 10311867, 1510375, 10778093, -2119455, -9145702, 32676003, 11149336, -26123651, 4985768),
array(-19096303, 341147, -6197485, -239033, 15756973, -8796662, -983043, 13794114, -19414307, -15621255),
),
array(
array(6490081, 11940286, 25495923, -7726360, 8668373, -8751316, 3367603, 6970005, -1691065, -9004790),
array(1656497, 13457317, 15370807, 6364910, 13605745, 8362338, -19174622, -5475723, -16796596, -5031438),
array(-22273315, -13524424, -64685, -4334223, -18605636, -10921968, -20571065, -7007978, -99853, -10237333),
),
array(
array(17747465, 10039260, 19368299, -4050591, -20630635, -16041286, 31992683, -15857976, -29260363, -5511971),
array(31932027, -4986141, -19612382, 16366580, 22023614, 88450, 11371999, -3744247, 4882242, -10626905),
array(29796507, 37186, 19818052, 10115756, -11829032, 3352736, 18551198, 3272828, -5190932, -4162409),
),
array(
array(12501286, 4044383, -8612957, -13392385, -32430052, 5136599, -19230378, -3529697, 330070, -3659409),
array(6384877, 2899513, 17807477, 7663917, -2358888, 12363165, 25366522, -8573892, -271295, 12071499),
array(-8365515, -4042521, 25133448, -4517355, -6211027, 2265927, -32769618, 1936675, -5159697, 3829363),
),
array(
array(28425966, -5835433, -577090, -4697198, -14217555, 6870930, 7921550, -6567787, 26333140, 14267664),
array(-11067219, 11871231, 27385719, -10559544, -4585914, -11189312, 10004786, -8709488, -21761224, 8930324),
array(-21197785, -16396035, 25654216, -1725397, 12282012, 11008919, 1541940, 4757911, -26491501, -16408940),
),
array(
array(13537262, -7759490, -20604840, 10961927, -5922820, -13218065, -13156584, 6217254, -15943699, 13814990),
array(-17422573, 15157790, 18705543, 29619, 24409717, -260476, 27361681, 9257833, -1956526, -1776914),
array(-25045300, -10191966, 15366585, 15166509, -13105086, 8423556, -29171540, 12361135, -18685978, 4578290),
),
array(
array(24579768, 3711570, 1342322, -11180126, -27005135, 14124956, -22544529, 14074919, 21964432, 8235257),
array(-6528613, -2411497, 9442966, -5925588, 12025640, -1487420, -2981514, -1669206, 13006806, 2355433),
array(-16304899, -13605259, -6632427, -5142349, 16974359, -10911083, 27202044, 1719366, 1141648, -12796236),
),
array(
array(-12863944, -13219986, -8318266, -11018091, -6810145, -4843894, 13475066, -3133972, 32674895, 13715045),
array(11423335, -5468059, 32344216, 8962751, 24989809, 9241752, -13265253, 16086212, -28740881, -15642093),
array(-1409668, 12530728, -6368726, 10847387, 19531186, -14132160, -11709148, 7791794, -27245943, 4383347),
),
),
array(
array(
array(-28970898, 5271447, -1266009, -9736989, -12455236, 16732599, -4862407, -4906449, 27193557, 6245191),
array(-15193956, 5362278, -1783893, 2695834, 4960227, 12840725, 23061898, 3260492, 22510453, 8577507),
array(-12632451, 11257346, -32692994, 13548177, -721004, 10879011, 31168030, 13952092, -29571492, -3635906),
),
array(
array(3877321, -9572739, 32416692, 5405324, -11004407, -13656635, 3759769, 11935320, 5611860, 8164018),
array(-16275802, 14667797, 15906460, 12155291, -22111149, -9039718, 32003002, -8832289, 5773085, -8422109),
array(-23788118, -8254300, 1950875, 8937633, 18686727, 16459170, -905725, 12376320, 31632953, 190926),
),
array(
array(-24593607, -16138885, -8423991, 13378746, 14162407, 6901328, -8288749, 4508564, -25341555, -3627528),
array(8884438, -5884009, 6023974, 10104341, -6881569, -4941533, 18722941, -14786005, -1672488, 827625),
array(-32720583, -16289296, -32503547, 7101210, 13354605, 2659080, -1800575, -14108036, -24878478, 1541286),
),
array(
array(2901347, -1117687, 3880376, -10059388, -17620940, -3612781, -21802117, -3567481, 20456845, -1885033),
array(27019610, 12299467, -13658288, -1603234, -12861660, -4861471, -19540150, -5016058, 29439641, 15138866),
array(21536104, -6626420, -32447818, -10690208, -22408077, 5175814, -5420040, -16361163, 7779328, 109896),
),
array(
array(30279744, 14648750, -8044871, 6425558, 13639621, -743509, 28698390, 12180118, 23177719, -554075),
array(26572847, 3405927, -31701700, 12890905, -19265668, 5335866, -6493768, 2378492, 4439158, -13279347),
array(-22716706, 3489070, -9225266, -332753, 18875722, -1140095, 14819434, -12731527, -17717757, -5461437),
),
array(
array(-5056483, 16566551, 15953661, 3767752, -10436499, 15627060, -820954, 2177225, 8550082, -15114165),
array(-18473302, 16596775, -381660, 15663611, 22860960, 15585581, -27844109, -3582739, -23260460, -8428588),
array(-32480551, 15707275, -8205912, -5652081, 29464558, 2713815, -22725137, 15860482, -21902570, 1494193),
),
array(
array(-19562091, -14087393, -25583872, -9299552, 13127842, 759709, 21923482, 16529112, 8742704, 12967017),
array(-28464899, 1553205, 32536856, -10473729, -24691605, -406174, -8914625, -2933896, -29903758, 15553883),
array(21877909, 3230008, 9881174, 10539357, -4797115, 2841332, 11543572, 14513274, 19375923, -12647961),
),
array(
array(8832269, -14495485, 13253511, 5137575, 5037871, 4078777, 24880818, -6222716, 2862653, 9455043),
array(29306751, 5123106, 20245049, -14149889, 9592566, 8447059, -2077124, -2990080, 15511449, 4789663),
array(-20679756, 7004547, 8824831, -9434977, -4045704, -3750736, -5754762, 108893, 23513200, 16652362),
),
),
array(
array(
array(-33256173, 4144782, -4476029, -6579123, 10770039, -7155542, -6650416, -12936300, -18319198, 10212860),
array(2756081, 8598110, 7383731, -6859892, 22312759, -1105012, 21179801, 2600940, -9988298, -12506466),
array(-24645692, 13317462, -30449259, -15653928, 21365574, -10869657, 11344424, 864440, -2499677, -16710063),
),
array(
array(-26432803, 6148329, -17184412, -14474154, 18782929, -275997, -22561534, 211300, 2719757, 4940997),
array(-1323882, 3911313, -6948744, 14759765, -30027150, 7851207, 21690126, 8518463, 26699843, 5276295),
array(-13149873, -6429067, 9396249, 365013, 24703301, -10488939, 1321586, 149635, -15452774, 7159369),
),
array(
array(9987780, -3404759, 17507962, 9505530, 9731535, -2165514, 22356009, 8312176, 22477218, -8403385),
array(18155857, -16504990, 19744716, 9006923, 15154154, -10538976, 24256460, -4864995, -22548173, 9334109),
array(2986088, -4911893, 10776628, -3473844, 10620590, -7083203, -21413845, 14253545, -22587149, 536906),
),
array(
array(4377756, 8115836, 24567078, 15495314, 11625074, 13064599, 7390551, 10589625, 10838060, -15420424),
array(-19342404, 867880, 9277171, -3218459, -14431572, -1986443, 19295826, -15796950, 6378260, 699185),
array(7895026, 4057113, -7081772, -13077756, -17886831, -323126, -716039, 15693155, -5045064, -13373962),
),
array(
array(-7737563, -5869402, -14566319, -7406919, 11385654, 13201616, 31730678, -10962840, -3918636, -9669325),
array(10188286, -15770834, -7336361, 13427543, 22223443, 14896287, 30743455, 7116568, -21786507, 5427593),
array(696102, 13206899, 27047647, -10632082, 15285305, -9853179, 10798490, -4578720, 19236243, 12477404),
),
array(
array(-11229439, 11243796, -17054270, -8040865, -788228, -8167967, -3897669, 11180504, -23169516, 7733644),
array(17800790, -14036179, -27000429, -11766671, 23887827, 3149671, 23466177, -10538171, 10322027, 15313801),
array(26246234, 11968874, 32263343, -5468728, 6830755, -13323031, -15794704, -101982, -24449242, 10890804),
),
array(
array(-31365647, 10271363, -12660625, -6267268, 16690207, -13062544, -14982212, 16484931, 25180797, -5334884),
array(-586574, 10376444, -32586414, -11286356, 19801893, 10997610, 2276632, 9482883, 316878, 13820577),
array(-9882808, -4510367, -2115506, 16457136, -11100081, 11674996, 30756178, -7515054, 30696930, -3712849),
),
array(
array(32988917, -9603412, 12499366, 7910787, -10617257, -11931514, -7342816, -9985397, -32349517, 7392473),
array(-8855661, 15927861, 9866406, -3649411, -2396914, -16655781, -30409476, -9134995, 25112947, -2926644),
array(-2504044, -436966, 25621774, -5678772, 15085042, -5479877, -24884878, -13526194, 5537438, -13914319),
),
),
array(
array(
array(-11225584, 2320285, -9584280, 10149187, -33444663, 5808648, -14876251, -1729667, 31234590, 6090599),
array(-9633316, 116426, 26083934, 2897444, -6364437, -2688086, 609721, 15878753, -6970405, -9034768),
array(-27757857, 247744, -15194774, -9002551, 23288161, -10011936, -23869595, 6503646, 20650474, 1804084),
),
array(
array(-27589786, 15456424, 8972517, 8469608, 15640622, 4439847, 3121995, -10329713, 27842616, -202328),
array(-15306973, 2839644, 22530074, 10026331, 4602058, 5048462, 28248656, 5031932, -11375082, 12714369),
array(20807691, -7270825, 29286141, 11421711, -27876523, -13868230, -21227475, 1035546, -19733229, 12796920),
),
array(
array(12076899, -14301286, -8785001, -11848922, -25012791, 16400684, -17591495, -12899438, 3480665, -15182815),
array(-32361549, 5457597, 28548107, 7833186, 7303070, -11953545, -24363064, -15921875, -33374054, 2771025),
array(-21389266, 421932, 26597266, 6860826, 22486084, -6737172, -17137485, -4210226, -24552282, 15673397),
),
array(
array(-20184622, 2338216, 19788685, -9620956, -4001265, -8740893, -20271184, 4733254, 3727144, -12934448),
array(6120119, 814863, -11794402, -622716, 6812205, -15747771, 2019594, 7975683, 31123697, -10958981),
array(30069250, -11435332, 30434654, 2958439, 18399564, -976289, 12296869, 9204260, -16432438, 9648165),
),
array(
array(32705432, -1550977, 30705658, 7451065, -11805606, 9631813, 3305266, 5248604, -26008332, -11377501),
array(17219865, 2375039, -31570947, -5575615, -19459679, 9219903, 294711, 15298639, 2662509, -16297073),
array(-1172927, -7558695, -4366770, -4287744, -21346413, -8434326, 32087529, -1222777, 32247248, -14389861),
),
array(
array(14312628, 1221556, 17395390, -8700143, -4945741, -8684635, -28197744, -9637817, -16027623, -13378845),
array(-1428825, -9678990, -9235681, 6549687, -7383069, -468664, 23046502, 9803137, 17597934, 2346211),
array(18510800, 15337574, 26171504, 981392, -22241552, 7827556, -23491134, -11323352, 3059833, -11782870),
),
array(
array(10141598, 6082907, 17829293, -1947643, 9830092, 13613136, -25556636, -5544586, -33502212, 3592096),
array(33114168, -15889352, -26525686, -13343397, 33076705, 8716171, 1151462, 1521897, -982665, -6837803),
array(-32939165, -4255815, 23947181, -324178, -33072974, -12305637, -16637686, 3891704, 26353178, 693168),
),
array(
array(30374239, 1595580, -16884039, 13186931, 4600344, 406904, 9585294, -400668, 31375464, 14369965),
array(-14370654, -7772529, 1510301, 6434173, -18784789, -6262728, 32732230, -13108839, 17901441, 16011505),
array(18171223, -11934626, -12500402, 15197122, -11038147, -15230035, -19172240, -16046376, 8764035, 12309598),
),
),
array(
array(
array(5975908, -5243188, -19459362, -9681747, -11541277, 14015782, -23665757, 1228319, 17544096, -10593782),
array(5811932, -1715293, 3442887, -2269310, -18367348, -8359541, -18044043, -15410127, -5565381, 12348900),
array(-31399660, 11407555, 25755363, 6891399, -3256938, 14872274, -24849353, 8141295, -10632534, -585479),
),
array(
array(-12675304, 694026, -5076145, 13300344, 14015258, -14451394, -9698672, -11329050, 30944593, 1130208),
array(8247766, -6710942, -26562381, -7709309, -14401939, -14648910, 4652152, 2488540, 23550156, -271232),
array(17294316, -3788438, 7026748, 15626851, 22990044, 113481, 2267737, -5908146, -408818, -137719),
),
array(
array(16091085, -16253926, 18599252, 7340678, 2137637, -1221657, -3364161, 14550936, 3260525, -7166271),
array(-4910104, -13332887, 18550887, 10864893, -16459325, -7291596, -23028869, -13204905, -12748722, 2701326),
array(-8574695, 16099415, 4629974, -16340524, -20786213, -6005432, -10018363, 9276971, 11329923, 1862132),
),
array(
array(14763076, -15903608, -30918270, 3689867, 3511892, 10313526, -21951088, 12219231, -9037963, -940300),
array(8894987, -3446094, 6150753, 3013931, 301220, 15693451, -31981216, -2909717, -15438168, 11595570),
array(15214962, 3537601, -26238722, -14058872, 4418657, -15230761, 13947276, 10730794, -13489462, -4363670),
),
array(
array(-2538306, 7682793, 32759013, 263109, -29984731, -7955452, -22332124, -10188635, 977108, 699994),
array(-12466472, 4195084, -9211532, 550904, -15565337, 12917920, 19118110, -439841, -30534533, -14337913),
array(31788461, -14507657, 4799989, 7372237, 8808585, -14747943, 9408237, -10051775, 12493932, -5409317),
),
array(
array(-25680606, 5260744, -19235809, -6284470, -3695942, 16566087, 27218280, 2607121, 29375955, 6024730),
array(842132, -2794693, -4763381, -8722815, 26332018, -12405641, 11831880, 6985184, -9940361, 2854096),
array(-4847262, -7969331, 2516242, -5847713, 9695691, -7221186, 16512645, 960770, 12121869, 16648078),
),
array(
array(-15218652, 14667096, -13336229, 2013717, 30598287, -464137, -31504922, -7882064, 20237806, 2838411),
array(-19288047, 4453152, 15298546, -16178388, 22115043, -15972604, 12544294, -13470457, 1068881, -12499905),
array(-9558883, -16518835, 33238498, 13506958, 30505848, -1114596, -8486907, -2630053, 12521378, 4845654),
),
array(
array(-28198521, 10744108, -2958380, 10199664, 7759311, -13088600, 3409348, -873400, -6482306, -12885870),
array(-23561822, 6230156, -20382013, 10655314, -24040585, -11621172, 10477734, -1240216, -3113227, 13974498),
array(12966261, 15550616, -32038948, -1615346, 21025980, -629444, 5642325, 7188737, 18895762, 12629579),
),
),
array(
array(
array(14741879, -14946887, 22177208, -11721237, 1279741, 8058600, 11758140, 789443, 32195181, 3895677),
array(10758205, 15755439, -4509950, 9243698, -4879422, 6879879, -2204575, -3566119, -8982069, 4429647),
array(-2453894, 15725973, -20436342, -10410672, -5803908, -11040220, -7135870, -11642895, 18047436, -15281743),
),
array(
array(-25173001, -11307165, 29759956, 11776784, -22262383, -15820455, 10993114, -12850837, -17620701, -9408468),
array(21987233, 700364, -24505048, 14972008, -7774265, -5718395, 32155026, 2581431, -29958985, 8773375),
array(-25568350, 454463, -13211935, 16126715, 25240068, 8594567, 20656846, 12017935, -7874389, -13920155),
),
array(
array(6028182, 6263078, -31011806, -11301710, -818919, 2461772, -31841174, -5468042, -1721788, -2776725),
array(-12278994, 16624277, 987579, -5922598, 32908203, 1248608, 7719845, -4166698, 28408820, 6816612),
array(-10358094, -8237829, 19549651, -12169222, 22082623, 16147817, 20613181, 13982702, -10339570, 5067943),
),
array(
array(-30505967, -3821767, 12074681, 13582412, -19877972, 2443951, -19719286, 12746132, 5331210, -10105944),
array(30528811, 3601899, -1957090, 4619785, -27361822, -15436388, 24180793, -12570394, 27679908, -1648928),
array(9402404, -13957065, 32834043, 10838634, -26580150, -13237195, 26653274, -8685565, 22611444, -12715406),
),
array(
array(22190590, 1118029, 22736441, 15130463, -30460692, -5991321, 19189625, -4648942, 4854859, 6622139),
array(-8310738, -2953450, -8262579, -3388049, -10401731, -271929, 13424426, -3567227, 26404409, 13001963),
array(-31241838, -15415700, -2994250, 8939346, 11562230, -12840670, -26064365, -11621720, -15405155, 11020693),
),
array(
array(1866042, -7949489, -7898649, -10301010, 12483315, 13477547, 3175636, -12424163, 28761762, 1406734),
array(-448555, -1777666, 13018551, 3194501, -9580420, -11161737, 24760585, -4347088, 25577411, -13378680),
array(-24290378, 4759345, -690653, -1852816, 2066747, 10693769, -29595790, 9884936, -9368926, 4745410),
),
array(
array(-9141284, 6049714, -19531061, -4341411, -31260798, 9944276, -15462008, -11311852, 10931924, -11931931),
array(-16561513, 14112680, -8012645, 4817318, -8040464, -11414606, -22853429, 10856641, -20470770, 13434654),
array(22759489, -10073434, -16766264, -1871422, 13637442, -10168091, 1765144, -12654326, 28445307, -5364710),
),
array(
array(29875063, 12493613, 2795536, -3786330, 1710620, 15181182, -10195717, -8788675, 9074234, 1167180),
array(-26205683, 11014233, -9842651, -2635485, -26908120, 7532294, -18716888, -9535498, 3843903, 9367684),
array(-10969595, -6403711, 9591134, 9582310, 11349256, 108879, 16235123, 8601684, -139197, 4242895),
),
),
array(
array(
array(22092954, -13191123, -2042793, -11968512, 32186753, -11517388, -6574341, 2470660, -27417366, 16625501),
array(-11057722, 3042016, 13770083, -9257922, 584236, -544855, -7770857, 2602725, -27351616, 14247413),
array(6314175, -10264892, -32772502, 15957557, -10157730, 168750, -8618807, 14290061, 27108877, -1180880),
),
array(
array(-8586597, -7170966, 13241782, 10960156, -32991015, -13794596, 33547976, -11058889, -27148451, 981874),
array(22833440, 9293594, -32649448, -13618667, -9136966, 14756819, -22928859, -13970780, -10479804, -16197962),
array(-7768587, 3326786, -28111797, 10783824, 19178761, 14905060, 22680049, 13906969, -15933690, 3797899),
),
array(
array(21721356, -4212746, -12206123, 9310182, -3882239, -13653110, 23740224, -2709232, 20491983, -8042152),
array(9209270, -15135055, -13256557, -6167798, -731016, 15289673, 25947805, 15286587, 30997318, -6703063),
array(7392032, 16618386, 23946583, -8039892, -13265164, -1533858, -14197445, -2321576, 17649998, -250080),
),
array(
array(-9301088, -14193827, 30609526, -3049543, -25175069, -1283752, -15241566, -9525724, -2233253, 7662146),
array(-17558673, 1763594, -33114336, 15908610, -30040870, -12174295, 7335080, -8472199, -3174674, 3440183),
array(-19889700, -5977008, -24111293, -9688870, 10799743, -16571957, 40450, -4431835, 4862400, 1133),
),
array(
array(-32856209, -7873957, -5422389, 14860950, -16319031, 7956142, 7258061, 311861, -30594991, -7379421),
array(-3773428, -1565936, 28985340, 7499440, 24445838, 9325937, 29727763, 16527196, 18278453, 15405622),
array(-4381906, 8508652, -19898366, -3674424, -5984453, 15149970, -13313598, 843523, -21875062, 13626197),
),
array(
array(2281448, -13487055, -10915418, -2609910, 1879358, 16164207, -10783882, 3953792, 13340839, 15928663),
array(31727126, -7179855, -18437503, -8283652, 2875793, -16390330, -25269894, -7014826, -23452306, 5964753),
array(4100420, -5959452, -17179337, 6017714, -18705837, 12227141, -26684835, 11344144, 2538215, -7570755),
),
array(
array(-9433605, 6123113, 11159803, -2156608, 30016280, 14966241, -20474983, 1485421, -629256, -15958862),
array(-26804558, 4260919, 11851389, 9658551, -32017107, 16367492, -20205425, -13191288, 11659922, -11115118),
array(26180396, 10015009, -30844224, -8581293, 5418197, 9480663, 2231568, -10170080, 33100372, -1306171),
),
array(
array(15121113, -5201871, -10389905, 15427821, -27509937, -15992507, 21670947, 4486675, -5931810, -14466380),
array(16166486, -9483733, -11104130, 6023908, -31926798, -1364923, 2340060, -16254968, -10735770, -10039824),
array(28042865, -3557089, -12126526, 12259706, -3717498, -6945899, 6766453, -8689599, 18036436, 5803270),
),
),
array(
array(
array(-817581, 6763912, 11803561, 1585585, 10958447, -2671165, 23855391, 4598332, -6159431, -14117438),
array(-31031306, -14256194, 17332029, -2383520, 31312682, -5967183, 696309, 50292, -20095739, 11763584),
array(-594563, -2514283, -32234153, 12643980, 12650761, 14811489, 665117, -12613632, -19773211, -10713562),
),
array(
array(30464590, -11262872, -4127476, -12734478, 19835327, -7105613, -24396175, 2075773, -17020157, 992471),
array(18357185, -6994433, 7766382, 16342475, -29324918, 411174, 14578841, 8080033, -11574335, -10601610),
array(19598397, 10334610, 12555054, 2555664, 18821899, -10339780, 21873263, 16014234, 26224780, 16452269),
),
array(
array(-30223925, 5145196, 5944548, 16385966, 3976735, 2009897, -11377804, -7618186, -20533829, 3698650),
array(14187449, 3448569, -10636236, -10810935, -22663880, -3433596, 7268410, -10890444, 27394301, 12015369),
array(19695761, 16087646, 28032085, 12999827, 6817792, 11427614, 20244189, -1312777, -13259127, -3402461),
),
array(
array(30860103, 12735208, -1888245, -4699734, -16974906, 2256940, -8166013, 12298312, -8550524, -10393462),
array(-5719826, -11245325, -1910649, 15569035, 26642876, -7587760, -5789354, -15118654, -4976164, 12651793),
array(-2848395, 9953421, 11531313, -5282879, 26895123, -12697089, -13118820, -16517902, 9768698, -2533218),
),
array(
array(-24719459, 1894651, -287698, -4704085, 15348719, -8156530, 32767513, 12765450, 4940095, 10678226),
array(18860224, 15980149, -18987240, -1562570, -26233012, -11071856, -7843882, 13944024, -24372348, 16582019),
array(-15504260, 4970268, -29893044, 4175593, -20993212, -2199756, -11704054, 15444560, -11003761, 7989037),
),
array(
array(31490452, 5568061, -2412803, 2182383, -32336847, 4531686, -32078269, 6200206, -19686113, -14800171),
array(-17308668, -15879940, -31522777, -2831, -32887382, 16375549, 8680158, -16371713, 28550068, -6857132),
array(-28126887, -5688091, 16837845, -1820458, -6850681, 12700016, -30039981, 4364038, 1155602, 5988841),
),
array(
array(21890435, -13272907, -12624011, 12154349, -7831873, 15300496, 23148983, -4470481, 24618407, 8283181),
array(-33136107, -10512751, 9975416, 6841041, -31559793, 16356536, 3070187, -7025928, 1466169, 10740210),
array(-1509399, -15488185, -13503385, -10655916, 32799044, 909394, -13938903, -5779719, -32164649, -15327040),
),
array(
array(3960823, -14267803, -28026090, -15918051, -19404858, 13146868, 15567327, 951507, -3260321, -573935),
array(24740841, 5052253, -30094131, 8961361, 25877428, 6165135, -24368180, 14397372, -7380369, -6144105),
array(-28888365, 3510803, -28103278, -1158478, -11238128, -10631454, -15441463, -14453128, -1625486, -6494814),
),
),
array(
array(
array(793299, -9230478, 8836302, -6235707, -27360908, -2369593, 33152843, -4885251, -9906200, -621852),
array(5666233, 525582, 20782575, -8038419, -24538499, 14657740, 16099374, 1468826, -6171428, -15186581),
array(-4859255, -3779343, -2917758, -6748019, 7778750, 11688288, -30404353, -9871238, -1558923, -9863646),
),
array(
array(10896332, -7719704, 824275, 472601, -19460308, 3009587, 25248958, 14783338, -30581476, -15757844),
array(10566929, 12612572, -31944212, 11118703, -12633376, 12362879, 21752402, 8822496, 24003793, 14264025),
array(27713862, -7355973, -11008240, 9227530, 27050101, 2504721, 23886875, -13117525, 13958495, -5732453),
),
array(
array(-23481610, 4867226, -27247128, 3900521, 29838369, -8212291, -31889399, -10041781, 7340521, -15410068),
array(4646514, -8011124, -22766023, -11532654, 23184553, 8566613, 31366726, -1381061, -15066784, -10375192),
array(-17270517, 12723032, -16993061, 14878794, 21619651, -6197576, 27584817, 3093888, -8843694, 3849921),
),
array(
array(-9064912, 2103172, 25561640, -15125738, -5239824, 9582958, 32477045, -9017955, 5002294, -15550259),
array(-12057553, -11177906, 21115585, -13365155, 8808712, -12030708, 16489530, 13378448, -25845716, 12741426),
array(-5946367, 10645103, -30911586, 15390284, -3286982, -7118677, 24306472, 15852464, 28834118, -7646072),
),
array(
array(-17335748, -9107057, -24531279, 9434953, -8472084, -583362, -13090771, 455841, 20461858, 5491305),
array(13669248, -16095482, -12481974, -10203039, -14569770, -11893198, -24995986, 11293807, -28588204, -9421832),
array(28497928, 6272777, -33022994, 14470570, 8906179, -1225630, 18504674, -14165166, 29867745, -8795943),
),
array(
array(-16207023, 13517196, -27799630, -13697798, 24009064, -6373891, -6367600, -13175392, 22853429, -4012011),
array(24191378, 16712145, -13931797, 15217831, 14542237, 1646131, 18603514, -11037887, 12876623, -2112447),
array(17902668, 4518229, -411702, -2829247, 26878217, 5258055, -12860753, 608397, 16031844, 3723494),
),
array(
array(-28632773, 12763728, -20446446, 7577504, 33001348, -13017745, 17558842, -7872890, 23896954, -4314245),
array(-20005381, -12011952, 31520464, 605201, 2543521, 5991821, -2945064, 7229064, -9919646, -8826859),
array(28816045, 298879, -28165016, -15920938, 19000928, -1665890, -12680833, -2949325, -18051778, -2082915),
),
array(
array(16000882, -344896, 3493092, -11447198, -29504595, -13159789, 12577740, 16041268, -19715240, 7847707),
array(10151868, 10572098, 27312476, 7922682, 14825339, 4723128, -32855931, -6519018, -10020567, 3852848),
array(-11430470, 15697596, -21121557, -4420647, 5386314, 15063598, 16514493, -15932110, 29330899, -15076224),
),
),
array(
array(
array(-25499735, -4378794, -15222908, -6901211, 16615731, 2051784, 3303702, 15490, -27548796, 12314391),
array(15683520, -6003043, 18109120, -9980648, 15337968, -5997823, -16717435, 15921866, 16103996, -3731215),
array(-23169824, -10781249, 13588192, -1628807, -3798557, -1074929, -19273607, 5402699, -29815713, -9841101),
),
array(
array(23190676, 2384583, -32714340, 3462154, -29903655, -1529132, -11266856, 8911517, -25205859, 2739713),
array(21374101, -3554250, -33524649, 9874411, 15377179, 11831242, -33529904, 6134907, 4931255, 11987849),
array(-7732, -2978858, -16223486, 7277597, 105524, -322051, -31480539, 13861388, -30076310, 10117930),
),
array(
array(-29501170, -10744872, -26163768, 13051539, -25625564, 5089643, -6325503, 6704079, 12890019, 15728940),
array(-21972360, -11771379, -951059, -4418840, 14704840, 2695116, 903376, -10428139, 12885167, 8311031),
array(-17516482, 5352194, 10384213, -13811658, 7506451, 13453191, 26423267, 4384730, 1888765, -5435404),
),
array(
array(-25817338, -3107312, -13494599, -3182506, 30896459, -13921729, -32251644, -12707869, -19464434, -3340243),
array(-23607977, -2665774, -526091, 4651136, 5765089, 4618330, 6092245, 14845197, 17151279, -9854116),
array(-24830458, -12733720, -15165978, 10367250, -29530908, -265356, 22825805, -7087279, -16866484, 16176525),
),
array(
array(-23583256, 6564961, 20063689, 3798228, -4740178, 7359225, 2006182, -10363426, -28746253, -10197509),
array(-10626600, -4486402, -13320562, -5125317, 3432136, -6393229, 23632037, -1940610, 32808310, 1099883),
array(15030977, 5768825, -27451236, -2887299, -6427378, -15361371, -15277896, -6809350, 2051441, -15225865),
),
array(
array(-3362323, -7239372, 7517890, 9824992, 23555850, 295369, 5148398, -14154188, -22686354, 16633660),
array(4577086, -16752288, 13249841, -15304328, 19958763, -14537274, 18559670, -10759549, 8402478, -9864273),
array(-28406330, -1051581, -26790155, -907698, -17212414, -11030789, 9453451, -14980072, 17983010, 9967138),
),
array(
array(-25762494, 6524722, 26585488, 9969270, 24709298, 1220360, -1677990, 7806337, 17507396, 3651560),
array(-10420457, -4118111, 14584639, 15971087, -15768321, 8861010, 26556809, -5574557, -18553322, -11357135),
array(2839101, 14284142, 4029895, 3472686, 14402957, 12689363, -26642121, 8459447, -5605463, -7621941),
),
array(
array(-4839289, -3535444, 9744961, 2871048, 25113978, 3187018, -25110813, -849066, 17258084, -7977739),
array(18164541, -10595176, -17154882, -1542417, 19237078, -9745295, 23357533, -15217008, 26908270, 12150756),
array(-30264870, -7647865, 5112249, -7036672, -1499807, -6974257, 43168, -5537701, -32302074, 16215819),
),
),
array(
array(
array(-6898905, 9824394, -12304779, -4401089, -31397141, -6276835, 32574489, 12532905, -7503072, -8675347),
array(-27343522, -16515468, -27151524, -10722951, 946346, 16291093, 254968, 7168080, 21676107, -1943028),
array(21260961, -8424752, -16831886, -11920822, -23677961, 3968121, -3651949, -6215466, -3556191, -7913075),
),
array(
array(16544754, 13250366, -16804428, 15546242, -4583003, 12757258, -2462308, -8680336, -18907032, -9662799),
array(-2415239, -15577728, 18312303, 4964443, -15272530, -12653564, 26820651, 16690659, 25459437, -4564609),
array(-25144690, 11425020, 28423002, -11020557, -6144921, -15826224, 9142795, -2391602, -6432418, -1644817),
),
array(
array(-23104652, 6253476, 16964147, -3768872, -25113972, -12296437, -27457225, -16344658, 6335692, 7249989),
array(-30333227, 13979675, 7503222, -12368314, -11956721, -4621693, -30272269, 2682242, 25993170, -12478523),
array(4364628, 5930691, 32304656, -10044554, -8054781, 15091131, 22857016, -10598955, 31820368, 15075278),
),
array(
array(31879134, -8918693, 17258761, 90626, -8041836, -4917709, 24162788, -9650886, -17970238, 12833045),
array(19073683, 14851414, -24403169, -11860168, 7625278, 11091125, -19619190, 2074449, -9413939, 14905377),
array(24483667, -11935567, -2518866, -11547418, -1553130, 15355506, -25282080, 9253129, 27628530, -7555480),
),
array(
array(17597607, 8340603, 19355617, 552187, 26198470, -3176583, 4593324, -9157582, -14110875, 15297016),
array(510886, 14337390, -31785257, 16638632, 6328095, 2713355, -20217417, -11864220, 8683221, 2921426),
array(18606791, 11874196, 27155355, -5281482, -24031742, 6265446, -25178240, -1278924, 4674690, 13890525),
),
array(
array(13609624, 13069022, -27372361, -13055908, 24360586, 9592974, 14977157, 9835105, 4389687, 288396),
array(9922506, -519394, 13613107, 5883594, -18758345, -434263, -12304062, 8317628, 23388070, 16052080),
array(12720016, 11937594, -31970060, -5028689, 26900120, 8561328, -20155687, -11632979, -14754271, -10812892),
),
array(
array(15961858, 14150409, 26716931, -665832, -22794328, 13603569, 11829573, 7467844, -28822128, 929275),
array(11038231, -11582396, -27310482, -7316562, -10498527, -16307831, -23479533, -9371869, -21393143, 2465074),
array(20017163, -4323226, 27915242, 1529148, 12396362, 15675764, 13817261, -9658066, 2463391, -4622140),
),
array(
array(-16358878, -12663911, -12065183, 4996454, -1256422, 1073572, 9583558, 12851107, 4003896, 12673717),
array(-1731589, -15155870, -3262930, 16143082, 19294135, 13385325, 14741514, -9103726, 7903886, 2348101),
array(24536016, -16515207, 12715592, -3862155, 1511293, 10047386, -3842346, -7129159, -28377538, 10048127),
),
),
array(
array(
array(-12622226, -6204820, 30718825, 2591312, -10617028, 12192840, 18873298, -7297090, -32297756, 15221632),
array(-26478122, -11103864, 11546244, -1852483, 9180880, 7656409, -21343950, 2095755, 29769758, 6593415),
array(-31994208, -2907461, 4176912, 3264766, 12538965, -868111, 26312345, -6118678, 30958054, 8292160),
),
array(
array(31429822, -13959116, 29173532, 15632448, 12174511, -2760094, 32808831, 3977186, 26143136, -3148876),
array(22648901, 1402143, -22799984, 13746059, 7936347, 365344, -8668633, -1674433, -3758243, -2304625),
array(-15491917, 8012313, -2514730, -12702462, -23965846, -10254029, -1612713, -1535569, -16664475, 8194478),
),
array(
array(27338066, -7507420, -7414224, 10140405, -19026427, -6589889, 27277191, 8855376, 28572286, 3005164),
array(26287124, 4821776, 25476601, -4145903, -3764513, -15788984, -18008582, 1182479, -26094821, -13079595),
array(-7171154, 3178080, 23970071, 6201893, -17195577, -4489192, -21876275, -13982627, 32208683, -1198248),
),
array(
array(-16657702, 2817643, -10286362, 14811298, 6024667, 13349505, -27315504, -10497842, -27672585, -11539858),
array(15941029, -9405932, -21367050, 8062055, 31876073, -238629, -15278393, -1444429, 15397331, -4130193),
array(8934485, -13485467, -23286397, -13423241, -32446090, 14047986, 31170398, -1441021, -27505566, 15087184),
),
array(
array(-18357243, -2156491, 24524913, -16677868, 15520427, -6360776, -15502406, 11461896, 16788528, -5868942),
array(-1947386, 16013773, 21750665, 3714552, -17401782, -16055433, -3770287, -10323320, 31322514, -11615635),
array(21426655, -5650218, -13648287, -5347537, -28812189, -4920970, -18275391, -14621414, 13040862, -12112948),
),
array(
array(11293895, 12478086, -27136401, 15083750, -29307421, 14748872, 14555558, -13417103, 1613711, 4896935),
array(-25894883, 15323294, -8489791, -8057900, 25967126, -13425460, 2825960, -4897045, -23971776, -11267415),
array(-15924766, -5229880, -17443532, 6410664, 3622847, 10243618, 20615400, 12405433, -23753030, -8436416),
),
array(
array(-7091295, 12556208, -20191352, 9025187, -17072479, 4333801, 4378436, 2432030, 23097949, -566018),
array(4565804, -16025654, 20084412, -7842817, 1724999, 189254, 24767264, 10103221, -18512313, 2424778),
array(366633, -11976806, 8173090, -6890119, 30788634, 5745705, -7168678, 1344109, -3642553, 12412659),
),
array(
array(-24001791, 7690286, 14929416, -168257, -32210835, -13412986, 24162697, -15326504, -3141501, 11179385),
array(18289522, -14724954, 8056945, 16430056, -21729724, 7842514, -6001441, -1486897, -18684645, -11443503),
array(476239, 6601091, -6152790, -9723375, 17503545, -4863900, 27672959, 13403813, 11052904, 5219329),
),
),
array(
array(
array(20678546, -8375738, -32671898, 8849123, -5009758, 14574752, 31186971, -3973730, 9014762, -8579056),
array(-13644050, -10350239, -15962508, 5075808, -1514661, -11534600, -33102500, 9160280, 8473550, -3256838),
array(24900749, 14435722, 17209120, -15292541, -22592275, 9878983, -7689309, -16335821, -24568481, 11788948),
),
array(
array(-3118155, -11395194, -13802089, 14797441, 9652448, -6845904, -20037437, 10410733, -24568470, -1458691),
array(-15659161, 16736706, -22467150, 10215878, -9097177, 7563911, 11871841, -12505194, -18513325, 8464118),
array(-23400612, 8348507, -14585951, -861714, -3950205, -6373419, 14325289, 8628612, 33313881, -8370517),
),
array(
array(-20186973, -4967935, 22367356, 5271547, -1097117, -4788838, -24805667, -10236854, -8940735, -5818269),
array(-6948785, -1795212, -32625683, -16021179, 32635414, -7374245, 15989197, -12838188, 28358192, -4253904),
array(-23561781, -2799059, -32351682, -1661963, -9147719, 10429267, -16637684, 4072016, -5351664, 5596589),
),
array(
array(-28236598, -3390048, 12312896, 6213178, 3117142, 16078565, 29266239, 2557221, 1768301, 15373193),
array(-7243358, -3246960, -4593467, -7553353, -127927, -912245, -1090902, -4504991, -24660491, 3442910),
array(-30210571, 5124043, 14181784, 8197961, 18964734, -11939093, 22597931, 7176455, -18585478, 13365930),
),
array(
array(-7877390, -1499958, 8324673, 4690079, 6261860, 890446, 24538107, -8570186, -9689599, -3031667),
array(25008904, -10771599, -4305031, -9638010, 16265036, 15721635, 683793, -11823784, 15723479, -15163481),
array(-9660625, 12374379, -27006999, -7026148, -7724114, -12314514, 11879682, 5400171, 519526, -1235876),
),
array(
array(22258397, -16332233, -7869817, 14613016, -22520255, -2950923, -20353881, 7315967, 16648397, 7605640),
array(-8081308, -8464597, -8223311, 9719710, 19259459, -15348212, 23994942, -5281555, -9468848, 4763278),
array(-21699244, 9220969, -15730624, 1084137, -25476107, -2852390, 31088447, -7764523, -11356529, 728112),
),
array(
array(26047220, -11751471, -6900323, -16521798, 24092068, 9158119, -4273545, -12555558, -29365436, -5498272),
array(17510331, -322857, 5854289, 8403524, 17133918, -3112612, -28111007, 12327945, 10750447, 10014012),
array(-10312768, 3936952, 9156313, -8897683, 16498692, -994647, -27481051, -666732, 3424691, 7540221),
),
array(
array(30322361, -6964110, 11361005, -4143317, 7433304, 4989748, -7071422, -16317219, -9244265, 15258046),
array(13054562, -2779497, 19155474, 469045, -12482797, 4566042, 5631406, 2711395, 1062915, -5136345),
array(-19240248, -11254599, -29509029, -7499965, -5835763, 13005411, -6066489, 12194497, 32960380, 1459310),
),
),
array(
array(
array(19852034, 7027924, 23669353, 10020366, 8586503, -6657907, 394197, -6101885, 18638003, -11174937),
array(31395534, 15098109, 26581030, 8030562, -16527914, -5007134, 9012486, -7584354, -6643087, -5442636),
array(-9192165, -2347377, -1997099, 4529534, 25766844, 607986, -13222, 9677543, -32294889, -6456008),
),
array(
array(-2444496, -149937, 29348902, 8186665, 1873760, 12489863, -30934579, -7839692, -7852844, -8138429),
array(-15236356, -15433509, 7766470, 746860, 26346930, -10221762, -27333451, 10754588, -9431476, 5203576),
array(31834314, 14135496, -770007, 5159118, 20917671, -16768096, -7467973, -7337524, 31809243, 7347066),
),
array(
array(-9606723, -11874240, 20414459, 13033986, 13716524, -11691881, 19797970, -12211255, 15192876, -2087490),
array(-12663563, -2181719, 1168162, -3804809, 26747877, -14138091, 10609330, 12694420, 33473243, -13382104),
array(33184999, 11180355, 15832085, -11385430, -1633671, 225884, 15089336, -11023903, -6135662, 14480053),
),
array(
array(31308717, -5619998, 31030840, -1897099, 15674547, -6582883, 5496208, 13685227, 27595050, 8737275),
array(-20318852, -15150239, 10933843, -16178022, 8335352, -7546022, -31008351, -12610604, 26498114, 66511),
array(22644454, -8761729, -16671776, 4884562, -3105614, -13559366, 30540766, -4286747, -13327787, -7515095),
),
array(
array(-28017847, 9834845, 18617207, -2681312, -3401956, -13307506, 8205540, 13585437, -17127465, 15115439),
array(23711543, -672915, 31206561, -8362711, 6164647, -9709987, -33535882, -1426096, 8236921, 16492939),
array(-23910559, -13515526, -26299483, -4503841, 25005590, -7687270, 19574902, 10071562, 6708380, -6222424),
),
array(
array(2101391, -4930054, 19702731, 2367575, -15427167, 1047675, 5301017, 9328700, 29955601, -11678310),
array(3096359, 9271816, -21620864, -15521844, -14847996, -7592937, -25892142, -12635595, -9917575, 6216608),
array(-32615849, 338663, -25195611, 2510422, -29213566, -13820213, 24822830, -6146567, -26767480, 7525079),
),
array(
array(-23066649, -13985623, 16133487, -7896178, -3389565, 778788, -910336, -2782495, -19386633, 11994101),
array(21691500, -13624626, -641331, -14367021, 3285881, -3483596, -25064666, 9718258, -7477437, 13381418),
array(18445390, -4202236, 14979846, 11622458, -1727110, -3582980, 23111648, -6375247, 28535282, 15779576),
),
array(
array(30098053, 3089662, -9234387, 16662135, -21306940, 11308411, -14068454, 12021730, 9955285, -16303356),
array(9734894, -14576830, -7473633, -9138735, 2060392, 11313496, -18426029, 9924399, 20194861, 13380996),
array(-26378102, -7965207, -22167821, 15789297, -18055342, -6168792, -1984914, 15707771, 26342023, 10146099),
),
),
array(
array(
array(-26016874, -219943, 21339191, -41388, 19745256, -2878700, -29637280, 2227040, 21612326, -545728),
array(-13077387, 1184228, 23562814, -5970442, -20351244, -6348714, 25764461, 12243797, -20856566, 11649658),
array(-10031494, 11262626, 27384172, 2271902, 26947504, -15997771, 39944, 6114064, 33514190, 2333242),
),
array(
array(-21433588, -12421821, 8119782, 7219913, -21830522, -9016134, -6679750, -12670638, 24350578, -13450001),
array(-4116307, -11271533, -23886186, 4843615, -30088339, 690623, -31536088, -10406836, 8317860, 12352766),
array(18200138, -14475911, -33087759, -2696619, -23702521, -9102511, -23552096, -2287550, 20712163, 6719373),
),
array(
array(26656208, 6075253, -7858556, 1886072, -28344043, 4262326, 11117530, -3763210, 26224235, -3297458),
array(-17168938, -14854097, -3395676, -16369877, -19954045, 14050420, 21728352, 9493610, 18620611, -16428628),
array(-13323321, 13325349, 11432106, 5964811, 18609221, 6062965, -5269471, -9725556, -30701573, -16479657),
),
array(
array(-23860538, -11233159, 26961357, 1640861, -32413112, -16737940, 12248509, -5240639, 13735342, 1934062),
array(25089769, 6742589, 17081145, -13406266, 21909293, -16067981, -15136294, -3765346, -21277997, 5473616),
array(31883677, -7961101, 1083432, -11572403, 22828471, 13290673, -7125085, 12469656, 29111212, -5451014),
),
array(
array(24244947, -15050407, -26262976, 2791540, -14997599, 16666678, 24367466, 6388839, -10295587, 452383),
array(-25640782, -3417841, 5217916, 16224624, 19987036, -4082269, -24236251, -5915248, 15766062, 8407814),
array(-20406999, 13990231, 15495425, 16395525, 5377168, 15166495, -8917023, -4388953, -8067909, 2276718),
),
array(
array(30157918, 12924066, -17712050, 9245753, 19895028, 3368142, -23827587, 5096219, 22740376, -7303417),
array(2041139, -14256350, 7783687, 13876377, -25946985, -13352459, 24051124, 13742383, -15637599, 13295222),
array(33338237, -8505733, 12532113, 7977527, 9106186, -1715251, -17720195, -4612972, -4451357, -14669444),
),
array(
array(-20045281, 5454097, -14346548, 6447146, 28862071, 1883651, -2469266, -4141880, 7770569, 9620597),
array(23208068, 7979712, 33071466, 8149229, 1758231, -10834995, 30945528, -1694323, -33502340, -14767970),
array(1439958, -16270480, -1079989, -793782, 4625402, 10647766, -5043801, 1220118, 30494170, -11440799),
),
array(
array(-5037580, -13028295, -2970559, -3061767, 15640974, -6701666, -26739026, 926050, -1684339, -13333647),
array(13908495, -3549272, 30919928, -6273825, -21521863, 7989039, 9021034, 9078865, 3353509, 4033511),
array(-29663431, -15113610, 32259991, -344482, 24295849, -12912123, 23161163, 8839127, 27485041, 7356032),
),
),
array(
array(
array(9661027, 705443, 11980065, -5370154, -1628543, 14661173, -6346142, 2625015, 28431036, -16771834),
array(-23839233, -8311415, -25945511, 7480958, -17681669, -8354183, -22545972, 14150565, 15970762, 4099461),
array(29262576, 16756590, 26350592, -8793563, 8529671, -11208050, 13617293, -9937143, 11465739, 8317062),
),
array(
array(-25493081, -6962928, 32500200, -9419051, -23038724, -2302222, 14898637, 3848455, 20969334, -5157516),
array(-20384450, -14347713, -18336405, 13884722, -33039454, 2842114, -21610826, -3649888, 11177095, 14989547),
array(-24496721, -11716016, 16959896, 2278463, 12066309, 10137771, 13515641, 2581286, -28487508, 9930240),
),
array(
array(-17751622, -2097826, 16544300, -13009300, -15914807, -14949081, 18345767, -13403753, 16291481, -5314038),
array(-33229194, 2553288, 32678213, 9875984, 8534129, 6889387, -9676774, 6957617, 4368891, 9788741),
array(16660756, 7281060, -10830758, 12911820, 20108584, -8101676, -21722536, -8613148, 16250552, -11111103),
),
array(
array(-19765507, 2390526, -16551031, 14161980, 1905286, 6414907, 4689584, 10604807, -30190403, 4782747),
array(-1354539, 14736941, -7367442, -13292886, 7710542, -14155590, -9981571, 4383045, 22546403, 437323),
array(31665577, -12180464, -16186830, 1491339, -18368625, 3294682, 27343084, 2786261, -30633590, -14097016),
),
array(
array(-14467279, -683715, -33374107, 7448552, 19294360, 14334329, -19690631, 2355319, -19284671, -6114373),
array(15121312, -15796162, 6377020, -6031361, -10798111, -12957845, 18952177, 15496498, -29380133, 11754228),
array(-2637277, -13483075, 8488727, -14303896, 12728761, -1622493, 7141596, 11724556, 22761615, -10134141),
),
array(
array(16918416, 11729663, -18083579, 3022987, -31015732, -13339659, -28741185, -12227393, 32851222, 11717399),
array(11166634, 7338049, -6722523, 4531520, -29468672, -7302055, 31474879, 3483633, -1193175, -4030831),
array(-185635, 9921305, 31456609, -13536438, -12013818, 13348923, 33142652, 6546660, -19985279, -3948376),
),
array(
array(-32460596, 11266712, -11197107, -7899103, 31703694, 3855903, -8537131, -12833048, -30772034, -15486313),
array(-18006477, 12709068, 3991746, -6479188, -21491523, -10550425, -31135347, -16049879, 10928917, 3011958),
array(-6957757, -15594337, 31696059, 334240, 29576716, 14796075, -30831056, -12805180, 18008031, 10258577),
),
array(
array(-22448644, 15655569, 7018479, -4410003, -30314266, -1201591, -1853465, 1367120, 25127874, 6671743),
array(29701166, -14373934, -10878120, 9279288, -17568, 13127210, 21382910, 11042292, 25838796, 4642684),
array(-20430234, 14955537, -24126347, 8124619, -5369288, -5990470, 30468147, -13900640, 18423289, 4177476),
),
)
);
/**
* See: libsodium's crypto_core/curve25519/ref10/base2.h
*
* @var array<int, array<int, array<int, int>>> basically int[8][3]
*/
protected static $base2 = array(
array(
array(25967493, -14356035, 29566456, 3660896, -12694345, 4014787, 27544626, -11754271, -6079156, 2047605),
array(-12545711, 934262, -2722910, 3049990, -727428, 9406986, 12720692, 5043384, 19500929, -15469378),
array(-8738181, 4489570, 9688441, -14785194, 10184609, -12363380, 29287919, 11864899, -24514362, -4438546),
),
array(
array(15636291, -9688557, 24204773, -7912398, 616977, -16685262, 27787600, -14772189, 28944400, -1550024),
array(16568933, 4717097, -11556148, -1102322, 15682896, -11807043, 16354577, -11775962, 7689662, 11199574),
array(30464156, -5976125, -11779434, -15670865, 23220365, 15915852, 7512774, 10017326, -17749093, -9920357),
),
array(
array(10861363, 11473154, 27284546, 1981175, -30064349, 12577861, 32867885, 14515107, -15438304, 10819380),
array(4708026, 6336745, 20377586, 9066809, -11272109, 6594696, -25653668, 12483688, -12668491, 5581306),
array(19563160, 16186464, -29386857, 4097519, 10237984, -4348115, 28542350, 13850243, -23678021, -15815942),
),
array(
array(5153746, 9909285, 1723747, -2777874, 30523605, 5516873, 19480852, 5230134, -23952439, -15175766),
array(-30269007, -3463509, 7665486, 10083793, 28475525, 1649722, 20654025, 16520125, 30598449, 7715701),
array(28881845, 14381568, 9657904, 3680757, -20181635, 7843316, -31400660, 1370708, 29794553, -1409300),
),
array(
array(-22518993, -6692182, 14201702, -8745502, -23510406, 8844726, 18474211, -1361450, -13062696, 13821877),
array(-6455177, -7839871, 3374702, -4740862, -27098617, -10571707, 31655028, -7212327, 18853322, -14220951),
array(4566830, -12963868, -28974889, -12240689, -7602672, -2830569, -8514358, -10431137, 2207753, -3209784),
),
array(
array(-25154831, -4185821, 29681144, 7868801, -6854661, -9423865, -12437364, -663000, -31111463, -16132436),
array(25576264, -2703214, 7349804, -11814844, 16472782, 9300885, 3844789, 15725684, 171356, 6466918),
array(23103977, 13316479, 9739013, -16149481, 817875, -15038942, 8965339, -14088058, -30714912, 16193877),
),
array(
array(-33521811, 3180713, -2394130, 14003687, -16903474, -16270840, 17238398, 4729455, -18074513, 9256800),
array(-25182317, -4174131, 32336398, 5036987, -21236817, 11360617, 22616405, 9761698, -19827198, 630305),
array(-13720693, 2639453, -24237460, -7406481, 9494427, -5774029, -6554551, -15960994, -2449256, -14291300),
),
array(
array(-3151181, -5046075, 9282714, 6866145, -31907062, -863023, -18940575, 15033784, 25105118, -7894876),
array(-24326370, 15950226, -31801215, -14592823, -11662737, -5090925, 1573892, -2625887, 2198790, -15804619),
array(-3099351, 10324967, -2241613, 7453183, -5446979, -2735503, -13812022, -16236442, -32461234, -12290683),
)
);
/**
* 37095705934669439343138083508754565189542113879843219016388785533085940283555
*
* @var array<int, int>
*/
protected static $d = array(
-10913610,
13857413,
-15372611,
6949391,
114729,
-8787816,
-6275908,
-3247719,
-18696448,
-12055116
);
/**
* 2 * d = 16295367250680780974490674513165176452449235426866156013048779062215315747161
*
* @var array<int, int>
*/
protected static $d2 = array(
-21827239,
-5839606,
-30745221,
13898782,
229458,
15978800,
-12551817,
-6495438,
29715968,
9444199
);
/**
* sqrt(-1)
*
* @var array<int, int>
*/
protected static $sqrtm1 = array(
-32595792,
-7943725,
9377950,
3500415,
12389472,
-272473,
-25146209,
-2005654,
326686,
11406482
);
}
Core32/Curve25519/README.md 0000644 00000000332 15153427537 0010564 0 ustar 00 # Curve25519 Data Structures
These are PHP implementation of the [structs used in the ref10 curve25519 code](https://github.com/jedisct1/libsodium/blob/master/src/libsodium/include/sodium/private/curve25519_ref10.h).
Core32/Curve25519.php 0000644 00000403556 15153427537 0010035 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Curve25519', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Curve25519
*
* Implements Curve25519 core functions
*
* Based on the ref10 curve25519 code provided by libsodium
*
* @ref https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_core/curve25519/ref10/curve25519_ref10.c
*/
abstract class ParagonIE_Sodium_Core32_Curve25519 extends ParagonIE_Sodium_Core32_Curve25519_H
{
/**
* Get a field element of size 10 with a value of 0
*
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
*/
public static function fe_0()
{
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32()
)
);
}
/**
* Get a field element of size 10 with a value of 1
*
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
*/
public static function fe_1()
{
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt(1),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32(),
new ParagonIE_Sodium_Core32_Int32()
)
);
}
/**
* Add two field elements.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $g
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedMethodCall
*/
public static function fe_add(
ParagonIE_Sodium_Core32_Curve25519_Fe $f,
ParagonIE_Sodium_Core32_Curve25519_Fe $g
) {
$arr = array();
for ($i = 0; $i < 10; ++$i) {
$arr[$i] = $f[$i]->addInt32($g[$i]);
}
/** @var array<int, ParagonIE_Sodium_Core32_Int32> $arr */
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray($arr);
}
/**
* Constant-time conditional move.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $g
* @param int $b
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedMethodCall
*/
public static function fe_cmov(
ParagonIE_Sodium_Core32_Curve25519_Fe $f,
ParagonIE_Sodium_Core32_Curve25519_Fe $g,
$b = 0
) {
/** @var array<int, ParagonIE_Sodium_Core32_Int32> $h */
$h = array();
for ($i = 0; $i < 10; ++$i) {
if (!($f[$i] instanceof ParagonIE_Sodium_Core32_Int32)) {
throw new TypeError('Expected Int32');
}
if (!($g[$i] instanceof ParagonIE_Sodium_Core32_Int32)) {
throw new TypeError('Expected Int32');
}
$h[$i] = $f[$i]->xorInt32(
$f[$i]->xorInt32($g[$i])->mask($b)
);
}
/** @var array<int, ParagonIE_Sodium_Core32_Int32> $h */
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray($h);
}
/**
* Create a copy of a field element.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
*/
public static function fe_copy(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
$h = clone $f;
return $h;
}
/**
* Give: 32-byte string.
* Receive: A field element object to use for internal calculations.
*
* @internal You should not use this directly from another application
*
* @param string $s
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws RangeException
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedMethodCall
*/
public static function fe_frombytes($s)
{
if (self::strlen($s) !== 32) {
throw new RangeException('Expected a 32-byte string.');
}
/** @var ParagonIE_Sodium_Core32_Int32 $h0 */
$h0 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_4($s)
);
/** @var ParagonIE_Sodium_Core32_Int32 $h1 */
$h1 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 4, 3)) << 6
);
/** @var ParagonIE_Sodium_Core32_Int32 $h2 */
$h2 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 7, 3)) << 5
);
/** @var ParagonIE_Sodium_Core32_Int32 $h3 */
$h3 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 10, 3)) << 3
);
/** @var ParagonIE_Sodium_Core32_Int32 $h4 */
$h4 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 13, 3)) << 2
);
/** @var ParagonIE_Sodium_Core32_Int32 $h5 */
$h5 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_4(self::substr($s, 16, 4))
);
/** @var ParagonIE_Sodium_Core32_Int32 $h6 */
$h6 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 20, 3)) << 7
);
/** @var ParagonIE_Sodium_Core32_Int32 $h7 */
$h7 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 23, 3)) << 5
);
/** @var ParagonIE_Sodium_Core32_Int32 $h8 */
$h8 = ParagonIE_Sodium_Core32_Int32::fromInt(
self::load_3(self::substr($s, 26, 3)) << 4
);
/** @var ParagonIE_Sodium_Core32_Int32 $h9 */
$h9 = ParagonIE_Sodium_Core32_Int32::fromInt(
(self::load_3(self::substr($s, 29, 3)) & 8388607) << 2
);
$carry9 = $h9->addInt(1 << 24)->shiftRight(25);
$h0 = $h0->addInt32($carry9->mulInt(19, 5));
$h9 = $h9->subInt32($carry9->shiftLeft(25));
$carry1 = $h1->addInt(1 << 24)->shiftRight(25);
$h2 = $h2->addInt32($carry1);
$h1 = $h1->subInt32($carry1->shiftLeft(25));
$carry3 = $h3->addInt(1 << 24)->shiftRight(25);
$h4 = $h4->addInt32($carry3);
$h3 = $h3->subInt32($carry3->shiftLeft(25));
$carry5 = $h5->addInt(1 << 24)->shiftRight(25);
$h6 = $h6->addInt32($carry5);
$h5 = $h5->subInt32($carry5->shiftLeft(25));
$carry7 = $h7->addInt(1 << 24)->shiftRight(25);
$h8 = $h8->addInt32($carry7);
$h7 = $h7->subInt32($carry7->shiftLeft(25));
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt32($carry0);
$h0 = $h0->subInt32($carry0->shiftLeft(26));
$carry2 = $h2->addInt(1 << 25)->shiftRight(26);
$h3 = $h3->addInt32($carry2);
$h2 = $h2->subInt32($carry2->shiftLeft(26));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt32($carry4);
$h4 = $h4->subInt32($carry4->shiftLeft(26));
$carry6 = $h6->addInt(1 << 25)->shiftRight(26);
$h7 = $h7->addInt32($carry6);
$h6 = $h6->subInt32($carry6->shiftLeft(26));
$carry8 = $h8->addInt(1 << 25)->shiftRight(26);
$h9 = $h9->addInt32($carry8);
$h8 = $h8->subInt32($carry8->shiftLeft(26));
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array($h0, $h1, $h2,$h3, $h4, $h5, $h6, $h7, $h8, $h9)
);
}
/**
* Convert a field element to a byte string.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $h
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedMethodCall
*/
public static function fe_tobytes(ParagonIE_Sodium_Core32_Curve25519_Fe $h)
{
/**
* @var ParagonIE_Sodium_Core32_Int64[] $f
* @var ParagonIE_Sodium_Core32_Int64 $q
*/
$f = array();
for ($i = 0; $i < 10; ++$i) {
$f[$i] = $h[$i]->toInt64();
}
$q = $f[9]->mulInt(19, 5)->addInt(1 << 14)->shiftRight(25)
->addInt64($f[0])->shiftRight(26)
->addInt64($f[1])->shiftRight(25)
->addInt64($f[2])->shiftRight(26)
->addInt64($f[3])->shiftRight(25)
->addInt64($f[4])->shiftRight(26)
->addInt64($f[5])->shiftRight(25)
->addInt64($f[6])->shiftRight(26)
->addInt64($f[7])->shiftRight(25)
->addInt64($f[8])->shiftRight(26)
->addInt64($f[9])->shiftRight(25);
$f[0] = $f[0]->addInt64($q->mulInt(19, 5));
$carry0 = $f[0]->shiftRight(26);
$f[1] = $f[1]->addInt64($carry0);
$f[0] = $f[0]->subInt64($carry0->shiftLeft(26));
$carry1 = $f[1]->shiftRight(25);
$f[2] = $f[2]->addInt64($carry1);
$f[1] = $f[1]->subInt64($carry1->shiftLeft(25));
$carry2 = $f[2]->shiftRight(26);
$f[3] = $f[3]->addInt64($carry2);
$f[2] = $f[2]->subInt64($carry2->shiftLeft(26));
$carry3 = $f[3]->shiftRight(25);
$f[4] = $f[4]->addInt64($carry3);
$f[3] = $f[3]->subInt64($carry3->shiftLeft(25));
$carry4 = $f[4]->shiftRight(26);
$f[5] = $f[5]->addInt64($carry4);
$f[4] = $f[4]->subInt64($carry4->shiftLeft(26));
$carry5 = $f[5]->shiftRight(25);
$f[6] = $f[6]->addInt64($carry5);
$f[5] = $f[5]->subInt64($carry5->shiftLeft(25));
$carry6 = $f[6]->shiftRight(26);
$f[7] = $f[7]->addInt64($carry6);
$f[6] = $f[6]->subInt64($carry6->shiftLeft(26));
$carry7 = $f[7]->shiftRight(25);
$f[8] = $f[8]->addInt64($carry7);
$f[7] = $f[7]->subInt64($carry7->shiftLeft(25));
$carry8 = $f[8]->shiftRight(26);
$f[9] = $f[9]->addInt64($carry8);
$f[8] = $f[8]->subInt64($carry8->shiftLeft(26));
$carry9 = $f[9]->shiftRight(25);
$f[9] = $f[9]->subInt64($carry9->shiftLeft(25));
$h0 = $f[0]->toInt32()->toInt();
$h1 = $f[1]->toInt32()->toInt();
$h2 = $f[2]->toInt32()->toInt();
$h3 = $f[3]->toInt32()->toInt();
$h4 = $f[4]->toInt32()->toInt();
$h5 = $f[5]->toInt32()->toInt();
$h6 = $f[6]->toInt32()->toInt();
$h7 = $f[7]->toInt32()->toInt();
$h8 = $f[8]->toInt32()->toInt();
$h9 = $f[9]->toInt32()->toInt();
/**
* @var array<int, int>
*/
$s = array(
(int) (($h0 >> 0) & 0xff),
(int) (($h0 >> 8) & 0xff),
(int) (($h0 >> 16) & 0xff),
(int) ((($h0 >> 24) | ($h1 << 2)) & 0xff),
(int) (($h1 >> 6) & 0xff),
(int) (($h1 >> 14) & 0xff),
(int) ((($h1 >> 22) | ($h2 << 3)) & 0xff),
(int) (($h2 >> 5) & 0xff),
(int) (($h2 >> 13) & 0xff),
(int) ((($h2 >> 21) | ($h3 << 5)) & 0xff),
(int) (($h3 >> 3) & 0xff),
(int) (($h3 >> 11) & 0xff),
(int) ((($h3 >> 19) | ($h4 << 6)) & 0xff),
(int) (($h4 >> 2) & 0xff),
(int) (($h4 >> 10) & 0xff),
(int) (($h4 >> 18) & 0xff),
(int) (($h5 >> 0) & 0xff),
(int) (($h5 >> 8) & 0xff),
(int) (($h5 >> 16) & 0xff),
(int) ((($h5 >> 24) | ($h6 << 1)) & 0xff),
(int) (($h6 >> 7) & 0xff),
(int) (($h6 >> 15) & 0xff),
(int) ((($h6 >> 23) | ($h7 << 3)) & 0xff),
(int) (($h7 >> 5) & 0xff),
(int) (($h7 >> 13) & 0xff),
(int) ((($h7 >> 21) | ($h8 << 4)) & 0xff),
(int) (($h8 >> 4) & 0xff),
(int) (($h8 >> 12) & 0xff),
(int) ((($h8 >> 20) | ($h9 << 6)) & 0xff),
(int) (($h9 >> 2) & 0xff),
(int) (($h9 >> 10) & 0xff),
(int) (($h9 >> 18) & 0xff)
);
return self::intArrayToString($s);
}
/**
* Is a field element negative? (1 = yes, 0 = no. Used in calculations.)
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return int
* @throws SodiumException
* @throws TypeError
*/
public static function fe_isnegative(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
$str = self::fe_tobytes($f);
return (int) (self::chrToInt($str[0]) & 1);
}
/**
* Returns 0 if this field element results in all NUL bytes.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function fe_isnonzero(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
static $zero;
if ($zero === null) {
$zero = str_repeat("\x00", 32);
}
$str = self::fe_tobytes($f);
/** @var string $zero */
return !self::verify_32($str, $zero);
}
/**
* Multiply two field elements
*
* h = f * g
*
* @internal You should not use this directly from another application
*
* @security Is multiplication a source of timing leaks? If so, can we do
* anything to prevent that from happening?
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $g
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
*/
public static function fe_mul(
ParagonIE_Sodium_Core32_Curve25519_Fe $f,
ParagonIE_Sodium_Core32_Curve25519_Fe $g
) {
/**
* @var ParagonIE_Sodium_Core32_Int32[] $f
* @var ParagonIE_Sodium_Core32_Int32[] $g
* @var ParagonIE_Sodium_Core32_Int64 $f0
* @var ParagonIE_Sodium_Core32_Int64 $f1
* @var ParagonIE_Sodium_Core32_Int64 $f2
* @var ParagonIE_Sodium_Core32_Int64 $f3
* @var ParagonIE_Sodium_Core32_Int64 $f4
* @var ParagonIE_Sodium_Core32_Int64 $f5
* @var ParagonIE_Sodium_Core32_Int64 $f6
* @var ParagonIE_Sodium_Core32_Int64 $f7
* @var ParagonIE_Sodium_Core32_Int64 $f8
* @var ParagonIE_Sodium_Core32_Int64 $f9
* @var ParagonIE_Sodium_Core32_Int64 $g0
* @var ParagonIE_Sodium_Core32_Int64 $g1
* @var ParagonIE_Sodium_Core32_Int64 $g2
* @var ParagonIE_Sodium_Core32_Int64 $g3
* @var ParagonIE_Sodium_Core32_Int64 $g4
* @var ParagonIE_Sodium_Core32_Int64 $g5
* @var ParagonIE_Sodium_Core32_Int64 $g6
* @var ParagonIE_Sodium_Core32_Int64 $g7
* @var ParagonIE_Sodium_Core32_Int64 $g8
* @var ParagonIE_Sodium_Core32_Int64 $g9
*/
$f0 = $f[0]->toInt64();
$f1 = $f[1]->toInt64();
$f2 = $f[2]->toInt64();
$f3 = $f[3]->toInt64();
$f4 = $f[4]->toInt64();
$f5 = $f[5]->toInt64();
$f6 = $f[6]->toInt64();
$f7 = $f[7]->toInt64();
$f8 = $f[8]->toInt64();
$f9 = $f[9]->toInt64();
$g0 = $g[0]->toInt64();
$g1 = $g[1]->toInt64();
$g2 = $g[2]->toInt64();
$g3 = $g[3]->toInt64();
$g4 = $g[4]->toInt64();
$g5 = $g[5]->toInt64();
$g6 = $g[6]->toInt64();
$g7 = $g[7]->toInt64();
$g8 = $g[8]->toInt64();
$g9 = $g[9]->toInt64();
$g1_19 = $g1->mulInt(19, 5); /* 2^4 <= 19 <= 2^5, but we only want 5 bits */
$g2_19 = $g2->mulInt(19, 5);
$g3_19 = $g3->mulInt(19, 5);
$g4_19 = $g4->mulInt(19, 5);
$g5_19 = $g5->mulInt(19, 5);
$g6_19 = $g6->mulInt(19, 5);
$g7_19 = $g7->mulInt(19, 5);
$g8_19 = $g8->mulInt(19, 5);
$g9_19 = $g9->mulInt(19, 5);
$f1_2 = $f1->shiftLeft(1);
$f3_2 = $f3->shiftLeft(1);
$f5_2 = $f5->shiftLeft(1);
$f7_2 = $f7->shiftLeft(1);
$f9_2 = $f9->shiftLeft(1);
$f0g0 = $f0->mulInt64($g0, 27);
$f0g1 = $f0->mulInt64($g1, 27);
$f0g2 = $f0->mulInt64($g2, 27);
$f0g3 = $f0->mulInt64($g3, 27);
$f0g4 = $f0->mulInt64($g4, 27);
$f0g5 = $f0->mulInt64($g5, 27);
$f0g6 = $f0->mulInt64($g6, 27);
$f0g7 = $f0->mulInt64($g7, 27);
$f0g8 = $f0->mulInt64($g8, 27);
$f0g9 = $f0->mulInt64($g9, 27);
$f1g0 = $f1->mulInt64($g0, 27);
$f1g1_2 = $f1_2->mulInt64($g1, 27);
$f1g2 = $f1->mulInt64($g2, 27);
$f1g3_2 = $f1_2->mulInt64($g3, 27);
$f1g4 = $f1->mulInt64($g4, 30);
$f1g5_2 = $f1_2->mulInt64($g5, 30);
$f1g6 = $f1->mulInt64($g6, 30);
$f1g7_2 = $f1_2->mulInt64($g7, 30);
$f1g8 = $f1->mulInt64($g8, 30);
$f1g9_38 = $g9_19->mulInt64($f1_2, 30);
$f2g0 = $f2->mulInt64($g0, 30);
$f2g1 = $f2->mulInt64($g1, 29);
$f2g2 = $f2->mulInt64($g2, 30);
$f2g3 = $f2->mulInt64($g3, 29);
$f2g4 = $f2->mulInt64($g4, 30);
$f2g5 = $f2->mulInt64($g5, 29);
$f2g6 = $f2->mulInt64($g6, 30);
$f2g7 = $f2->mulInt64($g7, 29);
$f2g8_19 = $g8_19->mulInt64($f2, 30);
$f2g9_19 = $g9_19->mulInt64($f2, 30);
$f3g0 = $f3->mulInt64($g0, 30);
$f3g1_2 = $f3_2->mulInt64($g1, 30);
$f3g2 = $f3->mulInt64($g2, 30);
$f3g3_2 = $f3_2->mulInt64($g3, 30);
$f3g4 = $f3->mulInt64($g4, 30);
$f3g5_2 = $f3_2->mulInt64($g5, 30);
$f3g6 = $f3->mulInt64($g6, 30);
$f3g7_38 = $g7_19->mulInt64($f3_2, 30);
$f3g8_19 = $g8_19->mulInt64($f3, 30);
$f3g9_38 = $g9_19->mulInt64($f3_2, 30);
$f4g0 = $f4->mulInt64($g0, 30);
$f4g1 = $f4->mulInt64($g1, 30);
$f4g2 = $f4->mulInt64($g2, 30);
$f4g3 = $f4->mulInt64($g3, 30);
$f4g4 = $f4->mulInt64($g4, 30);
$f4g5 = $f4->mulInt64($g5, 30);
$f4g6_19 = $g6_19->mulInt64($f4, 30);
$f4g7_19 = $g7_19->mulInt64($f4, 30);
$f4g8_19 = $g8_19->mulInt64($f4, 30);
$f4g9_19 = $g9_19->mulInt64($f4, 30);
$f5g0 = $f5->mulInt64($g0, 30);
$f5g1_2 = $f5_2->mulInt64($g1, 30);
$f5g2 = $f5->mulInt64($g2, 30);
$f5g3_2 = $f5_2->mulInt64($g3, 30);
$f5g4 = $f5->mulInt64($g4, 30);
$f5g5_38 = $g5_19->mulInt64($f5_2, 30);
$f5g6_19 = $g6_19->mulInt64($f5, 30);
$f5g7_38 = $g7_19->mulInt64($f5_2, 30);
$f5g8_19 = $g8_19->mulInt64($f5, 30);
$f5g9_38 = $g9_19->mulInt64($f5_2, 30);
$f6g0 = $f6->mulInt64($g0, 30);
$f6g1 = $f6->mulInt64($g1, 30);
$f6g2 = $f6->mulInt64($g2, 30);
$f6g3 = $f6->mulInt64($g3, 30);
$f6g4_19 = $g4_19->mulInt64($f6, 30);
$f6g5_19 = $g5_19->mulInt64($f6, 30);
$f6g6_19 = $g6_19->mulInt64($f6, 30);
$f6g7_19 = $g7_19->mulInt64($f6, 30);
$f6g8_19 = $g8_19->mulInt64($f6, 30);
$f6g9_19 = $g9_19->mulInt64($f6, 30);
$f7g0 = $f7->mulInt64($g0, 30);
$f7g1_2 = $g1->mulInt64($f7_2, 30);
$f7g2 = $f7->mulInt64($g2, 30);
$f7g3_38 = $g3_19->mulInt64($f7_2, 30);
$f7g4_19 = $g4_19->mulInt64($f7, 30);
$f7g5_38 = $g5_19->mulInt64($f7_2, 30);
$f7g6_19 = $g6_19->mulInt64($f7, 30);
$f7g7_38 = $g7_19->mulInt64($f7_2, 30);
$f7g8_19 = $g8_19->mulInt64($f7, 30);
$f7g9_38 = $g9_19->mulInt64($f7_2, 30);
$f8g0 = $f8->mulInt64($g0, 30);
$f8g1 = $f8->mulInt64($g1, 29);
$f8g2_19 = $g2_19->mulInt64($f8, 30);
$f8g3_19 = $g3_19->mulInt64($f8, 30);
$f8g4_19 = $g4_19->mulInt64($f8, 30);
$f8g5_19 = $g5_19->mulInt64($f8, 30);
$f8g6_19 = $g6_19->mulInt64($f8, 30);
$f8g7_19 = $g7_19->mulInt64($f8, 30);
$f8g8_19 = $g8_19->mulInt64($f8, 30);
$f8g9_19 = $g9_19->mulInt64($f8, 30);
$f9g0 = $f9->mulInt64($g0, 30);
$f9g1_38 = $g1_19->mulInt64($f9_2, 30);
$f9g2_19 = $g2_19->mulInt64($f9, 30);
$f9g3_38 = $g3_19->mulInt64($f9_2, 30);
$f9g4_19 = $g4_19->mulInt64($f9, 30);
$f9g5_38 = $g5_19->mulInt64($f9_2, 30);
$f9g6_19 = $g6_19->mulInt64($f9, 30);
$f9g7_38 = $g7_19->mulInt64($f9_2, 30);
$f9g8_19 = $g8_19->mulInt64($f9, 30);
$f9g9_38 = $g9_19->mulInt64($f9_2, 30);
// $h0 = $f0g0 + $f1g9_38 + $f2g8_19 + $f3g7_38 + $f4g6_19 + $f5g5_38 + $f6g4_19 + $f7g3_38 + $f8g2_19 + $f9g1_38;
$h0 = $f0g0->addInt64($f1g9_38)->addInt64($f2g8_19)->addInt64($f3g7_38)
->addInt64($f4g6_19)->addInt64($f5g5_38)->addInt64($f6g4_19)
->addInt64($f7g3_38)->addInt64($f8g2_19)->addInt64($f9g1_38);
// $h1 = $f0g1 + $f1g0 + $f2g9_19 + $f3g8_19 + $f4g7_19 + $f5g6_19 + $f6g5_19 + $f7g4_19 + $f8g3_19 + $f9g2_19;
$h1 = $f0g1->addInt64($f1g0)->addInt64($f2g9_19)->addInt64($f3g8_19)
->addInt64($f4g7_19)->addInt64($f5g6_19)->addInt64($f6g5_19)
->addInt64($f7g4_19)->addInt64($f8g3_19)->addInt64($f9g2_19);
// $h2 = $f0g2 + $f1g1_2 + $f2g0 + $f3g9_38 + $f4g8_19 + $f5g7_38 + $f6g6_19 + $f7g5_38 + $f8g4_19 + $f9g3_38;
$h2 = $f0g2->addInt64($f1g1_2)->addInt64($f2g0)->addInt64($f3g9_38)
->addInt64($f4g8_19)->addInt64($f5g7_38)->addInt64($f6g6_19)
->addInt64($f7g5_38)->addInt64($f8g4_19)->addInt64($f9g3_38);
// $h3 = $f0g3 + $f1g2 + $f2g1 + $f3g0 + $f4g9_19 + $f5g8_19 + $f6g7_19 + $f7g6_19 + $f8g5_19 + $f9g4_19;
$h3 = $f0g3->addInt64($f1g2)->addInt64($f2g1)->addInt64($f3g0)
->addInt64($f4g9_19)->addInt64($f5g8_19)->addInt64($f6g7_19)
->addInt64($f7g6_19)->addInt64($f8g5_19)->addInt64($f9g4_19);
// $h4 = $f0g4 + $f1g3_2 + $f2g2 + $f3g1_2 + $f4g0 + $f5g9_38 + $f6g8_19 + $f7g7_38 + $f8g6_19 + $f9g5_38;
$h4 = $f0g4->addInt64($f1g3_2)->addInt64($f2g2)->addInt64($f3g1_2)
->addInt64($f4g0)->addInt64($f5g9_38)->addInt64($f6g8_19)
->addInt64($f7g7_38)->addInt64($f8g6_19)->addInt64($f9g5_38);
// $h5 = $f0g5 + $f1g4 + $f2g3 + $f3g2 + $f4g1 + $f5g0 + $f6g9_19 + $f7g8_19 + $f8g7_19 + $f9g6_19;
$h5 = $f0g5->addInt64($f1g4)->addInt64($f2g3)->addInt64($f3g2)
->addInt64($f4g1)->addInt64($f5g0)->addInt64($f6g9_19)
->addInt64($f7g8_19)->addInt64($f8g7_19)->addInt64($f9g6_19);
// $h6 = $f0g6 + $f1g5_2 + $f2g4 + $f3g3_2 + $f4g2 + $f5g1_2 + $f6g0 + $f7g9_38 + $f8g8_19 + $f9g7_38;
$h6 = $f0g6->addInt64($f1g5_2)->addInt64($f2g4)->addInt64($f3g3_2)
->addInt64($f4g2)->addInt64($f5g1_2)->addInt64($f6g0)
->addInt64($f7g9_38)->addInt64($f8g8_19)->addInt64($f9g7_38);
// $h7 = $f0g7 + $f1g6 + $f2g5 + $f3g4 + $f4g3 + $f5g2 + $f6g1 + $f7g0 + $f8g9_19 + $f9g8_19;
$h7 = $f0g7->addInt64($f1g6)->addInt64($f2g5)->addInt64($f3g4)
->addInt64($f4g3)->addInt64($f5g2)->addInt64($f6g1)
->addInt64($f7g0)->addInt64($f8g9_19)->addInt64($f9g8_19);
// $h8 = $f0g8 + $f1g7_2 + $f2g6 + $f3g5_2 + $f4g4 + $f5g3_2 + $f6g2 + $f7g1_2 + $f8g0 + $f9g9_38;
$h8 = $f0g8->addInt64($f1g7_2)->addInt64($f2g6)->addInt64($f3g5_2)
->addInt64($f4g4)->addInt64($f5g3_2)->addInt64($f6g2)
->addInt64($f7g1_2)->addInt64($f8g0)->addInt64($f9g9_38);
// $h9 = $f0g9 + $f1g8 + $f2g7 + $f3g6 + $f4g5 + $f5g4 + $f6g3 + $f7g2 + $f8g1 + $f9g0 ;
$h9 = $f0g9->addInt64($f1g8)->addInt64($f2g7)->addInt64($f3g6)
->addInt64($f4g5)->addInt64($f5g4)->addInt64($f6g3)
->addInt64($f7g2)->addInt64($f8g1)->addInt64($f9g0);
/**
* @var ParagonIE_Sodium_Core32_Int64 $h0
* @var ParagonIE_Sodium_Core32_Int64 $h1
* @var ParagonIE_Sodium_Core32_Int64 $h2
* @var ParagonIE_Sodium_Core32_Int64 $h3
* @var ParagonIE_Sodium_Core32_Int64 $h4
* @var ParagonIE_Sodium_Core32_Int64 $h5
* @var ParagonIE_Sodium_Core32_Int64 $h6
* @var ParagonIE_Sodium_Core32_Int64 $h7
* @var ParagonIE_Sodium_Core32_Int64 $h8
* @var ParagonIE_Sodium_Core32_Int64 $h9
* @var ParagonIE_Sodium_Core32_Int64 $carry0
* @var ParagonIE_Sodium_Core32_Int64 $carry1
* @var ParagonIE_Sodium_Core32_Int64 $carry2
* @var ParagonIE_Sodium_Core32_Int64 $carry3
* @var ParagonIE_Sodium_Core32_Int64 $carry4
* @var ParagonIE_Sodium_Core32_Int64 $carry5
* @var ParagonIE_Sodium_Core32_Int64 $carry6
* @var ParagonIE_Sodium_Core32_Int64 $carry7
* @var ParagonIE_Sodium_Core32_Int64 $carry8
* @var ParagonIE_Sodium_Core32_Int64 $carry9
*/
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt64($carry0);
$h0 = $h0->subInt64($carry0->shiftLeft(26));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt64($carry4);
$h4 = $h4->subInt64($carry4->shiftLeft(26));
$carry1 = $h1->addInt(1 << 24)->shiftRight(25);
$h2 = $h2->addInt64($carry1);
$h1 = $h1->subInt64($carry1->shiftLeft(25));
$carry5 = $h5->addInt(1 << 24)->shiftRight(25);
$h6 = $h6->addInt64($carry5);
$h5 = $h5->subInt64($carry5->shiftLeft(25));
$carry2 = $h2->addInt(1 << 25)->shiftRight(26);
$h3 = $h3->addInt64($carry2);
$h2 = $h2->subInt64($carry2->shiftLeft(26));
$carry6 = $h6->addInt(1 << 25)->shiftRight(26);
$h7 = $h7->addInt64($carry6);
$h6 = $h6->subInt64($carry6->shiftLeft(26));
$carry3 = $h3->addInt(1 << 24)->shiftRight(25);
$h4 = $h4->addInt64($carry3);
$h3 = $h3->subInt64($carry3->shiftLeft(25));
$carry7 = $h7->addInt(1 << 24)->shiftRight(25);
$h8 = $h8->addInt64($carry7);
$h7 = $h7->subInt64($carry7->shiftLeft(25));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt64($carry4);
$h4 = $h4->subInt64($carry4->shiftLeft(26));
$carry8 = $h8->addInt(1 << 25)->shiftRight(26);
$h9 = $h9->addInt64($carry8);
$h8 = $h8->subInt64($carry8->shiftLeft(26));
$carry9 = $h9->addInt(1 << 24)->shiftRight(25);
$h0 = $h0->addInt64($carry9->mulInt(19, 5));
$h9 = $h9->subInt64($carry9->shiftLeft(25));
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt64($carry0);
$h0 = $h0->subInt64($carry0->shiftLeft(26));
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
$h0->toInt32(),
$h1->toInt32(),
$h2->toInt32(),
$h3->toInt32(),
$h4->toInt32(),
$h5->toInt32(),
$h6->toInt32(),
$h7->toInt32(),
$h8->toInt32(),
$h9->toInt32()
)
);
}
/**
* Get the negative values for each piece of the field element.
*
* h = -f
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedMethodCall
*/
public static function fe_neg(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
$h = new ParagonIE_Sodium_Core32_Curve25519_Fe();
for ($i = 0; $i < 10; ++$i) {
$h[$i] = $h[$i]->subInt32($f[$i]);
}
return $h;
}
/**
* Square a field element
*
* h = f * f
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedMethodCall
*/
public static function fe_sq(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
$f0 = $f[0]->toInt64();
$f1 = $f[1]->toInt64();
$f2 = $f[2]->toInt64();
$f3 = $f[3]->toInt64();
$f4 = $f[4]->toInt64();
$f5 = $f[5]->toInt64();
$f6 = $f[6]->toInt64();
$f7 = $f[7]->toInt64();
$f8 = $f[8]->toInt64();
$f9 = $f[9]->toInt64();
$f0_2 = $f0->shiftLeft(1);
$f1_2 = $f1->shiftLeft(1);
$f2_2 = $f2->shiftLeft(1);
$f3_2 = $f3->shiftLeft(1);
$f4_2 = $f4->shiftLeft(1);
$f5_2 = $f5->shiftLeft(1);
$f6_2 = $f6->shiftLeft(1);
$f7_2 = $f7->shiftLeft(1);
$f5_38 = $f5->mulInt(38, 6);
$f6_19 = $f6->mulInt(19, 5);
$f7_38 = $f7->mulInt(38, 6);
$f8_19 = $f8->mulInt(19, 5);
$f9_38 = $f9->mulInt(38, 6);
$f0f0 = $f0->mulInt64($f0, 28);
$f0f1_2 = $f0_2->mulInt64($f1, 28);
$f0f2_2 = $f0_2->mulInt64($f2, 28);
$f0f3_2 = $f0_2->mulInt64($f3, 28);
$f0f4_2 = $f0_2->mulInt64($f4, 28);
$f0f5_2 = $f0_2->mulInt64($f5, 28);
$f0f6_2 = $f0_2->mulInt64($f6, 28);
$f0f7_2 = $f0_2->mulInt64($f7, 28);
$f0f8_2 = $f0_2->mulInt64($f8, 28);
$f0f9_2 = $f0_2->mulInt64($f9, 28);
$f1f1_2 = $f1_2->mulInt64($f1, 28);
$f1f2_2 = $f1_2->mulInt64($f2, 28);
$f1f3_4 = $f1_2->mulInt64($f3_2, 28);
$f1f4_2 = $f1_2->mulInt64($f4, 28);
$f1f5_4 = $f1_2->mulInt64($f5_2, 30);
$f1f6_2 = $f1_2->mulInt64($f6, 28);
$f1f7_4 = $f1_2->mulInt64($f7_2, 28);
$f1f8_2 = $f1_2->mulInt64($f8, 28);
$f1f9_76 = $f9_38->mulInt64($f1_2, 30);
$f2f2 = $f2->mulInt64($f2, 28);
$f2f3_2 = $f2_2->mulInt64($f3, 28);
$f2f4_2 = $f2_2->mulInt64($f4, 28);
$f2f5_2 = $f2_2->mulInt64($f5, 28);
$f2f6_2 = $f2_2->mulInt64($f6, 28);
$f2f7_2 = $f2_2->mulInt64($f7, 28);
$f2f8_38 = $f8_19->mulInt64($f2_2, 30);
$f2f9_38 = $f9_38->mulInt64($f2, 30);
$f3f3_2 = $f3_2->mulInt64($f3, 28);
$f3f4_2 = $f3_2->mulInt64($f4, 28);
$f3f5_4 = $f3_2->mulInt64($f5_2, 30);
$f3f6_2 = $f3_2->mulInt64($f6, 28);
$f3f7_76 = $f7_38->mulInt64($f3_2, 30);
$f3f8_38 = $f8_19->mulInt64($f3_2, 30);
$f3f9_76 = $f9_38->mulInt64($f3_2, 30);
$f4f4 = $f4->mulInt64($f4, 28);
$f4f5_2 = $f4_2->mulInt64($f5, 28);
$f4f6_38 = $f6_19->mulInt64($f4_2, 30);
$f4f7_38 = $f7_38->mulInt64($f4, 30);
$f4f8_38 = $f8_19->mulInt64($f4_2, 30);
$f4f9_38 = $f9_38->mulInt64($f4, 30);
$f5f5_38 = $f5_38->mulInt64($f5, 30);
$f5f6_38 = $f6_19->mulInt64($f5_2, 30);
$f5f7_76 = $f7_38->mulInt64($f5_2, 30);
$f5f8_38 = $f8_19->mulInt64($f5_2, 30);
$f5f9_76 = $f9_38->mulInt64($f5_2, 30);
$f6f6_19 = $f6_19->mulInt64($f6, 30);
$f6f7_38 = $f7_38->mulInt64($f6, 30);
$f6f8_38 = $f8_19->mulInt64($f6_2, 30);
$f6f9_38 = $f9_38->mulInt64($f6, 30);
$f7f7_38 = $f7_38->mulInt64($f7, 28);
$f7f8_38 = $f8_19->mulInt64($f7_2, 30);
$f7f9_76 = $f9_38->mulInt64($f7_2, 30);
$f8f8_19 = $f8_19->mulInt64($f8, 30);
$f8f9_38 = $f9_38->mulInt64($f8, 30);
$f9f9_38 = $f9_38->mulInt64($f9, 28);
$h0 = $f0f0->addInt64($f1f9_76)->addInt64($f2f8_38)->addInt64($f3f7_76)->addInt64($f4f6_38)->addInt64($f5f5_38);
$h1 = $f0f1_2->addInt64($f2f9_38)->addInt64($f3f8_38)->addInt64($f4f7_38)->addInt64($f5f6_38);
$h2 = $f0f2_2->addInt64($f1f1_2)->addInt64($f3f9_76)->addInt64($f4f8_38)->addInt64($f5f7_76)->addInt64($f6f6_19);
$h3 = $f0f3_2->addInt64($f1f2_2)->addInt64($f4f9_38)->addInt64($f5f8_38)->addInt64($f6f7_38);
$h4 = $f0f4_2->addInt64($f1f3_4)->addInt64($f2f2)->addInt64($f5f9_76)->addInt64($f6f8_38)->addInt64($f7f7_38);
$h5 = $f0f5_2->addInt64($f1f4_2)->addInt64($f2f3_2)->addInt64($f6f9_38)->addInt64($f7f8_38);
$h6 = $f0f6_2->addInt64($f1f5_4)->addInt64($f2f4_2)->addInt64($f3f3_2)->addInt64($f7f9_76)->addInt64($f8f8_19);
$h7 = $f0f7_2->addInt64($f1f6_2)->addInt64($f2f5_2)->addInt64($f3f4_2)->addInt64($f8f9_38);
$h8 = $f0f8_2->addInt64($f1f7_4)->addInt64($f2f6_2)->addInt64($f3f5_4)->addInt64($f4f4)->addInt64($f9f9_38);
$h9 = $f0f9_2->addInt64($f1f8_2)->addInt64($f2f7_2)->addInt64($f3f6_2)->addInt64($f4f5_2);
/**
* @var ParagonIE_Sodium_Core32_Int64 $h0
* @var ParagonIE_Sodium_Core32_Int64 $h1
* @var ParagonIE_Sodium_Core32_Int64 $h2
* @var ParagonIE_Sodium_Core32_Int64 $h3
* @var ParagonIE_Sodium_Core32_Int64 $h4
* @var ParagonIE_Sodium_Core32_Int64 $h5
* @var ParagonIE_Sodium_Core32_Int64 $h6
* @var ParagonIE_Sodium_Core32_Int64 $h7
* @var ParagonIE_Sodium_Core32_Int64 $h8
* @var ParagonIE_Sodium_Core32_Int64 $h9
*/
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt64($carry0);
$h0 = $h0->subInt64($carry0->shiftLeft(26));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt64($carry4);
$h4 = $h4->subInt64($carry4->shiftLeft(26));
$carry1 = $h1->addInt(1 << 24)->shiftRight(25);
$h2 = $h2->addInt64($carry1);
$h1 = $h1->subInt64($carry1->shiftLeft(25));
$carry5 = $h5->addInt(1 << 24)->shiftRight(25);
$h6 = $h6->addInt64($carry5);
$h5 = $h5->subInt64($carry5->shiftLeft(25));
$carry2 = $h2->addInt(1 << 25)->shiftRight(26);
$h3 = $h3->addInt64($carry2);
$h2 = $h2->subInt64($carry2->shiftLeft(26));
$carry6 = $h6->addInt(1 << 25)->shiftRight(26);
$h7 = $h7->addInt64($carry6);
$h6 = $h6->subInt64($carry6->shiftLeft(26));
$carry3 = $h3->addInt(1 << 24)->shiftRight(25);
$h4 = $h4->addInt64($carry3);
$h3 = $h3->subInt64($carry3->shiftLeft(25));
$carry7 = $h7->addInt(1 << 24)->shiftRight(25);
$h8 = $h8->addInt64($carry7);
$h7 = $h7->subInt64($carry7->shiftLeft(25));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt64($carry4);
$h4 = $h4->subInt64($carry4->shiftLeft(26));
$carry8 = $h8->addInt(1 << 25)->shiftRight(26);
$h9 = $h9->addInt64($carry8);
$h8 = $h8->subInt64($carry8->shiftLeft(26));
$carry9 = $h9->addInt(1 << 24)->shiftRight(25);
$h0 = $h0->addInt64($carry9->mulInt(19, 5));
$h9 = $h9->subInt64($carry9->shiftLeft(25));
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt64($carry0);
$h0 = $h0->subInt64($carry0->shiftLeft(26));
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
$h0->toInt32(),
$h1->toInt32(),
$h2->toInt32(),
$h3->toInt32(),
$h4->toInt32(),
$h5->toInt32(),
$h6->toInt32(),
$h7->toInt32(),
$h8->toInt32(),
$h9->toInt32()
)
);
}
/**
* Square and double a field element
*
* h = 2 * f * f
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedMethodCall
*/
public static function fe_sq2(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
$f0 = $f[0]->toInt64();
$f1 = $f[1]->toInt64();
$f2 = $f[2]->toInt64();
$f3 = $f[3]->toInt64();
$f4 = $f[4]->toInt64();
$f5 = $f[5]->toInt64();
$f6 = $f[6]->toInt64();
$f7 = $f[7]->toInt64();
$f8 = $f[8]->toInt64();
$f9 = $f[9]->toInt64();
$f0_2 = $f0->shiftLeft(1);
$f1_2 = $f1->shiftLeft(1);
$f2_2 = $f2->shiftLeft(1);
$f3_2 = $f3->shiftLeft(1);
$f4_2 = $f4->shiftLeft(1);
$f5_2 = $f5->shiftLeft(1);
$f6_2 = $f6->shiftLeft(1);
$f7_2 = $f7->shiftLeft(1);
$f5_38 = $f5->mulInt(38, 6); /* 1.959375*2^30 */
$f6_19 = $f6->mulInt(19, 5); /* 1.959375*2^30 */
$f7_38 = $f7->mulInt(38, 6); /* 1.959375*2^30 */
$f8_19 = $f8->mulInt(19, 5); /* 1.959375*2^30 */
$f9_38 = $f9->mulInt(38, 6); /* 1.959375*2^30 */
$f0f0 = $f0->mulInt64($f0, 28);
$f0f1_2 = $f0_2->mulInt64($f1, 28);
$f0f2_2 = $f0_2->mulInt64($f2, 28);
$f0f3_2 = $f0_2->mulInt64($f3, 28);
$f0f4_2 = $f0_2->mulInt64($f4, 28);
$f0f5_2 = $f0_2->mulInt64($f5, 28);
$f0f6_2 = $f0_2->mulInt64($f6, 28);
$f0f7_2 = $f0_2->mulInt64($f7, 28);
$f0f8_2 = $f0_2->mulInt64($f8, 28);
$f0f9_2 = $f0_2->mulInt64($f9, 28);
$f1f1_2 = $f1_2->mulInt64($f1, 28);
$f1f2_2 = $f1_2->mulInt64($f2, 28);
$f1f3_4 = $f1_2->mulInt64($f3_2, 29);
$f1f4_2 = $f1_2->mulInt64($f4, 28);
$f1f5_4 = $f1_2->mulInt64($f5_2, 29);
$f1f6_2 = $f1_2->mulInt64($f6, 28);
$f1f7_4 = $f1_2->mulInt64($f7_2, 29);
$f1f8_2 = $f1_2->mulInt64($f8, 28);
$f1f9_76 = $f9_38->mulInt64($f1_2, 29);
$f2f2 = $f2->mulInt64($f2, 28);
$f2f3_2 = $f2_2->mulInt64($f3, 28);
$f2f4_2 = $f2_2->mulInt64($f4, 28);
$f2f5_2 = $f2_2->mulInt64($f5, 28);
$f2f6_2 = $f2_2->mulInt64($f6, 28);
$f2f7_2 = $f2_2->mulInt64($f7, 28);
$f2f8_38 = $f8_19->mulInt64($f2_2, 29);
$f2f9_38 = $f9_38->mulInt64($f2, 29);
$f3f3_2 = $f3_2->mulInt64($f3, 28);
$f3f4_2 = $f3_2->mulInt64($f4, 28);
$f3f5_4 = $f3_2->mulInt64($f5_2, 28);
$f3f6_2 = $f3_2->mulInt64($f6, 28);
$f3f7_76 = $f7_38->mulInt64($f3_2, 29);
$f3f8_38 = $f8_19->mulInt64($f3_2, 29);
$f3f9_76 = $f9_38->mulInt64($f3_2, 29);
$f4f4 = $f4->mulInt64($f4, 28);
$f4f5_2 = $f4_2->mulInt64($f5, 28);
$f4f6_38 = $f6_19->mulInt64($f4_2, 29);
$f4f7_38 = $f7_38->mulInt64($f4, 29);
$f4f8_38 = $f8_19->mulInt64($f4_2, 29);
$f4f9_38 = $f9_38->mulInt64($f4, 29);
$f5f5_38 = $f5_38->mulInt64($f5, 29);
$f5f6_38 = $f6_19->mulInt64($f5_2, 29);
$f5f7_76 = $f7_38->mulInt64($f5_2, 29);
$f5f8_38 = $f8_19->mulInt64($f5_2, 29);
$f5f9_76 = $f9_38->mulInt64($f5_2, 29);
$f6f6_19 = $f6_19->mulInt64($f6, 29);
$f6f7_38 = $f7_38->mulInt64($f6, 29);
$f6f8_38 = $f8_19->mulInt64($f6_2, 29);
$f6f9_38 = $f9_38->mulInt64($f6, 29);
$f7f7_38 = $f7_38->mulInt64($f7, 29);
$f7f8_38 = $f8_19->mulInt64($f7_2, 29);
$f7f9_76 = $f9_38->mulInt64($f7_2, 29);
$f8f8_19 = $f8_19->mulInt64($f8, 29);
$f8f9_38 = $f9_38->mulInt64($f8, 29);
$f9f9_38 = $f9_38->mulInt64($f9, 29);
$h0 = $f0f0->addInt64($f1f9_76)->addInt64($f2f8_38)->addInt64($f3f7_76)->addInt64($f4f6_38)->addInt64($f5f5_38);
$h1 = $f0f1_2->addInt64($f2f9_38)->addInt64($f3f8_38)->addInt64($f4f7_38)->addInt64($f5f6_38);
$h2 = $f0f2_2->addInt64($f1f1_2)->addInt64($f3f9_76)->addInt64($f4f8_38)->addInt64($f5f7_76)->addInt64($f6f6_19);
$h3 = $f0f3_2->addInt64($f1f2_2)->addInt64($f4f9_38)->addInt64($f5f8_38)->addInt64($f6f7_38);
$h4 = $f0f4_2->addInt64($f1f3_4)->addInt64($f2f2)->addInt64($f5f9_76)->addInt64($f6f8_38)->addInt64($f7f7_38);
$h5 = $f0f5_2->addInt64($f1f4_2)->addInt64($f2f3_2)->addInt64($f6f9_38)->addInt64($f7f8_38);
$h6 = $f0f6_2->addInt64($f1f5_4)->addInt64($f2f4_2)->addInt64($f3f3_2)->addInt64($f7f9_76)->addInt64($f8f8_19);
$h7 = $f0f7_2->addInt64($f1f6_2)->addInt64($f2f5_2)->addInt64($f3f4_2)->addInt64($f8f9_38);
$h8 = $f0f8_2->addInt64($f1f7_4)->addInt64($f2f6_2)->addInt64($f3f5_4)->addInt64($f4f4)->addInt64($f9f9_38);
$h9 = $f0f9_2->addInt64($f1f8_2)->addInt64($f2f7_2)->addInt64($f3f6_2)->addInt64($f4f5_2);
/**
* @var ParagonIE_Sodium_Core32_Int64 $h0
* @var ParagonIE_Sodium_Core32_Int64 $h1
* @var ParagonIE_Sodium_Core32_Int64 $h2
* @var ParagonIE_Sodium_Core32_Int64 $h3
* @var ParagonIE_Sodium_Core32_Int64 $h4
* @var ParagonIE_Sodium_Core32_Int64 $h5
* @var ParagonIE_Sodium_Core32_Int64 $h6
* @var ParagonIE_Sodium_Core32_Int64 $h7
* @var ParagonIE_Sodium_Core32_Int64 $h8
* @var ParagonIE_Sodium_Core32_Int64 $h9
*/
$h0 = $h0->shiftLeft(1);
$h1 = $h1->shiftLeft(1);
$h2 = $h2->shiftLeft(1);
$h3 = $h3->shiftLeft(1);
$h4 = $h4->shiftLeft(1);
$h5 = $h5->shiftLeft(1);
$h6 = $h6->shiftLeft(1);
$h7 = $h7->shiftLeft(1);
$h8 = $h8->shiftLeft(1);
$h9 = $h9->shiftLeft(1);
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt64($carry0);
$h0 = $h0->subInt64($carry0->shiftLeft(26));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt64($carry4);
$h4 = $h4->subInt64($carry4->shiftLeft(26));
$carry1 = $h1->addInt(1 << 24)->shiftRight(25);
$h2 = $h2->addInt64($carry1);
$h1 = $h1->subInt64($carry1->shiftLeft(25));
$carry5 = $h5->addInt(1 << 24)->shiftRight(25);
$h6 = $h6->addInt64($carry5);
$h5 = $h5->subInt64($carry5->shiftLeft(25));
$carry2 = $h2->addInt(1 << 25)->shiftRight(26);
$h3 = $h3->addInt64($carry2);
$h2 = $h2->subInt64($carry2->shiftLeft(26));
$carry6 = $h6->addInt(1 << 25)->shiftRight(26);
$h7 = $h7->addInt64($carry6);
$h6 = $h6->subInt64($carry6->shiftLeft(26));
$carry3 = $h3->addInt(1 << 24)->shiftRight(25);
$h4 = $h4->addInt64($carry3);
$h3 = $h3->subInt64($carry3->shiftLeft(25));
$carry7 = $h7->addInt(1 << 24)->shiftRight(25);
$h8 = $h8->addInt64($carry7);
$h7 = $h7->subInt64($carry7->shiftLeft(25));
$carry4 = $h4->addInt(1 << 25)->shiftRight(26);
$h5 = $h5->addInt64($carry4);
$h4 = $h4->subInt64($carry4->shiftLeft(26));
$carry8 = $h8->addInt(1 << 25)->shiftRight(26);
$h9 = $h9->addInt64($carry8);
$h8 = $h8->subInt64($carry8->shiftLeft(26));
$carry9 = $h9->addInt(1 << 24)->shiftRight(25);
$h0 = $h0->addInt64($carry9->mulInt(19, 5));
$h9 = $h9->subInt64($carry9->shiftLeft(25));
$carry0 = $h0->addInt(1 << 25)->shiftRight(26);
$h1 = $h1->addInt64($carry0);
$h0 = $h0->subInt64($carry0->shiftLeft(26));
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
$h0->toInt32(),
$h1->toInt32(),
$h2->toInt32(),
$h3->toInt32(),
$h4->toInt32(),
$h5->toInt32(),
$h6->toInt32(),
$h7->toInt32(),
$h8->toInt32(),
$h9->toInt32()
)
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $Z
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
*/
public static function fe_invert(ParagonIE_Sodium_Core32_Curve25519_Fe $Z)
{
$z = clone $Z;
$t0 = self::fe_sq($z);
$t1 = self::fe_sq($t0);
$t1 = self::fe_sq($t1);
$t1 = self::fe_mul($z, $t1);
$t0 = self::fe_mul($t0, $t1);
$t2 = self::fe_sq($t0);
$t1 = self::fe_mul($t1, $t2);
$t2 = self::fe_sq($t1);
for ($i = 1; $i < 5; ++$i) {
$t2 = self::fe_sq($t2);
}
$t1 = self::fe_mul($t2, $t1);
$t2 = self::fe_sq($t1);
for ($i = 1; $i < 10; ++$i) {
$t2 = self::fe_sq($t2);
}
$t2 = self::fe_mul($t2, $t1);
$t3 = self::fe_sq($t2);
for ($i = 1; $i < 20; ++$i) {
$t3 = self::fe_sq($t3);
}
$t2 = self::fe_mul($t3, $t2);
$t2 = self::fe_sq($t2);
for ($i = 1; $i < 10; ++$i) {
$t2 = self::fe_sq($t2);
}
$t1 = self::fe_mul($t2, $t1);
$t2 = self::fe_sq($t1);
for ($i = 1; $i < 50; ++$i) {
$t2 = self::fe_sq($t2);
}
$t2 = self::fe_mul($t2, $t1);
$t3 = self::fe_sq($t2);
for ($i = 1; $i < 100; ++$i) {
$t3 = self::fe_sq($t3);
}
$t2 = self::fe_mul($t3, $t2);
$t2 = self::fe_sq($t2);
for ($i = 1; $i < 50; ++$i) {
$t2 = self::fe_sq($t2);
}
$t1 = self::fe_mul($t2, $t1);
$t1 = self::fe_sq($t1);
for ($i = 1; $i < 5; ++$i) {
$t1 = self::fe_sq($t1);
}
return self::fe_mul($t1, $t0);
}
/**
* @internal You should not use this directly from another application
*
* @ref https://github.com/jedisct1/libsodium/blob/68564326e1e9dc57ef03746f85734232d20ca6fb/src/libsodium/crypto_core/curve25519/ref10/curve25519_ref10.c#L1054-L1106
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $z
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
*/
public static function fe_pow22523(ParagonIE_Sodium_Core32_Curve25519_Fe $z)
{
# fe_sq(t0, z);
# fe_sq(t1, t0);
# fe_sq(t1, t1);
# fe_mul(t1, z, t1);
# fe_mul(t0, t0, t1);
# fe_sq(t0, t0);
# fe_mul(t0, t1, t0);
# fe_sq(t1, t0);
$t0 = self::fe_sq($z);
$t1 = self::fe_sq($t0);
$t1 = self::fe_sq($t1);
$t1 = self::fe_mul($z, $t1);
$t0 = self::fe_mul($t0, $t1);
$t0 = self::fe_sq($t0);
$t0 = self::fe_mul($t1, $t0);
$t1 = self::fe_sq($t0);
# for (i = 1; i < 5; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 5; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t0, t1, t0);
# fe_sq(t1, t0);
$t0 = self::fe_mul($t1, $t0);
$t1 = self::fe_sq($t0);
# for (i = 1; i < 10; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 10; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t1, t1, t0);
# fe_sq(t2, t1);
$t1 = self::fe_mul($t1, $t0);
$t2 = self::fe_sq($t1);
# for (i = 1; i < 20; ++i) {
# fe_sq(t2, t2);
# }
for ($i = 1; $i < 20; ++$i) {
$t2 = self::fe_sq($t2);
}
# fe_mul(t1, t2, t1);
# fe_sq(t1, t1);
$t1 = self::fe_mul($t2, $t1);
$t1 = self::fe_sq($t1);
# for (i = 1; i < 10; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 10; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t0, t1, t0);
# fe_sq(t1, t0);
$t0 = self::fe_mul($t1, $t0);
$t1 = self::fe_sq($t0);
# for (i = 1; i < 50; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 50; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t1, t1, t0);
# fe_sq(t2, t1);
$t1 = self::fe_mul($t1, $t0);
$t2 = self::fe_sq($t1);
# for (i = 1; i < 100; ++i) {
# fe_sq(t2, t2);
# }
for ($i = 1; $i < 100; ++$i) {
$t2 = self::fe_sq($t2);
}
# fe_mul(t1, t2, t1);
# fe_sq(t1, t1);
$t1 = self::fe_mul($t2, $t1);
$t1 = self::fe_sq($t1);
# for (i = 1; i < 50; ++i) {
# fe_sq(t1, t1);
# }
for ($i = 1; $i < 50; ++$i) {
$t1 = self::fe_sq($t1);
}
# fe_mul(t0, t1, t0);
# fe_sq(t0, t0);
# fe_sq(t0, t0);
# fe_mul(out, t0, z);
$t0 = self::fe_mul($t1, $t0);
$t0 = self::fe_sq($t0);
$t0 = self::fe_sq($t0);
return self::fe_mul($t0, $z);
}
/**
* Subtract two field elements.
*
* h = f - g
*
* Preconditions:
* |f| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc.
* |g| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc.
*
* Postconditions:
* |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $g
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedMethodCall
* @psalm-suppress MixedTypeCoercion
*/
public static function fe_sub(ParagonIE_Sodium_Core32_Curve25519_Fe $f, ParagonIE_Sodium_Core32_Curve25519_Fe $g)
{
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
$f[0]->subInt32($g[0]),
$f[1]->subInt32($g[1]),
$f[2]->subInt32($g[2]),
$f[3]->subInt32($g[3]),
$f[4]->subInt32($g[4]),
$f[5]->subInt32($g[5]),
$f[6]->subInt32($g[6]),
$f[7]->subInt32($g[7]),
$f[8]->subInt32($g[8]),
$f[9]->subInt32($g[9])
)
);
}
/**
* Add two group elements.
*
* r = p + q
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_Cached $q
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
* @throws SodiumException
* @throws TypeError
*/
public static function ge_add(
ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core32_Curve25519_Ge_Cached $q
) {
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1();
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->YplusX);
$r->Y = self::fe_mul($r->Y, $q->YminusX);
$r->T = self::fe_mul($q->T2d, $p->T);
$r->X = self::fe_mul($p->Z, $q->Z);
$t0 = self::fe_add($r->X, $r->X);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_add($t0, $r->T);
$r->T = self::fe_sub($t0, $r->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @ref https://github.com/jedisct1/libsodium/blob/157c4a80c13b117608aeae12178b2d38825f9f8f/src/libsodium/crypto_core/curve25519/ref10/curve25519_ref10.c#L1185-L1215
* @param string $a
* @return array<int, mixed>
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayOffset
*/
public static function slide($a)
{
if (self::strlen($a) < 256) {
if (self::strlen($a) < 16) {
$a = str_pad($a, 256, '0', STR_PAD_RIGHT);
}
}
/** @var array<int, int> $r */
$r = array();
for ($i = 0; $i < 256; ++$i) {
$r[$i] = (int) (1 &
(
self::chrToInt($a[$i >> 3])
>>
($i & 7)
)
);
}
for ($i = 0;$i < 256;++$i) {
if ($r[$i]) {
for ($b = 1;$b <= 6 && $i + $b < 256;++$b) {
if ($r[$i + $b]) {
if ($r[$i] + ($r[$i + $b] << $b) <= 15) {
$r[$i] += $r[$i + $b] << $b;
$r[$i + $b] = 0;
} elseif ($r[$i] - ($r[$i + $b] << $b) >= -15) {
$r[$i] -= $r[$i + $b] << $b;
for ($k = $i + $b; $k < 256; ++$k) {
if (!$r[$k]) {
$r[$k] = 1;
break;
}
$r[$k] = 0;
}
} else {
break;
}
}
}
}
}
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param string $s
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
*/
public static function ge_frombytes_negate_vartime($s)
{
static $d = null;
if (!$d) {
$d = ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[0]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[1]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[2]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[3]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[4]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[5]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[6]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[7]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[8]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d[9])
)
);
}
/** @var ParagonIE_Sodium_Core32_Curve25519_Fe $d */
# fe_frombytes(h->Y,s);
# fe_1(h->Z);
$h = new ParagonIE_Sodium_Core32_Curve25519_Ge_P3(
self::fe_0(),
self::fe_frombytes($s),
self::fe_1()
);
# fe_sq(u,h->Y);
# fe_mul(v,u,d);
# fe_sub(u,u,h->Z); /* u = y^2-1 */
# fe_add(v,v,h->Z); /* v = dy^2+1 */
$u = self::fe_sq($h->Y);
/** @var ParagonIE_Sodium_Core32_Curve25519_Fe $d */
$v = self::fe_mul($u, $d);
$u = self::fe_sub($u, $h->Z); /* u = y^2 - 1 */
$v = self::fe_add($v, $h->Z); /* v = dy^2 + 1 */
# fe_sq(v3,v);
# fe_mul(v3,v3,v); /* v3 = v^3 */
# fe_sq(h->X,v3);
# fe_mul(h->X,h->X,v);
# fe_mul(h->X,h->X,u); /* x = uv^7 */
$v3 = self::fe_sq($v);
$v3 = self::fe_mul($v3, $v); /* v3 = v^3 */
$h->X = self::fe_sq($v3);
$h->X = self::fe_mul($h->X, $v);
$h->X = self::fe_mul($h->X, $u); /* x = uv^7 */
# fe_pow22523(h->X,h->X); /* x = (uv^7)^((q-5)/8) */
# fe_mul(h->X,h->X,v3);
# fe_mul(h->X,h->X,u); /* x = uv^3(uv^7)^((q-5)/8) */
$h->X = self::fe_pow22523($h->X); /* x = (uv^7)^((q-5)/8) */
$h->X = self::fe_mul($h->X, $v3);
$h->X = self::fe_mul($h->X, $u); /* x = uv^3(uv^7)^((q-5)/8) */
# fe_sq(vxx,h->X);
# fe_mul(vxx,vxx,v);
# fe_sub(check,vxx,u); /* vx^2-u */
$vxx = self::fe_sq($h->X);
$vxx = self::fe_mul($vxx, $v);
$check = self::fe_sub($vxx, $u); /* vx^2 - u */
# if (fe_isnonzero(check)) {
# fe_add(check,vxx,u); /* vx^2+u */
# if (fe_isnonzero(check)) {
# return -1;
# }
# fe_mul(h->X,h->X,sqrtm1);
# }
if (self::fe_isnonzero($check)) {
$check = self::fe_add($vxx, $u); /* vx^2 + u */
if (self::fe_isnonzero($check)) {
throw new RangeException('Internal check failed.');
}
$h->X = self::fe_mul(
$h->X,
ParagonIE_Sodium_Core32_Curve25519_Fe::fromIntArray(self::$sqrtm1)
);
}
# if (fe_isnegative(h->X) == (s[31] >> 7)) {
# fe_neg(h->X,h->X);
# }
$i = self::chrToInt($s[31]);
if (self::fe_isnegative($h->X) === ($i >> 7)) {
$h->X = self::fe_neg($h->X);
}
# fe_mul(h->T,h->X,h->Y);
$h->T = self::fe_mul($h->X, $h->Y);
return $h;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $R
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $q
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
* @throws SodiumException
* @throws TypeError
*/
public static function ge_madd(
ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $R,
ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $q
) {
$r = clone $R;
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->yplusx);
$r->Y = self::fe_mul($r->Y, $q->yminusx);
$r->T = self::fe_mul($q->xy2d, $p->T);
$t0 = self::fe_add(clone $p->Z, clone $p->Z);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_add($t0, $r->T);
$r->T = self::fe_sub($t0, $r->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $R
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $q
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
* @throws SodiumException
* @throws TypeError
*/
public static function ge_msub(
ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $R,
ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $q
) {
$r = clone $R;
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->yminusx);
$r->Y = self::fe_mul($r->Y, $q->yplusx);
$r->T = self::fe_mul($q->xy2d, $p->T);
$t0 = self::fe_add($p->Z, $p->Z);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_sub($t0, $r->T);
$r->T = self::fe_add($t0, $r->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $p
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P2
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p1p1_to_p2(ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $p)
{
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_P2();
$r->X = self::fe_mul($p->X, $p->T);
$r->Y = self::fe_mul($p->Y, $p->Z);
$r->Z = self::fe_mul($p->Z, $p->T);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $p
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p1p1_to_p3(ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1 $p)
{
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_P3();
$r->X = self::fe_mul($p->X, $p->T);
$r->Y = self::fe_mul($p->Y, $p->Z);
$r->Z = self::fe_mul($p->Z, $p->T);
$r->T = self::fe_mul($p->X, $p->Y);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P2
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p2_0()
{
return new ParagonIE_Sodium_Core32_Curve25519_Ge_P2(
self::fe_0(),
self::fe_1(),
self::fe_1()
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P2 $p
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p2_dbl(ParagonIE_Sodium_Core32_Curve25519_Ge_P2 $p)
{
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1();
$r->X = self::fe_sq($p->X);
$r->Z = self::fe_sq($p->Y);
$r->T = self::fe_sq2($p->Z);
$r->Y = self::fe_add($p->X, $p->Y);
$t0 = self::fe_sq($r->Y);
$r->Y = self::fe_add($r->Z, $r->X);
$r->Z = self::fe_sub($r->Z, $r->X);
$r->X = self::fe_sub($t0, $r->Y);
$r->T = self::fe_sub($r->T, $r->Z);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p3_0()
{
return new ParagonIE_Sodium_Core32_Curve25519_Ge_P3(
self::fe_0(),
self::fe_1(),
self::fe_1(),
self::fe_0()
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_Cached
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p3_to_cached(ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p)
{
static $d2 = null;
if ($d2 === null) {
$d2 = ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[0]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[1]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[2]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[3]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[4]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[5]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[6]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[7]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[8]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$d2[9])
)
);
}
/** @var ParagonIE_Sodium_Core32_Curve25519_Fe $d2 */
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_Cached();
$r->YplusX = self::fe_add($p->Y, $p->X);
$r->YminusX = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_copy($p->Z);
$r->T2d = self::fe_mul($p->T, $d2);
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P2
*/
public static function ge_p3_to_p2(ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p)
{
return new ParagonIE_Sodium_Core32_Curve25519_Ge_P2(
$p->X,
$p->Y,
$p->Z
);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $h
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p3_tobytes(ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $h)
{
$recip = self::fe_invert($h->Z);
$x = self::fe_mul($h->X, $recip);
$y = self::fe_mul($h->Y, $recip);
$s = self::fe_tobytes($y);
$s[31] = self::intToChr(
self::chrToInt($s[31]) ^ (self::fe_isnegative($x) << 7)
);
return $s;
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
* @throws SodiumException
* @throws TypeError
*/
public static function ge_p3_dbl(ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p)
{
$q = self::ge_p3_to_p2($p);
return self::ge_p2_dbl($q);
}
/**
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp
* @throws SodiumException
* @throws TypeError
*/
public static function ge_precomp_0()
{
return new ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp(
self::fe_1(),
self::fe_1(),
self::fe_0()
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $b
* @param int $c
* @return int
* @psalm-suppress MixedReturnStatement
*/
public static function equal($b, $c)
{
$b0 = $b & 0xffff;
$b1 = ($b >> 16) & 0xffff;
$c0 = $c & 0xffff;
$c1 = ($c >> 16) & 0xffff;
$d0 = (($b0 ^ $c0) - 1) >> 31;
$d1 = (($b1 ^ $c1) - 1) >> 31;
return ($d0 & $d1) & 1;
}
/**
* @internal You should not use this directly from another application
*
* @param string|int $char
* @return int (1 = yes, 0 = no)
* @throws SodiumException
* @throws TypeError
*/
public static function negative($char)
{
if (is_int($char)) {
return $char < 0 ? 1 : 0;
}
/** @var string $char */
$x = self::chrToInt(self::substr($char, 0, 1));
return (int) ($x >> 31);
}
/**
* Conditional move
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $t
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $u
* @param int $b
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp
* @throws SodiumException
* @throws TypeError
*/
public static function cmov(
ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $t,
ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $u,
$b
) {
if (!is_int($b)) {
throw new InvalidArgumentException('Expected an integer.');
}
return new ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp(
self::fe_cmov($t->yplusx, $u->yplusx, $b),
self::fe_cmov($t->yminusx, $u->yminusx, $b),
self::fe_cmov($t->xy2d, $u->xy2d, $b)
);
}
/**
* @internal You should not use this directly from another application
*
* @param int $pos
* @param int $b
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArrayOffset
* @psalm-suppress MixedArgument
*/
public static function ge_select($pos = 0, $b = 0)
{
static $base = null;
if ($base === null) {
$base = array();
foreach (self::$base as $i => $bas) {
for ($j = 0; $j < 8; ++$j) {
$base[$i][$j] = new ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp(
ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][0]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][1]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][2]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][3]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][4]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][5]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][6]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][7]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][8]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][0][9])
)
),
ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][0]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][1]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][2]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][3]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][4]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][5]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][6]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][7]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][8]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][1][9])
)
),
ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][0]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][1]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][2]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][3]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][4]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][5]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][6]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][7]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][8]),
ParagonIE_Sodium_Core32_Int32::fromInt($bas[$j][2][9])
)
)
);
}
}
}
if (!is_int($pos)) {
throw new InvalidArgumentException('Position must be an integer');
}
if ($pos < 0 || $pos > 31) {
throw new RangeException('Position is out of range [0, 31]');
}
$bnegative = self::negative($b);
$babs = $b - (((-$bnegative) & $b) << 1);
$t = self::ge_precomp_0();
for ($i = 0; $i < 8; ++$i) {
$t = self::cmov(
$t,
$base[$pos][$i],
-self::equal($babs, $i + 1)
);
}
$minusT = new ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp(
self::fe_copy($t->yminusx),
self::fe_copy($t->yplusx),
self::fe_neg($t->xy2d)
);
return self::cmov($t, $minusT, -$bnegative);
}
/**
* Subtract two group elements.
*
* r = p - q
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_Cached $q
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1
* @throws SodiumException
* @throws TypeError
*/
public static function ge_sub(
ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $p,
ParagonIE_Sodium_Core32_Curve25519_Ge_Cached $q
) {
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1();
$r->X = self::fe_add($p->Y, $p->X);
$r->Y = self::fe_sub($p->Y, $p->X);
$r->Z = self::fe_mul($r->X, $q->YminusX);
$r->Y = self::fe_mul($r->Y, $q->YplusX);
$r->T = self::fe_mul($q->T2d, $p->T);
$r->X = self::fe_mul($p->Z, $q->Z);
$t0 = self::fe_add($r->X, $r->X);
$r->X = self::fe_sub($r->Z, $r->Y);
$r->Y = self::fe_add($r->Z, $r->Y);
$r->Z = self::fe_sub($t0, $r->T);
$r->T = self::fe_add($t0, $r->T);
return $r;
}
/**
* Convert a group element to a byte string.
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P2 $h
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ge_tobytes(ParagonIE_Sodium_Core32_Curve25519_Ge_P2 $h)
{
$recip = self::fe_invert($h->Z);
$x = self::fe_mul($h->X, $recip);
$y = self::fe_mul($h->Y, $recip);
$s = self::fe_tobytes($y);
$s[31] = self::intToChr(
self::chrToInt($s[31]) ^ (self::fe_isnegative($x) << 7)
);
return $s;
}
/**
* @internal You should not use this directly from another application
*
* @param string $a
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $A
* @param string $b
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P2
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
*/
public static function ge_double_scalarmult_vartime(
$a,
ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $A,
$b
) {
/** @var array<int, ParagonIE_Sodium_Core32_Curve25519_Ge_Cached> $Ai */
$Ai = array();
static $Bi = array();
/** @var array<int, ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp> $Bi */
if (!$Bi) {
for ($i = 0; $i < 8; ++$i) {
$Bi[$i] = new ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp(
ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][0]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][1]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][2]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][3]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][4]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][5]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][6]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][7]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][8]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][0][9])
)
),
ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][0]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][1]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][2]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][3]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][4]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][5]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][6]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][7]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][8]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][1][9])
)
),
ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray(
array(
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][0]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][1]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][2]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][3]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][4]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][5]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][6]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][7]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][8]),
ParagonIE_Sodium_Core32_Int32::fromInt(self::$base2[$i][2][9])
)
)
);
}
}
for ($i = 0; $i < 8; ++$i) {
$Ai[$i] = new ParagonIE_Sodium_Core32_Curve25519_Ge_Cached(
self::fe_0(),
self::fe_0(),
self::fe_0(),
self::fe_0()
);
}
/** @var array<int, ParagonIE_Sodium_Core32_Curve25519_Ge_Cached> $Ai */
# slide(aslide,a);
# slide(bslide,b);
/** @var array<int, int> $aslide */
$aslide = self::slide($a);
/** @var array<int, int> $bslide */
$bslide = self::slide($b);
# ge_p3_to_cached(&Ai[0],A);
# ge_p3_dbl(&t,A); ge_p1p1_to_p3(&A2,&t);
$Ai[0] = self::ge_p3_to_cached($A);
$t = self::ge_p3_dbl($A);
$A2 = self::ge_p1p1_to_p3($t);
# ge_add(&t,&A2,&Ai[0]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[1],&u);
# ge_add(&t,&A2,&Ai[1]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[2],&u);
# ge_add(&t,&A2,&Ai[2]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[3],&u);
# ge_add(&t,&A2,&Ai[3]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[4],&u);
# ge_add(&t,&A2,&Ai[4]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[5],&u);
# ge_add(&t,&A2,&Ai[5]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[6],&u);
# ge_add(&t,&A2,&Ai[6]); ge_p1p1_to_p3(&u,&t); ge_p3_to_cached(&Ai[7],&u);
for ($i = 0; $i < 7; ++$i) {
$t = self::ge_add($A2, $Ai[$i]);
$u = self::ge_p1p1_to_p3($t);
$Ai[$i + 1] = self::ge_p3_to_cached($u);
}
# ge_p2_0(r);
$r = self::ge_p2_0();
# for (i = 255;i >= 0;--i) {
# if (aslide[i] || bslide[i]) break;
# }
$i = 255;
for (; $i >= 0; --$i) {
if ($aslide[$i] || $bslide[$i]) {
break;
}
}
# for (;i >= 0;--i) {
for (; $i >= 0; --$i) {
# ge_p2_dbl(&t,r);
$t = self::ge_p2_dbl($r);
# if (aslide[i] > 0) {
if ($aslide[$i] > 0) {
# ge_p1p1_to_p3(&u,&t);
# ge_add(&t,&u,&Ai[aslide[i]/2]);
$u = self::ge_p1p1_to_p3($t);
$t = self::ge_add(
$u,
$Ai[(int) floor($aslide[$i] / 2)]
);
# } else if (aslide[i] < 0) {
} elseif ($aslide[$i] < 0) {
# ge_p1p1_to_p3(&u,&t);
# ge_sub(&t,&u,&Ai[(-aslide[i])/2]);
$u = self::ge_p1p1_to_p3($t);
$t = self::ge_sub(
$u,
$Ai[(int) floor(-$aslide[$i] / 2)]
);
}
/** @var array<int, ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp> $Bi */
# if (bslide[i] > 0) {
if ($bslide[$i] > 0) {
# ge_p1p1_to_p3(&u,&t);
# ge_madd(&t,&u,&Bi[bslide[i]/2]);
$u = self::ge_p1p1_to_p3($t);
/** @var int $index */
$index = (int) floor($bslide[$i] / 2);
/** @var ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $thisB */
$thisB = $Bi[$index];
$t = self::ge_madd($t, $u, $thisB);
# } else if (bslide[i] < 0) {
} elseif ($bslide[$i] < 0) {
# ge_p1p1_to_p3(&u,&t);
# ge_msub(&t,&u,&Bi[(-bslide[i])/2]);
$u = self::ge_p1p1_to_p3($t);
/** @var int $index */
$index = (int) floor(-$bslide[$i] / 2);
/** @var ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp $thisB */
$thisB = $Bi[$index];
$t = self::ge_msub($t, $u, $thisB);
}
# ge_p1p1_to_p2(r,&t);
$r = self::ge_p1p1_to_p2($t);
}
return $r;
}
/**
* @internal You should not use this directly from another application
*
* @param string $a
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P3
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedOperand
* @throws SodiumException
* @throws TypeError
*/
public static function ge_scalarmult_base($a)
{
/** @var array<int, int> $e */
$e = array();
$r = new ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1();
for ($i = 0; $i < 32; ++$i) {
/** @var int $dbl */
$dbl = (int) $i << 1;
$e[$dbl] = (int) self::chrToInt($a[$i]) & 15;
$e[$dbl + 1] = (int) (self::chrToInt($a[$i]) >> 4) & 15;
}
/** @var int $carry */
$carry = 0;
for ($i = 0; $i < 63; ++$i) {
$e[$i] += $carry;
$carry = $e[$i] + 8;
$carry >>= 4;
$e[$i] -= $carry << 4;
}
/** @var array<int, int> $e */
$e[63] += (int) $carry;
$h = self::ge_p3_0();
for ($i = 1; $i < 64; $i += 2) {
$t = self::ge_select((int) floor($i / 2), (int) $e[$i]);
$r = self::ge_madd($r, $h, $t);
$h = self::ge_p1p1_to_p3($r);
}
$r = self::ge_p3_dbl($h);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$s = self::ge_p1p1_to_p2($r);
$r = self::ge_p2_dbl($s);
$h = self::ge_p1p1_to_p3($r);
for ($i = 0; $i < 64; $i += 2) {
$t = self::ge_select($i >> 1, (int) $e[$i]);
$r = self::ge_madd($r, $h, $t);
$h = self::ge_p1p1_to_p3($r);
}
return $h;
}
/**
* Calculates (ab + c) mod l
* where l = 2^252 + 27742317777372353535851937790883648493
*
* @internal You should not use this directly from another application
*
* @param string $a
* @param string $b
* @param string $c
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sc_muladd($a, $b, $c)
{
$a0 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($a, 0, 3)));
$a1 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($a, 2, 4)) >> 5));
$a2 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($a, 5, 3)) >> 2));
$a3 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($a, 7, 4)) >> 7));
$a4 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($a, 10, 4)) >> 4));
$a5 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($a, 13, 3)) >> 1));
$a6 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($a, 15, 4)) >> 6));
$a7 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($a, 18, 3)) >> 3));
$a8 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($a, 21, 3)));
$a9 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($a, 23, 4)) >> 5));
$a10 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($a, 26, 3)) >> 2));
$a11 = ParagonIE_Sodium_Core32_Int64::fromInt(0x1fffffff & (self::load_4(self::substr($a, 28, 4)) >> 7));
$b0 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($b, 0, 3)));
$b1 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($b, 2, 4)) >> 5));
$b2 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($b, 5, 3)) >> 2));
$b3 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($b, 7, 4)) >> 7));
$b4 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($b, 10, 4)) >> 4));
$b5 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($b, 13, 3)) >> 1));
$b6 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($b, 15, 4)) >> 6));
$b7 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($b, 18, 3)) >> 3));
$b8 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($b, 21, 3)));
$b9 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($b, 23, 4)) >> 5));
$b10 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($b, 26, 3)) >> 2));
$b11 = ParagonIE_Sodium_Core32_Int64::fromInt(0x1fffffff & (self::load_4(self::substr($b, 28, 4)) >> 7));
$c0 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($c, 0, 3)));
$c1 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($c, 2, 4)) >> 5));
$c2 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($c, 5, 3)) >> 2));
$c3 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($c, 7, 4)) >> 7));
$c4 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($c, 10, 4)) >> 4));
$c5 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($c, 13, 3)) >> 1));
$c6 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($c, 15, 4)) >> 6));
$c7 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($c, 18, 3)) >> 3));
$c8 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($c, 21, 3)));
$c9 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($c, 23, 4)) >> 5));
$c10 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($c, 26, 3)) >> 2));
$c11 = ParagonIE_Sodium_Core32_Int64::fromInt(0x1fffffff & (self::load_4(self::substr($c, 28, 4)) >> 7));
/* Can't really avoid the pyramid here: */
/**
* @var ParagonIE_Sodium_Core32_Int64 $s0
* @var ParagonIE_Sodium_Core32_Int64 $s1
* @var ParagonIE_Sodium_Core32_Int64 $s2
* @var ParagonIE_Sodium_Core32_Int64 $s3
* @var ParagonIE_Sodium_Core32_Int64 $s4
* @var ParagonIE_Sodium_Core32_Int64 $s5
* @var ParagonIE_Sodium_Core32_Int64 $s6
* @var ParagonIE_Sodium_Core32_Int64 $s7
* @var ParagonIE_Sodium_Core32_Int64 $s8
* @var ParagonIE_Sodium_Core32_Int64 $s9
* @var ParagonIE_Sodium_Core32_Int64 $s10
* @var ParagonIE_Sodium_Core32_Int64 $s11
* @var ParagonIE_Sodium_Core32_Int64 $s12
* @var ParagonIE_Sodium_Core32_Int64 $s13
* @var ParagonIE_Sodium_Core32_Int64 $s14
* @var ParagonIE_Sodium_Core32_Int64 $s15
* @var ParagonIE_Sodium_Core32_Int64 $s16
* @var ParagonIE_Sodium_Core32_Int64 $s17
* @var ParagonIE_Sodium_Core32_Int64 $s18
* @var ParagonIE_Sodium_Core32_Int64 $s19
* @var ParagonIE_Sodium_Core32_Int64 $s20
* @var ParagonIE_Sodium_Core32_Int64 $s21
* @var ParagonIE_Sodium_Core32_Int64 $s22
* @var ParagonIE_Sodium_Core32_Int64 $s23
*/
$s0 = $c0->addInt64($a0->mulInt64($b0, 24));
$s1 = $c1->addInt64($a0->mulInt64($b1, 24))->addInt64($a1->mulInt64($b0, 24));
$s2 = $c2->addInt64($a0->mulInt64($b2, 24))->addInt64($a1->mulInt64($b1, 24))->addInt64($a2->mulInt64($b0, 24));
$s3 = $c3->addInt64($a0->mulInt64($b3, 24))->addInt64($a1->mulInt64($b2, 24))->addInt64($a2->mulInt64($b1, 24))
->addInt64($a3->mulInt64($b0, 24));
$s4 = $c4->addInt64($a0->mulInt64($b4, 24))->addInt64($a1->mulInt64($b3, 24))->addInt64($a2->mulInt64($b2, 24))
->addInt64($a3->mulInt64($b1, 24))->addInt64($a4->mulInt64($b0, 24));
$s5 = $c5->addInt64($a0->mulInt64($b5, 24))->addInt64($a1->mulInt64($b4, 24))->addInt64($a2->mulInt64($b3, 24))
->addInt64($a3->mulInt64($b2, 24))->addInt64($a4->mulInt64($b1, 24))->addInt64($a5->mulInt64($b0, 24));
$s6 = $c6->addInt64($a0->mulInt64($b6, 24))->addInt64($a1->mulInt64($b5, 24))->addInt64($a2->mulInt64($b4, 24))
->addInt64($a3->mulInt64($b3, 24))->addInt64($a4->mulInt64($b2, 24))->addInt64($a5->mulInt64($b1, 24))
->addInt64($a6->mulInt64($b0, 24));
$s7 = $c7->addInt64($a0->mulInt64($b7, 24))->addInt64($a1->mulInt64($b6, 24))->addInt64($a2->mulInt64($b5, 24))
->addInt64($a3->mulInt64($b4, 24))->addInt64($a4->mulInt64($b3, 24))->addInt64($a5->mulInt64($b2, 24))
->addInt64($a6->mulInt64($b1, 24))->addInt64($a7->mulInt64($b0, 24));
$s8 = $c8->addInt64($a0->mulInt64($b8, 24))->addInt64($a1->mulInt64($b7, 24))->addInt64($a2->mulInt64($b6, 24))
->addInt64($a3->mulInt64($b5, 24))->addInt64($a4->mulInt64($b4, 24))->addInt64($a5->mulInt64($b3, 24))
->addInt64($a6->mulInt64($b2, 24))->addInt64($a7->mulInt64($b1, 24))->addInt64($a8->mulInt64($b0, 24));
$s9 = $c9->addInt64($a0->mulInt64($b9, 24))->addInt64($a1->mulInt64($b8, 24))->addInt64($a2->mulInt64($b7, 24))
->addInt64($a3->mulInt64($b6, 24))->addInt64($a4->mulInt64($b5, 24))->addInt64($a5->mulInt64($b4, 24))
->addInt64($a6->mulInt64($b3, 24))->addInt64($a7->mulInt64($b2, 24))->addInt64($a8->mulInt64($b1, 24))
->addInt64($a9->mulInt64($b0, 24));
$s10 = $c10->addInt64($a0->mulInt64($b10, 24))->addInt64($a1->mulInt64($b9, 24))->addInt64($a2->mulInt64($b8, 24))
->addInt64($a3->mulInt64($b7, 24))->addInt64($a4->mulInt64($b6, 24))->addInt64($a5->mulInt64($b5, 24))
->addInt64($a6->mulInt64($b4, 24))->addInt64($a7->mulInt64($b3, 24))->addInt64($a8->mulInt64($b2, 24))
->addInt64($a9->mulInt64($b1, 24))->addInt64($a10->mulInt64($b0, 24));
$s11 = $c11->addInt64($a0->mulInt64($b11, 24))->addInt64($a1->mulInt64($b10, 24))->addInt64($a2->mulInt64($b9, 24))
->addInt64($a3->mulInt64($b8, 24))->addInt64($a4->mulInt64($b7, 24))->addInt64($a5->mulInt64($b6, 24))
->addInt64($a6->mulInt64($b5, 24))->addInt64($a7->mulInt64($b4, 24))->addInt64($a8->mulInt64($b3, 24))
->addInt64($a9->mulInt64($b2, 24))->addInt64($a10->mulInt64($b1, 24))->addInt64($a11->mulInt64($b0, 24));
$s12 = $a1->mulInt64($b11, 24)->addInt64($a2->mulInt64($b10, 24))->addInt64($a3->mulInt64($b9, 24))
->addInt64($a4->mulInt64($b8, 24))->addInt64($a5->mulInt64($b7, 24))->addInt64($a6->mulInt64($b6, 24))
->addInt64($a7->mulInt64($b5, 24))->addInt64($a8->mulInt64($b4, 24))->addInt64($a9->mulInt64($b3, 24))
->addInt64($a10->mulInt64($b2, 24))->addInt64($a11->mulInt64($b1, 24));
$s13 = $a2->mulInt64($b11, 24)->addInt64($a3->mulInt64($b10, 24))->addInt64($a4->mulInt64($b9, 24))
->addInt64($a5->mulInt64($b8, 24))->addInt64($a6->mulInt64($b7, 24))->addInt64($a7->mulInt64($b6, 24))
->addInt64($a8->mulInt64($b5, 24))->addInt64($a9->mulInt64($b4, 24))->addInt64($a10->mulInt64($b3, 24))
->addInt64($a11->mulInt64($b2, 24));
$s14 = $a3->mulInt64($b11, 24)->addInt64($a4->mulInt64($b10, 24))->addInt64($a5->mulInt64($b9, 24))
->addInt64($a6->mulInt64($b8, 24))->addInt64($a7->mulInt64($b7, 24))->addInt64($a8->mulInt64($b6, 24))
->addInt64($a9->mulInt64($b5, 24))->addInt64($a10->mulInt64($b4, 24))->addInt64($a11->mulInt64($b3, 24));
$s15 = $a4->mulInt64($b11, 24)->addInt64($a5->mulInt64($b10, 24))->addInt64($a6->mulInt64($b9, 24))
->addInt64($a7->mulInt64($b8, 24))->addInt64($a8->mulInt64($b7, 24))->addInt64($a9->mulInt64($b6, 24))
->addInt64($a10->mulInt64($b5, 24))->addInt64($a11->mulInt64($b4, 24));
$s16 = $a5->mulInt64($b11, 24)->addInt64($a6->mulInt64($b10, 24))->addInt64($a7->mulInt64($b9, 24))
->addInt64($a8->mulInt64($b8, 24))->addInt64($a9->mulInt64($b7, 24))->addInt64($a10->mulInt64($b6, 24))
->addInt64($a11->mulInt64($b5, 24));
$s17 = $a6->mulInt64($b11, 24)->addInt64($a7->mulInt64($b10, 24))->addInt64($a8->mulInt64($b9, 24))
->addInt64($a9->mulInt64($b8, 24))->addInt64($a10->mulInt64($b7, 24))->addInt64($a11->mulInt64($b6, 24));
$s18 = $a7->mulInt64($b11, 24)->addInt64($a8->mulInt64($b10, 24))->addInt64($a9->mulInt64($b9, 24))
->addInt64($a10->mulInt64($b8, 24))->addInt64($a11->mulInt64($b7, 24));
$s19 = $a8->mulInt64($b11, 24)->addInt64($a9->mulInt64($b10, 24))->addInt64($a10->mulInt64($b9, 24))
->addInt64($a11->mulInt64($b8, 24));
$s20 = $a9->mulInt64($b11, 24)->addInt64($a10->mulInt64($b10, 24))->addInt64($a11->mulInt64($b9, 24));
$s21 = $a10->mulInt64($b11, 24)->addInt64($a11->mulInt64($b10, 24));
$s22 = $a11->mulInt64($b11, 24);
$s23 = new ParagonIE_Sodium_Core32_Int64();
$carry0 = $s0->addInt(1 << 20)->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry2 = $s2->addInt(1 << 20)->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry4 = $s4->addInt(1 << 20)->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry6 = $s6->addInt(1 << 20)->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry8 = $s8->addInt(1 << 20)->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry10 = $s10->addInt(1 << 20)->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry12 = $s12->addInt(1 << 20)->shiftRight(21);
$s13 = $s13->addInt64($carry12);
$s12 = $s12->subInt64($carry12->shiftLeft(21));
$carry14 = $s14->addInt(1 << 20)->shiftRight(21);
$s15 = $s15->addInt64($carry14);
$s14 = $s14->subInt64($carry14->shiftLeft(21));
$carry16 = $s16->addInt(1 << 20)->shiftRight(21);
$s17 = $s17->addInt64($carry16);
$s16 = $s16->subInt64($carry16->shiftLeft(21));
$carry18 = $s18->addInt(1 << 20)->shiftRight(21);
$s19 = $s19->addInt64($carry18);
$s18 = $s18->subInt64($carry18->shiftLeft(21));
$carry20 = $s20->addInt(1 << 20)->shiftRight(21);
$s21 = $s21->addInt64($carry20);
$s20 = $s20->subInt64($carry20->shiftLeft(21));
$carry22 = $s22->addInt(1 << 20)->shiftRight(21);
$s23 = $s23->addInt64($carry22);
$s22 = $s22->subInt64($carry22->shiftLeft(21));
$carry1 = $s1->addInt(1 << 20)->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry3 = $s3->addInt(1 << 20)->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry5 = $s5->addInt(1 << 20)->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry7 = $s7->addInt(1 << 20)->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry9 = $s9->addInt(1 << 20)->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry11 = $s11->addInt(1 << 20)->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$carry13 = $s13->addInt(1 << 20)->shiftRight(21);
$s14 = $s14->addInt64($carry13);
$s13 = $s13->subInt64($carry13->shiftLeft(21));
$carry15 = $s15->addInt(1 << 20)->shiftRight(21);
$s16 = $s16->addInt64($carry15);
$s15 = $s15->subInt64($carry15->shiftLeft(21));
$carry17 = $s17->addInt(1 << 20)->shiftRight(21);
$s18 = $s18->addInt64($carry17);
$s17 = $s17->subInt64($carry17->shiftLeft(21));
$carry19 = $s19->addInt(1 << 20)->shiftRight(21);
$s20 = $s20->addInt64($carry19);
$s19 = $s19->subInt64($carry19->shiftLeft(21));
$carry21 = $s21->addInt(1 << 20)->shiftRight(21);
$s22 = $s22->addInt64($carry21);
$s21 = $s21->subInt64($carry21->shiftLeft(21));
$s11 = $s11->addInt64($s23->mulInt(666643, 20));
$s12 = $s12->addInt64($s23->mulInt(470296, 19));
$s13 = $s13->addInt64($s23->mulInt(654183, 20));
$s14 = $s14->subInt64($s23->mulInt(997805, 20));
$s15 = $s15->addInt64($s23->mulInt(136657, 18));
$s16 = $s16->subInt64($s23->mulInt(683901, 20));
$s10 = $s10->addInt64($s22->mulInt(666643, 20));
$s11 = $s11->addInt64($s22->mulInt(470296, 19));
$s12 = $s12->addInt64($s22->mulInt(654183, 20));
$s13 = $s13->subInt64($s22->mulInt(997805, 20));
$s14 = $s14->addInt64($s22->mulInt(136657, 18));
$s15 = $s15->subInt64($s22->mulInt(683901, 20));
$s9 = $s9->addInt64($s21->mulInt(666643, 20));
$s10 = $s10->addInt64($s21->mulInt(470296, 19));
$s11 = $s11->addInt64($s21->mulInt(654183, 20));
$s12 = $s12->subInt64($s21->mulInt(997805, 20));
$s13 = $s13->addInt64($s21->mulInt(136657, 18));
$s14 = $s14->subInt64($s21->mulInt(683901, 20));
$s8 = $s8->addInt64($s20->mulInt(666643, 20));
$s9 = $s9->addInt64($s20->mulInt(470296, 19));
$s10 = $s10->addInt64($s20->mulInt(654183, 20));
$s11 = $s11->subInt64($s20->mulInt(997805, 20));
$s12 = $s12->addInt64($s20->mulInt(136657, 18));
$s13 = $s13->subInt64($s20->mulInt(683901, 20));
$s7 = $s7->addInt64($s19->mulInt(666643, 20));
$s8 = $s8->addInt64($s19->mulInt(470296, 19));
$s9 = $s9->addInt64($s19->mulInt(654183, 20));
$s10 = $s10->subInt64($s19->mulInt(997805, 20));
$s11 = $s11->addInt64($s19->mulInt(136657, 18));
$s12 = $s12->subInt64($s19->mulInt(683901, 20));
$s6 = $s6->addInt64($s18->mulInt(666643, 20));
$s7 = $s7->addInt64($s18->mulInt(470296, 19));
$s8 = $s8->addInt64($s18->mulInt(654183, 20));
$s9 = $s9->subInt64($s18->mulInt(997805, 20));
$s10 = $s10->addInt64($s18->mulInt(136657, 18));
$s11 = $s11->subInt64($s18->mulInt(683901, 20));
$carry6 = $s6->addInt(1 << 20)->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry8 = $s8->addInt(1 << 20)->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry10 = $s10->addInt(1 << 20)->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry12 = $s12->addInt(1 << 20)->shiftRight(21);
$s13 = $s13->addInt64($carry12);
$s12 = $s12->subInt64($carry12->shiftLeft(21));
$carry14 = $s14->addInt(1 << 20)->shiftRight(21);
$s15 = $s15->addInt64($carry14);
$s14 = $s14->subInt64($carry14->shiftLeft(21));
$carry16 = $s16->addInt(1 << 20)->shiftRight(21);
$s17 = $s17->addInt64($carry16);
$s16 = $s16->subInt64($carry16->shiftLeft(21));
$carry7 = $s7->addInt(1 << 20)->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry9 = $s9->addInt(1 << 20)->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry11 = $s11->addInt(1 << 20)->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$carry13 = $s13->addInt(1 << 20)->shiftRight(21);
$s14 = $s14->addInt64($carry13);
$s13 = $s13->subInt64($carry13->shiftLeft(21));
$carry15 = $s15->addInt(1 << 20)->shiftRight(21);
$s16 = $s16->addInt64($carry15);
$s15 = $s15->subInt64($carry15->shiftLeft(21));
$s5 = $s5->addInt64($s17->mulInt(666643, 20));
$s6 = $s6->addInt64($s17->mulInt(470296, 19));
$s7 = $s7->addInt64($s17->mulInt(654183, 20));
$s8 = $s8->subInt64($s17->mulInt(997805, 20));
$s9 = $s9->addInt64($s17->mulInt(136657, 18));
$s10 = $s10->subInt64($s17->mulInt(683901, 20));
$s4 = $s4->addInt64($s16->mulInt(666643, 20));
$s5 = $s5->addInt64($s16->mulInt(470296, 19));
$s6 = $s6->addInt64($s16->mulInt(654183, 20));
$s7 = $s7->subInt64($s16->mulInt(997805, 20));
$s8 = $s8->addInt64($s16->mulInt(136657, 18));
$s9 = $s9->subInt64($s16->mulInt(683901, 20));
$s3 = $s3->addInt64($s15->mulInt(666643, 20));
$s4 = $s4->addInt64($s15->mulInt(470296, 19));
$s5 = $s5->addInt64($s15->mulInt(654183, 20));
$s6 = $s6->subInt64($s15->mulInt(997805, 20));
$s7 = $s7->addInt64($s15->mulInt(136657, 18));
$s8 = $s8->subInt64($s15->mulInt(683901, 20));
$s2 = $s2->addInt64($s14->mulInt(666643, 20));
$s3 = $s3->addInt64($s14->mulInt(470296, 19));
$s4 = $s4->addInt64($s14->mulInt(654183, 20));
$s5 = $s5->subInt64($s14->mulInt(997805, 20));
$s6 = $s6->addInt64($s14->mulInt(136657, 18));
$s7 = $s7->subInt64($s14->mulInt(683901, 20));
$s1 = $s1->addInt64($s13->mulInt(666643, 20));
$s2 = $s2->addInt64($s13->mulInt(470296, 19));
$s3 = $s3->addInt64($s13->mulInt(654183, 20));
$s4 = $s4->subInt64($s13->mulInt(997805, 20));
$s5 = $s5->addInt64($s13->mulInt(136657, 18));
$s6 = $s6->subInt64($s13->mulInt(683901, 20));
$s0 = $s0->addInt64($s12->mulInt(666643, 20));
$s1 = $s1->addInt64($s12->mulInt(470296, 19));
$s2 = $s2->addInt64($s12->mulInt(654183, 20));
$s3 = $s3->subInt64($s12->mulInt(997805, 20));
$s4 = $s4->addInt64($s12->mulInt(136657, 18));
$s5 = $s5->subInt64($s12->mulInt(683901, 20));
$s12 = new ParagonIE_Sodium_Core32_Int64();
$carry0 = $s0->addInt(1 << 20)->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry2 = $s2->addInt(1 << 20)->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry4 = $s4->addInt(1 << 20)->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry6 = $s6->addInt(1 << 20)->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry8 = $s8->addInt(1 << 20)->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry10 = $s10->addInt(1 << 20)->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry1 = $s1->addInt(1 << 20)->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry3 = $s3->addInt(1 << 20)->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry5 = $s5->addInt(1 << 20)->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry7 = $s7->addInt(1 << 20)->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry9 = $s9->addInt(1 << 20)->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry11 = $s11->addInt(1 << 20)->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$s0 = $s0->addInt64($s12->mulInt(666643, 20));
$s1 = $s1->addInt64($s12->mulInt(470296, 19));
$s2 = $s2->addInt64($s12->mulInt(654183, 20));
$s3 = $s3->subInt64($s12->mulInt(997805, 20));
$s4 = $s4->addInt64($s12->mulInt(136657, 18));
$s5 = $s5->subInt64($s12->mulInt(683901, 20));
$s12 = new ParagonIE_Sodium_Core32_Int64();
$carry0 = $s0->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry1 = $s1->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry2 = $s2->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry3 = $s3->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry4 = $s4->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry5 = $s5->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry6 = $s6->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry7 = $s7->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry8 = $s8->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry9 = $s9->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry10 = $s10->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry11 = $s11->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$s0 = $s0->addInt64($s12->mulInt(666643, 20));
$s1 = $s1->addInt64($s12->mulInt(470296, 19));
$s2 = $s2->addInt64($s12->mulInt(654183, 20));
$s3 = $s3->subInt64($s12->mulInt(997805, 20));
$s4 = $s4->addInt64($s12->mulInt(136657, 18));
$s5 = $s5->subInt64($s12->mulInt(683901, 20));
$carry0 = $s0->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry1 = $s1->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry2 = $s2->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry3 = $s3->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry4 = $s4->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry5 = $s5->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry6 = $s6->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry7 = $s7->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry8 = $s10->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry9 = $s9->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry10 = $s10->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$S0 = $s0->toInt();
$S1 = $s1->toInt();
$S2 = $s2->toInt();
$S3 = $s3->toInt();
$S4 = $s4->toInt();
$S5 = $s5->toInt();
$S6 = $s6->toInt();
$S7 = $s7->toInt();
$S8 = $s8->toInt();
$S9 = $s9->toInt();
$S10 = $s10->toInt();
$S11 = $s11->toInt();
/**
* @var array<int, int>
*/
$arr = array(
(int) (0xff & ($S0 >> 0)),
(int) (0xff & ($S0 >> 8)),
(int) (0xff & (($S0 >> 16) | ($S1 << 5))),
(int) (0xff & ($S1 >> 3)),
(int) (0xff & ($S1 >> 11)),
(int) (0xff & (($S1 >> 19) | ($S2 << 2))),
(int) (0xff & ($S2 >> 6)),
(int) (0xff & (($S2 >> 14) | ($S3 << 7))),
(int) (0xff & ($S3 >> 1)),
(int) (0xff & ($S3 >> 9)),
(int) (0xff & (($S3 >> 17) | ($S4 << 4))),
(int) (0xff & ($S4 >> 4)),
(int) (0xff & ($S4 >> 12)),
(int) (0xff & (($S4 >> 20) | ($S5 << 1))),
(int) (0xff & ($S5 >> 7)),
(int) (0xff & (($S5 >> 15) | ($S6 << 6))),
(int) (0xff & ($S6 >> 2)),
(int) (0xff & ($S6 >> 10)),
(int) (0xff & (($S6 >> 18) | ($S7 << 3))),
(int) (0xff & ($S7 >> 5)),
(int) (0xff & ($S7 >> 13)),
(int) (0xff & ($S8 >> 0)),
(int) (0xff & ($S8 >> 8)),
(int) (0xff & (($S8 >> 16) | ($S9 << 5))),
(int) (0xff & ($S9 >> 3)),
(int) (0xff & ($S9 >> 11)),
(int) (0xff & (($S9 >> 19) | ($S10 << 2))),
(int) (0xff & ($S10 >> 6)),
(int) (0xff & (($S10 >> 14) | ($S11 << 7))),
(int) (0xff & ($S11 >> 1)),
(int) (0xff & ($S11 >> 9)),
(int) (0xff & ($S11 >> 17))
);
return self::intArrayToString($arr);
}
/**
* @internal You should not use this directly from another application
*
* @param string $s
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sc_reduce($s)
{
/**
* @var ParagonIE_Sodium_Core32_Int64 $s0
* @var ParagonIE_Sodium_Core32_Int64 $s1
* @var ParagonIE_Sodium_Core32_Int64 $s2
* @var ParagonIE_Sodium_Core32_Int64 $s3
* @var ParagonIE_Sodium_Core32_Int64 $s4
* @var ParagonIE_Sodium_Core32_Int64 $s5
* @var ParagonIE_Sodium_Core32_Int64 $s6
* @var ParagonIE_Sodium_Core32_Int64 $s7
* @var ParagonIE_Sodium_Core32_Int64 $s8
* @var ParagonIE_Sodium_Core32_Int64 $s9
* @var ParagonIE_Sodium_Core32_Int64 $s10
* @var ParagonIE_Sodium_Core32_Int64 $s11
* @var ParagonIE_Sodium_Core32_Int64 $s12
* @var ParagonIE_Sodium_Core32_Int64 $s13
* @var ParagonIE_Sodium_Core32_Int64 $s14
* @var ParagonIE_Sodium_Core32_Int64 $s15
* @var ParagonIE_Sodium_Core32_Int64 $s16
* @var ParagonIE_Sodium_Core32_Int64 $s17
* @var ParagonIE_Sodium_Core32_Int64 $s18
* @var ParagonIE_Sodium_Core32_Int64 $s19
* @var ParagonIE_Sodium_Core32_Int64 $s20
* @var ParagonIE_Sodium_Core32_Int64 $s21
* @var ParagonIE_Sodium_Core32_Int64 $s22
* @var ParagonIE_Sodium_Core32_Int64 $s23
*/
$s0 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($s, 0, 3)));
$s1 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 2, 4)) >> 5));
$s2 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 5, 3)) >> 2));
$s3 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 7, 4)) >> 7));
$s4 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 10, 4)) >> 4));
$s5 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 13, 3)) >> 1));
$s6 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 15, 4)) >> 6));
$s7 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 18, 4)) >> 3));
$s8 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($s, 21, 3)));
$s9 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 23, 4)) >> 5));
$s10 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 26, 3)) >> 2));
$s11 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 28, 4)) >> 7));
$s12 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 31, 4)) >> 4));
$s13 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 34, 3)) >> 1));
$s14 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 36, 4)) >> 6));
$s15 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 39, 4)) >> 3));
$s16 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & self::load_3(self::substr($s, 42, 3)));
$s17 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 44, 4)) >> 5));
$s18 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 47, 3)) >> 2));
$s19 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 49, 4)) >> 7));
$s20 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 52, 4)) >> 4));
$s21 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_3(self::substr($s, 55, 3)) >> 1));
$s22 = ParagonIE_Sodium_Core32_Int64::fromInt(2097151 & (self::load_4(self::substr($s, 57, 4)) >> 6));
$s23 = ParagonIE_Sodium_Core32_Int64::fromInt(0x1fffffff & (self::load_4(self::substr($s, 60, 4)) >> 3));
$s11 = $s11->addInt64($s23->mulInt(666643, 20));
$s12 = $s12->addInt64($s23->mulInt(470296, 19));
$s13 = $s13->addInt64($s23->mulInt(654183, 20));
$s14 = $s14->subInt64($s23->mulInt(997805, 20));
$s15 = $s15->addInt64($s23->mulInt(136657, 18));
$s16 = $s16->subInt64($s23->mulInt(683901, 20));
$s10 = $s10->addInt64($s22->mulInt(666643, 20));
$s11 = $s11->addInt64($s22->mulInt(470296, 19));
$s12 = $s12->addInt64($s22->mulInt(654183, 20));
$s13 = $s13->subInt64($s22->mulInt(997805, 20));
$s14 = $s14->addInt64($s22->mulInt(136657, 18));
$s15 = $s15->subInt64($s22->mulInt(683901, 20));
$s9 = $s9->addInt64($s21->mulInt(666643, 20));
$s10 = $s10->addInt64($s21->mulInt(470296, 19));
$s11 = $s11->addInt64($s21->mulInt(654183, 20));
$s12 = $s12->subInt64($s21->mulInt(997805, 20));
$s13 = $s13->addInt64($s21->mulInt(136657, 18));
$s14 = $s14->subInt64($s21->mulInt(683901, 20));
$s8 = $s8->addInt64($s20->mulInt(666643, 20));
$s9 = $s9->addInt64($s20->mulInt(470296, 19));
$s10 = $s10->addInt64($s20->mulInt(654183, 20));
$s11 = $s11->subInt64($s20->mulInt(997805, 20));
$s12 = $s12->addInt64($s20->mulInt(136657, 18));
$s13 = $s13->subInt64($s20->mulInt(683901, 20));
$s7 = $s7->addInt64($s19->mulInt(666643, 20));
$s8 = $s8->addInt64($s19->mulInt(470296, 19));
$s9 = $s9->addInt64($s19->mulInt(654183, 20));
$s10 = $s10->subInt64($s19->mulInt(997805, 20));
$s11 = $s11->addInt64($s19->mulInt(136657, 18));
$s12 = $s12->subInt64($s19->mulInt(683901, 20));
$s6 = $s6->addInt64($s18->mulInt(666643, 20));
$s7 = $s7->addInt64($s18->mulInt(470296, 19));
$s8 = $s8->addInt64($s18->mulInt(654183, 20));
$s9 = $s9->subInt64($s18->mulInt(997805, 20));
$s10 = $s10->addInt64($s18->mulInt(136657, 18));
$s11 = $s11->subInt64($s18->mulInt(683901, 20));
$carry6 = $s6->addInt(1 << 20)->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry8 = $s8->addInt(1 << 20)->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry10 = $s10->addInt(1 << 20)->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry12 = $s12->addInt(1 << 20)->shiftRight(21);
$s13 = $s13->addInt64($carry12);
$s12 = $s12->subInt64($carry12->shiftLeft(21));
$carry14 = $s14->addInt(1 << 20)->shiftRight(21);
$s15 = $s15->addInt64($carry14);
$s14 = $s14->subInt64($carry14->shiftLeft(21));
$carry16 = $s16->addInt(1 << 20)->shiftRight(21);
$s17 = $s17->addInt64($carry16);
$s16 = $s16->subInt64($carry16->shiftLeft(21));
$carry7 = $s7->addInt(1 << 20)->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry9 = $s9->addInt(1 << 20)->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry11 = $s11->addInt(1 << 20)->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$carry13 = $s13->addInt(1 << 20)->shiftRight(21);
$s14 = $s14->addInt64($carry13);
$s13 = $s13->subInt64($carry13->shiftLeft(21));
$carry15 = $s15->addInt(1 << 20)->shiftRight(21);
$s16 = $s16->addInt64($carry15);
$s15 = $s15->subInt64($carry15->shiftLeft(21));
$s5 = $s5->addInt64($s17->mulInt(666643, 20));
$s6 = $s6->addInt64($s17->mulInt(470296, 19));
$s7 = $s7->addInt64($s17->mulInt(654183, 20));
$s8 = $s8->subInt64($s17->mulInt(997805, 20));
$s9 = $s9->addInt64($s17->mulInt(136657, 18));
$s10 = $s10->subInt64($s17->mulInt(683901, 20));
$s4 = $s4->addInt64($s16->mulInt(666643, 20));
$s5 = $s5->addInt64($s16->mulInt(470296, 19));
$s6 = $s6->addInt64($s16->mulInt(654183, 20));
$s7 = $s7->subInt64($s16->mulInt(997805, 20));
$s8 = $s8->addInt64($s16->mulInt(136657, 18));
$s9 = $s9->subInt64($s16->mulInt(683901, 20));
$s3 = $s3->addInt64($s15->mulInt(666643, 20));
$s4 = $s4->addInt64($s15->mulInt(470296, 19));
$s5 = $s5->addInt64($s15->mulInt(654183, 20));
$s6 = $s6->subInt64($s15->mulInt(997805, 20));
$s7 = $s7->addInt64($s15->mulInt(136657, 18));
$s8 = $s8->subInt64($s15->mulInt(683901, 20));
$s2 = $s2->addInt64($s14->mulInt(666643, 20));
$s3 = $s3->addInt64($s14->mulInt(470296, 19));
$s4 = $s4->addInt64($s14->mulInt(654183, 20));
$s5 = $s5->subInt64($s14->mulInt(997805, 20));
$s6 = $s6->addInt64($s14->mulInt(136657, 18));
$s7 = $s7->subInt64($s14->mulInt(683901, 20));
$s1 = $s1->addInt64($s13->mulInt(666643, 20));
$s2 = $s2->addInt64($s13->mulInt(470296, 19));
$s3 = $s3->addInt64($s13->mulInt(654183, 20));
$s4 = $s4->subInt64($s13->mulInt(997805, 20));
$s5 = $s5->addInt64($s13->mulInt(136657, 18));
$s6 = $s6->subInt64($s13->mulInt(683901, 20));
$s0 = $s0->addInt64($s12->mulInt(666643, 20));
$s1 = $s1->addInt64($s12->mulInt(470296, 19));
$s2 = $s2->addInt64($s12->mulInt(654183, 20));
$s3 = $s3->subInt64($s12->mulInt(997805, 20));
$s4 = $s4->addInt64($s12->mulInt(136657, 18));
$s5 = $s5->subInt64($s12->mulInt(683901, 20));
$s12 = new ParagonIE_Sodium_Core32_Int64();
$carry0 = $s0->addInt(1 << 20)->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry2 = $s2->addInt(1 << 20)->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry4 = $s4->addInt(1 << 20)->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry6 = $s6->addInt(1 << 20)->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry8 = $s8->addInt(1 << 20)->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry10 = $s10->addInt(1 << 20)->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry1 = $s1->addInt(1 << 20)->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry3 = $s3->addInt(1 << 20)->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry5 = $s5->addInt(1 << 20)->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry7 = $s7->addInt(1 << 20)->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry9 = $s9->addInt(1 << 20)->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry11 = $s11->addInt(1 << 20)->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$s0 = $s0->addInt64($s12->mulInt(666643, 20));
$s1 = $s1->addInt64($s12->mulInt(470296, 19));
$s2 = $s2->addInt64($s12->mulInt(654183, 20));
$s3 = $s3->subInt64($s12->mulInt(997805, 20));
$s4 = $s4->addInt64($s12->mulInt(136657, 18));
$s5 = $s5->subInt64($s12->mulInt(683901, 20));
$s12 = new ParagonIE_Sodium_Core32_Int64();
$carry0 = $s0->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry1 = $s1->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry2 = $s2->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry3 = $s3->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry4 = $s4->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry5 = $s5->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry6 = $s6->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry7 = $s7->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry8 = $s8->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry9 = $s9->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry10 = $s10->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$carry11 = $s11->shiftRight(21);
$s12 = $s12->addInt64($carry11);
$s11 = $s11->subInt64($carry11->shiftLeft(21));
$s0 = $s0->addInt64($s12->mulInt(666643, 20));
$s1 = $s1->addInt64($s12->mulInt(470296, 19));
$s2 = $s2->addInt64($s12->mulInt(654183, 20));
$s3 = $s3->subInt64($s12->mulInt(997805, 20));
$s4 = $s4->addInt64($s12->mulInt(136657, 18));
$s5 = $s5->subInt64($s12->mulInt(683901, 20));
$carry0 = $s0->shiftRight(21);
$s1 = $s1->addInt64($carry0);
$s0 = $s0->subInt64($carry0->shiftLeft(21));
$carry1 = $s1->shiftRight(21);
$s2 = $s2->addInt64($carry1);
$s1 = $s1->subInt64($carry1->shiftLeft(21));
$carry2 = $s2->shiftRight(21);
$s3 = $s3->addInt64($carry2);
$s2 = $s2->subInt64($carry2->shiftLeft(21));
$carry3 = $s3->shiftRight(21);
$s4 = $s4->addInt64($carry3);
$s3 = $s3->subInt64($carry3->shiftLeft(21));
$carry4 = $s4->shiftRight(21);
$s5 = $s5->addInt64($carry4);
$s4 = $s4->subInt64($carry4->shiftLeft(21));
$carry5 = $s5->shiftRight(21);
$s6 = $s6->addInt64($carry5);
$s5 = $s5->subInt64($carry5->shiftLeft(21));
$carry6 = $s6->shiftRight(21);
$s7 = $s7->addInt64($carry6);
$s6 = $s6->subInt64($carry6->shiftLeft(21));
$carry7 = $s7->shiftRight(21);
$s8 = $s8->addInt64($carry7);
$s7 = $s7->subInt64($carry7->shiftLeft(21));
$carry8 = $s8->shiftRight(21);
$s9 = $s9->addInt64($carry8);
$s8 = $s8->subInt64($carry8->shiftLeft(21));
$carry9 = $s9->shiftRight(21);
$s10 = $s10->addInt64($carry9);
$s9 = $s9->subInt64($carry9->shiftLeft(21));
$carry10 = $s10->shiftRight(21);
$s11 = $s11->addInt64($carry10);
$s10 = $s10->subInt64($carry10->shiftLeft(21));
$S0 = $s0->toInt32()->toInt();
$S1 = $s1->toInt32()->toInt();
$S2 = $s2->toInt32()->toInt();
$S3 = $s3->toInt32()->toInt();
$S4 = $s4->toInt32()->toInt();
$S5 = $s5->toInt32()->toInt();
$S6 = $s6->toInt32()->toInt();
$S7 = $s7->toInt32()->toInt();
$S8 = $s8->toInt32()->toInt();
$S9 = $s9->toInt32()->toInt();
$S10 = $s10->toInt32()->toInt();
$S11 = $s11->toInt32()->toInt();
/**
* @var array<int, int>
*/
$arr = array(
(int) ($S0 >> 0),
(int) ($S0 >> 8),
(int) (($S0 >> 16) | ($S1 << 5)),
(int) ($S1 >> 3),
(int) ($S1 >> 11),
(int) (($S1 >> 19) | ($S2 << 2)),
(int) ($S2 >> 6),
(int) (($S2 >> 14) | ($S3 << 7)),
(int) ($S3 >> 1),
(int) ($S3 >> 9),
(int) (($S3 >> 17) | ($S4 << 4)),
(int) ($S4 >> 4),
(int) ($S4 >> 12),
(int) (($S4 >> 20) | ($S5 << 1)),
(int) ($S5 >> 7),
(int) (($S5 >> 15) | ($S6 << 6)),
(int) ($S6 >> 2),
(int) ($S6 >> 10),
(int) (($S6 >> 18) | ($S7 << 3)),
(int) ($S7 >> 5),
(int) ($S7 >> 13),
(int) ($S8 >> 0),
(int) ($S8 >> 8),
(int) (($S8 >> 16) | ($S9 << 5)),
(int) ($S9 >> 3),
(int) ($S9 >> 11),
(int) (($S9 >> 19) | ($S10 << 2)),
(int) ($S10 >> 6),
(int) (($S10 >> 14) | ($S11 << 7)),
(int) ($S11 >> 1),
(int) ($S11 >> 9),
(int) $S11 >> 17
);
return self::intArrayToString($arr);
}
/**
* multiply by the order of the main subgroup l = 2^252+27742317777372353535851937790883648493
*
* @param ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $A
* @return ParagonIE_Sodium_Core32_Curve25519_Ge_P3
* @throws SodiumException
* @throws TypeError
*/
public static function ge_mul_l(ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $A)
{
$aslide = array(
13, 0, 0, 0, 0, -1, 0, 0, 0, 0, -11, 0, 0, 0, 0, 0, 0, -5, 0, 0, 0,
0, 0, 0, -3, 0, 0, 0, 0, -13, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 3, 0,
0, 0, 0, -13, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0,
0, 0, 11, 0, 0, 0, 0, -13, 0, 0, 0, 0, 0, 0, -3, 0, 0, 0, 0, 0, -1,
0, 0, 0, 0, 3, 0, 0, 0, 0, -11, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0,
0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 7, 0, 0, 0, 0, 5, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
);
/** @var array<int, ParagonIE_Sodium_Core32_Curve25519_Ge_Cached> $Ai size 8 */
$Ai = array();
# ge_p3_to_cached(&Ai[0], A);
$Ai[0] = self::ge_p3_to_cached($A);
# ge_p3_dbl(&t, A);
$t = self::ge_p3_dbl($A);
# ge_p1p1_to_p3(&A2, &t);
$A2 = self::ge_p1p1_to_p3($t);
for ($i = 1; $i < 8; ++$i) {
# ge_add(&t, &A2, &Ai[0]);
$t = self::ge_add($A2, $Ai[$i - 1]);
# ge_p1p1_to_p3(&u, &t);
$u = self::ge_p1p1_to_p3($t);
# ge_p3_to_cached(&Ai[i], &u);
$Ai[$i] = self::ge_p3_to_cached($u);
}
$r = self::ge_p3_0();
for ($i = 252; $i >= 0; --$i) {
$t = self::ge_p3_dbl($r);
if ($aslide[$i] > 0) {
# ge_p1p1_to_p3(&u, &t);
$u = self::ge_p1p1_to_p3($t);
# ge_add(&t, &u, &Ai[aslide[i] / 2]);
$t = self::ge_add($u, $Ai[(int)($aslide[$i] / 2)]);
} elseif ($aslide[$i] < 0) {
# ge_p1p1_to_p3(&u, &t);
$u = self::ge_p1p1_to_p3($t);
# ge_sub(&t, &u, &Ai[(-aslide[i]) / 2]);
$t = self::ge_sub($u, $Ai[(int)(-$aslide[$i] / 2)]);
}
}
# ge_p1p1_to_p3(r, &t);
return self::ge_p1p1_to_p3($t);
}
}
Core32/Ed25519.php 0000644 00000036567 15153427537 0007305 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Ed25519', false)) {
return;
}
if (!class_exists('ParagonIE_Sodium_Core32_Curve25519')) {
require_once dirname(__FILE__) . '/Curve25519.php';
}
/**
* Class ParagonIE_Sodium_Core32_Ed25519
*/
abstract class ParagonIE_Sodium_Core32_Ed25519 extends ParagonIE_Sodium_Core32_Curve25519
{
const KEYPAIR_BYTES = 96;
const SEED_BYTES = 32;
/**
* @internal You should not use this directly from another application
*
* @return string (96 bytes)
* @throws Exception
* @throws SodiumException
* @throws TypeError
*/
public static function keypair()
{
$seed = random_bytes(self::SEED_BYTES);
$pk = '';
$sk = '';
self::seed_keypair($pk, $sk, $seed);
return $sk . $pk;
}
/**
* @internal You should not use this directly from another application
*
* @param string $pk
* @param string $sk
* @param string $seed
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function seed_keypair(&$pk, &$sk, $seed)
{
if (self::strlen($seed) !== self::SEED_BYTES) {
throw new RangeException('crypto_sign keypair seed must be 32 bytes long');
}
/** @var string $pk */
$pk = self::publickey_from_secretkey($seed);
$sk = $seed . $pk;
return $sk;
}
/**
* @internal You should not use this directly from another application
*
* @param string $keypair
* @return string
* @throws TypeError
*/
public static function secretkey($keypair)
{
if (self::strlen($keypair) !== self::KEYPAIR_BYTES) {
throw new RangeException('crypto_sign keypair must be 96 bytes long');
}
return self::substr($keypair, 0, 64);
}
/**
* @internal You should not use this directly from another application
*
* @param string $keypair
* @return string
* @throws RangeException
* @throws TypeError
*/
public static function publickey($keypair)
{
if (self::strlen($keypair) !== self::KEYPAIR_BYTES) {
throw new RangeException('crypto_sign keypair must be 96 bytes long');
}
return self::substr($keypair, 64, 32);
}
/**
* @internal You should not use this directly from another application
*
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function publickey_from_secretkey($sk)
{
/** @var string $sk */
$sk = hash('sha512', self::substr($sk, 0, 32), true);
$sk[0] = self::intToChr(
self::chrToInt($sk[0]) & 248
);
$sk[31] = self::intToChr(
(self::chrToInt($sk[31]) & 63) | 64
);
return self::sk_to_pk($sk);
}
/**
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function pk_to_curve25519($pk)
{
if (self::small_order($pk)) {
throw new SodiumException('Public key is on a small order');
}
$A = self::ge_frombytes_negate_vartime($pk);
$p1 = self::ge_mul_l($A);
if (!self::fe_isnonzero($p1->X)) {
throw new SodiumException('Unexpected zero result');
}
# fe_1(one_minus_y);
# fe_sub(one_minus_y, one_minus_y, A.Y);
# fe_invert(one_minus_y, one_minus_y);
$one_minux_y = self::fe_invert(
self::fe_sub(
self::fe_1(),
$A->Y
)
);
# fe_1(x);
# fe_add(x, x, A.Y);
# fe_mul(x, x, one_minus_y);
$x = self::fe_mul(
self::fe_add(self::fe_1(), $A->Y),
$one_minux_y
);
# fe_tobytes(curve25519_pk, x);
return self::fe_tobytes($x);
}
/**
* @internal You should not use this directly from another application
*
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sk_to_pk($sk)
{
return self::ge_p3_tobytes(
self::ge_scalarmult_base(
self::substr($sk, 0, 32)
)
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign($message, $sk)
{
/** @var string $signature */
$signature = self::sign_detached($message, $sk);
return $signature . $message;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message A signed message
* @param string $pk Public key
* @return string Message (without signature)
* @throws SodiumException
* @throws TypeError
*/
public static function sign_open($message, $pk)
{
/** @var string $signature */
$signature = self::substr($message, 0, 64);
/** @var string $message */
$message = self::substr($message, 64);
if (self::verify_detached($signature, $message, $pk)) {
return $message;
}
throw new SodiumException('Invalid signature');
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
* @psalm-suppress PossiblyInvalidArgument
*/
public static function sign_detached($message, $sk)
{
# crypto_hash_sha512(az, sk, 32);
$az = hash('sha512', self::substr($sk, 0, 32), true);
# az[0] &= 248;
# az[31] &= 63;
# az[31] |= 64;
$az[0] = self::intToChr(self::chrToInt($az[0]) & 248);
$az[31] = self::intToChr((self::chrToInt($az[31]) & 63) | 64);
# crypto_hash_sha512_init(&hs);
# crypto_hash_sha512_update(&hs, az + 32, 32);
# crypto_hash_sha512_update(&hs, m, mlen);
# crypto_hash_sha512_final(&hs, nonce);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($az, 32, 32));
self::hash_update($hs, $message);
$nonceHash = hash_final($hs, true);
# memmove(sig + 32, sk + 32, 32);
$pk = self::substr($sk, 32, 32);
# sc_reduce(nonce);
# ge_scalarmult_base(&R, nonce);
# ge_p3_tobytes(sig, &R);
$nonce = self::sc_reduce($nonceHash) . self::substr($nonceHash, 32);
$sig = self::ge_p3_tobytes(
self::ge_scalarmult_base($nonce)
);
# crypto_hash_sha512_init(&hs);
# crypto_hash_sha512_update(&hs, sig, 64);
# crypto_hash_sha512_update(&hs, m, mlen);
# crypto_hash_sha512_final(&hs, hram);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($sig, 0, 32));
self::hash_update($hs, self::substr($pk, 0, 32));
self::hash_update($hs, $message);
$hramHash = hash_final($hs, true);
# sc_reduce(hram);
# sc_muladd(sig + 32, hram, az, nonce);
$hram = self::sc_reduce($hramHash);
$sigAfter = self::sc_muladd($hram, $az, $nonce);
$sig = self::substr($sig, 0, 32) . self::substr($sigAfter, 0, 32);
try {
ParagonIE_Sodium_Compat::memzero($az);
} catch (SodiumException $ex) {
$az = null;
}
return $sig;
}
/**
* @internal You should not use this directly from another application
*
* @param string $sig
* @param string $message
* @param string $pk
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function verify_detached($sig, $message, $pk)
{
if (self::strlen($sig) < 64) {
throw new SodiumException('Signature is too short');
}
if ((self::chrToInt($sig[63]) & 240) && self::check_S_lt_L(self::substr($sig, 32, 32))) {
throw new SodiumException('S < L - Invalid signature');
}
if (self::small_order($sig)) {
throw new SodiumException('Signature is on too small of an order');
}
if ((self::chrToInt($sig[63]) & 224) !== 0) {
throw new SodiumException('Invalid signature');
}
$d = 0;
for ($i = 0; $i < 32; ++$i) {
$d |= self::chrToInt($pk[$i]);
}
if ($d === 0) {
throw new SodiumException('All zero public key');
}
/** @var bool The original value of ParagonIE_Sodium_Compat::$fastMult */
$orig = ParagonIE_Sodium_Compat::$fastMult;
// Set ParagonIE_Sodium_Compat::$fastMult to true to speed up verification.
ParagonIE_Sodium_Compat::$fastMult = true;
/** @var ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $A */
$A = self::ge_frombytes_negate_vartime($pk);
/** @var string $hDigest */
$hDigest = hash(
'sha512',
self::substr($sig, 0, 32) .
self::substr($pk, 0, 32) .
$message,
true
);
/** @var string $h */
$h = self::sc_reduce($hDigest) . self::substr($hDigest, 32);
/** @var ParagonIE_Sodium_Core32_Curve25519_Ge_P2 $R */
$R = self::ge_double_scalarmult_vartime(
$h,
$A,
self::substr($sig, 32)
);
/** @var string $rcheck */
$rcheck = self::ge_tobytes($R);
// Reset ParagonIE_Sodium_Compat::$fastMult to what it was before.
ParagonIE_Sodium_Compat::$fastMult = $orig;
return self::verify_32($rcheck, self::substr($sig, 0, 32));
}
/**
* @internal You should not use this directly from another application
*
* @param string $S
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function check_S_lt_L($S)
{
if (self::strlen($S) < 32) {
throw new SodiumException('Signature must be 32 bytes');
}
static $L = array(
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
);
/** @var array<int, int> $L */
$c = 0;
$n = 1;
$i = 32;
do {
--$i;
$x = self::chrToInt($S[$i]);
$c |= (
(($x - $L[$i]) >> 8) & $n
);
$n &= (
(($x ^ $L[$i]) - 1) >> 8
);
} while ($i !== 0);
return $c === 0;
}
/**
* @param string $R
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function small_order($R)
{
static $blocklist = array(
/* 0 (order 4) */
array(
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
),
/* 1 (order 1) */
array(
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
),
/* 2707385501144840649318225287225658788936804267575313519463743609750303402022 (order 8) */
array(
0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0,
0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, 0xf0,
0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39,
0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, 0xfc, 0x05
),
/* 55188659117513257062467267217118295137698188065244968500265048394206261417927 (order 8) */
array(
0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f,
0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, 0x0f,
0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6,
0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, 0x03, 0x7a
),
/* p-1 (order 2) */
array(
0x13, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0,
0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, 0xf0,
0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39,
0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, 0xfc, 0x85
),
/* p (order 4) */
array(
0xb4, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f,
0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, 0x0f,
0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6,
0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, 0x03, 0xfa
),
/* p+1 (order 1) */
array(
0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
),
/* p+2707385501144840649318225287225658788936804267575313519463743609750303402022 (order 8) */
array(
0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
),
/* p+55188659117513257062467267217118295137698188065244968500265048394206261417927 (order 8) */
array(
0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
),
/* 2p-1 (order 2) */
array(
0xd9, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
),
/* 2p (order 4) */
array(
0xda, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
),
/* 2p+1 (order 1) */
array(
0xdb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
)
);
/** @var array<int, array<int, int>> $blocklist */
$countBlocklist = count($blocklist);
for ($i = 0; $i < $countBlocklist; ++$i) {
$c = 0;
for ($j = 0; $j < 32; ++$j) {
$c |= self::chrToInt($R[$j]) ^ $blocklist[$i][$j];
}
if ($c === 0) {
return true;
}
}
return false;
}
}
Core32/HChaCha20.php 0000644 00000012261 15153427537 0007711 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_HChaCha20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_HChaCha20
*/
class ParagonIE_Sodium_Core32_HChaCha20 extends ParagonIE_Sodium_Core32_ChaCha20
{
/**
* @param string $in
* @param string $key
* @param string|null $c
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function hChaCha20($in = '', $key = '', $c = null)
{
$ctx = array();
if ($c === null) {
$ctx[0] = new ParagonIE_Sodium_Core32_Int32(array(0x6170, 0x7865));
$ctx[1] = new ParagonIE_Sodium_Core32_Int32(array(0x3320, 0x646e));
$ctx[2] = new ParagonIE_Sodium_Core32_Int32(array(0x7962, 0x2d32));
$ctx[3] = new ParagonIE_Sodium_Core32_Int32(array(0x6b20, 0x6574));
} else {
$ctx[0] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 0, 4));
$ctx[1] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 4, 4));
$ctx[2] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 8, 4));
$ctx[3] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 12, 4));
}
$ctx[4] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 0, 4));
$ctx[5] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 4, 4));
$ctx[6] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 8, 4));
$ctx[7] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 12, 4));
$ctx[8] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 16, 4));
$ctx[9] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 20, 4));
$ctx[10] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 24, 4));
$ctx[11] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 28, 4));
$ctx[12] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 0, 4));
$ctx[13] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 4, 4));
$ctx[14] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 8, 4));
$ctx[15] = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 12, 4));
return self::hChaCha20Bytes($ctx);
}
/**
* @param array $ctx
* @return string
* @throws SodiumException
* @throws TypeError
*/
protected static function hChaCha20Bytes(array $ctx)
{
/** @var ParagonIE_Sodium_Core32_Int32 $x0 */
$x0 = $ctx[0];
/** @var ParagonIE_Sodium_Core32_Int32 $x1 */
$x1 = $ctx[1];
/** @var ParagonIE_Sodium_Core32_Int32 $x2 */
$x2 = $ctx[2];
/** @var ParagonIE_Sodium_Core32_Int32 $x3 */
$x3 = $ctx[3];
/** @var ParagonIE_Sodium_Core32_Int32 $x4 */
$x4 = $ctx[4];
/** @var ParagonIE_Sodium_Core32_Int32 $x5 */
$x5 = $ctx[5];
/** @var ParagonIE_Sodium_Core32_Int32 $x6 */
$x6 = $ctx[6];
/** @var ParagonIE_Sodium_Core32_Int32 $x7 */
$x7 = $ctx[7];
/** @var ParagonIE_Sodium_Core32_Int32 $x8 */
$x8 = $ctx[8];
/** @var ParagonIE_Sodium_Core32_Int32 $x9 */
$x9 = $ctx[9];
/** @var ParagonIE_Sodium_Core32_Int32 $x10 */
$x10 = $ctx[10];
/** @var ParagonIE_Sodium_Core32_Int32 $x11 */
$x11 = $ctx[11];
/** @var ParagonIE_Sodium_Core32_Int32 $x12 */
$x12 = $ctx[12];
/** @var ParagonIE_Sodium_Core32_Int32 $x13 */
$x13 = $ctx[13];
/** @var ParagonIE_Sodium_Core32_Int32 $x14 */
$x14 = $ctx[14];
/** @var ParagonIE_Sodium_Core32_Int32 $x15 */
$x15 = $ctx[15];
for ($i = 0; $i < 10; ++$i) {
# QUARTERROUND( x0, x4, x8, x12)
list($x0, $x4, $x8, $x12) = self::quarterRound($x0, $x4, $x8, $x12);
# QUARTERROUND( x1, x5, x9, x13)
list($x1, $x5, $x9, $x13) = self::quarterRound($x1, $x5, $x9, $x13);
# QUARTERROUND( x2, x6, x10, x14)
list($x2, $x6, $x10, $x14) = self::quarterRound($x2, $x6, $x10, $x14);
# QUARTERROUND( x3, x7, x11, x15)
list($x3, $x7, $x11, $x15) = self::quarterRound($x3, $x7, $x11, $x15);
# QUARTERROUND( x0, x5, x10, x15)
list($x0, $x5, $x10, $x15) = self::quarterRound($x0, $x5, $x10, $x15);
# QUARTERROUND( x1, x6, x11, x12)
list($x1, $x6, $x11, $x12) = self::quarterRound($x1, $x6, $x11, $x12);
# QUARTERROUND( x2, x7, x8, x13)
list($x2, $x7, $x8, $x13) = self::quarterRound($x2, $x7, $x8, $x13);
# QUARTERROUND( x3, x4, x9, x14)
list($x3, $x4, $x9, $x14) = self::quarterRound($x3, $x4, $x9, $x14);
}
return $x0->toReverseString() .
$x1->toReverseString() .
$x2->toReverseString() .
$x3->toReverseString() .
$x12->toReverseString() .
$x13->toReverseString() .
$x14->toReverseString() .
$x15->toReverseString();
}
}
Core32/HSalsa20.php 0000644 00000015435 15153427537 0007653 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_HSalsa20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_HSalsa20
*/
abstract class ParagonIE_Sodium_Core32_HSalsa20 extends ParagonIE_Sodium_Core32_Salsa20
{
/**
* Calculate an hsalsa20 hash of a single block
*
* HSalsa20 doesn't have a counter and will never be used for more than
* one block (used to derive a subkey for xsalsa20).
*
* @internal You should not use this directly from another application
*
* @param string $in
* @param string $k
* @param string|null $c
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function hsalsa20($in, $k, $c = null)
{
/**
* @var ParagonIE_Sodium_Core32_Int32 $x0
* @var ParagonIE_Sodium_Core32_Int32 $x1
* @var ParagonIE_Sodium_Core32_Int32 $x2
* @var ParagonIE_Sodium_Core32_Int32 $x3
* @var ParagonIE_Sodium_Core32_Int32 $x4
* @var ParagonIE_Sodium_Core32_Int32 $x5
* @var ParagonIE_Sodium_Core32_Int32 $x6
* @var ParagonIE_Sodium_Core32_Int32 $x7
* @var ParagonIE_Sodium_Core32_Int32 $x8
* @var ParagonIE_Sodium_Core32_Int32 $x9
* @var ParagonIE_Sodium_Core32_Int32 $x10
* @var ParagonIE_Sodium_Core32_Int32 $x11
* @var ParagonIE_Sodium_Core32_Int32 $x12
* @var ParagonIE_Sodium_Core32_Int32 $x13
* @var ParagonIE_Sodium_Core32_Int32 $x14
* @var ParagonIE_Sodium_Core32_Int32 $x15
* @var ParagonIE_Sodium_Core32_Int32 $j0
* @var ParagonIE_Sodium_Core32_Int32 $j1
* @var ParagonIE_Sodium_Core32_Int32 $j2
* @var ParagonIE_Sodium_Core32_Int32 $j3
* @var ParagonIE_Sodium_Core32_Int32 $j4
* @var ParagonIE_Sodium_Core32_Int32 $j5
* @var ParagonIE_Sodium_Core32_Int32 $j6
* @var ParagonIE_Sodium_Core32_Int32 $j7
* @var ParagonIE_Sodium_Core32_Int32 $j8
* @var ParagonIE_Sodium_Core32_Int32 $j9
* @var ParagonIE_Sodium_Core32_Int32 $j10
* @var ParagonIE_Sodium_Core32_Int32 $j11
* @var ParagonIE_Sodium_Core32_Int32 $j12
* @var ParagonIE_Sodium_Core32_Int32 $j13
* @var ParagonIE_Sodium_Core32_Int32 $j14
* @var ParagonIE_Sodium_Core32_Int32 $j15
*/
if (self::strlen($k) < 32) {
throw new RangeException('Key must be 32 bytes long');
}
if ($c === null) {
$x0 = new ParagonIE_Sodium_Core32_Int32(array(0x6170, 0x7865));
$x5 = new ParagonIE_Sodium_Core32_Int32(array(0x3320, 0x646e));
$x10 = new ParagonIE_Sodium_Core32_Int32(array(0x7962, 0x2d32));
$x15 = new ParagonIE_Sodium_Core32_Int32(array(0x6b20, 0x6574));
} else {
$x0 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 0, 4));
$x5 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 4, 4));
$x10 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 8, 4));
$x15 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 12, 4));
}
$x1 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 0, 4));
$x2 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 4, 4));
$x3 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 8, 4));
$x4 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 12, 4));
$x6 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 0, 4));
$x7 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 4, 4));
$x8 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 8, 4));
$x9 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 12, 4));
$x11 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 16, 4));
$x12 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 20, 4));
$x13 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 24, 4));
$x14 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 28, 4));
for ($i = self::ROUNDS; $i > 0; $i -= 2) {
$x4 = $x4->xorInt32($x0->addInt32($x12)->rotateLeft(7));
$x8 = $x8->xorInt32($x4->addInt32($x0)->rotateLeft(9));
$x12 = $x12->xorInt32($x8->addInt32($x4)->rotateLeft(13));
$x0 = $x0->xorInt32($x12->addInt32($x8)->rotateLeft(18));
$x9 = $x9->xorInt32($x5->addInt32($x1)->rotateLeft(7));
$x13 = $x13->xorInt32($x9->addInt32($x5)->rotateLeft(9));
$x1 = $x1->xorInt32($x13->addInt32($x9)->rotateLeft(13));
$x5 = $x5->xorInt32($x1->addInt32($x13)->rotateLeft(18));
$x14 = $x14->xorInt32($x10->addInt32($x6)->rotateLeft(7));
$x2 = $x2->xorInt32($x14->addInt32($x10)->rotateLeft(9));
$x6 = $x6->xorInt32($x2->addInt32($x14)->rotateLeft(13));
$x10 = $x10->xorInt32($x6->addInt32($x2)->rotateLeft(18));
$x3 = $x3->xorInt32($x15->addInt32($x11)->rotateLeft(7));
$x7 = $x7->xorInt32($x3->addInt32($x15)->rotateLeft(9));
$x11 = $x11->xorInt32($x7->addInt32($x3)->rotateLeft(13));
$x15 = $x15->xorInt32($x11->addInt32($x7)->rotateLeft(18));
$x1 = $x1->xorInt32($x0->addInt32($x3)->rotateLeft(7));
$x2 = $x2->xorInt32($x1->addInt32($x0)->rotateLeft(9));
$x3 = $x3->xorInt32($x2->addInt32($x1)->rotateLeft(13));
$x0 = $x0->xorInt32($x3->addInt32($x2)->rotateLeft(18));
$x6 = $x6->xorInt32($x5->addInt32($x4)->rotateLeft(7));
$x7 = $x7->xorInt32($x6->addInt32($x5)->rotateLeft(9));
$x4 = $x4->xorInt32($x7->addInt32($x6)->rotateLeft(13));
$x5 = $x5->xorInt32($x4->addInt32($x7)->rotateLeft(18));
$x11 = $x11->xorInt32($x10->addInt32($x9)->rotateLeft(7));
$x8 = $x8->xorInt32($x11->addInt32($x10)->rotateLeft(9));
$x9 = $x9->xorInt32($x8->addInt32($x11)->rotateLeft(13));
$x10 = $x10->xorInt32($x9->addInt32($x8)->rotateLeft(18));
$x12 = $x12->xorInt32($x15->addInt32($x14)->rotateLeft(7));
$x13 = $x13->xorInt32($x12->addInt32($x15)->rotateLeft(9));
$x14 = $x14->xorInt32($x13->addInt32($x12)->rotateLeft(13));
$x15 = $x15->xorInt32($x14->addInt32($x13)->rotateLeft(18));
}
return $x0->toReverseString() .
$x5->toReverseString() .
$x10->toReverseString() .
$x15->toReverseString() .
$x6->toReverseString() .
$x7->toReverseString() .
$x8->toReverseString() .
$x9->toReverseString();
}
}
Core32/Int32.php 0000644 00000060004 15153427537 0007225 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core32_Int32
*
* Encapsulates a 32-bit integer.
*
* These are immutable. It always returns a new instance.
*/
class ParagonIE_Sodium_Core32_Int32
{
/**
* @var array<int, int> - two 16-bit integers
*
* 0 is the higher 16 bits
* 1 is the lower 16 bits
*/
public $limbs = array(0, 0);
/**
* @var int
*/
public $overflow = 0;
/**
* @var bool
*/
public $unsignedInt = false;
/**
* ParagonIE_Sodium_Core32_Int32 constructor.
* @param array $array
* @param bool $unsignedInt
*/
public function __construct($array = array(0, 0), $unsignedInt = false)
{
$this->limbs = array(
(int) $array[0],
(int) $array[1]
);
$this->overflow = 0;
$this->unsignedInt = $unsignedInt;
}
/**
* Adds two int32 objects
*
* @param ParagonIE_Sodium_Core32_Int32 $addend
* @return ParagonIE_Sodium_Core32_Int32
*/
public function addInt32(ParagonIE_Sodium_Core32_Int32 $addend)
{
$i0 = $this->limbs[0];
$i1 = $this->limbs[1];
$j0 = $addend->limbs[0];
$j1 = $addend->limbs[1];
$r1 = $i1 + ($j1 & 0xffff);
$carry = $r1 >> 16;
$r0 = $i0 + ($j0 & 0xffff) + $carry;
$carry = $r0 >> 16;
$r0 &= 0xffff;
$r1 &= 0xffff;
$return = new ParagonIE_Sodium_Core32_Int32(
array($r0, $r1)
);
$return->overflow = $carry;
$return->unsignedInt = $this->unsignedInt;
return $return;
}
/**
* Adds a normal integer to an int32 object
*
* @param int $int
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
*/
public function addInt($int)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($int, 'int', 1);
/** @var int $int */
$int = (int) $int;
$int = (int) $int;
$i0 = $this->limbs[0];
$i1 = $this->limbs[1];
$r1 = $i1 + ($int & 0xffff);
$carry = $r1 >> 16;
$r0 = $i0 + (($int >> 16) & 0xffff) + $carry;
$carry = $r0 >> 16;
$r0 &= 0xffff;
$r1 &= 0xffff;
$return = new ParagonIE_Sodium_Core32_Int32(
array($r0, $r1)
);
$return->overflow = $carry;
$return->unsignedInt = $this->unsignedInt;
return $return;
}
/**
* @param int $b
* @return int
*/
public function compareInt($b = 0)
{
$gt = 0;
$eq = 1;
$i = 2;
$j = 0;
while ($i > 0) {
--$i;
/** @var int $x1 */
$x1 = $this->limbs[$i];
/** @var int $x2 */
$x2 = ($b >> ($j << 4)) & 0xffff;
/** @var int $gt */
$gt |= (($x2 - $x1) >> 8) & $eq;
/** @var int $eq */
$eq &= (($x2 ^ $x1) - 1) >> 8;
}
return ($gt + $gt - $eq) + 1;
}
/**
* @param int $m
* @return ParagonIE_Sodium_Core32_Int32
*/
public function mask($m = 0)
{
/** @var int $hi */
$hi = ((int) $m >> 16);
$hi &= 0xffff;
/** @var int $lo */
$lo = ((int) $m) & 0xffff;
return new ParagonIE_Sodium_Core32_Int32(
array(
(int) ($this->limbs[0] & $hi),
(int) ($this->limbs[1] & $lo)
),
$this->unsignedInt
);
}
/**
* @param array<int, int> $a
* @param array<int, int> $b
* @param int $baseLog2
* @return array<int, int>
*/
public function multiplyLong(array $a, array $b, $baseLog2 = 16)
{
$a_l = count($a);
$b_l = count($b);
/** @var array<int, int> $r */
$r = array_fill(0, $a_l + $b_l + 1, 0);
$base = 1 << $baseLog2;
for ($i = 0; $i < $a_l; ++$i) {
$a_i = $a[$i];
for ($j = 0; $j < $a_l; ++$j) {
$b_j = $b[$j];
$product = ($a_i * $b_j) + $r[$i + $j];
$carry = ((int) $product >> $baseLog2 & 0xffff);
$r[$i + $j] = ((int) $product - (int) ($carry * $base)) & 0xffff;
$r[$i + $j + 1] += $carry;
}
}
return array_slice($r, 0, 5);
}
/**
* @param int $int
* @return ParagonIE_Sodium_Core32_Int32
*/
public function mulIntFast($int)
{
// Handle negative numbers
$aNeg = ($this->limbs[0] >> 15) & 1;
$bNeg = ($int >> 31) & 1;
$a = array_reverse($this->limbs);
$b = array(
$int & 0xffff,
($int >> 16) & 0xffff
);
if ($aNeg) {
for ($i = 0; $i < 2; ++$i) {
$a[$i] = ($a[$i] ^ 0xffff) & 0xffff;
}
++$a[0];
}
if ($bNeg) {
for ($i = 0; $i < 2; ++$i) {
$b[$i] = ($b[$i] ^ 0xffff) & 0xffff;
}
++$b[0];
}
// Multiply
$res = $this->multiplyLong($a, $b);
// Re-apply negation to results
if ($aNeg !== $bNeg) {
for ($i = 0; $i < 2; ++$i) {
$res[$i] = (0xffff ^ $res[$i]) & 0xffff;
}
// Handle integer overflow
$c = 1;
for ($i = 0; $i < 2; ++$i) {
$res[$i] += $c;
$c = $res[$i] >> 16;
$res[$i] &= 0xffff;
}
}
// Return our values
$return = new ParagonIE_Sodium_Core32_Int32();
$return->limbs = array(
$res[1] & 0xffff,
$res[0] & 0xffff
);
if (count($res) > 2) {
$return->overflow = $res[2] & 0xffff;
}
$return->unsignedInt = $this->unsignedInt;
return $return;
}
/**
* @param ParagonIE_Sodium_Core32_Int32 $right
* @return ParagonIE_Sodium_Core32_Int32
*/
public function mulInt32Fast(ParagonIE_Sodium_Core32_Int32 $right)
{
$aNeg = ($this->limbs[0] >> 15) & 1;
$bNeg = ($right->limbs[0] >> 15) & 1;
$a = array_reverse($this->limbs);
$b = array_reverse($right->limbs);
if ($aNeg) {
for ($i = 0; $i < 2; ++$i) {
$a[$i] = ($a[$i] ^ 0xffff) & 0xffff;
}
++$a[0];
}
if ($bNeg) {
for ($i = 0; $i < 2; ++$i) {
$b[$i] = ($b[$i] ^ 0xffff) & 0xffff;
}
++$b[0];
}
$res = $this->multiplyLong($a, $b);
if ($aNeg !== $bNeg) {
if ($aNeg !== $bNeg) {
for ($i = 0; $i < 2; ++$i) {
$res[$i] = ($res[$i] ^ 0xffff) & 0xffff;
}
$c = 1;
for ($i = 0; $i < 2; ++$i) {
$res[$i] += $c;
$c = $res[$i] >> 16;
$res[$i] &= 0xffff;
}
}
}
$return = new ParagonIE_Sodium_Core32_Int32();
$return->limbs = array(
$res[1] & 0xffff,
$res[0] & 0xffff
);
if (count($res) > 2) {
$return->overflow = $res[2];
}
return $return;
}
/**
* @param int $int
* @param int $size
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
*/
public function mulInt($int = 0, $size = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($int, 'int', 1);
ParagonIE_Sodium_Core32_Util::declareScalarType($size, 'int', 2);
if (ParagonIE_Sodium_Compat::$fastMult) {
return $this->mulIntFast((int) $int);
}
/** @var int $int */
$int = (int) $int;
/** @var int $size */
$size = (int) $size;
if (!$size) {
$size = 31;
}
/** @var int $size */
$a = clone $this;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
// Initialize:
$ret0 = 0;
$ret1 = 0;
$a0 = $a->limbs[0];
$a1 = $a->limbs[1];
/** @var int $size */
/** @var int $i */
for ($i = $size; $i >= 0; --$i) {
$m = (int) (-($int & 1));
$x0 = $a0 & $m;
$x1 = $a1 & $m;
$ret1 += $x1;
$c = $ret1 >> 16;
$ret0 += $x0 + $c;
$ret0 &= 0xffff;
$ret1 &= 0xffff;
$a1 = ($a1 << 1);
$x1 = $a1 >> 16;
$a0 = ($a0 << 1) | $x1;
$a0 &= 0xffff;
$a1 &= 0xffff;
$int >>= 1;
}
$return->limbs[0] = $ret0;
$return->limbs[1] = $ret1;
return $return;
}
/**
* @param ParagonIE_Sodium_Core32_Int32 $int
* @param int $size
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
*/
public function mulInt32(ParagonIE_Sodium_Core32_Int32 $int, $size = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($size, 'int', 2);
if (ParagonIE_Sodium_Compat::$fastMult) {
return $this->mulInt32Fast($int);
}
if (!$size) {
$size = 31;
}
/** @var int $size */
$a = clone $this;
$b = clone $int;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
// Initialize:
$ret0 = 0;
$ret1 = 0;
$a0 = $a->limbs[0];
$a1 = $a->limbs[1];
$b0 = $b->limbs[0];
$b1 = $b->limbs[1];
/** @var int $size */
/** @var int $i */
for ($i = $size; $i >= 0; --$i) {
$m = (int) (-($b1 & 1));
$x0 = $a0 & $m;
$x1 = $a1 & $m;
$ret1 += $x1;
$c = $ret1 >> 16;
$ret0 += $x0 + $c;
$ret0 &= 0xffff;
$ret1 &= 0xffff;
$a1 = ($a1 << 1);
$x1 = $a1 >> 16;
$a0 = ($a0 << 1) | $x1;
$a0 &= 0xffff;
$a1 &= 0xffff;
$x0 = ($b0 & 1) << 16;
$b0 = ($b0 >> 1);
$b1 = (($b1 | $x0) >> 1);
$b0 &= 0xffff;
$b1 &= 0xffff;
}
$return->limbs[0] = $ret0;
$return->limbs[1] = $ret1;
return $return;
}
/**
* OR this 32-bit integer with another.
*
* @param ParagonIE_Sodium_Core32_Int32 $b
* @return ParagonIE_Sodium_Core32_Int32
*/
public function orInt32(ParagonIE_Sodium_Core32_Int32 $b)
{
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
$return->limbs = array(
(int) ($this->limbs[0] | $b->limbs[0]),
(int) ($this->limbs[1] | $b->limbs[1])
);
/** @var int overflow */
$return->overflow = $this->overflow | $b->overflow;
return $return;
}
/**
* @param int $b
* @return bool
*/
public function isGreaterThan($b = 0)
{
return $this->compareInt($b) > 0;
}
/**
* @param int $b
* @return bool
*/
public function isLessThanInt($b = 0)
{
return $this->compareInt($b) < 0;
}
/**
* @param int $c
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
*/
public function rotateLeft($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
$c &= 31;
if ($c === 0) {
// NOP, but we want a copy.
$return->limbs = $this->limbs;
} else {
/** @var int $c */
/** @var int $idx_shift */
$idx_shift = ($c >> 4) & 1;
/** @var int $sub_shift */
$sub_shift = $c & 15;
/** @var array<int, int> $limbs */
$limbs =& $return->limbs;
/** @var array<int, int> $myLimbs */
$myLimbs =& $this->limbs;
for ($i = 1; $i >= 0; --$i) {
/** @var int $j */
$j = ($i + $idx_shift) & 1;
/** @var int $k */
$k = ($i + $idx_shift + 1) & 1;
$limbs[$i] = (int) (
(
((int) ($myLimbs[$j]) << $sub_shift)
|
((int) ($myLimbs[$k]) >> (16 - $sub_shift))
) & 0xffff
);
}
}
return $return;
}
/**
* Rotate to the right
*
* @param int $c
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
*/
public function rotateRight($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
$c &= 31;
/** @var int $c */
if ($c === 0) {
// NOP, but we want a copy.
$return->limbs = $this->limbs;
} else {
/** @var int $c */
/** @var int $idx_shift */
$idx_shift = ($c >> 4) & 1;
/** @var int $sub_shift */
$sub_shift = $c & 15;
/** @var array<int, int> $limbs */
$limbs =& $return->limbs;
/** @var array<int, int> $myLimbs */
$myLimbs =& $this->limbs;
for ($i = 1; $i >= 0; --$i) {
/** @var int $j */
$j = ($i - $idx_shift) & 1;
/** @var int $k */
$k = ($i - $idx_shift - 1) & 1;
$limbs[$i] = (int) (
(
((int) ($myLimbs[$j]) >> (int) ($sub_shift))
|
((int) ($myLimbs[$k]) << (16 - (int) ($sub_shift)))
) & 0xffff
);
}
}
return $return;
}
/**
* @param bool $bool
* @return self
*/
public function setUnsignedInt($bool = false)
{
$this->unsignedInt = !empty($bool);
return $this;
}
/**
* @param int $c
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
*/
public function shiftLeft($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
$c &= 63;
/** @var int $c */
if ($c === 0) {
$return->limbs = $this->limbs;
} elseif ($c < 0) {
/** @var int $c */
return $this->shiftRight(-$c);
} else {
/** @var int $c */
/** @var int $tmp */
$tmp = $this->limbs[1] << $c;
$return->limbs[1] = (int)($tmp & 0xffff);
/** @var int $carry */
$carry = $tmp >> 16;
/** @var int $tmp */
$tmp = ($this->limbs[0] << $c) | ($carry & 0xffff);
$return->limbs[0] = (int) ($tmp & 0xffff);
}
return $return;
}
/**
* @param int $c
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedOperand
*/
public function shiftRight($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
$c &= 63;
/** @var int $c */
if ($c >= 16) {
$return->limbs = array(
(int) ($this->overflow & 0xffff),
(int) ($this->limbs[0])
);
$return->overflow = $this->overflow >> 16;
return $return->shiftRight($c & 15);
}
if ($c === 0) {
$return->limbs = $this->limbs;
} elseif ($c < 0) {
/** @var int $c */
return $this->shiftLeft(-$c);
} else {
if (!is_int($c)) {
throw new TypeError();
}
/** @var int $c */
// $return->limbs[0] = (int) (($this->limbs[0] >> $c) & 0xffff);
$carryLeft = (int) ($this->overflow & ((1 << ($c + 1)) - 1));
$return->limbs[0] = (int) ((($this->limbs[0] >> $c) | ($carryLeft << (16 - $c))) & 0xffff);
$carryRight = (int) ($this->limbs[0] & ((1 << ($c + 1)) - 1));
$return->limbs[1] = (int) ((($this->limbs[1] >> $c) | ($carryRight << (16 - $c))) & 0xffff);
$return->overflow >>= $c;
}
return $return;
}
/**
* Subtract a normal integer from an int32 object.
*
* @param int $int
* @return ParagonIE_Sodium_Core32_Int32
* @throws SodiumException
* @throws TypeError
*/
public function subInt($int)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($int, 'int', 1);
/** @var int $int */
$int = (int) $int;
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
/** @var int $tmp */
$tmp = $this->limbs[1] - ($int & 0xffff);
/** @var int $carry */
$carry = $tmp >> 16;
$return->limbs[1] = (int) ($tmp & 0xffff);
/** @var int $tmp */
$tmp = $this->limbs[0] - (($int >> 16) & 0xffff) + $carry;
$return->limbs[0] = (int) ($tmp & 0xffff);
return $return;
}
/**
* Subtract two int32 objects from each other
*
* @param ParagonIE_Sodium_Core32_Int32 $b
* @return ParagonIE_Sodium_Core32_Int32
*/
public function subInt32(ParagonIE_Sodium_Core32_Int32 $b)
{
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
/** @var int $tmp */
$tmp = $this->limbs[1] - ($b->limbs[1] & 0xffff);
/** @var int $carry */
$carry = $tmp >> 16;
$return->limbs[1] = (int) ($tmp & 0xffff);
/** @var int $tmp */
$tmp = $this->limbs[0] - ($b->limbs[0] & 0xffff) + $carry;
$return->limbs[0] = (int) ($tmp & 0xffff);
return $return;
}
/**
* XOR this 32-bit integer with another.
*
* @param ParagonIE_Sodium_Core32_Int32 $b
* @return ParagonIE_Sodium_Core32_Int32
*/
public function xorInt32(ParagonIE_Sodium_Core32_Int32 $b)
{
$return = new ParagonIE_Sodium_Core32_Int32();
$return->unsignedInt = $this->unsignedInt;
$return->limbs = array(
(int) ($this->limbs[0] ^ $b->limbs[0]),
(int) ($this->limbs[1] ^ $b->limbs[1])
);
return $return;
}
/**
* @param int $signed
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromInt($signed)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($signed, 'int', 1);;
/** @var int $signed */
$signed = (int) $signed;
return new ParagonIE_Sodium_Core32_Int32(
array(
(int) (($signed >> 16) & 0xffff),
(int) ($signed & 0xffff)
)
);
}
/**
* @param string $string
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromString($string)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($string, 'string', 1);
$string = (string) $string;
if (ParagonIE_Sodium_Core32_Util::strlen($string) !== 4) {
throw new RangeException(
'String must be 4 bytes; ' . ParagonIE_Sodium_Core32_Util::strlen($string) . ' given.'
);
}
$return = new ParagonIE_Sodium_Core32_Int32();
$return->limbs[0] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[0]) & 0xff) << 8);
$return->limbs[0] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[1]) & 0xff);
$return->limbs[1] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[2]) & 0xff) << 8);
$return->limbs[1] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[3]) & 0xff);
return $return;
}
/**
* @param string $string
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromReverseString($string)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($string, 'string', 1);
$string = (string) $string;
if (ParagonIE_Sodium_Core32_Util::strlen($string) !== 4) {
throw new RangeException(
'String must be 4 bytes; ' . ParagonIE_Sodium_Core32_Util::strlen($string) . ' given.'
);
}
$return = new ParagonIE_Sodium_Core32_Int32();
$return->limbs[0] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[3]) & 0xff) << 8);
$return->limbs[0] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[2]) & 0xff);
$return->limbs[1] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[1]) & 0xff) << 8);
$return->limbs[1] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[0]) & 0xff);
return $return;
}
/**
* @return array<int, int>
*/
public function toArray()
{
return array((int) ($this->limbs[0] << 16 | $this->limbs[1]));
}
/**
* @return string
* @throws TypeError
*/
public function toString()
{
return
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[0] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[0] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[1] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[1] & 0xff);
}
/**
* @return int
*/
public function toInt()
{
return (int) (
(($this->limbs[0] & 0xffff) << 16)
|
($this->limbs[1] & 0xffff)
);
}
/**
* @return ParagonIE_Sodium_Core32_Int32
*/
public function toInt32()
{
$return = new ParagonIE_Sodium_Core32_Int32();
$return->limbs[0] = (int) ($this->limbs[0] & 0xffff);
$return->limbs[1] = (int) ($this->limbs[1] & 0xffff);
$return->unsignedInt = $this->unsignedInt;
$return->overflow = (int) ($this->overflow & 0x7fffffff);
return $return;
}
/**
* @return ParagonIE_Sodium_Core32_Int64
*/
public function toInt64()
{
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
if ($this->unsignedInt) {
$return->limbs[0] += (($this->overflow >> 16) & 0xffff);
$return->limbs[1] += (($this->overflow) & 0xffff);
} else {
$neg = -(($this->limbs[0] >> 15) & 1);
$return->limbs[0] = (int)($neg & 0xffff);
$return->limbs[1] = (int)($neg & 0xffff);
}
$return->limbs[2] = (int) ($this->limbs[0] & 0xffff);
$return->limbs[3] = (int) ($this->limbs[1] & 0xffff);
return $return;
}
/**
* @return string
* @throws TypeError
*/
public function toReverseString()
{
return ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[1] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[1] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[0] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[0] >> 8) & 0xff);
}
/**
* @return string
*/
public function __toString()
{
try {
return $this->toString();
} catch (TypeError $ex) {
// PHP engine can't handle exceptions from __toString()
return '';
}
}
}
Core32/Int64.php 0000644 00000074704 15153427537 0007246 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core32_Int64
*
* Encapsulates a 64-bit integer.
*
* These are immutable. It always returns a new instance.
*/
class ParagonIE_Sodium_Core32_Int64
{
/**
* @var array<int, int> - four 16-bit integers
*/
public $limbs = array(0, 0, 0, 0);
/**
* @var int
*/
public $overflow = 0;
/**
* @var bool
*/
public $unsignedInt = false;
/**
* ParagonIE_Sodium_Core32_Int64 constructor.
* @param array $array
* @param bool $unsignedInt
*/
public function __construct($array = array(0, 0, 0, 0), $unsignedInt = false)
{
$this->limbs = array(
(int) $array[0],
(int) $array[1],
(int) $array[2],
(int) $array[3]
);
$this->overflow = 0;
$this->unsignedInt = $unsignedInt;
}
/**
* Adds two int64 objects
*
* @param ParagonIE_Sodium_Core32_Int64 $addend
* @return ParagonIE_Sodium_Core32_Int64
*/
public function addInt64(ParagonIE_Sodium_Core32_Int64 $addend)
{
$i0 = $this->limbs[0];
$i1 = $this->limbs[1];
$i2 = $this->limbs[2];
$i3 = $this->limbs[3];
$j0 = $addend->limbs[0];
$j1 = $addend->limbs[1];
$j2 = $addend->limbs[2];
$j3 = $addend->limbs[3];
$r3 = $i3 + ($j3 & 0xffff);
$carry = $r3 >> 16;
$r2 = $i2 + ($j2 & 0xffff) + $carry;
$carry = $r2 >> 16;
$r1 = $i1 + ($j1 & 0xffff) + $carry;
$carry = $r1 >> 16;
$r0 = $i0 + ($j0 & 0xffff) + $carry;
$carry = $r0 >> 16;
$r0 &= 0xffff;
$r1 &= 0xffff;
$r2 &= 0xffff;
$r3 &= 0xffff;
$return = new ParagonIE_Sodium_Core32_Int64(
array($r0, $r1, $r2, $r3)
);
$return->overflow = $carry;
$return->unsignedInt = $this->unsignedInt;
return $return;
}
/**
* Adds a normal integer to an int64 object
*
* @param int $int
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public function addInt($int)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($int, 'int', 1);
/** @var int $int */
$int = (int) $int;
$i0 = $this->limbs[0];
$i1 = $this->limbs[1];
$i2 = $this->limbs[2];
$i3 = $this->limbs[3];
$r3 = $i3 + ($int & 0xffff);
$carry = $r3 >> 16;
$r2 = $i2 + (($int >> 16) & 0xffff) + $carry;
$carry = $r2 >> 16;
$r1 = $i1 + $carry;
$carry = $r1 >> 16;
$r0 = $i0 + $carry;
$carry = $r0 >> 16;
$r0 &= 0xffff;
$r1 &= 0xffff;
$r2 &= 0xffff;
$r3 &= 0xffff;
$return = new ParagonIE_Sodium_Core32_Int64(
array($r0, $r1, $r2, $r3)
);
$return->overflow = $carry;
$return->unsignedInt = $this->unsignedInt;
return $return;
}
/**
* @param int $b
* @return int
*/
public function compareInt($b = 0)
{
$gt = 0;
$eq = 1;
$i = 4;
$j = 0;
while ($i > 0) {
--$i;
/** @var int $x1 */
$x1 = $this->limbs[$i];
/** @var int $x2 */
$x2 = ($b >> ($j << 4)) & 0xffff;
/** int */
$gt |= (($x2 - $x1) >> 8) & $eq;
/** int */
$eq &= (($x2 ^ $x1) - 1) >> 8;
}
return ($gt + $gt - $eq) + 1;
}
/**
* @param int $b
* @return bool
*/
public function isGreaterThan($b = 0)
{
return $this->compareInt($b) > 0;
}
/**
* @param int $b
* @return bool
*/
public function isLessThanInt($b = 0)
{
return $this->compareInt($b) < 0;
}
/**
* @param int $hi
* @param int $lo
* @return ParagonIE_Sodium_Core32_Int64
*/
public function mask64($hi = 0, $lo = 0)
{
/** @var int $a */
$a = ($hi >> 16) & 0xffff;
/** @var int $b */
$b = ($hi) & 0xffff;
/** @var int $c */
$c = ($lo >> 16) & 0xffff;
/** @var int $d */
$d = ($lo & 0xffff);
return new ParagonIE_Sodium_Core32_Int64(
array(
$this->limbs[0] & $a,
$this->limbs[1] & $b,
$this->limbs[2] & $c,
$this->limbs[3] & $d
),
$this->unsignedInt
);
}
/**
* @param int $int
* @param int $size
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
*/
public function mulInt($int = 0, $size = 0)
{
if (ParagonIE_Sodium_Compat::$fastMult) {
return $this->mulIntFast($int);
}
ParagonIE_Sodium_Core32_Util::declareScalarType($int, 'int', 1);
ParagonIE_Sodium_Core32_Util::declareScalarType($size, 'int', 2);
/** @var int $int */
$int = (int) $int;
/** @var int $size */
$size = (int) $size;
if (!$size) {
$size = 63;
}
$a = clone $this;
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
// Initialize:
$ret0 = 0;
$ret1 = 0;
$ret2 = 0;
$ret3 = 0;
$a0 = $a->limbs[0];
$a1 = $a->limbs[1];
$a2 = $a->limbs[2];
$a3 = $a->limbs[3];
/** @var int $size */
/** @var int $i */
for ($i = $size; $i >= 0; --$i) {
$mask = -($int & 1);
$x0 = $a0 & $mask;
$x1 = $a1 & $mask;
$x2 = $a2 & $mask;
$x3 = $a3 & $mask;
$ret3 += $x3;
$c = $ret3 >> 16;
$ret2 += $x2 + $c;
$c = $ret2 >> 16;
$ret1 += $x1 + $c;
$c = $ret1 >> 16;
$ret0 += $x0 + $c;
$ret0 &= 0xffff;
$ret1 &= 0xffff;
$ret2 &= 0xffff;
$ret3 &= 0xffff;
$a3 = $a3 << 1;
$x3 = $a3 >> 16;
$a2 = ($a2 << 1) | $x3;
$x2 = $a2 >> 16;
$a1 = ($a1 << 1) | $x2;
$x1 = $a1 >> 16;
$a0 = ($a0 << 1) | $x1;
$a0 &= 0xffff;
$a1 &= 0xffff;
$a2 &= 0xffff;
$a3 &= 0xffff;
$int >>= 1;
}
$return->limbs[0] = $ret0;
$return->limbs[1] = $ret1;
$return->limbs[2] = $ret2;
$return->limbs[3] = $ret3;
return $return;
}
/**
* @param ParagonIE_Sodium_Core32_Int64 $A
* @param ParagonIE_Sodium_Core32_Int64 $B
* @return array<int, ParagonIE_Sodium_Core32_Int64>
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedInferredReturnType
*/
public static function ctSelect(
ParagonIE_Sodium_Core32_Int64 $A,
ParagonIE_Sodium_Core32_Int64 $B
) {
$a = clone $A;
$b = clone $B;
/** @var int $aNeg */
$aNeg = ($a->limbs[0] >> 15) & 1;
/** @var int $bNeg */
$bNeg = ($b->limbs[0] >> 15) & 1;
/** @var int $m */
$m = (-($aNeg & $bNeg)) | 1;
/** @var int $swap */
$swap = $bNeg & ~$aNeg;
/** @var int $d */
$d = -$swap;
/*
if ($bNeg && !$aNeg) {
$a = clone $int;
$b = clone $this;
} elseif($bNeg && $aNeg) {
$a = $this->mulInt(-1);
$b = $int->mulInt(-1);
}
*/
$x = $a->xorInt64($b)->mask64($d, $d);
return array(
$a->xorInt64($x)->mulInt($m),
$b->xorInt64($x)->mulInt($m)
);
}
/**
* @param array<int, int> $a
* @param array<int, int> $b
* @param int $baseLog2
* @return array<int, int>
*/
public function multiplyLong(array $a, array $b, $baseLog2 = 16)
{
$a_l = count($a);
$b_l = count($b);
/** @var array<int, int> $r */
$r = array_fill(0, $a_l + $b_l + 1, 0);
$base = 1 << $baseLog2;
for ($i = 0; $i < $a_l; ++$i) {
$a_i = $a[$i];
for ($j = 0; $j < $a_l; ++$j) {
$b_j = $b[$j];
$product = (($a_i * $b_j) + $r[$i + $j]);
$carry = (((int) $product >> $baseLog2) & 0xffff);
$r[$i + $j] = ((int) $product - (int) ($carry * $base)) & 0xffff;
$r[$i + $j + 1] += $carry;
}
}
return array_slice($r, 0, 5);
}
/**
* @param int $int
* @return ParagonIE_Sodium_Core32_Int64
*/
public function mulIntFast($int)
{
// Handle negative numbers
$aNeg = ($this->limbs[0] >> 15) & 1;
$bNeg = ($int >> 31) & 1;
$a = array_reverse($this->limbs);
$b = array(
$int & 0xffff,
($int >> 16) & 0xffff,
-$bNeg & 0xffff,
-$bNeg & 0xffff
);
if ($aNeg) {
for ($i = 0; $i < 4; ++$i) {
$a[$i] = ($a[$i] ^ 0xffff) & 0xffff;
}
++$a[0];
}
if ($bNeg) {
for ($i = 0; $i < 4; ++$i) {
$b[$i] = ($b[$i] ^ 0xffff) & 0xffff;
}
++$b[0];
}
// Multiply
$res = $this->multiplyLong($a, $b);
// Re-apply negation to results
if ($aNeg !== $bNeg) {
for ($i = 0; $i < 4; ++$i) {
$res[$i] = (0xffff ^ $res[$i]) & 0xffff;
}
// Handle integer overflow
$c = 1;
for ($i = 0; $i < 4; ++$i) {
$res[$i] += $c;
$c = $res[$i] >> 16;
$res[$i] &= 0xffff;
}
}
// Return our values
$return = new ParagonIE_Sodium_Core32_Int64();
$return->limbs = array(
$res[3] & 0xffff,
$res[2] & 0xffff,
$res[1] & 0xffff,
$res[0] & 0xffff
);
if (count($res) > 4) {
$return->overflow = $res[4] & 0xffff;
}
$return->unsignedInt = $this->unsignedInt;
return $return;
}
/**
* @param ParagonIE_Sodium_Core32_Int64 $right
* @return ParagonIE_Sodium_Core32_Int64
*/
public function mulInt64Fast(ParagonIE_Sodium_Core32_Int64 $right)
{
$aNeg = ($this->limbs[0] >> 15) & 1;
$bNeg = ($right->limbs[0] >> 15) & 1;
$a = array_reverse($this->limbs);
$b = array_reverse($right->limbs);
if ($aNeg) {
for ($i = 0; $i < 4; ++$i) {
$a[$i] = ($a[$i] ^ 0xffff) & 0xffff;
}
++$a[0];
}
if ($bNeg) {
for ($i = 0; $i < 4; ++$i) {
$b[$i] = ($b[$i] ^ 0xffff) & 0xffff;
}
++$b[0];
}
$res = $this->multiplyLong($a, $b);
if ($aNeg !== $bNeg) {
if ($aNeg !== $bNeg) {
for ($i = 0; $i < 4; ++$i) {
$res[$i] = ($res[$i] ^ 0xffff) & 0xffff;
}
$c = 1;
for ($i = 0; $i < 4; ++$i) {
$res[$i] += $c;
$c = $res[$i] >> 16;
$res[$i] &= 0xffff;
}
}
}
$return = new ParagonIE_Sodium_Core32_Int64();
$return->limbs = array(
$res[3] & 0xffff,
$res[2] & 0xffff,
$res[1] & 0xffff,
$res[0] & 0xffff
);
if (count($res) > 4) {
$return->overflow = $res[4];
}
return $return;
}
/**
* @param ParagonIE_Sodium_Core32_Int64 $int
* @param int $size
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
*/
public function mulInt64(ParagonIE_Sodium_Core32_Int64 $int, $size = 0)
{
if (ParagonIE_Sodium_Compat::$fastMult) {
return $this->mulInt64Fast($int);
}
ParagonIE_Sodium_Core32_Util::declareScalarType($size, 'int', 2);
if (!$size) {
$size = 63;
}
list($a, $b) = self::ctSelect($this, $int);
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
// Initialize:
$ret0 = 0;
$ret1 = 0;
$ret2 = 0;
$ret3 = 0;
$a0 = $a->limbs[0];
$a1 = $a->limbs[1];
$a2 = $a->limbs[2];
$a3 = $a->limbs[3];
$b0 = $b->limbs[0];
$b1 = $b->limbs[1];
$b2 = $b->limbs[2];
$b3 = $b->limbs[3];
/** @var int $size */
/** @var int $i */
for ($i = (int) $size; $i >= 0; --$i) {
$mask = -($b3 & 1);
$x0 = $a0 & $mask;
$x1 = $a1 & $mask;
$x2 = $a2 & $mask;
$x3 = $a3 & $mask;
$ret3 += $x3;
$c = $ret3 >> 16;
$ret2 += $x2 + $c;
$c = $ret2 >> 16;
$ret1 += $x1 + $c;
$c = $ret1 >> 16;
$ret0 += $x0 + $c;
$ret0 &= 0xffff;
$ret1 &= 0xffff;
$ret2 &= 0xffff;
$ret3 &= 0xffff;
$a3 = $a3 << 1;
$x3 = $a3 >> 16;
$a2 = ($a2 << 1) | $x3;
$x2 = $a2 >> 16;
$a1 = ($a1 << 1) | $x2;
$x1 = $a1 >> 16;
$a0 = ($a0 << 1) | $x1;
$a0 &= 0xffff;
$a1 &= 0xffff;
$a2 &= 0xffff;
$a3 &= 0xffff;
$x0 = ($b0 & 1) << 16;
$x1 = ($b1 & 1) << 16;
$x2 = ($b2 & 1) << 16;
$b0 = ($b0 >> 1);
$b1 = (($b1 | $x0) >> 1);
$b2 = (($b2 | $x1) >> 1);
$b3 = (($b3 | $x2) >> 1);
$b0 &= 0xffff;
$b1 &= 0xffff;
$b2 &= 0xffff;
$b3 &= 0xffff;
}
$return->limbs[0] = $ret0;
$return->limbs[1] = $ret1;
$return->limbs[2] = $ret2;
$return->limbs[3] = $ret3;
return $return;
}
/**
* OR this 64-bit integer with another.
*
* @param ParagonIE_Sodium_Core32_Int64 $b
* @return ParagonIE_Sodium_Core32_Int64
*/
public function orInt64(ParagonIE_Sodium_Core32_Int64 $b)
{
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
$return->limbs = array(
(int) ($this->limbs[0] | $b->limbs[0]),
(int) ($this->limbs[1] | $b->limbs[1]),
(int) ($this->limbs[2] | $b->limbs[2]),
(int) ($this->limbs[3] | $b->limbs[3])
);
return $return;
}
/**
* @param int $c
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
*/
public function rotateLeft($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
$c &= 63;
if ($c === 0) {
// NOP, but we want a copy.
$return->limbs = $this->limbs;
} else {
/** @var array<int, int> $limbs */
$limbs =& $return->limbs;
/** @var array<int, int> $myLimbs */
$myLimbs =& $this->limbs;
/** @var int $idx_shift */
$idx_shift = ($c >> 4) & 3;
/** @var int $sub_shift */
$sub_shift = $c & 15;
for ($i = 3; $i >= 0; --$i) {
/** @var int $j */
$j = ($i + $idx_shift) & 3;
/** @var int $k */
$k = ($i + $idx_shift + 1) & 3;
$limbs[$i] = (int) (
(
((int) ($myLimbs[$j]) << $sub_shift)
|
((int) ($myLimbs[$k]) >> (16 - $sub_shift))
) & 0xffff
);
}
}
return $return;
}
/**
* Rotate to the right
*
* @param int $c
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedArrayAccess
*/
public function rotateRight($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
/** @var ParagonIE_Sodium_Core32_Int64 $return */
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
$c &= 63;
/** @var int $c */
if ($c === 0) {
// NOP, but we want a copy.
$return->limbs = $this->limbs;
} else {
/** @var array<int, int> $limbs */
$limbs =& $return->limbs;
/** @var array<int, int> $myLimbs */
$myLimbs =& $this->limbs;
/** @var int $idx_shift */
$idx_shift = ($c >> 4) & 3;
/** @var int $sub_shift */
$sub_shift = $c & 15;
for ($i = 3; $i >= 0; --$i) {
/** @var int $j */
$j = ($i - $idx_shift) & 3;
/** @var int $k */
$k = ($i - $idx_shift - 1) & 3;
$limbs[$i] = (int) (
(
((int) ($myLimbs[$j]) >> (int) ($sub_shift))
|
((int) ($myLimbs[$k]) << (16 - (int) ($sub_shift)))
) & 0xffff
);
}
}
return $return;
}
/**
* @param int $c
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public function shiftLeft($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
/** @var int $c */
$c = (int) $c;
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
$c &= 63;
if ($c >= 16) {
if ($c >= 48) {
$return->limbs = array(
$this->limbs[3], 0, 0, 0
);
} elseif ($c >= 32) {
$return->limbs = array(
$this->limbs[2], $this->limbs[3], 0, 0
);
} else {
$return->limbs = array(
$this->limbs[1], $this->limbs[2], $this->limbs[3], 0
);
}
return $return->shiftLeft($c & 15);
}
if ($c === 0) {
$return->limbs = $this->limbs;
} elseif ($c < 0) {
/** @var int $c */
return $this->shiftRight(-$c);
} else {
if (!is_int($c)) {
throw new TypeError();
}
/** @var int $carry */
$carry = 0;
for ($i = 3; $i >= 0; --$i) {
/** @var int $tmp */
$tmp = ($this->limbs[$i] << $c) | ($carry & 0xffff);
$return->limbs[$i] = (int) ($tmp & 0xffff);
/** @var int $carry */
$carry = $tmp >> 16;
}
}
return $return;
}
/**
* @param int $c
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public function shiftRight($c = 0)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($c, 'int', 1);
$c = (int) $c;
/** @var int $c */
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
$c &= 63;
$negative = -(($this->limbs[0] >> 15) & 1);
if ($c >= 16) {
if ($c >= 48) {
$return->limbs = array(
(int) ($negative & 0xffff),
(int) ($negative & 0xffff),
(int) ($negative & 0xffff),
(int) $this->limbs[0]
);
} elseif ($c >= 32) {
$return->limbs = array(
(int) ($negative & 0xffff),
(int) ($negative & 0xffff),
(int) $this->limbs[0],
(int) $this->limbs[1]
);
} else {
$return->limbs = array(
(int) ($negative & 0xffff),
(int) $this->limbs[0],
(int) $this->limbs[1],
(int) $this->limbs[2]
);
}
return $return->shiftRight($c & 15);
}
if ($c === 0) {
$return->limbs = $this->limbs;
} elseif ($c < 0) {
return $this->shiftLeft(-$c);
} else {
if (!is_int($c)) {
throw new TypeError();
}
/** @var int $carryRight */
$carryRight = ($negative & 0xffff);
$mask = (int) (((1 << ($c + 1)) - 1) & 0xffff);
for ($i = 0; $i < 4; ++$i) {
$return->limbs[$i] = (int) (
(($this->limbs[$i] >> $c) | ($carryRight << (16 - $c))) & 0xffff
);
$carryRight = (int) ($this->limbs[$i] & $mask);
}
}
return $return;
}
/**
* Subtract a normal integer from an int64 object.
*
* @param int $int
* @return ParagonIE_Sodium_Core32_Int64
* @throws SodiumException
* @throws TypeError
*/
public function subInt($int)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($int, 'int', 1);
$int = (int) $int;
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
/** @var int $carry */
$carry = 0;
for ($i = 3; $i >= 0; --$i) {
/** @var int $tmp */
$tmp = $this->limbs[$i] - (($int >> 16) & 0xffff) + $carry;
/** @var int $carry */
$carry = $tmp >> 16;
$return->limbs[$i] = (int) ($tmp & 0xffff);
}
return $return;
}
/**
* The difference between two Int64 objects.
*
* @param ParagonIE_Sodium_Core32_Int64 $b
* @return ParagonIE_Sodium_Core32_Int64
*/
public function subInt64(ParagonIE_Sodium_Core32_Int64 $b)
{
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
/** @var int $carry */
$carry = 0;
for ($i = 3; $i >= 0; --$i) {
/** @var int $tmp */
$tmp = $this->limbs[$i] - $b->limbs[$i] + $carry;
/** @var int $carry */
$carry = ($tmp >> 16);
$return->limbs[$i] = (int) ($tmp & 0xffff);
}
return $return;
}
/**
* XOR this 64-bit integer with another.
*
* @param ParagonIE_Sodium_Core32_Int64 $b
* @return ParagonIE_Sodium_Core32_Int64
*/
public function xorInt64(ParagonIE_Sodium_Core32_Int64 $b)
{
$return = new ParagonIE_Sodium_Core32_Int64();
$return->unsignedInt = $this->unsignedInt;
$return->limbs = array(
(int) ($this->limbs[0] ^ $b->limbs[0]),
(int) ($this->limbs[1] ^ $b->limbs[1]),
(int) ($this->limbs[2] ^ $b->limbs[2]),
(int) ($this->limbs[3] ^ $b->limbs[3])
);
return $return;
}
/**
* @param int $low
* @param int $high
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromInts($low, $high)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($low, 'int', 1);
ParagonIE_Sodium_Core32_Util::declareScalarType($high, 'int', 2);
$high = (int) $high;
$low = (int) $low;
return new ParagonIE_Sodium_Core32_Int64(
array(
(int) (($high >> 16) & 0xffff),
(int) ($high & 0xffff),
(int) (($low >> 16) & 0xffff),
(int) ($low & 0xffff)
)
);
}
/**
* @param int $low
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromInt($low)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($low, 'int', 1);
$low = (int) $low;
return new ParagonIE_Sodium_Core32_Int64(
array(
0,
0,
(int) (($low >> 16) & 0xffff),
(int) ($low & 0xffff)
)
);
}
/**
* @return int
*/
public function toInt()
{
return (int) (
(($this->limbs[2] & 0xffff) << 16)
|
($this->limbs[3] & 0xffff)
);
}
/**
* @param string $string
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromString($string)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($string, 'string', 1);
$string = (string) $string;
if (ParagonIE_Sodium_Core32_Util::strlen($string) !== 8) {
throw new RangeException(
'String must be 8 bytes; ' . ParagonIE_Sodium_Core32_Util::strlen($string) . ' given.'
);
}
$return = new ParagonIE_Sodium_Core32_Int64();
$return->limbs[0] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[0]) & 0xff) << 8);
$return->limbs[0] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[1]) & 0xff);
$return->limbs[1] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[2]) & 0xff) << 8);
$return->limbs[1] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[3]) & 0xff);
$return->limbs[2] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[4]) & 0xff) << 8);
$return->limbs[2] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[5]) & 0xff);
$return->limbs[3] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[6]) & 0xff) << 8);
$return->limbs[3] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[7]) & 0xff);
return $return;
}
/**
* @param string $string
* @return self
* @throws SodiumException
* @throws TypeError
*/
public static function fromReverseString($string)
{
ParagonIE_Sodium_Core32_Util::declareScalarType($string, 'string', 1);
$string = (string) $string;
if (ParagonIE_Sodium_Core32_Util::strlen($string) !== 8) {
throw new RangeException(
'String must be 8 bytes; ' . ParagonIE_Sodium_Core32_Util::strlen($string) . ' given.'
);
}
$return = new ParagonIE_Sodium_Core32_Int64();
$return->limbs[0] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[7]) & 0xff) << 8);
$return->limbs[0] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[6]) & 0xff);
$return->limbs[1] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[5]) & 0xff) << 8);
$return->limbs[1] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[4]) & 0xff);
$return->limbs[2] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[3]) & 0xff) << 8);
$return->limbs[2] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[2]) & 0xff);
$return->limbs[3] = (int) ((ParagonIE_Sodium_Core32_Util::chrToInt($string[1]) & 0xff) << 8);
$return->limbs[3] |= (ParagonIE_Sodium_Core32_Util::chrToInt($string[0]) & 0xff);
return $return;
}
/**
* @return array<int, int>
*/
public function toArray()
{
return array(
(int) ((($this->limbs[0] & 0xffff) << 16) | ($this->limbs[1] & 0xffff)),
(int) ((($this->limbs[2] & 0xffff) << 16) | ($this->limbs[3] & 0xffff))
);
}
/**
* @return ParagonIE_Sodium_Core32_Int32
*/
public function toInt32()
{
$return = new ParagonIE_Sodium_Core32_Int32();
$return->limbs[0] = (int) ($this->limbs[2]);
$return->limbs[1] = (int) ($this->limbs[3]);
$return->unsignedInt = $this->unsignedInt;
$return->overflow = (int) (ParagonIE_Sodium_Core32_Util::abs($this->limbs[1], 16) & 0xffff);
return $return;
}
/**
* @return ParagonIE_Sodium_Core32_Int64
*/
public function toInt64()
{
$return = new ParagonIE_Sodium_Core32_Int64();
$return->limbs[0] = (int) ($this->limbs[0]);
$return->limbs[1] = (int) ($this->limbs[1]);
$return->limbs[2] = (int) ($this->limbs[2]);
$return->limbs[3] = (int) ($this->limbs[3]);
$return->unsignedInt = $this->unsignedInt;
$return->overflow = ParagonIE_Sodium_Core32_Util::abs($this->overflow);
return $return;
}
/**
* @param bool $bool
* @return self
*/
public function setUnsignedInt($bool = false)
{
$this->unsignedInt = !empty($bool);
return $this;
}
/**
* @return string
* @throws TypeError
*/
public function toString()
{
return ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[0] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[0] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[1] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[1] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[2] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[2] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[3] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[3] & 0xff);
}
/**
* @return string
* @throws TypeError
*/
public function toReverseString()
{
return ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[3] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[3] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[2] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[2] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[1] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[1] >> 8) & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr($this->limbs[0] & 0xff) .
ParagonIE_Sodium_Core32_Util::intToChr(($this->limbs[0] >> 8) & 0xff);
}
/**
* @return string
*/
public function __toString()
{
try {
return $this->toString();
} catch (TypeError $ex) {
// PHP engine can't handle exceptions from __toString()
return '';
}
}
}
Core32/Poly1305/State.php 0000644 00000037135 15153427537 0010653 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Poly1305_State', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Poly1305_State
*/
class ParagonIE_Sodium_Core32_Poly1305_State extends ParagonIE_Sodium_Core32_Util
{
/**
* @var array<int, int>
*/
protected $buffer = array();
/**
* @var bool
*/
protected $final = false;
/**
* @var array<int, ParagonIE_Sodium_Core32_Int32>
*/
public $h;
/**
* @var int
*/
protected $leftover = 0;
/**
* @var array<int, ParagonIE_Sodium_Core32_Int32>
*/
public $r;
/**
* @var array<int, ParagonIE_Sodium_Core32_Int64>
*/
public $pad;
/**
* ParagonIE_Sodium_Core32_Poly1305_State constructor.
*
* @internal You should not use this directly from another application
*
* @param string $key
* @throws InvalidArgumentException
* @throws SodiumException
* @throws TypeError
*/
public function __construct($key = '')
{
if (self::strlen($key) < 32) {
throw new InvalidArgumentException(
'Poly1305 requires a 32-byte key'
);
}
/* r &= 0xffffffc0ffffffc0ffffffc0fffffff */
$this->r = array(
// st->r[0] = ...
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 0, 4))
->setUnsignedInt(true)
->mask(0x3ffffff),
// st->r[1] = ...
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 3, 4))
->setUnsignedInt(true)
->shiftRight(2)
->mask(0x3ffff03),
// st->r[2] = ...
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 6, 4))
->setUnsignedInt(true)
->shiftRight(4)
->mask(0x3ffc0ff),
// st->r[3] = ...
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 9, 4))
->setUnsignedInt(true)
->shiftRight(6)
->mask(0x3f03fff),
// st->r[4] = ...
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 12, 4))
->setUnsignedInt(true)
->shiftRight(8)
->mask(0x00fffff)
);
/* h = 0 */
$this->h = array(
new ParagonIE_Sodium_Core32_Int32(array(0, 0), true),
new ParagonIE_Sodium_Core32_Int32(array(0, 0), true),
new ParagonIE_Sodium_Core32_Int32(array(0, 0), true),
new ParagonIE_Sodium_Core32_Int32(array(0, 0), true),
new ParagonIE_Sodium_Core32_Int32(array(0, 0), true)
);
/* save pad for later */
$this->pad = array(
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 16, 4))
->setUnsignedInt(true)->toInt64(),
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 20, 4))
->setUnsignedInt(true)->toInt64(),
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 24, 4))
->setUnsignedInt(true)->toInt64(),
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($key, 28, 4))
->setUnsignedInt(true)->toInt64(),
);
$this->leftover = 0;
$this->final = false;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @return self
* @throws SodiumException
* @throws TypeError
*/
public function update($message = '')
{
$bytes = self::strlen($message);
/* handle leftover */
if ($this->leftover) {
/** @var int $want */
$want = ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE - $this->leftover;
if ($want > $bytes) {
$want = $bytes;
}
for ($i = 0; $i < $want; ++$i) {
$mi = self::chrToInt($message[$i]);
$this->buffer[$this->leftover + $i] = $mi;
}
// We snip off the leftmost bytes.
$message = self::substr($message, $want);
$bytes = self::strlen($message);
$this->leftover += $want;
if ($this->leftover < ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE) {
// We still don't have enough to run $this->blocks()
return $this;
}
$this->blocks(
self::intArrayToString($this->buffer),
ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE
);
$this->leftover = 0;
}
/* process full blocks */
if ($bytes >= ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE) {
/** @var int $want */
$want = $bytes & ~(ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE - 1);
if ($want >= ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE) {
/** @var string $block */
$block = self::substr($message, 0, $want);
if (self::strlen($block) >= ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE) {
$this->blocks($block, $want);
$message = self::substr($message, $want);
$bytes = self::strlen($message);
}
}
}
/* store leftover */
if ($bytes) {
for ($i = 0; $i < $bytes; ++$i) {
$mi = self::chrToInt($message[$i]);
$this->buffer[$this->leftover + $i] = $mi;
}
$this->leftover = (int) $this->leftover + $bytes;
}
return $this;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param int $bytes
* @return self
* @throws SodiumException
* @throws TypeError
*/
public function blocks($message, $bytes)
{
if (self::strlen($message) < 16) {
$message = str_pad($message, 16, "\x00", STR_PAD_RIGHT);
}
$hibit = ParagonIE_Sodium_Core32_Int32::fromInt((int) ($this->final ? 0 : 1 << 24)); /* 1 << 128 */
$hibit->setUnsignedInt(true);
$zero = new ParagonIE_Sodium_Core32_Int64(array(0, 0, 0, 0), true);
/**
* @var ParagonIE_Sodium_Core32_Int64 $d0
* @var ParagonIE_Sodium_Core32_Int64 $d1
* @var ParagonIE_Sodium_Core32_Int64 $d2
* @var ParagonIE_Sodium_Core32_Int64 $d3
* @var ParagonIE_Sodium_Core32_Int64 $d4
* @var ParagonIE_Sodium_Core32_Int64 $r0
* @var ParagonIE_Sodium_Core32_Int64 $r1
* @var ParagonIE_Sodium_Core32_Int64 $r2
* @var ParagonIE_Sodium_Core32_Int64 $r3
* @var ParagonIE_Sodium_Core32_Int64 $r4
*
* @var ParagonIE_Sodium_Core32_Int32 $h0
* @var ParagonIE_Sodium_Core32_Int32 $h1
* @var ParagonIE_Sodium_Core32_Int32 $h2
* @var ParagonIE_Sodium_Core32_Int32 $h3
* @var ParagonIE_Sodium_Core32_Int32 $h4
*/
$r0 = $this->r[0]->toInt64();
$r1 = $this->r[1]->toInt64();
$r2 = $this->r[2]->toInt64();
$r3 = $this->r[3]->toInt64();
$r4 = $this->r[4]->toInt64();
$s1 = $r1->toInt64()->mulInt(5, 3);
$s2 = $r2->toInt64()->mulInt(5, 3);
$s3 = $r3->toInt64()->mulInt(5, 3);
$s4 = $r4->toInt64()->mulInt(5, 3);
$h0 = $this->h[0];
$h1 = $this->h[1];
$h2 = $this->h[2];
$h3 = $this->h[3];
$h4 = $this->h[4];
while ($bytes >= ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE) {
/* h += m[i] */
$h0 = $h0->addInt32(
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 0, 4))
->mask(0x3ffffff)
)->toInt64();
$h1 = $h1->addInt32(
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 3, 4))
->shiftRight(2)
->mask(0x3ffffff)
)->toInt64();
$h2 = $h2->addInt32(
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 6, 4))
->shiftRight(4)
->mask(0x3ffffff)
)->toInt64();
$h3 = $h3->addInt32(
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 9, 4))
->shiftRight(6)
->mask(0x3ffffff)
)->toInt64();
$h4 = $h4->addInt32(
ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($message, 12, 4))
->shiftRight(8)
->orInt32($hibit)
)->toInt64();
/* h *= r */
$d0 = $zero
->addInt64($h0->mulInt64($r0, 27))
->addInt64($s4->mulInt64($h1, 27))
->addInt64($s3->mulInt64($h2, 27))
->addInt64($s2->mulInt64($h3, 27))
->addInt64($s1->mulInt64($h4, 27));
$d1 = $zero
->addInt64($h0->mulInt64($r1, 27))
->addInt64($h1->mulInt64($r0, 27))
->addInt64($s4->mulInt64($h2, 27))
->addInt64($s3->mulInt64($h3, 27))
->addInt64($s2->mulInt64($h4, 27));
$d2 = $zero
->addInt64($h0->mulInt64($r2, 27))
->addInt64($h1->mulInt64($r1, 27))
->addInt64($h2->mulInt64($r0, 27))
->addInt64($s4->mulInt64($h3, 27))
->addInt64($s3->mulInt64($h4, 27));
$d3 = $zero
->addInt64($h0->mulInt64($r3, 27))
->addInt64($h1->mulInt64($r2, 27))
->addInt64($h2->mulInt64($r1, 27))
->addInt64($h3->mulInt64($r0, 27))
->addInt64($s4->mulInt64($h4, 27));
$d4 = $zero
->addInt64($h0->mulInt64($r4, 27))
->addInt64($h1->mulInt64($r3, 27))
->addInt64($h2->mulInt64($r2, 27))
->addInt64($h3->mulInt64($r1, 27))
->addInt64($h4->mulInt64($r0, 27));
/* (partial) h %= p */
$c = $d0->shiftRight(26);
$h0 = $d0->toInt32()->mask(0x3ffffff);
$d1 = $d1->addInt64($c);
$c = $d1->shiftRight(26);
$h1 = $d1->toInt32()->mask(0x3ffffff);
$d2 = $d2->addInt64($c);
$c = $d2->shiftRight(26);
$h2 = $d2->toInt32()->mask(0x3ffffff);
$d3 = $d3->addInt64($c);
$c = $d3->shiftRight(26);
$h3 = $d3->toInt32()->mask(0x3ffffff);
$d4 = $d4->addInt64($c);
$c = $d4->shiftRight(26);
$h4 = $d4->toInt32()->mask(0x3ffffff);
$h0 = $h0->addInt32($c->toInt32()->mulInt(5, 3));
$c = $h0->shiftRight(26);
$h0 = $h0->mask(0x3ffffff);
$h1 = $h1->addInt32($c);
// Chop off the left 32 bytes.
$message = self::substr(
$message,
ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE
);
$bytes -= ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE;
}
/** @var array<int, ParagonIE_Sodium_Core32_Int32> $h */
$this->h = array($h0, $h1, $h2, $h3, $h4);
return $this;
}
/**
* @internal You should not use this directly from another application
*
* @return string
* @throws SodiumException
* @throws TypeError
*/
public function finish()
{
/* process the remaining block */
if ($this->leftover) {
$i = $this->leftover;
$this->buffer[$i++] = 1;
for (; $i < ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE; ++$i) {
$this->buffer[$i] = 0;
}
$this->final = true;
$this->blocks(
self::substr(
self::intArrayToString($this->buffer),
0,
ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE
),
$b = ParagonIE_Sodium_Core32_Poly1305::BLOCK_SIZE
);
}
/**
* @var ParagonIE_Sodium_Core32_Int32 $f
* @var ParagonIE_Sodium_Core32_Int32 $g0
* @var ParagonIE_Sodium_Core32_Int32 $g1
* @var ParagonIE_Sodium_Core32_Int32 $g2
* @var ParagonIE_Sodium_Core32_Int32 $g3
* @var ParagonIE_Sodium_Core32_Int32 $g4
* @var ParagonIE_Sodium_Core32_Int32 $h0
* @var ParagonIE_Sodium_Core32_Int32 $h1
* @var ParagonIE_Sodium_Core32_Int32 $h2
* @var ParagonIE_Sodium_Core32_Int32 $h3
* @var ParagonIE_Sodium_Core32_Int32 $h4
*/
$h0 = $this->h[0];
$h1 = $this->h[1];
$h2 = $this->h[2];
$h3 = $this->h[3];
$h4 = $this->h[4];
$c = $h1->shiftRight(26); # $c = $h1 >> 26;
$h1 = $h1->mask(0x3ffffff); # $h1 &= 0x3ffffff;
$h2 = $h2->addInt32($c); # $h2 += $c;
$c = $h2->shiftRight(26); # $c = $h2 >> 26;
$h2 = $h2->mask(0x3ffffff); # $h2 &= 0x3ffffff;
$h3 = $h3->addInt32($c); # $h3 += $c;
$c = $h3->shiftRight(26); # $c = $h3 >> 26;
$h3 = $h3->mask(0x3ffffff); # $h3 &= 0x3ffffff;
$h4 = $h4->addInt32($c); # $h4 += $c;
$c = $h4->shiftRight(26); # $c = $h4 >> 26;
$h4 = $h4->mask(0x3ffffff); # $h4 &= 0x3ffffff;
$h0 = $h0->addInt32($c->mulInt(5, 3)); # $h0 += self::mul($c, 5);
$c = $h0->shiftRight(26); # $c = $h0 >> 26;
$h0 = $h0->mask(0x3ffffff); # $h0 &= 0x3ffffff;
$h1 = $h1->addInt32($c); # $h1 += $c;
/* compute h + -p */
$g0 = $h0->addInt(5);
$c = $g0->shiftRight(26);
$g0 = $g0->mask(0x3ffffff);
$g1 = $h1->addInt32($c);
$c = $g1->shiftRight(26);
$g1 = $g1->mask(0x3ffffff);
$g2 = $h2->addInt32($c);
$c = $g2->shiftRight(26);
$g2 = $g2->mask(0x3ffffff);
$g3 = $h3->addInt32($c);
$c = $g3->shiftRight(26);
$g3 = $g3->mask(0x3ffffff);
$g4 = $h4->addInt32($c)->subInt(1 << 26);
# $mask = ($g4 >> 31) - 1;
/* select h if h < p, or h + -p if h >= p */
$mask = (int) (($g4->toInt() >> 31) + 1);
$g0 = $g0->mask($mask);
$g1 = $g1->mask($mask);
$g2 = $g2->mask($mask);
$g3 = $g3->mask($mask);
$g4 = $g4->mask($mask);
/** @var int $mask */
$mask = ~$mask;
$h0 = $h0->mask($mask)->orInt32($g0);
$h1 = $h1->mask($mask)->orInt32($g1);
$h2 = $h2->mask($mask)->orInt32($g2);
$h3 = $h3->mask($mask)->orInt32($g3);
$h4 = $h4->mask($mask)->orInt32($g4);
/* h = h % (2^128) */
$h0 = $h0->orInt32($h1->shiftLeft(26));
$h1 = $h1->shiftRight(6)->orInt32($h2->shiftLeft(20));
$h2 = $h2->shiftRight(12)->orInt32($h3->shiftLeft(14));
$h3 = $h3->shiftRight(18)->orInt32($h4->shiftLeft(8));
/* mac = (h + pad) % (2^128) */
$f = $h0->toInt64()->addInt64($this->pad[0]);
$h0 = $f->toInt32();
$f = $h1->toInt64()->addInt64($this->pad[1])->addInt($h0->overflow);
$h1 = $f->toInt32();
$f = $h2->toInt64()->addInt64($this->pad[2])->addInt($h1->overflow);
$h2 = $f->toInt32();
$f = $h3->toInt64()->addInt64($this->pad[3])->addInt($h2->overflow);
$h3 = $f->toInt32();
return $h0->toReverseString() .
$h1->toReverseString() .
$h2->toReverseString() .
$h3->toReverseString();
}
}
Core32/Poly1305.php 0000644 00000003062 15153427537 0007563 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Poly1305', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Poly1305
*/
abstract class ParagonIE_Sodium_Core32_Poly1305 extends ParagonIE_Sodium_Core32_Util
{
const BLOCK_SIZE = 16;
/**
* @internal You should not use this directly from another application
*
* @param string $m
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function onetimeauth($m, $key)
{
if (self::strlen($key) < 32) {
throw new InvalidArgumentException(
'Key must be 32 bytes long.'
);
}
$state = new ParagonIE_Sodium_Core32_Poly1305_State(
self::substr($key, 0, 32)
);
return $state
->update($m)
->finish();
}
/**
* @internal You should not use this directly from another application
*
* @param string $mac
* @param string $m
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function onetimeauth_verify($mac, $m, $key)
{
if (self::strlen($key) < 32) {
throw new InvalidArgumentException(
'Key must be 32 bytes long.'
);
}
$state = new ParagonIE_Sodium_Core32_Poly1305_State(
self::substr($key, 0, 32)
);
$calc = $state
->update($m)
->finish();
return self::verify_16($calc, $mac);
}
}
Core32/Salsa20.php 0000644 00000026362 15153427537 0007544 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Salsa20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_Salsa20
*/
abstract class ParagonIE_Sodium_Core32_Salsa20 extends ParagonIE_Sodium_Core32_Util
{
const ROUNDS = 20;
/**
* Calculate an salsa20 hash of a single block
*
* @internal You should not use this directly from another application
*
* @param string $in
* @param string $k
* @param string|null $c
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function core_salsa20($in, $k, $c = null)
{
/**
* @var ParagonIE_Sodium_Core32_Int32 $x0
* @var ParagonIE_Sodium_Core32_Int32 $x1
* @var ParagonIE_Sodium_Core32_Int32 $x2
* @var ParagonIE_Sodium_Core32_Int32 $x3
* @var ParagonIE_Sodium_Core32_Int32 $x4
* @var ParagonIE_Sodium_Core32_Int32 $x5
* @var ParagonIE_Sodium_Core32_Int32 $x6
* @var ParagonIE_Sodium_Core32_Int32 $x7
* @var ParagonIE_Sodium_Core32_Int32 $x8
* @var ParagonIE_Sodium_Core32_Int32 $x9
* @var ParagonIE_Sodium_Core32_Int32 $x10
* @var ParagonIE_Sodium_Core32_Int32 $x11
* @var ParagonIE_Sodium_Core32_Int32 $x12
* @var ParagonIE_Sodium_Core32_Int32 $x13
* @var ParagonIE_Sodium_Core32_Int32 $x14
* @var ParagonIE_Sodium_Core32_Int32 $x15
* @var ParagonIE_Sodium_Core32_Int32 $j0
* @var ParagonIE_Sodium_Core32_Int32 $j1
* @var ParagonIE_Sodium_Core32_Int32 $j2
* @var ParagonIE_Sodium_Core32_Int32 $j3
* @var ParagonIE_Sodium_Core32_Int32 $j4
* @var ParagonIE_Sodium_Core32_Int32 $j5
* @var ParagonIE_Sodium_Core32_Int32 $j6
* @var ParagonIE_Sodium_Core32_Int32 $j7
* @var ParagonIE_Sodium_Core32_Int32 $j8
* @var ParagonIE_Sodium_Core32_Int32 $j9
* @var ParagonIE_Sodium_Core32_Int32 $j10
* @var ParagonIE_Sodium_Core32_Int32 $j11
* @var ParagonIE_Sodium_Core32_Int32 $j12
* @var ParagonIE_Sodium_Core32_Int32 $j13
* @var ParagonIE_Sodium_Core32_Int32 $j14
* @var ParagonIE_Sodium_Core32_Int32 $j15
*/
if (self::strlen($k) < 32) {
throw new RangeException('Key must be 32 bytes long');
}
if ($c === null) {
$x0 = new ParagonIE_Sodium_Core32_Int32(array(0x6170, 0x7865));
$x5 = new ParagonIE_Sodium_Core32_Int32(array(0x3320, 0x646e));
$x10 = new ParagonIE_Sodium_Core32_Int32(array(0x7962, 0x2d32));
$x15 = new ParagonIE_Sodium_Core32_Int32(array(0x6b20, 0x6574));
} else {
$x0 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 0, 4));
$x5 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 4, 4));
$x10 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 8, 4));
$x15 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($c, 12, 4));
}
$x1 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 0, 4));
$x2 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 4, 4));
$x3 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 8, 4));
$x4 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 12, 4));
$x6 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 0, 4));
$x7 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 4, 4));
$x8 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 8, 4));
$x9 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($in, 12, 4));
$x11 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 16, 4));
$x12 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 20, 4));
$x13 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 24, 4));
$x14 = ParagonIE_Sodium_Core32_Int32::fromReverseString(self::substr($k, 28, 4));
$j0 = clone $x0;
$j1 = clone $x1;
$j2 = clone $x2;
$j3 = clone $x3;
$j4 = clone $x4;
$j5 = clone $x5;
$j6 = clone $x6;
$j7 = clone $x7;
$j8 = clone $x8;
$j9 = clone $x9;
$j10 = clone $x10;
$j11 = clone $x11;
$j12 = clone $x12;
$j13 = clone $x13;
$j14 = clone $x14;
$j15 = clone $x15;
for ($i = self::ROUNDS; $i > 0; $i -= 2) {
$x4 = $x4->xorInt32($x0->addInt32($x12)->rotateLeft(7));
$x8 = $x8->xorInt32($x4->addInt32($x0)->rotateLeft(9));
$x12 = $x12->xorInt32($x8->addInt32($x4)->rotateLeft(13));
$x0 = $x0->xorInt32($x12->addInt32($x8)->rotateLeft(18));
$x9 = $x9->xorInt32($x5->addInt32($x1)->rotateLeft(7));
$x13 = $x13->xorInt32($x9->addInt32($x5)->rotateLeft(9));
$x1 = $x1->xorInt32($x13->addInt32($x9)->rotateLeft(13));
$x5 = $x5->xorInt32($x1->addInt32($x13)->rotateLeft(18));
$x14 = $x14->xorInt32($x10->addInt32($x6)->rotateLeft(7));
$x2 = $x2->xorInt32($x14->addInt32($x10)->rotateLeft(9));
$x6 = $x6->xorInt32($x2->addInt32($x14)->rotateLeft(13));
$x10 = $x10->xorInt32($x6->addInt32($x2)->rotateLeft(18));
$x3 = $x3->xorInt32($x15->addInt32($x11)->rotateLeft(7));
$x7 = $x7->xorInt32($x3->addInt32($x15)->rotateLeft(9));
$x11 = $x11->xorInt32($x7->addInt32($x3)->rotateLeft(13));
$x15 = $x15->xorInt32($x11->addInt32($x7)->rotateLeft(18));
$x1 = $x1->xorInt32($x0->addInt32($x3)->rotateLeft(7));
$x2 = $x2->xorInt32($x1->addInt32($x0)->rotateLeft(9));
$x3 = $x3->xorInt32($x2->addInt32($x1)->rotateLeft(13));
$x0 = $x0->xorInt32($x3->addInt32($x2)->rotateLeft(18));
$x6 = $x6->xorInt32($x5->addInt32($x4)->rotateLeft(7));
$x7 = $x7->xorInt32($x6->addInt32($x5)->rotateLeft(9));
$x4 = $x4->xorInt32($x7->addInt32($x6)->rotateLeft(13));
$x5 = $x5->xorInt32($x4->addInt32($x7)->rotateLeft(18));
$x11 = $x11->xorInt32($x10->addInt32($x9)->rotateLeft(7));
$x8 = $x8->xorInt32($x11->addInt32($x10)->rotateLeft(9));
$x9 = $x9->xorInt32($x8->addInt32($x11)->rotateLeft(13));
$x10 = $x10->xorInt32($x9->addInt32($x8)->rotateLeft(18));
$x12 = $x12->xorInt32($x15->addInt32($x14)->rotateLeft(7));
$x13 = $x13->xorInt32($x12->addInt32($x15)->rotateLeft(9));
$x14 = $x14->xorInt32($x13->addInt32($x12)->rotateLeft(13));
$x15 = $x15->xorInt32($x14->addInt32($x13)->rotateLeft(18));
}
$x0 = $x0->addInt32($j0);
$x1 = $x1->addInt32($j1);
$x2 = $x2->addInt32($j2);
$x3 = $x3->addInt32($j3);
$x4 = $x4->addInt32($j4);
$x5 = $x5->addInt32($j5);
$x6 = $x6->addInt32($j6);
$x7 = $x7->addInt32($j7);
$x8 = $x8->addInt32($j8);
$x9 = $x9->addInt32($j9);
$x10 = $x10->addInt32($j10);
$x11 = $x11->addInt32($j11);
$x12 = $x12->addInt32($j12);
$x13 = $x13->addInt32($j13);
$x14 = $x14->addInt32($j14);
$x15 = $x15->addInt32($j15);
return $x0->toReverseString() .
$x1->toReverseString() .
$x2->toReverseString() .
$x3->toReverseString() .
$x4->toReverseString() .
$x5->toReverseString() .
$x6->toReverseString() .
$x7->toReverseString() .
$x8->toReverseString() .
$x9->toReverseString() .
$x10->toReverseString() .
$x11->toReverseString() .
$x12->toReverseString() .
$x13->toReverseString() .
$x14->toReverseString() .
$x15->toReverseString();
}
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function salsa20($len, $nonce, $key)
{
if (self::strlen($key) !== 32) {
throw new RangeException('Key must be 32 bytes long');
}
$kcopy = '' . $key;
$in = self::substr($nonce, 0, 8) . str_repeat("\0", 8);
$c = '';
while ($len >= 64) {
$c .= self::core_salsa20($in, $kcopy, null);
$u = 1;
// Internal counter.
for ($i = 8; $i < 16; ++$i) {
$u += self::chrToInt($in[$i]);
$in[$i] = self::intToChr($u & 0xff);
$u >>= 8;
}
$len -= 64;
}
if ($len > 0) {
$c .= self::substr(
self::core_salsa20($in, $kcopy, null),
0,
$len
);
}
try {
ParagonIE_Sodium_Compat::memzero($kcopy);
} catch (SodiumException $ex) {
$kcopy = null;
}
return $c;
}
/**
* @internal You should not use this directly from another application
*
* @param string $m
* @param string $n
* @param int $ic
* @param string $k
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function salsa20_xor_ic($m, $n, $ic, $k)
{
$mlen = self::strlen($m);
if ($mlen < 1) {
return '';
}
$kcopy = self::substr($k, 0, 32);
$in = self::substr($n, 0, 8);
// Initialize the counter
$in .= ParagonIE_Sodium_Core32_Util::store64_le($ic);
$c = '';
while ($mlen >= 64) {
$block = self::core_salsa20($in, $kcopy, null);
$c .= self::xorStrings(
self::substr($m, 0, 64),
self::substr($block, 0, 64)
);
$u = 1;
for ($i = 8; $i < 16; ++$i) {
$u += self::chrToInt($in[$i]);
$in[$i] = self::intToChr($u & 0xff);
$u >>= 8;
}
$mlen -= 64;
$m = self::substr($m, 64);
}
if ($mlen) {
$block = self::core_salsa20($in, $kcopy, null);
$c .= self::xorStrings(
self::substr($m, 0, $mlen),
self::substr($block, 0, $mlen)
);
}
try {
ParagonIE_Sodium_Compat::memzero($block);
ParagonIE_Sodium_Compat::memzero($kcopy);
} catch (SodiumException $ex) {
$block = null;
$kcopy = null;
}
return $c;
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function salsa20_xor($message, $nonce, $key)
{
return self::xorStrings(
$message,
self::salsa20(
self::strlen($message),
$nonce,
$key
)
);
}
}
Core32/SecretStream/State.php 0000644 00000007110 15153427537 0012006 0 ustar 00 <?php
/**
* Class ParagonIE_Sodium_Core32_SecretStream_State
*/
class ParagonIE_Sodium_Core32_SecretStream_State
{
/** @var string $key */
protected $key;
/** @var int $counter */
protected $counter;
/** @var string $nonce */
protected $nonce;
/** @var string $_pad */
protected $_pad;
/**
* ParagonIE_Sodium_Core32_SecretStream_State constructor.
* @param string $key
* @param string|null $nonce
*/
public function __construct($key, $nonce = null)
{
$this->key = $key;
$this->counter = 1;
if (is_null($nonce)) {
$nonce = str_repeat("\0", 12);
}
$this->nonce = str_pad($nonce, 12, "\0", STR_PAD_RIGHT);;
$this->_pad = str_repeat("\0", 4);
}
/**
* @return self
*/
public function counterReset()
{
$this->counter = 1;
$this->_pad = str_repeat("\0", 4);
return $this;
}
/**
* @return string
*/
public function getKey()
{
return $this->key;
}
/**
* @return string
*/
public function getCounter()
{
return ParagonIE_Sodium_Core32_Util::store32_le($this->counter);
}
/**
* @return string
*/
public function getNonce()
{
if (!is_string($this->nonce)) {
$this->nonce = str_repeat("\0", 12);
}
if (ParagonIE_Sodium_Core32_Util::strlen($this->nonce) !== 12) {
$this->nonce = str_pad($this->nonce, 12, "\0", STR_PAD_RIGHT);
}
return $this->nonce;
}
/**
* @return string
*/
public function getCombinedNonce()
{
return $this->getCounter() .
ParagonIE_Sodium_Core32_Util::substr($this->getNonce(), 0, 8);
}
/**
* @return self
*/
public function incrementCounter()
{
++$this->counter;
return $this;
}
/**
* @return bool
*/
public function needsRekey()
{
return ($this->counter & 0xffff) === 0;
}
/**
* @param string $newKeyAndNonce
* @return self
*/
public function rekey($newKeyAndNonce)
{
$this->key = ParagonIE_Sodium_Core32_Util::substr($newKeyAndNonce, 0, 32);
$this->nonce = str_pad(
ParagonIE_Sodium_Core32_Util::substr($newKeyAndNonce, 32),
12,
"\0",
STR_PAD_RIGHT
);
return $this;
}
/**
* @param string $str
* @return self
*/
public function xorNonce($str)
{
$this->nonce = ParagonIE_Sodium_Core32_Util::xorStrings(
$this->getNonce(),
str_pad(
ParagonIE_Sodium_Core32_Util::substr($str, 0, 8),
12,
"\0",
STR_PAD_RIGHT
)
);
return $this;
}
/**
* @param string $string
* @return self
*/
public static function fromString($string)
{
$state = new ParagonIE_Sodium_Core32_SecretStream_State(
ParagonIE_Sodium_Core32_Util::substr($string, 0, 32)
);
$state->counter = ParagonIE_Sodium_Core32_Util::load_4(
ParagonIE_Sodium_Core32_Util::substr($string, 32, 4)
);
$state->nonce = ParagonIE_Sodium_Core32_Util::substr($string, 36, 12);
$state->_pad = ParagonIE_Sodium_Core32_Util::substr($string, 48, 8);
return $state;
}
/**
* @return string
*/
public function toString()
{
return $this->key .
$this->getCounter() .
$this->nonce .
$this->_pad;
}
}
Core32/SipHash.php 0000644 00000014725 15153427537 0007676 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_SipHash', false)) {
return;
}
/**
* Class ParagonIE_SodiumCompat_Core32_SipHash
*
* Only uses 32-bit arithmetic, while the original SipHash used 64-bit integers
*/
class ParagonIE_Sodium_Core32_SipHash extends ParagonIE_Sodium_Core32_Util
{
/**
* @internal You should not use this directly from another application
*
* @param array<int, ParagonIE_Sodium_Core32_Int64> $v
* @return array<int, ParagonIE_Sodium_Core32_Int64>
*/
public static function sipRound(array $v)
{
# v0 += v1;
$v[0] = $v[0]->addInt64($v[1]);
# v1 = ROTL(v1, 13);
$v[1] = $v[1]->rotateLeft(13);
# v1 ^= v0;
$v[1] = $v[1]->xorInt64($v[0]);
# v0=ROTL(v0,32);
$v[0] = $v[0]->rotateLeft(32);
# v2 += v3;
$v[2] = $v[2]->addInt64($v[3]);
# v3=ROTL(v3,16);
$v[3] = $v[3]->rotateLeft(16);
# v3 ^= v2;
$v[3] = $v[3]->xorInt64($v[2]);
# v0 += v3;
$v[0] = $v[0]->addInt64($v[3]);
# v3=ROTL(v3,21);
$v[3] = $v[3]->rotateLeft(21);
# v3 ^= v0;
$v[3] = $v[3]->xorInt64($v[0]);
# v2 += v1;
$v[2] = $v[2]->addInt64($v[1]);
# v1=ROTL(v1,17);
$v[1] = $v[1]->rotateLeft(17);
# v1 ^= v2;
$v[1] = $v[1]->xorInt64($v[2]);
# v2=ROTL(v2,32)
$v[2] = $v[2]->rotateLeft(32);
return $v;
}
/**
* @internal You should not use this directly from another application
*
* @param string $in
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sipHash24($in, $key)
{
$inlen = self::strlen($in);
# /* "somepseudorandomlygeneratedbytes" */
# u64 v0 = 0x736f6d6570736575ULL;
# u64 v1 = 0x646f72616e646f6dULL;
# u64 v2 = 0x6c7967656e657261ULL;
# u64 v3 = 0x7465646279746573ULL;
$v = array(
new ParagonIE_Sodium_Core32_Int64(
array(0x736f, 0x6d65, 0x7073, 0x6575)
),
new ParagonIE_Sodium_Core32_Int64(
array(0x646f, 0x7261, 0x6e64, 0x6f6d)
),
new ParagonIE_Sodium_Core32_Int64(
array(0x6c79, 0x6765, 0x6e65, 0x7261)
),
new ParagonIE_Sodium_Core32_Int64(
array(0x7465, 0x6462, 0x7974, 0x6573)
)
);
# u64 k0 = LOAD64_LE( k );
# u64 k1 = LOAD64_LE( k + 8 );
$k = array(
ParagonIE_Sodium_Core32_Int64::fromReverseString(
self::substr($key, 0, 8)
),
ParagonIE_Sodium_Core32_Int64::fromReverseString(
self::substr($key, 8, 8)
)
);
# b = ( ( u64 )inlen ) << 56;
$b = new ParagonIE_Sodium_Core32_Int64(
array(($inlen << 8) & 0xffff, 0, 0, 0)
);
# v3 ^= k1;
$v[3] = $v[3]->xorInt64($k[1]);
# v2 ^= k0;
$v[2] = $v[2]->xorInt64($k[0]);
# v1 ^= k1;
$v[1] = $v[1]->xorInt64($k[1]);
# v0 ^= k0;
$v[0] = $v[0]->xorInt64($k[0]);
$left = $inlen;
# for ( ; in != end; in += 8 )
while ($left >= 8) {
# m = LOAD64_LE( in );
$m = ParagonIE_Sodium_Core32_Int64::fromReverseString(
self::substr($in, 0, 8)
);
# v3 ^= m;
$v[3] = $v[3]->xorInt64($m);
# SIPROUND;
# SIPROUND;
$v = self::sipRound($v);
$v = self::sipRound($v);
# v0 ^= m;
$v[0] = $v[0]->xorInt64($m);
$in = self::substr($in, 8);
$left -= 8;
}
# switch( left )
# {
# case 7: b |= ( ( u64 )in[ 6] ) << 48;
# case 6: b |= ( ( u64 )in[ 5] ) << 40;
# case 5: b |= ( ( u64 )in[ 4] ) << 32;
# case 4: b |= ( ( u64 )in[ 3] ) << 24;
# case 3: b |= ( ( u64 )in[ 2] ) << 16;
# case 2: b |= ( ( u64 )in[ 1] ) << 8;
# case 1: b |= ( ( u64 )in[ 0] ); break;
# case 0: break;
# }
switch ($left) {
case 7:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
0, self::chrToInt($in[6]) << 16
)
);
case 6:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
0, self::chrToInt($in[5]) << 8
)
);
case 5:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
0, self::chrToInt($in[4])
)
);
case 4:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
self::chrToInt($in[3]) << 24, 0
)
);
case 3:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
self::chrToInt($in[2]) << 16, 0
)
);
case 2:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
self::chrToInt($in[1]) << 8, 0
)
);
case 1:
$b = $b->orInt64(
ParagonIE_Sodium_Core32_Int64::fromInts(
self::chrToInt($in[0]), 0
)
);
case 0:
break;
}
# v3 ^= b;
$v[3] = $v[3]->xorInt64($b);
# SIPROUND;
# SIPROUND;
$v = self::sipRound($v);
$v = self::sipRound($v);
# v0 ^= b;
$v[0] = $v[0]->xorInt64($b);
// Flip the lower 8 bits of v2 which is ($v[4], $v[5]) in our implementation
# v2 ^= 0xff;
$v[2]->limbs[3] ^= 0xff;
# SIPROUND;
# SIPROUND;
# SIPROUND;
# SIPROUND;
$v = self::sipRound($v);
$v = self::sipRound($v);
$v = self::sipRound($v);
$v = self::sipRound($v);
# b = v0 ^ v1 ^ v2 ^ v3;
# STORE64_LE( out, b );
return $v[0]
->xorInt64($v[1])
->xorInt64($v[2])
->xorInt64($v[3])
->toReverseString();
}
}
Core32/Util.php 0000644 00000000321 15153427537 0007237 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_Util', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core_Util
*/
abstract class ParagonIE_Sodium_Core32_Util extends ParagonIE_Sodium_Core_Util
{
}
Core32/X25519.php 0000644 00000025442 15153427537 0007152 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_X25519', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_X25519
*/
abstract class ParagonIE_Sodium_Core32_X25519 extends ParagonIE_Sodium_Core32_Curve25519
{
/**
* Alters the objects passed to this method in place.
*
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $g
* @param int $b
* @return void
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedMethodCall
*/
public static function fe_cswap(
ParagonIE_Sodium_Core32_Curve25519_Fe $f,
ParagonIE_Sodium_Core32_Curve25519_Fe $g,
$b = 0
) {
$f0 = (int) $f[0]->toInt();
$f1 = (int) $f[1]->toInt();
$f2 = (int) $f[2]->toInt();
$f3 = (int) $f[3]->toInt();
$f4 = (int) $f[4]->toInt();
$f5 = (int) $f[5]->toInt();
$f6 = (int) $f[6]->toInt();
$f7 = (int) $f[7]->toInt();
$f8 = (int) $f[8]->toInt();
$f9 = (int) $f[9]->toInt();
$g0 = (int) $g[0]->toInt();
$g1 = (int) $g[1]->toInt();
$g2 = (int) $g[2]->toInt();
$g3 = (int) $g[3]->toInt();
$g4 = (int) $g[4]->toInt();
$g5 = (int) $g[5]->toInt();
$g6 = (int) $g[6]->toInt();
$g7 = (int) $g[7]->toInt();
$g8 = (int) $g[8]->toInt();
$g9 = (int) $g[9]->toInt();
$b = -$b;
/** @var int $x0 */
$x0 = ($f0 ^ $g0) & $b;
/** @var int $x1 */
$x1 = ($f1 ^ $g1) & $b;
/** @var int $x2 */
$x2 = ($f2 ^ $g2) & $b;
/** @var int $x3 */
$x3 = ($f3 ^ $g3) & $b;
/** @var int $x4 */
$x4 = ($f4 ^ $g4) & $b;
/** @var int $x5 */
$x5 = ($f5 ^ $g5) & $b;
/** @var int $x6 */
$x6 = ($f6 ^ $g6) & $b;
/** @var int $x7 */
$x7 = ($f7 ^ $g7) & $b;
/** @var int $x8 */
$x8 = ($f8 ^ $g8) & $b;
/** @var int $x9 */
$x9 = ($f9 ^ $g9) & $b;
$f[0] = ParagonIE_Sodium_Core32_Int32::fromInt($f0 ^ $x0);
$f[1] = ParagonIE_Sodium_Core32_Int32::fromInt($f1 ^ $x1);
$f[2] = ParagonIE_Sodium_Core32_Int32::fromInt($f2 ^ $x2);
$f[3] = ParagonIE_Sodium_Core32_Int32::fromInt($f3 ^ $x3);
$f[4] = ParagonIE_Sodium_Core32_Int32::fromInt($f4 ^ $x4);
$f[5] = ParagonIE_Sodium_Core32_Int32::fromInt($f5 ^ $x5);
$f[6] = ParagonIE_Sodium_Core32_Int32::fromInt($f6 ^ $x6);
$f[7] = ParagonIE_Sodium_Core32_Int32::fromInt($f7 ^ $x7);
$f[8] = ParagonIE_Sodium_Core32_Int32::fromInt($f8 ^ $x8);
$f[9] = ParagonIE_Sodium_Core32_Int32::fromInt($f9 ^ $x9);
$g[0] = ParagonIE_Sodium_Core32_Int32::fromInt($g0 ^ $x0);
$g[1] = ParagonIE_Sodium_Core32_Int32::fromInt($g1 ^ $x1);
$g[2] = ParagonIE_Sodium_Core32_Int32::fromInt($g2 ^ $x2);
$g[3] = ParagonIE_Sodium_Core32_Int32::fromInt($g3 ^ $x3);
$g[4] = ParagonIE_Sodium_Core32_Int32::fromInt($g4 ^ $x4);
$g[5] = ParagonIE_Sodium_Core32_Int32::fromInt($g5 ^ $x5);
$g[6] = ParagonIE_Sodium_Core32_Int32::fromInt($g6 ^ $x6);
$g[7] = ParagonIE_Sodium_Core32_Int32::fromInt($g7 ^ $x7);
$g[8] = ParagonIE_Sodium_Core32_Int32::fromInt($g8 ^ $x8);
$g[9] = ParagonIE_Sodium_Core32_Int32::fromInt($g9 ^ $x9);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $f
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedMethodCall
*/
public static function fe_mul121666(ParagonIE_Sodium_Core32_Curve25519_Fe $f)
{
/** @var array<int, ParagonIE_Sodium_Core32_Int64> $h */
$h = array();
for ($i = 0; $i < 10; ++$i) {
$h[$i] = $f[$i]->toInt64()->mulInt(121666, 17);
}
$carry9 = $h[9]->addInt(1 << 24)->shiftRight(25);
$h[0] = $h[0]->addInt64($carry9->mulInt(19, 5));
$h[9] = $h[9]->subInt64($carry9->shiftLeft(25));
$carry1 = $h[1]->addInt(1 << 24)->shiftRight(25);
$h[2] = $h[2]->addInt64($carry1);
$h[1] = $h[1]->subInt64($carry1->shiftLeft(25));
$carry3 = $h[3]->addInt(1 << 24)->shiftRight(25);
$h[4] = $h[4]->addInt64($carry3);
$h[3] = $h[3]->subInt64($carry3->shiftLeft(25));
$carry5 = $h[5]->addInt(1 << 24)->shiftRight(25);
$h[6] = $h[6]->addInt64($carry5);
$h[5] = $h[5]->subInt64($carry5->shiftLeft(25));
$carry7 = $h[7]->addInt(1 << 24)->shiftRight(25);
$h[8] = $h[8]->addInt64($carry7);
$h[7] = $h[7]->subInt64($carry7->shiftLeft(25));
$carry0 = $h[0]->addInt(1 << 25)->shiftRight(26);
$h[1] = $h[1]->addInt64($carry0);
$h[0] = $h[0]->subInt64($carry0->shiftLeft(26));
$carry2 = $h[2]->addInt(1 << 25)->shiftRight(26);
$h[3] = $h[3]->addInt64($carry2);
$h[2] = $h[2]->subInt64($carry2->shiftLeft(26));
$carry4 = $h[4]->addInt(1 << 25)->shiftRight(26);
$h[5] = $h[5]->addInt64($carry4);
$h[4] = $h[4]->subInt64($carry4->shiftLeft(26));
$carry6 = $h[6]->addInt(1 << 25)->shiftRight(26);
$h[7] = $h[7]->addInt64($carry6);
$h[6] = $h[6]->subInt64($carry6->shiftLeft(26));
$carry8 = $h[8]->addInt(1 << 25)->shiftRight(26);
$h[9] = $h[9]->addInt64($carry8);
$h[8] = $h[8]->subInt64($carry8->shiftLeft(26));
for ($i = 0; $i < 10; ++$i) {
$h[$i] = $h[$i]->toInt32();
}
/** @var array<int, ParagonIE_Sodium_Core32_Int32> $h2 */
$h2 = $h;
return ParagonIE_Sodium_Core32_Curve25519_Fe::fromArray($h2);
}
/**
* @internal You should not use this directly from another application
*
* Inline comments preceded by # are from libsodium's ref10 code.
*
* @param string $n
* @param string $p
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_scalarmult_curve25519_ref10($n, $p)
{
# for (i = 0;i < 32;++i) e[i] = n[i];
$e = '' . $n;
# e[0] &= 248;
$e[0] = self::intToChr(
self::chrToInt($e[0]) & 248
);
# e[31] &= 127;
# e[31] |= 64;
$e[31] = self::intToChr(
(self::chrToInt($e[31]) & 127) | 64
);
# fe_frombytes(x1,p);
$x1 = self::fe_frombytes($p);
# fe_1(x2);
$x2 = self::fe_1();
# fe_0(z2);
$z2 = self::fe_0();
# fe_copy(x3,x1);
$x3 = self::fe_copy($x1);
# fe_1(z3);
$z3 = self::fe_1();
# swap = 0;
/** @var int $swap */
$swap = 0;
# for (pos = 254;pos >= 0;--pos) {
for ($pos = 254; $pos >= 0; --$pos) {
# b = e[pos / 8] >> (pos & 7);
/** @var int $b */
$b = self::chrToInt(
$e[(int) floor($pos / 8)]
) >> ($pos & 7);
# b &= 1;
$b &= 1;
# swap ^= b;
$swap ^= $b;
# fe_cswap(x2,x3,swap);
self::fe_cswap($x2, $x3, $swap);
# fe_cswap(z2,z3,swap);
self::fe_cswap($z2, $z3, $swap);
# swap = b;
/** @var int $swap */
$swap = $b;
# fe_sub(tmp0,x3,z3);
$tmp0 = self::fe_sub($x3, $z3);
# fe_sub(tmp1,x2,z2);
$tmp1 = self::fe_sub($x2, $z2);
# fe_add(x2,x2,z2);
$x2 = self::fe_add($x2, $z2);
# fe_add(z2,x3,z3);
$z2 = self::fe_add($x3, $z3);
# fe_mul(z3,tmp0,x2);
$z3 = self::fe_mul($tmp0, $x2);
# fe_mul(z2,z2,tmp1);
$z2 = self::fe_mul($z2, $tmp1);
# fe_sq(tmp0,tmp1);
$tmp0 = self::fe_sq($tmp1);
# fe_sq(tmp1,x2);
$tmp1 = self::fe_sq($x2);
# fe_add(x3,z3,z2);
$x3 = self::fe_add($z3, $z2);
# fe_sub(z2,z3,z2);
$z2 = self::fe_sub($z3, $z2);
# fe_mul(x2,tmp1,tmp0);
$x2 = self::fe_mul($tmp1, $tmp0);
# fe_sub(tmp1,tmp1,tmp0);
$tmp1 = self::fe_sub($tmp1, $tmp0);
# fe_sq(z2,z2);
$z2 = self::fe_sq($z2);
# fe_mul121666(z3,tmp1);
$z3 = self::fe_mul121666($tmp1);
# fe_sq(x3,x3);
$x3 = self::fe_sq($x3);
# fe_add(tmp0,tmp0,z3);
$tmp0 = self::fe_add($tmp0, $z3);
# fe_mul(z3,x1,z2);
$z3 = self::fe_mul($x1, $z2);
# fe_mul(z2,tmp1,tmp0);
$z2 = self::fe_mul($tmp1, $tmp0);
}
# fe_cswap(x2,x3,swap);
self::fe_cswap($x2, $x3, $swap);
# fe_cswap(z2,z3,swap);
self::fe_cswap($z2, $z3, $swap);
# fe_invert(z2,z2);
$z2 = self::fe_invert($z2);
# fe_mul(x2,x2,z2);
$x2 = self::fe_mul($x2, $z2);
# fe_tobytes(q,x2);
return (string) self::fe_tobytes($x2);
}
/**
* @internal You should not use this directly from another application
*
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $edwardsY
* @param ParagonIE_Sodium_Core32_Curve25519_Fe $edwardsZ
* @return ParagonIE_Sodium_Core32_Curve25519_Fe
* @throws SodiumException
* @throws TypeError
*/
public static function edwards_to_montgomery(
ParagonIE_Sodium_Core32_Curve25519_Fe $edwardsY,
ParagonIE_Sodium_Core32_Curve25519_Fe $edwardsZ
) {
$tempX = self::fe_add($edwardsZ, $edwardsY);
$tempZ = self::fe_sub($edwardsZ, $edwardsY);
$tempZ = self::fe_invert($tempZ);
return self::fe_mul($tempX, $tempZ);
}
/**
* @internal You should not use this directly from another application
*
* @param string $n
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function crypto_scalarmult_curve25519_ref10_base($n)
{
# for (i = 0;i < 32;++i) e[i] = n[i];
$e = '' . $n;
# e[0] &= 248;
$e[0] = self::intToChr(
self::chrToInt($e[0]) & 248
);
# e[31] &= 127;
# e[31] |= 64;
$e[31] = self::intToChr(
(self::chrToInt($e[31]) & 127) | 64
);
$A = self::ge_scalarmult_base($e);
if (
!($A->Y instanceof ParagonIE_Sodium_Core32_Curve25519_Fe)
||
!($A->Z instanceof ParagonIE_Sodium_Core32_Curve25519_Fe)
) {
throw new TypeError('Null points encountered');
}
$pk = self::edwards_to_montgomery($A->Y, $A->Z);
return self::fe_tobytes($pk);
}
}
Core32/XChaCha20.php 0000644 00000004626 15153427537 0007737 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_XChaCha20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_XChaCha20
*/
class ParagonIE_Sodium_Core32_XChaCha20 extends ParagonIE_Sodium_Core32_HChaCha20
{
/**
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function stream($len = 64, $nonce = '', $key = '')
{
if (self::strlen($nonce) !== 24) {
throw new SodiumException('Nonce must be 24 bytes long');
}
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_Ctx(
self::hChaCha20(
self::substr($nonce, 0, 16),
$key
),
self::substr($nonce, 16, 8)
),
str_repeat("\x00", $len)
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function streamXorIc($message, $nonce = '', $key = '', $ic = '')
{
if (self::strlen($nonce) !== 24) {
throw new SodiumException('Nonce must be 24 bytes long');
}
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_Ctx(
self::hChaCha20(self::substr($nonce, 0, 16), $key),
self::substr($nonce, 16, 8),
$ic
),
$message
);
}
/**
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @param string $ic
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function ietfStreamXorIc($message, $nonce = '', $key = '', $ic = '')
{
return self::encryptBytes(
new ParagonIE_Sodium_Core32_ChaCha20_IetfCtx(
self::hChaCha20(self::substr($nonce, 0, 16), $key),
"\x00\x00\x00\x00" . self::substr($nonce, 16, 8),
$ic
),
$message
);
}
}
Core32/XSalsa20.php 0000644 00000002543 15153427537 0007667 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Core32_XSalsa20', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Core32_XSalsa20
*/
abstract class ParagonIE_Sodium_Core32_XSalsa20 extends ParagonIE_Sodium_Core32_HSalsa20
{
/**
* Expand a key and nonce into an xsalsa20 keystream.
*
* @internal You should not use this directly from another application
*
* @param int $len
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function xsalsa20($len, $nonce, $key)
{
$ret = self::salsa20(
$len,
self::substr($nonce, 16, 8),
self::hsalsa20($nonce, $key)
);
return $ret;
}
/**
* Encrypt a string with XSalsa20. Doesn't provide integrity.
*
* @internal You should not use this directly from another application
*
* @param string $message
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function xsalsa20_xor($message, $nonce, $key)
{
return self::xorStrings(
$message,
self::xsalsa20(
self::strlen($message),
$nonce,
$key
)
);
}
}
Crypto.php 0000644 00000153032 15153427537 0006555 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Crypto', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Crypto
*
* ATTENTION!
*
* If you are using this library, you should be using
* ParagonIE_Sodium_Compat in your code, not this class.
*/
abstract class ParagonIE_Sodium_Crypto
{
const aead_chacha20poly1305_KEYBYTES = 32;
const aead_chacha20poly1305_NSECBYTES = 0;
const aead_chacha20poly1305_NPUBBYTES = 8;
const aead_chacha20poly1305_ABYTES = 16;
const aead_chacha20poly1305_IETF_KEYBYTES = 32;
const aead_chacha20poly1305_IETF_NSECBYTES = 0;
const aead_chacha20poly1305_IETF_NPUBBYTES = 12;
const aead_chacha20poly1305_IETF_ABYTES = 16;
const aead_xchacha20poly1305_IETF_KEYBYTES = 32;
const aead_xchacha20poly1305_IETF_NSECBYTES = 0;
const aead_xchacha20poly1305_IETF_NPUBBYTES = 24;
const aead_xchacha20poly1305_IETF_ABYTES = 16;
const box_curve25519xsalsa20poly1305_SEEDBYTES = 32;
const box_curve25519xsalsa20poly1305_PUBLICKEYBYTES = 32;
const box_curve25519xsalsa20poly1305_SECRETKEYBYTES = 32;
const box_curve25519xsalsa20poly1305_BEFORENMBYTES = 32;
const box_curve25519xsalsa20poly1305_NONCEBYTES = 24;
const box_curve25519xsalsa20poly1305_MACBYTES = 16;
const box_curve25519xsalsa20poly1305_BOXZEROBYTES = 16;
const box_curve25519xsalsa20poly1305_ZEROBYTES = 32;
const onetimeauth_poly1305_BYTES = 16;
const onetimeauth_poly1305_KEYBYTES = 32;
const secretbox_xsalsa20poly1305_KEYBYTES = 32;
const secretbox_xsalsa20poly1305_NONCEBYTES = 24;
const secretbox_xsalsa20poly1305_MACBYTES = 16;
const secretbox_xsalsa20poly1305_BOXZEROBYTES = 16;
const secretbox_xsalsa20poly1305_ZEROBYTES = 32;
const secretbox_xchacha20poly1305_KEYBYTES = 32;
const secretbox_xchacha20poly1305_NONCEBYTES = 24;
const secretbox_xchacha20poly1305_MACBYTES = 16;
const secretbox_xchacha20poly1305_BOXZEROBYTES = 16;
const secretbox_xchacha20poly1305_ZEROBYTES = 32;
const stream_salsa20_KEYBYTES = 32;
/**
* AEAD Decryption with ChaCha20-Poly1305
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_decrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $len - Length of message (ciphertext + MAC) */
$len = ParagonIE_Sodium_Core_Util::strlen($message);
/** @var int $clen - Length of ciphertext */
$clen = $len - self::aead_chacha20poly1305_ABYTES;
/** @var int $adlen - Length of associated data */
$adlen = ParagonIE_Sodium_Core_Util::strlen($ad);
/** @var string $mac - Message authentication code */
$mac = ParagonIE_Sodium_Core_Util::substr(
$message,
$clen,
self::aead_chacha20poly1305_ABYTES
);
/** @var string $ciphertext - The encrypted message (sans MAC) */
$ciphertext = ParagonIE_Sodium_Core_Util::substr($message, 0, $clen);
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core_ChaCha20::stream(
32,
$nonce,
$key
);
/* Recalculate the Poly1305 authentication tag (MAC): */
$state = new ParagonIE_Sodium_Core_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
$state->update($ad);
$state->update(ParagonIE_Sodium_Core_Util::store64_le($adlen));
$state->update($ciphertext);
$state->update(ParagonIE_Sodium_Core_Util::store64_le($clen));
$computed_mac = $state->finish();
/* Compare the given MAC with the recalculated MAC: */
if (!ParagonIE_Sodium_Core_Util::verify_16($computed_mac, $mac)) {
throw new SodiumException('Invalid MAC');
}
// Here, we know that the MAC is valid, so we decrypt and return the plaintext
return ParagonIE_Sodium_Core_ChaCha20::streamXorIc(
$ciphertext,
$nonce,
$key,
ParagonIE_Sodium_Core_Util::store64_le(1)
);
}
/**
* AEAD Encryption with ChaCha20-Poly1305
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_encrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $len - Length of the plaintext message */
$len = ParagonIE_Sodium_Core_Util::strlen($message);
/** @var int $adlen - Length of the associated data */
$adlen = ParagonIE_Sodium_Core_Util::strlen($ad);
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core_ChaCha20::stream(
32,
$nonce,
$key
);
$state = new ParagonIE_Sodium_Core_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
/** @var string $ciphertext - Raw encrypted data */
$ciphertext = ParagonIE_Sodium_Core_ChaCha20::streamXorIc(
$message,
$nonce,
$key,
ParagonIE_Sodium_Core_Util::store64_le(1)
);
$state->update($ad);
$state->update(ParagonIE_Sodium_Core_Util::store64_le($adlen));
$state->update($ciphertext);
$state->update(ParagonIE_Sodium_Core_Util::store64_le($len));
return $ciphertext . $state->finish();
}
/**
* AEAD Decryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_ietf_decrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $adlen - Length of associated data */
$adlen = ParagonIE_Sodium_Core_Util::strlen($ad);
/** @var int $len - Length of message (ciphertext + MAC) */
$len = ParagonIE_Sodium_Core_Util::strlen($message);
/** @var int $clen - Length of ciphertext */
$clen = $len - self::aead_chacha20poly1305_IETF_ABYTES;
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core_ChaCha20::ietfStream(
32,
$nonce,
$key
);
/** @var string $mac - Message authentication code */
$mac = ParagonIE_Sodium_Core_Util::substr(
$message,
$len - self::aead_chacha20poly1305_IETF_ABYTES,
self::aead_chacha20poly1305_IETF_ABYTES
);
/** @var string $ciphertext - The encrypted message (sans MAC) */
$ciphertext = ParagonIE_Sodium_Core_Util::substr(
$message,
0,
$len - self::aead_chacha20poly1305_IETF_ABYTES
);
/* Recalculate the Poly1305 authentication tag (MAC): */
$state = new ParagonIE_Sodium_Core_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
$state->update($ad);
$state->update(str_repeat("\x00", ((0x10 - $adlen) & 0xf)));
$state->update($ciphertext);
$state->update(str_repeat("\x00", (0x10 - $clen) & 0xf));
$state->update(ParagonIE_Sodium_Core_Util::store64_le($adlen));
$state->update(ParagonIE_Sodium_Core_Util::store64_le($clen));
$computed_mac = $state->finish();
/* Compare the given MAC with the recalculated MAC: */
if (!ParagonIE_Sodium_Core_Util::verify_16($computed_mac, $mac)) {
throw new SodiumException('Invalid MAC');
}
// Here, we know that the MAC is valid, so we decrypt and return the plaintext
return ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
$ciphertext,
$nonce,
$key,
ParagonIE_Sodium_Core_Util::store64_le(1)
);
}
/**
* AEAD Encryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_ietf_encrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $len - Length of the plaintext message */
$len = ParagonIE_Sodium_Core_Util::strlen($message);
/** @var int $adlen - Length of the associated data */
$adlen = ParagonIE_Sodium_Core_Util::strlen($ad);
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core_ChaCha20::ietfStream(
32,
$nonce,
$key
);
$state = new ParagonIE_Sodium_Core_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
/** @var string $ciphertext - Raw encrypted data */
$ciphertext = ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
$message,
$nonce,
$key,
ParagonIE_Sodium_Core_Util::store64_le(1)
);
$state->update($ad);
$state->update(str_repeat("\x00", ((0x10 - $adlen) & 0xf)));
$state->update($ciphertext);
$state->update(str_repeat("\x00", ((0x10 - $len) & 0xf)));
$state->update(ParagonIE_Sodium_Core_Util::store64_le($adlen));
$state->update(ParagonIE_Sodium_Core_Util::store64_le($len));
return $ciphertext . $state->finish();
}
/**
* AEAD Decryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_xchacha20poly1305_ietf_decrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
$subkey = ParagonIE_Sodium_Core_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core_Util::substr($nonce, 0, 16),
$key
);
$nonceLast = "\x00\x00\x00\x00" .
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8);
return self::aead_chacha20poly1305_ietf_decrypt($message, $ad, $nonceLast, $subkey);
}
/**
* AEAD Encryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_xchacha20poly1305_ietf_encrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
$subkey = ParagonIE_Sodium_Core_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core_Util::substr($nonce, 0, 16),
$key
);
$nonceLast = "\x00\x00\x00\x00" .
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8);
return self::aead_chacha20poly1305_ietf_encrypt($message, $ad, $nonceLast, $subkey);
}
/**
* HMAC-SHA-512-256 (a.k.a. the leftmost 256 bits of HMAC-SHA-512)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $key
* @return string
* @throws TypeError
*/
public static function auth($message, $key)
{
return ParagonIE_Sodium_Core_Util::substr(
hash_hmac('sha512', $message, $key, true),
0,
32
);
}
/**
* HMAC-SHA-512-256 validation. Constant-time via hash_equals().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $mac
* @param string $message
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function auth_verify($mac, $message, $key)
{
return ParagonIE_Sodium_Core_Util::hashEquals(
$mac,
self::auth($message, $key)
);
}
/**
* X25519 key exchange followed by XSalsa20Poly1305 symmetric encryption
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $plaintext
* @param string $nonce
* @param string $keypair
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box($plaintext, $nonce, $keypair)
{
$c = self::secretbox(
$plaintext,
$nonce,
self::box_beforenm(
self::box_secretkey($keypair),
self::box_publickey($keypair)
)
);
return $c;
}
/**
* X25519-XSalsa20-Poly1305 with one ephemeral X25519 keypair.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $publicKey
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_seal($message, $publicKey)
{
/** @var string $ephemeralKeypair */
$ephemeralKeypair = self::box_keypair();
/** @var string $ephemeralSK */
$ephemeralSK = self::box_secretkey($ephemeralKeypair);
/** @var string $ephemeralPK */
$ephemeralPK = self::box_publickey($ephemeralKeypair);
/** @var string $nonce */
$nonce = self::generichash(
$ephemeralPK . $publicKey,
'',
24
);
/** @var string $keypair - The combined keypair used in crypto_box() */
$keypair = self::box_keypair_from_secretkey_and_publickey($ephemeralSK, $publicKey);
/** @var string $ciphertext Ciphertext + MAC from crypto_box */
$ciphertext = self::box($message, $nonce, $keypair);
try {
ParagonIE_Sodium_Compat::memzero($ephemeralKeypair);
ParagonIE_Sodium_Compat::memzero($ephemeralSK);
ParagonIE_Sodium_Compat::memzero($nonce);
} catch (SodiumException $ex) {
$ephemeralKeypair = null;
$ephemeralSK = null;
$nonce = null;
}
return $ephemeralPK . $ciphertext;
}
/**
* Opens a message encrypted via box_seal().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $keypair
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_seal_open($message, $keypair)
{
/** @var string $ephemeralPK */
$ephemeralPK = ParagonIE_Sodium_Core_Util::substr($message, 0, 32);
/** @var string $ciphertext (ciphertext + MAC) */
$ciphertext = ParagonIE_Sodium_Core_Util::substr($message, 32);
/** @var string $secretKey */
$secretKey = self::box_secretkey($keypair);
/** @var string $publicKey */
$publicKey = self::box_publickey($keypair);
/** @var string $nonce */
$nonce = self::generichash(
$ephemeralPK . $publicKey,
'',
24
);
/** @var string $keypair */
$keypair = self::box_keypair_from_secretkey_and_publickey($secretKey, $ephemeralPK);
/** @var string $m */
$m = self::box_open($ciphertext, $nonce, $keypair);
try {
ParagonIE_Sodium_Compat::memzero($secretKey);
ParagonIE_Sodium_Compat::memzero($ephemeralPK);
ParagonIE_Sodium_Compat::memzero($nonce);
} catch (SodiumException $ex) {
$secretKey = null;
$ephemeralPK = null;
$nonce = null;
}
return $m;
}
/**
* Used by crypto_box() to get the crypto_secretbox() key.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sk
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_beforenm($sk, $pk)
{
return ParagonIE_Sodium_Core_HSalsa20::hsalsa20(
str_repeat("\x00", 16),
self::scalarmult($sk, $pk)
);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @return string
* @throws Exception
* @throws SodiumException
* @throws TypeError
*/
public static function box_keypair()
{
$sKey = random_bytes(32);
$pKey = self::scalarmult_base($sKey);
return $sKey . $pKey;
}
/**
* @param string $seed
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_seed_keypair($seed)
{
$sKey = ParagonIE_Sodium_Core_Util::substr(
hash('sha512', $seed, true),
0,
32
);
$pKey = self::scalarmult_base($sKey);
return $sKey . $pKey;
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sKey
* @param string $pKey
* @return string
* @throws TypeError
*/
public static function box_keypair_from_secretkey_and_publickey($sKey, $pKey)
{
return ParagonIE_Sodium_Core_Util::substr($sKey, 0, 32) .
ParagonIE_Sodium_Core_Util::substr($pKey, 0, 32);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $keypair
* @return string
* @throws RangeException
* @throws TypeError
*/
public static function box_secretkey($keypair)
{
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== 64) {
throw new RangeException(
'Must be ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES bytes long.'
);
}
return ParagonIE_Sodium_Core_Util::substr($keypair, 0, 32);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $keypair
* @return string
* @throws RangeException
* @throws TypeError
*/
public static function box_publickey($keypair)
{
if (ParagonIE_Sodium_Core_Util::strlen($keypair) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES) {
throw new RangeException(
'Must be ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES bytes long.'
);
}
return ParagonIE_Sodium_Core_Util::substr($keypair, 32, 32);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sKey
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function box_publickey_from_secretkey($sKey)
{
if (ParagonIE_Sodium_Core_Util::strlen($sKey) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_SECRETKEYBYTES) {
throw new RangeException(
'Must be ParagonIE_Sodium_Compat::CRYPTO_BOX_SECRETKEYBYTES bytes long.'
);
}
return self::scalarmult_base($sKey);
}
/**
* Decrypt a message encrypted with box().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ciphertext
* @param string $nonce
* @param string $keypair
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_open($ciphertext, $nonce, $keypair)
{
return self::secretbox_open(
$ciphertext,
$nonce,
self::box_beforenm(
self::box_secretkey($keypair),
self::box_publickey($keypair)
)
);
}
/**
* Calculate a BLAKE2b hash.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string|null $key
* @param int $outlen
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function generichash($message, $key = '', $outlen = 32)
{
// This ensures that ParagonIE_Sodium_Core_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core_BLAKE2b::pseudoConstructor();
$k = null;
if (!empty($key)) {
/** @var SplFixedArray $k */
$k = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($key);
if ($k->count() > ParagonIE_Sodium_Core_BLAKE2b::KEYBYTES) {
throw new RangeException('Invalid key size');
}
}
/** @var SplFixedArray $in */
$in = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($message);
/** @var SplFixedArray $ctx */
$ctx = ParagonIE_Sodium_Core_BLAKE2b::init($k, $outlen);
ParagonIE_Sodium_Core_BLAKE2b::update($ctx, $in, $in->count());
/** @var SplFixedArray $out */
$out = new SplFixedArray($outlen);
$out = ParagonIE_Sodium_Core_BLAKE2b::finish($ctx, $out);
/** @var array<int, int> */
$outArray = $out->toArray();
return ParagonIE_Sodium_Core_Util::intArrayToString($outArray);
}
/**
* Finalize a BLAKE2b hashing context, returning the hash.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ctx
* @param int $outlen
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_final($ctx, $outlen = 32)
{
if (!is_string($ctx)) {
throw new TypeError('Context must be a string');
}
$out = new SplFixedArray($outlen);
/** @var SplFixedArray $context */
$context = ParagonIE_Sodium_Core_BLAKE2b::stringToContext($ctx);
/** @var SplFixedArray $out */
$out = ParagonIE_Sodium_Core_BLAKE2b::finish($context, $out);
/** @var array<int, int> */
$outArray = $out->toArray();
return ParagonIE_Sodium_Core_Util::intArrayToString($outArray);
}
/**
* Initialize a hashing context for BLAKE2b.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $key
* @param int $outputLength
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_init($key = '', $outputLength = 32)
{
// This ensures that ParagonIE_Sodium_Core_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core_BLAKE2b::pseudoConstructor();
$k = null;
if (!empty($key)) {
$k = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($key);
if ($k->count() > ParagonIE_Sodium_Core_BLAKE2b::KEYBYTES) {
throw new RangeException('Invalid key size');
}
}
/** @var SplFixedArray $ctx */
$ctx = ParagonIE_Sodium_Core_BLAKE2b::init($k, $outputLength);
return ParagonIE_Sodium_Core_BLAKE2b::contextToString($ctx);
}
/**
* Initialize a hashing context for BLAKE2b.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $key
* @param int $outputLength
* @param string $salt
* @param string $personal
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_init_salt_personal(
$key = '',
$outputLength = 32,
$salt = '',
$personal = ''
) {
// This ensures that ParagonIE_Sodium_Core_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core_BLAKE2b::pseudoConstructor();
$k = null;
if (!empty($key)) {
$k = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($key);
if ($k->count() > ParagonIE_Sodium_Core_BLAKE2b::KEYBYTES) {
throw new RangeException('Invalid key size');
}
}
if (!empty($salt)) {
$s = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($salt);
} else {
$s = null;
}
if (!empty($salt)) {
$p = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($personal);
} else {
$p = null;
}
/** @var SplFixedArray $ctx */
$ctx = ParagonIE_Sodium_Core_BLAKE2b::init($k, $outputLength, $s, $p);
return ParagonIE_Sodium_Core_BLAKE2b::contextToString($ctx);
}
/**
* Update a hashing context for BLAKE2b with $message
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ctx
* @param string $message
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_update($ctx, $message)
{
// This ensures that ParagonIE_Sodium_Core_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core_BLAKE2b::pseudoConstructor();
/** @var SplFixedArray $context */
$context = ParagonIE_Sodium_Core_BLAKE2b::stringToContext($ctx);
/** @var SplFixedArray $in */
$in = ParagonIE_Sodium_Core_BLAKE2b::stringToSplFixedArray($message);
ParagonIE_Sodium_Core_BLAKE2b::update($context, $in, $in->count());
return ParagonIE_Sodium_Core_BLAKE2b::contextToString($context);
}
/**
* Libsodium's crypto_kx().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $my_sk
* @param string $their_pk
* @param string $client_pk
* @param string $server_pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function keyExchange($my_sk, $their_pk, $client_pk, $server_pk)
{
return ParagonIE_Sodium_Compat::crypto_generichash(
ParagonIE_Sodium_Compat::crypto_scalarmult($my_sk, $their_pk) .
$client_pk .
$server_pk
);
}
/**
* ECDH over Curve25519
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sKey
* @param string $pKey
* @return string
*
* @throws SodiumException
* @throws TypeError
*/
public static function scalarmult($sKey, $pKey)
{
$q = ParagonIE_Sodium_Core_X25519::crypto_scalarmult_curve25519_ref10($sKey, $pKey);
self::scalarmult_throw_if_zero($q);
return $q;
}
/**
* ECDH over Curve25519, using the basepoint.
* Used to get a secret key from a public key.
*
* @param string $secret
* @return string
*
* @throws SodiumException
* @throws TypeError
*/
public static function scalarmult_base($secret)
{
$q = ParagonIE_Sodium_Core_X25519::crypto_scalarmult_curve25519_ref10_base($secret);
self::scalarmult_throw_if_zero($q);
return $q;
}
/**
* This throws an Error if a zero public key was passed to the function.
*
* @param string $q
* @return void
* @throws SodiumException
* @throws TypeError
*/
protected static function scalarmult_throw_if_zero($q)
{
$d = 0;
for ($i = 0; $i < self::box_curve25519xsalsa20poly1305_SECRETKEYBYTES; ++$i) {
$d |= ParagonIE_Sodium_Core_Util::chrToInt($q[$i]);
}
/* branch-free variant of === 0 */
if (-(1 & (($d - 1) >> 8))) {
throw new SodiumException('Zero public key is not allowed');
}
}
/**
* XSalsa20-Poly1305 authenticated symmetric-key encryption.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $plaintext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox($plaintext, $nonce, $key)
{
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core_HSalsa20::hsalsa20($nonce, $key);
/** @var string $block0 */
$block0 = str_repeat("\x00", 32);
/** @var int $mlen - Length of the plaintext message */
$mlen = ParagonIE_Sodium_Core_Util::strlen($plaintext);
$mlen0 = $mlen;
if ($mlen0 > 64 - self::secretbox_xsalsa20poly1305_ZEROBYTES) {
$mlen0 = 64 - self::secretbox_xsalsa20poly1305_ZEROBYTES;
}
$block0 .= ParagonIE_Sodium_Core_Util::substr($plaintext, 0, $mlen0);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core_Salsa20::salsa20_xor(
$block0,
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
$subkey
);
/** @var string $c */
$c = ParagonIE_Sodium_Core_Util::substr(
$block0,
self::secretbox_xsalsa20poly1305_ZEROBYTES
);
if ($mlen > $mlen0) {
$c .= ParagonIE_Sodium_Core_Salsa20::salsa20_xor_ic(
ParagonIE_Sodium_Core_Util::substr(
$plaintext,
self::secretbox_xsalsa20poly1305_ZEROBYTES
),
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
1,
$subkey
);
}
$state = new ParagonIE_Sodium_Core_Poly1305_State(
ParagonIE_Sodium_Core_Util::substr(
$block0,
0,
self::onetimeauth_poly1305_KEYBYTES
)
);
try {
ParagonIE_Sodium_Compat::memzero($block0);
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$block0 = null;
$subkey = null;
}
$state->update($c);
/** @var string $c - MAC || ciphertext */
$c = $state->finish() . $c;
unset($state);
return $c;
}
/**
* Decrypt a ciphertext generated via secretbox().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ciphertext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_open($ciphertext, $nonce, $key)
{
/** @var string $mac */
$mac = ParagonIE_Sodium_Core_Util::substr(
$ciphertext,
0,
self::secretbox_xsalsa20poly1305_MACBYTES
);
/** @var string $c */
$c = ParagonIE_Sodium_Core_Util::substr(
$ciphertext,
self::secretbox_xsalsa20poly1305_MACBYTES
);
/** @var int $clen */
$clen = ParagonIE_Sodium_Core_Util::strlen($c);
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core_HSalsa20::hsalsa20($nonce, $key);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core_Salsa20::salsa20(
64,
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
$subkey
);
$verified = ParagonIE_Sodium_Core_Poly1305::onetimeauth_verify(
$mac,
$c,
ParagonIE_Sodium_Core_Util::substr($block0, 0, 32)
);
if (!$verified) {
try {
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$subkey = null;
}
throw new SodiumException('Invalid MAC');
}
/** @var string $m - Decrypted message */
$m = ParagonIE_Sodium_Core_Util::xorStrings(
ParagonIE_Sodium_Core_Util::substr($block0, self::secretbox_xsalsa20poly1305_ZEROBYTES),
ParagonIE_Sodium_Core_Util::substr($c, 0, self::secretbox_xsalsa20poly1305_ZEROBYTES)
);
if ($clen > self::secretbox_xsalsa20poly1305_ZEROBYTES) {
// We had more than 1 block, so let's continue to decrypt the rest.
$m .= ParagonIE_Sodium_Core_Salsa20::salsa20_xor_ic(
ParagonIE_Sodium_Core_Util::substr(
$c,
self::secretbox_xsalsa20poly1305_ZEROBYTES
),
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
1,
(string) $subkey
);
}
return $m;
}
/**
* XChaCha20-Poly1305 authenticated symmetric-key encryption.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $plaintext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_xchacha20poly1305($plaintext, $nonce, $key)
{
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core_Util::substr($nonce, 0, 16),
$key
);
$nonceLast = ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8);
/** @var string $block0 */
$block0 = str_repeat("\x00", 32);
/** @var int $mlen - Length of the plaintext message */
$mlen = ParagonIE_Sodium_Core_Util::strlen($plaintext);
$mlen0 = $mlen;
if ($mlen0 > 64 - self::secretbox_xchacha20poly1305_ZEROBYTES) {
$mlen0 = 64 - self::secretbox_xchacha20poly1305_ZEROBYTES;
}
$block0 .= ParagonIE_Sodium_Core_Util::substr($plaintext, 0, $mlen0);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core_ChaCha20::streamXorIc(
$block0,
$nonceLast,
$subkey
);
/** @var string $c */
$c = ParagonIE_Sodium_Core_Util::substr(
$block0,
self::secretbox_xchacha20poly1305_ZEROBYTES
);
if ($mlen > $mlen0) {
$c .= ParagonIE_Sodium_Core_ChaCha20::streamXorIc(
ParagonIE_Sodium_Core_Util::substr(
$plaintext,
self::secretbox_xchacha20poly1305_ZEROBYTES
),
$nonceLast,
$subkey,
ParagonIE_Sodium_Core_Util::store64_le(1)
);
}
$state = new ParagonIE_Sodium_Core_Poly1305_State(
ParagonIE_Sodium_Core_Util::substr(
$block0,
0,
self::onetimeauth_poly1305_KEYBYTES
)
);
try {
ParagonIE_Sodium_Compat::memzero($block0);
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$block0 = null;
$subkey = null;
}
$state->update($c);
/** @var string $c - MAC || ciphertext */
$c = $state->finish() . $c;
unset($state);
return $c;
}
/**
* Decrypt a ciphertext generated via secretbox_xchacha20poly1305().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ciphertext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_xchacha20poly1305_open($ciphertext, $nonce, $key)
{
/** @var string $mac */
$mac = ParagonIE_Sodium_Core_Util::substr(
$ciphertext,
0,
self::secretbox_xchacha20poly1305_MACBYTES
);
/** @var string $c */
$c = ParagonIE_Sodium_Core_Util::substr(
$ciphertext,
self::secretbox_xchacha20poly1305_MACBYTES
);
/** @var int $clen */
$clen = ParagonIE_Sodium_Core_Util::strlen($c);
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core_HChaCha20::hchacha20($nonce, $key);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core_ChaCha20::stream(
64,
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
$subkey
);
$verified = ParagonIE_Sodium_Core_Poly1305::onetimeauth_verify(
$mac,
$c,
ParagonIE_Sodium_Core_Util::substr($block0, 0, 32)
);
if (!$verified) {
try {
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$subkey = null;
}
throw new SodiumException('Invalid MAC');
}
/** @var string $m - Decrypted message */
$m = ParagonIE_Sodium_Core_Util::xorStrings(
ParagonIE_Sodium_Core_Util::substr($block0, self::secretbox_xchacha20poly1305_ZEROBYTES),
ParagonIE_Sodium_Core_Util::substr($c, 0, self::secretbox_xchacha20poly1305_ZEROBYTES)
);
if ($clen > self::secretbox_xchacha20poly1305_ZEROBYTES) {
// We had more than 1 block, so let's continue to decrypt the rest.
$m .= ParagonIE_Sodium_Core_ChaCha20::streamXorIc(
ParagonIE_Sodium_Core_Util::substr(
$c,
self::secretbox_xchacha20poly1305_ZEROBYTES
),
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
(string) $subkey,
ParagonIE_Sodium_Core_Util::store64_le(1)
);
}
return $m;
}
/**
* @param string $key
* @return array<int, string> Returns a state and a header.
* @throws Exception
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_init_push($key)
{
# randombytes_buf(out, crypto_secretstream_xchacha20poly1305_HEADERBYTES);
$out = random_bytes(24);
# crypto_core_hchacha20(state->k, out, k, NULL);
$subkey = ParagonIE_Sodium_Core_HChaCha20::hChaCha20($out, $key);
$state = new ParagonIE_Sodium_Core_SecretStream_State(
$subkey,
ParagonIE_Sodium_Core_Util::substr($out, 16, 8) . str_repeat("\0", 4)
);
# _crypto_secretstream_xchacha20poly1305_counter_reset(state);
$state->counterReset();
# memcpy(STATE_INONCE(state), out + crypto_core_hchacha20_INPUTBYTES,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
# memset(state->_pad, 0, sizeof state->_pad);
return array(
$state->toString(),
$out
);
}
/**
* @param string $key
* @param string $header
* @return string Returns a state.
* @throws Exception
*/
public static function secretstream_xchacha20poly1305_init_pull($key, $header)
{
# crypto_core_hchacha20(state->k, in, k, NULL);
$subkey = ParagonIE_Sodium_Core_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core_Util::substr($header, 0, 16),
$key
);
$state = new ParagonIE_Sodium_Core_SecretStream_State(
$subkey,
ParagonIE_Sodium_Core_Util::substr($header, 16)
);
$state->counterReset();
# memcpy(STATE_INONCE(state), in + crypto_core_hchacha20_INPUTBYTES,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
# memset(state->_pad, 0, sizeof state->_pad);
# return 0;
return $state->toString();
}
/**
* @param string $state
* @param string $msg
* @param string $aad
* @param int $tag
* @return string
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_push(&$state, $msg, $aad = '', $tag = 0)
{
$st = ParagonIE_Sodium_Core_SecretStream_State::fromString($state);
# crypto_onetimeauth_poly1305_state poly1305_state;
# unsigned char block[64U];
# unsigned char slen[8U];
# unsigned char *c;
# unsigned char *mac;
$msglen = ParagonIE_Sodium_Core_Util::strlen($msg);
$aadlen = ParagonIE_Sodium_Core_Util::strlen($aad);
if ((($msglen + 63) >> 6) > 0xfffffffe) {
throw new SodiumException(
'message cannot be larger than SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX bytes'
);
}
# if (outlen_p != NULL) {
# *outlen_p = 0U;
# }
# if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) {
# sodium_misuse();
# }
# crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k);
# crypto_onetimeauth_poly1305_init(&poly1305_state, block);
# sodium_memzero(block, sizeof block);
$auth = new ParagonIE_Sodium_Core_Poly1305_State(
ParagonIE_Sodium_Core_ChaCha20::ietfStream(32, $st->getCombinedNonce(), $st->getKey())
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen);
$auth->update($aad);
# crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0,
# (0x10 - adlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - $aadlen) & 0xf)));
# memset(block, 0, sizeof block);
# block[0] = tag;
# crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block,
# state->nonce, 1U, state->k);
$block = ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
ParagonIE_Sodium_Core_Util::intToChr($tag) . str_repeat("\0", 63),
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core_Util::store64_le(1)
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block);
$auth->update($block);
# out[0] = block[0];
$out = $block[0];
# c = out + (sizeof tag);
# crypto_stream_chacha20_ietf_xor_ic(c, m, mlen, state->nonce, 2U, state->k);
$cipher = ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
$msg,
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core_Util::store64_le(2)
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen);
$auth->update($cipher);
$out .= $cipher;
unset($cipher);
# crypto_onetimeauth_poly1305_update
# (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - 64 + $msglen) & 0xf)));
# STORE64_LE(slen, (uint64_t) adlen);
$slen = ParagonIE_Sodium_Core_Util::store64_le($aadlen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$auth->update($slen);
# STORE64_LE(slen, (sizeof block) + mlen);
$slen = ParagonIE_Sodium_Core_Util::store64_le(64 + $msglen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$auth->update($slen);
# mac = c + mlen;
# crypto_onetimeauth_poly1305_final(&poly1305_state, mac);
$mac = $auth->finish();
$out .= $mac;
# sodium_memzero(&poly1305_state, sizeof poly1305_state);
unset($auth);
# XOR_BUF(STATE_INONCE(state), mac,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
$st->xorNonce($mac);
# sodium_increment(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES);
$st->incrementCounter();
// Overwrite by reference:
$state = $st->toString();
/** @var bool $rekey */
$rekey = ($tag & ParagonIE_Sodium_Compat::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY) !== 0;
# if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 ||
# sodium_is_zero(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) {
# crypto_secretstream_xchacha20poly1305_rekey(state);
# }
if ($rekey || $st->needsRekey()) {
// DO REKEY
self::secretstream_xchacha20poly1305_rekey($state);
}
# if (outlen_p != NULL) {
# *outlen_p = crypto_secretstream_xchacha20poly1305_ABYTES + mlen;
# }
return $out;
}
/**
* @param string $state
* @param string $cipher
* @param string $aad
* @return bool|array{0: string, 1: int}
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_pull(&$state, $cipher, $aad = '')
{
$st = ParagonIE_Sodium_Core_SecretStream_State::fromString($state);
$cipherlen = ParagonIE_Sodium_Core_Util::strlen($cipher);
# mlen = inlen - crypto_secretstream_xchacha20poly1305_ABYTES;
$msglen = $cipherlen - ParagonIE_Sodium_Compat::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES;
$aadlen = ParagonIE_Sodium_Core_Util::strlen($aad);
# if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) {
# sodium_misuse();
# }
if ((($msglen + 63) >> 6) > 0xfffffffe) {
throw new SodiumException(
'message cannot be larger than SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX bytes'
);
}
# crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k);
# crypto_onetimeauth_poly1305_init(&poly1305_state, block);
# sodium_memzero(block, sizeof block);
$auth = new ParagonIE_Sodium_Core_Poly1305_State(
ParagonIE_Sodium_Core_ChaCha20::ietfStream(32, $st->getCombinedNonce(), $st->getKey())
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen);
$auth->update($aad);
# crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0,
# (0x10 - adlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - $aadlen) & 0xf)));
# memset(block, 0, sizeof block);
# block[0] = in[0];
# crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block,
# state->nonce, 1U, state->k);
$block = ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
$cipher[0] . str_repeat("\0", 63),
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core_Util::store64_le(1)
);
# tag = block[0];
# block[0] = in[0];
# crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block);
$tag = ParagonIE_Sodium_Core_Util::chrToInt($block[0]);
$block[0] = $cipher[0];
$auth->update($block);
# c = in + (sizeof tag);
# crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen);
$auth->update(ParagonIE_Sodium_Core_Util::substr($cipher, 1, $msglen));
# crypto_onetimeauth_poly1305_update
# (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - 64 + $msglen) & 0xf)));
# STORE64_LE(slen, (uint64_t) adlen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$slen = ParagonIE_Sodium_Core_Util::store64_le($aadlen);
$auth->update($slen);
# STORE64_LE(slen, (sizeof block) + mlen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$slen = ParagonIE_Sodium_Core_Util::store64_le(64 + $msglen);
$auth->update($slen);
# crypto_onetimeauth_poly1305_final(&poly1305_state, mac);
# sodium_memzero(&poly1305_state, sizeof poly1305_state);
$mac = $auth->finish();
# stored_mac = c + mlen;
# if (sodium_memcmp(mac, stored_mac, sizeof mac) != 0) {
# sodium_memzero(mac, sizeof mac);
# return -1;
# }
$stored = ParagonIE_Sodium_Core_Util::substr($cipher, $msglen + 1, 16);
if (!ParagonIE_Sodium_Core_Util::hashEquals($mac, $stored)) {
return false;
}
# crypto_stream_chacha20_ietf_xor_ic(m, c, mlen, state->nonce, 2U, state->k);
$out = ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
ParagonIE_Sodium_Core_Util::substr($cipher, 1, $msglen),
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core_Util::store64_le(2)
);
# XOR_BUF(STATE_INONCE(state), mac,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
$st->xorNonce($mac);
# sodium_increment(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES);
$st->incrementCounter();
# if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 ||
# sodium_is_zero(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) {
# crypto_secretstream_xchacha20poly1305_rekey(state);
# }
// Overwrite by reference:
$state = $st->toString();
/** @var bool $rekey */
$rekey = ($tag & ParagonIE_Sodium_Compat::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY) !== 0;
if ($rekey || $st->needsRekey()) {
// DO REKEY
self::secretstream_xchacha20poly1305_rekey($state);
}
return array($out, $tag);
}
/**
* @param string $state
* @return void
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_rekey(&$state)
{
$st = ParagonIE_Sodium_Core_SecretStream_State::fromString($state);
# unsigned char new_key_and_inonce[crypto_stream_chacha20_ietf_KEYBYTES +
# crypto_secretstream_xchacha20poly1305_INONCEBYTES];
# size_t i;
# for (i = 0U; i < crypto_stream_chacha20_ietf_KEYBYTES; i++) {
# new_key_and_inonce[i] = state->k[i];
# }
$new_key_and_inonce = $st->getKey();
# for (i = 0U; i < crypto_secretstream_xchacha20poly1305_INONCEBYTES; i++) {
# new_key_and_inonce[crypto_stream_chacha20_ietf_KEYBYTES + i] =
# STATE_INONCE(state)[i];
# }
$new_key_and_inonce .= ParagonIE_Sodium_Core_Util::substR($st->getNonce(), 0, 8);
# crypto_stream_chacha20_ietf_xor(new_key_and_inonce, new_key_and_inonce,
# sizeof new_key_and_inonce,
# state->nonce, state->k);
$st->rekey(ParagonIE_Sodium_Core_ChaCha20::ietfStreamXorIc(
$new_key_and_inonce,
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core_Util::store64_le(0)
));
# for (i = 0U; i < crypto_stream_chacha20_ietf_KEYBYTES; i++) {
# state->k[i] = new_key_and_inonce[i];
# }
# for (i = 0U; i < crypto_secretstream_xchacha20poly1305_INONCEBYTES; i++) {
# STATE_INONCE(state)[i] =
# new_key_and_inonce[crypto_stream_chacha20_ietf_KEYBYTES + i];
# }
# _crypto_secretstream_xchacha20poly1305_counter_reset(state);
$st->counterReset();
$state = $st->toString();
}
/**
* Detached Ed25519 signature.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign_detached($message, $sk)
{
return ParagonIE_Sodium_Core_Ed25519::sign_detached($message, $sk);
}
/**
* Attached Ed25519 signature. (Returns a signed message.)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign($message, $sk)
{
return ParagonIE_Sodium_Core_Ed25519::sign($message, $sk);
}
/**
* Opens a signed message. If valid, returns the message.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $signedMessage
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign_open($signedMessage, $pk)
{
return ParagonIE_Sodium_Core_Ed25519::sign_open($signedMessage, $pk);
}
/**
* Verify a detached signature of a given message and public key.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $signature
* @param string $message
* @param string $pk
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function sign_verify_detached($signature, $message, $pk)
{
return ParagonIE_Sodium_Core_Ed25519::verify_detached($signature, $message, $pk);
}
}
Crypto32.php 0000644 00000153517 15153427537 0006732 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_Crypto32', false)) {
return;
}
/**
* Class ParagonIE_Sodium_Crypto
*
* ATTENTION!
*
* If you are using this library, you should be using
* ParagonIE_Sodium_Compat in your code, not this class.
*/
abstract class ParagonIE_Sodium_Crypto32
{
const aead_chacha20poly1305_KEYBYTES = 32;
const aead_chacha20poly1305_NSECBYTES = 0;
const aead_chacha20poly1305_NPUBBYTES = 8;
const aead_chacha20poly1305_ABYTES = 16;
const aead_chacha20poly1305_IETF_KEYBYTES = 32;
const aead_chacha20poly1305_IETF_NSECBYTES = 0;
const aead_chacha20poly1305_IETF_NPUBBYTES = 12;
const aead_chacha20poly1305_IETF_ABYTES = 16;
const aead_xchacha20poly1305_IETF_KEYBYTES = 32;
const aead_xchacha20poly1305_IETF_NSECBYTES = 0;
const aead_xchacha20poly1305_IETF_NPUBBYTES = 24;
const aead_xchacha20poly1305_IETF_ABYTES = 16;
const box_curve25519xsalsa20poly1305_SEEDBYTES = 32;
const box_curve25519xsalsa20poly1305_PUBLICKEYBYTES = 32;
const box_curve25519xsalsa20poly1305_SECRETKEYBYTES = 32;
const box_curve25519xsalsa20poly1305_BEFORENMBYTES = 32;
const box_curve25519xsalsa20poly1305_NONCEBYTES = 24;
const box_curve25519xsalsa20poly1305_MACBYTES = 16;
const box_curve25519xsalsa20poly1305_BOXZEROBYTES = 16;
const box_curve25519xsalsa20poly1305_ZEROBYTES = 32;
const onetimeauth_poly1305_BYTES = 16;
const onetimeauth_poly1305_KEYBYTES = 32;
const secretbox_xsalsa20poly1305_KEYBYTES = 32;
const secretbox_xsalsa20poly1305_NONCEBYTES = 24;
const secretbox_xsalsa20poly1305_MACBYTES = 16;
const secretbox_xsalsa20poly1305_BOXZEROBYTES = 16;
const secretbox_xsalsa20poly1305_ZEROBYTES = 32;
const secretbox_xchacha20poly1305_KEYBYTES = 32;
const secretbox_xchacha20poly1305_NONCEBYTES = 24;
const secretbox_xchacha20poly1305_MACBYTES = 16;
const secretbox_xchacha20poly1305_BOXZEROBYTES = 16;
const secretbox_xchacha20poly1305_ZEROBYTES = 32;
const stream_salsa20_KEYBYTES = 32;
/**
* AEAD Decryption with ChaCha20-Poly1305
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_decrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $len - Length of message (ciphertext + MAC) */
$len = ParagonIE_Sodium_Core32_Util::strlen($message);
/** @var int $clen - Length of ciphertext */
$clen = $len - self::aead_chacha20poly1305_ABYTES;
/** @var int $adlen - Length of associated data */
$adlen = ParagonIE_Sodium_Core32_Util::strlen($ad);
/** @var string $mac - Message authentication code */
$mac = ParagonIE_Sodium_Core32_Util::substr(
$message,
$clen,
self::aead_chacha20poly1305_ABYTES
);
/** @var string $ciphertext - The encrypted message (sans MAC) */
$ciphertext = ParagonIE_Sodium_Core32_Util::substr($message, 0, $clen);
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core32_ChaCha20::stream(
32,
$nonce,
$key
);
/* Recalculate the Poly1305 authentication tag (MAC): */
$state = new ParagonIE_Sodium_Core32_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
$state->update($ad);
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($adlen));
$state->update($ciphertext);
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($clen));
$computed_mac = $state->finish();
/* Compare the given MAC with the recalculated MAC: */
if (!ParagonIE_Sodium_Core32_Util::verify_16($computed_mac, $mac)) {
throw new SodiumException('Invalid MAC');
}
// Here, we know that the MAC is valid, so we decrypt and return the plaintext
return ParagonIE_Sodium_Core32_ChaCha20::streamXorIc(
$ciphertext,
$nonce,
$key,
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
}
/**
* AEAD Encryption with ChaCha20-Poly1305
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_encrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $len - Length of the plaintext message */
$len = ParagonIE_Sodium_Core32_Util::strlen($message);
/** @var int $adlen - Length of the associated data */
$adlen = ParagonIE_Sodium_Core32_Util::strlen($ad);
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core32_ChaCha20::stream(
32,
$nonce,
$key
);
$state = new ParagonIE_Sodium_Core32_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
/** @var string $ciphertext - Raw encrypted data */
$ciphertext = ParagonIE_Sodium_Core32_ChaCha20::streamXorIc(
$message,
$nonce,
$key,
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
$state->update($ad);
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($adlen));
$state->update($ciphertext);
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($len));
return $ciphertext . $state->finish();
}
/**
* AEAD Decryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_ietf_decrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $adlen - Length of associated data */
$adlen = ParagonIE_Sodium_Core32_Util::strlen($ad);
/** @var int $len - Length of message (ciphertext + MAC) */
$len = ParagonIE_Sodium_Core32_Util::strlen($message);
/** @var int $clen - Length of ciphertext */
$clen = $len - self::aead_chacha20poly1305_IETF_ABYTES;
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core32_ChaCha20::ietfStream(
32,
$nonce,
$key
);
/** @var string $mac - Message authentication code */
$mac = ParagonIE_Sodium_Core32_Util::substr(
$message,
$len - self::aead_chacha20poly1305_IETF_ABYTES,
self::aead_chacha20poly1305_IETF_ABYTES
);
/** @var string $ciphertext - The encrypted message (sans MAC) */
$ciphertext = ParagonIE_Sodium_Core32_Util::substr(
$message,
0,
$len - self::aead_chacha20poly1305_IETF_ABYTES
);
/* Recalculate the Poly1305 authentication tag (MAC): */
$state = new ParagonIE_Sodium_Core32_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
$state->update($ad);
$state->update(str_repeat("\x00", ((0x10 - $adlen) & 0xf)));
$state->update($ciphertext);
$state->update(str_repeat("\x00", (0x10 - $clen) & 0xf));
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($adlen));
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($clen));
$computed_mac = $state->finish();
/* Compare the given MAC with the recalculated MAC: */
if (!ParagonIE_Sodium_Core32_Util::verify_16($computed_mac, $mac)) {
throw new SodiumException('Invalid MAC');
}
// Here, we know that the MAC is valid, so we decrypt and return the plaintext
return ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
$ciphertext,
$nonce,
$key,
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
}
/**
* AEAD Encryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_chacha20poly1305_ietf_encrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
/** @var int $len - Length of the plaintext message */
$len = ParagonIE_Sodium_Core32_Util::strlen($message);
/** @var int $adlen - Length of the associated data */
$adlen = ParagonIE_Sodium_Core32_Util::strlen($ad);
/** @var string The first block of the chacha20 keystream, used as a poly1305 key */
$block0 = ParagonIE_Sodium_Core32_ChaCha20::ietfStream(
32,
$nonce,
$key
);
$state = new ParagonIE_Sodium_Core32_Poly1305_State($block0);
try {
ParagonIE_Sodium_Compat::memzero($block0);
} catch (SodiumException $ex) {
$block0 = null;
}
/** @var string $ciphertext - Raw encrypted data */
$ciphertext = ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
$message,
$nonce,
$key,
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
$state->update($ad);
$state->update(str_repeat("\x00", ((0x10 - $adlen) & 0xf)));
$state->update($ciphertext);
$state->update(str_repeat("\x00", ((0x10 - $len) & 0xf)));
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($adlen));
$state->update(ParagonIE_Sodium_Core32_Util::store64_le($len));
return $ciphertext . $state->finish();
}
/**
* AEAD Decryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_xchacha20poly1305_ietf_decrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
$subkey = ParagonIE_Sodium_Core32_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core32_Util::substr($nonce, 0, 16),
$key
);
$nonceLast = "\x00\x00\x00\x00" .
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8);
return self::aead_chacha20poly1305_ietf_decrypt($message, $ad, $nonceLast, $subkey);
}
/**
* AEAD Encryption with ChaCha20-Poly1305, IETF mode (96-bit nonce)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $ad
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function aead_xchacha20poly1305_ietf_encrypt(
$message = '',
$ad = '',
$nonce = '',
$key = ''
) {
$subkey = ParagonIE_Sodium_Core32_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core32_Util::substr($nonce, 0, 16),
$key
);
$nonceLast = "\x00\x00\x00\x00" .
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8);
return self::aead_chacha20poly1305_ietf_encrypt($message, $ad, $nonceLast, $subkey);
}
/**
* HMAC-SHA-512-256 (a.k.a. the leftmost 256 bits of HMAC-SHA-512)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $key
* @return string
* @throws TypeError
*/
public static function auth($message, $key)
{
return ParagonIE_Sodium_Core32_Util::substr(
hash_hmac('sha512', $message, $key, true),
0,
32
);
}
/**
* HMAC-SHA-512-256 validation. Constant-time via hash_equals().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $mac
* @param string $message
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function auth_verify($mac, $message, $key)
{
return ParagonIE_Sodium_Core32_Util::hashEquals(
$mac,
self::auth($message, $key)
);
}
/**
* X25519 key exchange followed by XSalsa20Poly1305 symmetric encryption
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $plaintext
* @param string $nonce
* @param string $keypair
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box($plaintext, $nonce, $keypair)
{
return self::secretbox(
$plaintext,
$nonce,
self::box_beforenm(
self::box_secretkey($keypair),
self::box_publickey($keypair)
)
);
}
/**
* X25519-XSalsa20-Poly1305 with one ephemeral X25519 keypair.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $publicKey
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_seal($message, $publicKey)
{
/** @var string $ephemeralKeypair */
$ephemeralKeypair = self::box_keypair();
/** @var string $ephemeralSK */
$ephemeralSK = self::box_secretkey($ephemeralKeypair);
/** @var string $ephemeralPK */
$ephemeralPK = self::box_publickey($ephemeralKeypair);
/** @var string $nonce */
$nonce = self::generichash(
$ephemeralPK . $publicKey,
'',
24
);
/** @var string $keypair - The combined keypair used in crypto_box() */
$keypair = self::box_keypair_from_secretkey_and_publickey($ephemeralSK, $publicKey);
/** @var string $ciphertext Ciphertext + MAC from crypto_box */
$ciphertext = self::box($message, $nonce, $keypair);
try {
ParagonIE_Sodium_Compat::memzero($ephemeralKeypair);
ParagonIE_Sodium_Compat::memzero($ephemeralSK);
ParagonIE_Sodium_Compat::memzero($nonce);
} catch (SodiumException $ex) {
$ephemeralKeypair = null;
$ephemeralSK = null;
$nonce = null;
}
return $ephemeralPK . $ciphertext;
}
/**
* Opens a message encrypted via box_seal().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $keypair
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_seal_open($message, $keypair)
{
/** @var string $ephemeralPK */
$ephemeralPK = ParagonIE_Sodium_Core32_Util::substr($message, 0, 32);
/** @var string $ciphertext (ciphertext + MAC) */
$ciphertext = ParagonIE_Sodium_Core32_Util::substr($message, 32);
/** @var string $secretKey */
$secretKey = self::box_secretkey($keypair);
/** @var string $publicKey */
$publicKey = self::box_publickey($keypair);
/** @var string $nonce */
$nonce = self::generichash(
$ephemeralPK . $publicKey,
'',
24
);
/** @var string $keypair */
$keypair = self::box_keypair_from_secretkey_and_publickey($secretKey, $ephemeralPK);
/** @var string $m */
$m = self::box_open($ciphertext, $nonce, $keypair);
try {
ParagonIE_Sodium_Compat::memzero($secretKey);
ParagonIE_Sodium_Compat::memzero($ephemeralPK);
ParagonIE_Sodium_Compat::memzero($nonce);
} catch (SodiumException $ex) {
$secretKey = null;
$ephemeralPK = null;
$nonce = null;
}
return $m;
}
/**
* Used by crypto_box() to get the crypto_secretbox() key.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sk
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_beforenm($sk, $pk)
{
return ParagonIE_Sodium_Core32_HSalsa20::hsalsa20(
str_repeat("\x00", 16),
self::scalarmult($sk, $pk)
);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @return string
* @throws Exception
* @throws SodiumException
* @throws TypeError
*/
public static function box_keypair()
{
$sKey = random_bytes(32);
$pKey = self::scalarmult_base($sKey);
return $sKey . $pKey;
}
/**
* @param string $seed
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_seed_keypair($seed)
{
$sKey = ParagonIE_Sodium_Core32_Util::substr(
hash('sha512', $seed, true),
0,
32
);
$pKey = self::scalarmult_base($sKey);
return $sKey . $pKey;
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sKey
* @param string $pKey
* @return string
* @throws TypeError
*/
public static function box_keypair_from_secretkey_and_publickey($sKey, $pKey)
{
return ParagonIE_Sodium_Core32_Util::substr($sKey, 0, 32) .
ParagonIE_Sodium_Core32_Util::substr($pKey, 0, 32);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $keypair
* @return string
* @throws RangeException
* @throws TypeError
*/
public static function box_secretkey($keypair)
{
if (ParagonIE_Sodium_Core32_Util::strlen($keypair) !== 64) {
throw new RangeException(
'Must be ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES bytes long.'
);
}
return ParagonIE_Sodium_Core32_Util::substr($keypair, 0, 32);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $keypair
* @return string
* @throws RangeException
* @throws TypeError
*/
public static function box_publickey($keypair)
{
if (ParagonIE_Sodium_Core32_Util::strlen($keypair) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES) {
throw new RangeException(
'Must be ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES bytes long.'
);
}
return ParagonIE_Sodium_Core32_Util::substr($keypair, 32, 32);
}
/**
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sKey
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function box_publickey_from_secretkey($sKey)
{
if (ParagonIE_Sodium_Core32_Util::strlen($sKey) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_SECRETKEYBYTES) {
throw new RangeException(
'Must be ParagonIE_Sodium_Compat::CRYPTO_BOX_SECRETKEYBYTES bytes long.'
);
}
return self::scalarmult_base($sKey);
}
/**
* Decrypt a message encrypted with box().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ciphertext
* @param string $nonce
* @param string $keypair
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function box_open($ciphertext, $nonce, $keypair)
{
return self::secretbox_open(
$ciphertext,
$nonce,
self::box_beforenm(
self::box_secretkey($keypair),
self::box_publickey($keypair)
)
);
}
/**
* Calculate a BLAKE2b hash.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string|null $key
* @param int $outlen
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function generichash($message, $key = '', $outlen = 32)
{
// This ensures that ParagonIE_Sodium_Core32_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core32_BLAKE2b::pseudoConstructor();
$k = null;
if (!empty($key)) {
/** @var SplFixedArray $k */
$k = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($key);
if ($k->count() > ParagonIE_Sodium_Core32_BLAKE2b::KEYBYTES) {
throw new RangeException('Invalid key size');
}
}
/** @var SplFixedArray $in */
$in = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($message);
/** @var SplFixedArray $ctx */
$ctx = ParagonIE_Sodium_Core32_BLAKE2b::init($k, $outlen);
ParagonIE_Sodium_Core32_BLAKE2b::update($ctx, $in, $in->count());
/** @var SplFixedArray $out */
$out = new SplFixedArray($outlen);
$out = ParagonIE_Sodium_Core32_BLAKE2b::finish($ctx, $out);
/** @var array<int, int> */
$outArray = $out->toArray();
return ParagonIE_Sodium_Core32_Util::intArrayToString($outArray);
}
/**
* Finalize a BLAKE2b hashing context, returning the hash.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ctx
* @param int $outlen
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_final($ctx, $outlen = 32)
{
if (!is_string($ctx)) {
throw new TypeError('Context must be a string');
}
$out = new SplFixedArray($outlen);
/** @var SplFixedArray $context */
$context = ParagonIE_Sodium_Core32_BLAKE2b::stringToContext($ctx);
/** @var SplFixedArray $out */
$out = ParagonIE_Sodium_Core32_BLAKE2b::finish($context, $out);
/** @var array<int, int> */
$outArray = $out->toArray();
return ParagonIE_Sodium_Core32_Util::intArrayToString($outArray);
}
/**
* Initialize a hashing context for BLAKE2b.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $key
* @param int $outputLength
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_init($key = '', $outputLength = 32)
{
// This ensures that ParagonIE_Sodium_Core32_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core32_BLAKE2b::pseudoConstructor();
$k = null;
if (!empty($key)) {
$k = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($key);
if ($k->count() > ParagonIE_Sodium_Core32_BLAKE2b::KEYBYTES) {
throw new RangeException('Invalid key size');
}
}
/** @var SplFixedArray $ctx */
$ctx = ParagonIE_Sodium_Core32_BLAKE2b::init($k, $outputLength);
return ParagonIE_Sodium_Core32_BLAKE2b::contextToString($ctx);
}
/**
* Initialize a hashing context for BLAKE2b.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $key
* @param int $outputLength
* @param string $salt
* @param string $personal
* @return string
* @throws RangeException
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_init_salt_personal(
$key = '',
$outputLength = 32,
$salt = '',
$personal = ''
) {
// This ensures that ParagonIE_Sodium_Core32_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core32_BLAKE2b::pseudoConstructor();
$k = null;
if (!empty($key)) {
$k = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($key);
if ($k->count() > ParagonIE_Sodium_Core32_BLAKE2b::KEYBYTES) {
throw new RangeException('Invalid key size');
}
}
if (!empty($salt)) {
$s = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($salt);
} else {
$s = null;
}
if (!empty($salt)) {
$p = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($personal);
} else {
$p = null;
}
/** @var SplFixedArray $ctx */
$ctx = ParagonIE_Sodium_Core32_BLAKE2b::init($k, $outputLength, $s, $p);
return ParagonIE_Sodium_Core32_BLAKE2b::contextToString($ctx);
}
/**
* Update a hashing context for BLAKE2b with $message
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ctx
* @param string $message
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function generichash_update($ctx, $message)
{
// This ensures that ParagonIE_Sodium_Core32_BLAKE2b::$iv is initialized
ParagonIE_Sodium_Core32_BLAKE2b::pseudoConstructor();
/** @var SplFixedArray $context */
$context = ParagonIE_Sodium_Core32_BLAKE2b::stringToContext($ctx);
/** @var SplFixedArray $in */
$in = ParagonIE_Sodium_Core32_BLAKE2b::stringToSplFixedArray($message);
ParagonIE_Sodium_Core32_BLAKE2b::update($context, $in, $in->count());
return ParagonIE_Sodium_Core32_BLAKE2b::contextToString($context);
}
/**
* Libsodium's crypto_kx().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $my_sk
* @param string $their_pk
* @param string $client_pk
* @param string $server_pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function keyExchange($my_sk, $their_pk, $client_pk, $server_pk)
{
return self::generichash(
self::scalarmult($my_sk, $their_pk) .
$client_pk .
$server_pk
);
}
/**
* ECDH over Curve25519
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $sKey
* @param string $pKey
* @return string
*
* @throws SodiumException
* @throws TypeError
*/
public static function scalarmult($sKey, $pKey)
{
$q = ParagonIE_Sodium_Core32_X25519::crypto_scalarmult_curve25519_ref10($sKey, $pKey);
self::scalarmult_throw_if_zero($q);
return $q;
}
/**
* ECDH over Curve25519, using the basepoint.
* Used to get a secret key from a public key.
*
* @param string $secret
* @return string
*
* @throws SodiumException
* @throws TypeError
*/
public static function scalarmult_base($secret)
{
$q = ParagonIE_Sodium_Core32_X25519::crypto_scalarmult_curve25519_ref10_base($secret);
self::scalarmult_throw_if_zero($q);
return $q;
}
/**
* This throws an Error if a zero public key was passed to the function.
*
* @param string $q
* @return void
* @throws SodiumException
* @throws TypeError
*/
protected static function scalarmult_throw_if_zero($q)
{
$d = 0;
for ($i = 0; $i < self::box_curve25519xsalsa20poly1305_SECRETKEYBYTES; ++$i) {
$d |= ParagonIE_Sodium_Core32_Util::chrToInt($q[$i]);
}
/* branch-free variant of === 0 */
if (-(1 & (($d - 1) >> 8))) {
throw new SodiumException('Zero public key is not allowed');
}
}
/**
* XSalsa20-Poly1305 authenticated symmetric-key encryption.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $plaintext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox($plaintext, $nonce, $key)
{
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core32_HSalsa20::hsalsa20($nonce, $key);
/** @var string $block0 */
$block0 = str_repeat("\x00", 32);
/** @var int $mlen - Length of the plaintext message */
$mlen = ParagonIE_Sodium_Core32_Util::strlen($plaintext);
$mlen0 = $mlen;
if ($mlen0 > 64 - self::secretbox_xsalsa20poly1305_ZEROBYTES) {
$mlen0 = 64 - self::secretbox_xsalsa20poly1305_ZEROBYTES;
}
$block0 .= ParagonIE_Sodium_Core32_Util::substr($plaintext, 0, $mlen0);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core32_Salsa20::salsa20_xor(
$block0,
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
$subkey
);
/** @var string $c */
$c = ParagonIE_Sodium_Core32_Util::substr(
$block0,
self::secretbox_xsalsa20poly1305_ZEROBYTES
);
if ($mlen > $mlen0) {
$c .= ParagonIE_Sodium_Core32_Salsa20::salsa20_xor_ic(
ParagonIE_Sodium_Core32_Util::substr(
$plaintext,
self::secretbox_xsalsa20poly1305_ZEROBYTES
),
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
1,
$subkey
);
}
$state = new ParagonIE_Sodium_Core32_Poly1305_State(
ParagonIE_Sodium_Core32_Util::substr(
$block0,
0,
self::onetimeauth_poly1305_KEYBYTES
)
);
try {
ParagonIE_Sodium_Compat::memzero($block0);
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$block0 = null;
$subkey = null;
}
$state->update($c);
/** @var string $c - MAC || ciphertext */
$c = $state->finish() . $c;
unset($state);
return $c;
}
/**
* Decrypt a ciphertext generated via secretbox().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ciphertext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_open($ciphertext, $nonce, $key)
{
/** @var string $mac */
$mac = ParagonIE_Sodium_Core32_Util::substr(
$ciphertext,
0,
self::secretbox_xsalsa20poly1305_MACBYTES
);
/** @var string $c */
$c = ParagonIE_Sodium_Core32_Util::substr(
$ciphertext,
self::secretbox_xsalsa20poly1305_MACBYTES
);
/** @var int $clen */
$clen = ParagonIE_Sodium_Core32_Util::strlen($c);
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core32_HSalsa20::hsalsa20($nonce, $key);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core32_Salsa20::salsa20(
64,
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
$subkey
);
$verified = ParagonIE_Sodium_Core32_Poly1305::onetimeauth_verify(
$mac,
$c,
ParagonIE_Sodium_Core32_Util::substr($block0, 0, 32)
);
if (!$verified) {
try {
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$subkey = null;
}
throw new SodiumException('Invalid MAC');
}
/** @var string $m - Decrypted message */
$m = ParagonIE_Sodium_Core32_Util::xorStrings(
ParagonIE_Sodium_Core32_Util::substr($block0, self::secretbox_xsalsa20poly1305_ZEROBYTES),
ParagonIE_Sodium_Core32_Util::substr($c, 0, self::secretbox_xsalsa20poly1305_ZEROBYTES)
);
if ($clen > self::secretbox_xsalsa20poly1305_ZEROBYTES) {
// We had more than 1 block, so let's continue to decrypt the rest.
$m .= ParagonIE_Sodium_Core32_Salsa20::salsa20_xor_ic(
ParagonIE_Sodium_Core32_Util::substr(
$c,
self::secretbox_xsalsa20poly1305_ZEROBYTES
),
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
1,
(string) $subkey
);
}
return $m;
}
/**
* XChaCha20-Poly1305 authenticated symmetric-key encryption.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $plaintext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_xchacha20poly1305($plaintext, $nonce, $key)
{
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core32_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core32_Util::substr($nonce, 0, 16),
$key
);
$nonceLast = ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8);
/** @var string $block0 */
$block0 = str_repeat("\x00", 32);
/** @var int $mlen - Length of the plaintext message */
$mlen = ParagonIE_Sodium_Core32_Util::strlen($plaintext);
$mlen0 = $mlen;
if ($mlen0 > 64 - self::secretbox_xchacha20poly1305_ZEROBYTES) {
$mlen0 = 64 - self::secretbox_xchacha20poly1305_ZEROBYTES;
}
$block0 .= ParagonIE_Sodium_Core32_Util::substr($plaintext, 0, $mlen0);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core32_ChaCha20::streamXorIc(
$block0,
$nonceLast,
$subkey
);
/** @var string $c */
$c = ParagonIE_Sodium_Core32_Util::substr(
$block0,
self::secretbox_xchacha20poly1305_ZEROBYTES
);
if ($mlen > $mlen0) {
$c .= ParagonIE_Sodium_Core32_ChaCha20::streamXorIc(
ParagonIE_Sodium_Core32_Util::substr(
$plaintext,
self::secretbox_xchacha20poly1305_ZEROBYTES
),
$nonceLast,
$subkey,
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
}
$state = new ParagonIE_Sodium_Core32_Poly1305_State(
ParagonIE_Sodium_Core32_Util::substr(
$block0,
0,
self::onetimeauth_poly1305_KEYBYTES
)
);
try {
ParagonIE_Sodium_Compat::memzero($block0);
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$block0 = null;
$subkey = null;
}
$state->update($c);
/** @var string $c - MAC || ciphertext */
$c = $state->finish() . $c;
unset($state);
return $c;
}
/**
* Decrypt a ciphertext generated via secretbox_xchacha20poly1305().
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $ciphertext
* @param string $nonce
* @param string $key
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_xchacha20poly1305_open($ciphertext, $nonce, $key)
{
/** @var string $mac */
$mac = ParagonIE_Sodium_Core32_Util::substr(
$ciphertext,
0,
self::secretbox_xchacha20poly1305_MACBYTES
);
/** @var string $c */
$c = ParagonIE_Sodium_Core32_Util::substr(
$ciphertext,
self::secretbox_xchacha20poly1305_MACBYTES
);
/** @var int $clen */
$clen = ParagonIE_Sodium_Core32_Util::strlen($c);
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core32_HChaCha20::hchacha20($nonce, $key);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core32_ChaCha20::stream(
64,
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
$subkey
);
$verified = ParagonIE_Sodium_Core32_Poly1305::onetimeauth_verify(
$mac,
$c,
ParagonIE_Sodium_Core32_Util::substr($block0, 0, 32)
);
if (!$verified) {
try {
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$subkey = null;
}
throw new SodiumException('Invalid MAC');
}
/** @var string $m - Decrypted message */
$m = ParagonIE_Sodium_Core32_Util::xorStrings(
ParagonIE_Sodium_Core32_Util::substr($block0, self::secretbox_xchacha20poly1305_ZEROBYTES),
ParagonIE_Sodium_Core32_Util::substr($c, 0, self::secretbox_xchacha20poly1305_ZEROBYTES)
);
if ($clen > self::secretbox_xchacha20poly1305_ZEROBYTES) {
// We had more than 1 block, so let's continue to decrypt the rest.
$m .= ParagonIE_Sodium_Core32_ChaCha20::streamXorIc(
ParagonIE_Sodium_Core32_Util::substr(
$c,
self::secretbox_xchacha20poly1305_ZEROBYTES
),
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
(string) $subkey,
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
}
return $m;
}
/**
* @param string $key
* @return array<int, string> Returns a state and a header.
* @throws Exception
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_init_push($key)
{
# randombytes_buf(out, crypto_secretstream_xchacha20poly1305_HEADERBYTES);
$out = random_bytes(24);
# crypto_core_hchacha20(state->k, out, k, NULL);
$subkey = ParagonIE_Sodium_Core32_HChaCha20::hChaCha20($out, $key);
$state = new ParagonIE_Sodium_Core32_SecretStream_State(
$subkey,
ParagonIE_Sodium_Core32_Util::substr($out, 16, 8) . str_repeat("\0", 4)
);
# _crypto_secretstream_xchacha20poly1305_counter_reset(state);
$state->counterReset();
# memcpy(STATE_INONCE(state), out + crypto_core_hchacha20_INPUTBYTES,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
# memset(state->_pad, 0, sizeof state->_pad);
return array(
$state->toString(),
$out
);
}
/**
* @param string $key
* @param string $header
* @return string Returns a state.
* @throws Exception
*/
public static function secretstream_xchacha20poly1305_init_pull($key, $header)
{
# crypto_core_hchacha20(state->k, in, k, NULL);
$subkey = ParagonIE_Sodium_Core32_HChaCha20::hChaCha20(
ParagonIE_Sodium_Core32_Util::substr($header, 0, 16),
$key
);
$state = new ParagonIE_Sodium_Core32_SecretStream_State(
$subkey,
ParagonIE_Sodium_Core32_Util::substr($header, 16)
);
$state->counterReset();
# memcpy(STATE_INONCE(state), in + crypto_core_hchacha20_INPUTBYTES,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
# memset(state->_pad, 0, sizeof state->_pad);
# return 0;
return $state->toString();
}
/**
* @param string $state
* @param string $msg
* @param string $aad
* @param int $tag
* @return string
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_push(&$state, $msg, $aad = '', $tag = 0)
{
$st = ParagonIE_Sodium_Core32_SecretStream_State::fromString($state);
# crypto_onetimeauth_poly1305_state poly1305_state;
# unsigned char block[64U];
# unsigned char slen[8U];
# unsigned char *c;
# unsigned char *mac;
$msglen = ParagonIE_Sodium_Core32_Util::strlen($msg);
$aadlen = ParagonIE_Sodium_Core32_Util::strlen($aad);
if ((($msglen + 63) >> 6) > 0xfffffffe) {
throw new SodiumException(
'message cannot be larger than SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX bytes'
);
}
# if (outlen_p != NULL) {
# *outlen_p = 0U;
# }
# if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) {
# sodium_misuse();
# }
# crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k);
# crypto_onetimeauth_poly1305_init(&poly1305_state, block);
# sodium_memzero(block, sizeof block);
$auth = new ParagonIE_Sodium_Core32_Poly1305_State(
ParagonIE_Sodium_Core32_ChaCha20::ietfStream(32, $st->getCombinedNonce(), $st->getKey())
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen);
$auth->update($aad);
# crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0,
# (0x10 - adlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - $aadlen) & 0xf)));
# memset(block, 0, sizeof block);
# block[0] = tag;
# crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block,
# state->nonce, 1U, state->k);
$block = ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
ParagonIE_Sodium_Core32_Util::intToChr($tag) . str_repeat("\0", 63),
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block);
$auth->update($block);
# out[0] = block[0];
$out = $block[0];
# c = out + (sizeof tag);
# crypto_stream_chacha20_ietf_xor_ic(c, m, mlen, state->nonce, 2U, state->k);
$cipher = ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
$msg,
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core32_Util::store64_le(2)
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen);
$auth->update($cipher);
$out .= $cipher;
unset($cipher);
# crypto_onetimeauth_poly1305_update
# (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - 64 + $msglen) & 0xf)));
# STORE64_LE(slen, (uint64_t) adlen);
$slen = ParagonIE_Sodium_Core32_Util::store64_le($aadlen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$auth->update($slen);
# STORE64_LE(slen, (sizeof block) + mlen);
$slen = ParagonIE_Sodium_Core32_Util::store64_le(64 + $msglen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$auth->update($slen);
# mac = c + mlen;
# crypto_onetimeauth_poly1305_final(&poly1305_state, mac);
$mac = $auth->finish();
$out .= $mac;
# sodium_memzero(&poly1305_state, sizeof poly1305_state);
unset($auth);
# XOR_BUF(STATE_INONCE(state), mac,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
$st->xorNonce($mac);
# sodium_increment(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES);
$st->incrementCounter();
// Overwrite by reference:
$state = $st->toString();
/** @var bool $rekey */
$rekey = ($tag & ParagonIE_Sodium_Compat::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY) !== 0;
# if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 ||
# sodium_is_zero(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) {
# crypto_secretstream_xchacha20poly1305_rekey(state);
# }
if ($rekey || $st->needsRekey()) {
// DO REKEY
self::secretstream_xchacha20poly1305_rekey($state);
}
# if (outlen_p != NULL) {
# *outlen_p = crypto_secretstream_xchacha20poly1305_ABYTES + mlen;
# }
return $out;
}
/**
* @param string $state
* @param string $cipher
* @param string $aad
* @return bool|array{0: string, 1: int}
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_pull(&$state, $cipher, $aad = '')
{
$st = ParagonIE_Sodium_Core32_SecretStream_State::fromString($state);
$cipherlen = ParagonIE_Sodium_Core32_Util::strlen($cipher);
# mlen = inlen - crypto_secretstream_xchacha20poly1305_ABYTES;
$msglen = $cipherlen - ParagonIE_Sodium_Compat::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES;
$aadlen = ParagonIE_Sodium_Core32_Util::strlen($aad);
# if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) {
# sodium_misuse();
# }
if ((($msglen + 63) >> 6) > 0xfffffffe) {
throw new SodiumException(
'message cannot be larger than SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX bytes'
);
}
# crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k);
# crypto_onetimeauth_poly1305_init(&poly1305_state, block);
# sodium_memzero(block, sizeof block);
$auth = new ParagonIE_Sodium_Core32_Poly1305_State(
ParagonIE_Sodium_Core32_ChaCha20::ietfStream(32, $st->getCombinedNonce(), $st->getKey())
);
# crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen);
$auth->update($aad);
# crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0,
# (0x10 - adlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - $aadlen) & 0xf)));
# memset(block, 0, sizeof block);
# block[0] = in[0];
# crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block,
# state->nonce, 1U, state->k);
$block = ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
$cipher[0] . str_repeat("\0", 63),
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core32_Util::store64_le(1)
);
# tag = block[0];
# block[0] = in[0];
# crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block);
$tag = ParagonIE_Sodium_Core32_Util::chrToInt($block[0]);
$block[0] = $cipher[0];
$auth->update($block);
# c = in + (sizeof tag);
# crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen);
$auth->update(ParagonIE_Sodium_Core32_Util::substr($cipher, 1, $msglen));
# crypto_onetimeauth_poly1305_update
# (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf);
$auth->update(str_repeat("\0", ((0x10 - 64 + $msglen) & 0xf)));
# STORE64_LE(slen, (uint64_t) adlen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$slen = ParagonIE_Sodium_Core32_Util::store64_le($aadlen);
$auth->update($slen);
# STORE64_LE(slen, (sizeof block) + mlen);
# crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
$slen = ParagonIE_Sodium_Core32_Util::store64_le(64 + $msglen);
$auth->update($slen);
# crypto_onetimeauth_poly1305_final(&poly1305_state, mac);
# sodium_memzero(&poly1305_state, sizeof poly1305_state);
$mac = $auth->finish();
# stored_mac = c + mlen;
# if (sodium_memcmp(mac, stored_mac, sizeof mac) != 0) {
# sodium_memzero(mac, sizeof mac);
# return -1;
# }
$stored = ParagonIE_Sodium_Core32_Util::substr($cipher, $msglen + 1, 16);
if (!ParagonIE_Sodium_Core32_Util::hashEquals($mac, $stored)) {
return false;
}
# crypto_stream_chacha20_ietf_xor_ic(m, c, mlen, state->nonce, 2U, state->k);
$out = ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
ParagonIE_Sodium_Core32_Util::substr($cipher, 1, $msglen),
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core32_Util::store64_le(2)
);
# XOR_BUF(STATE_INONCE(state), mac,
# crypto_secretstream_xchacha20poly1305_INONCEBYTES);
$st->xorNonce($mac);
# sodium_increment(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES);
$st->incrementCounter();
# if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 ||
# sodium_is_zero(STATE_COUNTER(state),
# crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) {
# crypto_secretstream_xchacha20poly1305_rekey(state);
# }
// Overwrite by reference:
$state = $st->toString();
/** @var bool $rekey */
$rekey = ($tag & ParagonIE_Sodium_Compat::CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY) !== 0;
if ($rekey || $st->needsRekey()) {
// DO REKEY
self::secretstream_xchacha20poly1305_rekey($state);
}
return array($out, $tag);
}
/**
* @param string $state
* @return void
* @throws SodiumException
*/
public static function secretstream_xchacha20poly1305_rekey(&$state)
{
$st = ParagonIE_Sodium_Core32_SecretStream_State::fromString($state);
# unsigned char new_key_and_inonce[crypto_stream_chacha20_ietf_KEYBYTES +
# crypto_secretstream_xchacha20poly1305_INONCEBYTES];
# size_t i;
# for (i = 0U; i < crypto_stream_chacha20_ietf_KEYBYTES; i++) {
# new_key_and_inonce[i] = state->k[i];
# }
$new_key_and_inonce = $st->getKey();
# for (i = 0U; i < crypto_secretstream_xchacha20poly1305_INONCEBYTES; i++) {
# new_key_and_inonce[crypto_stream_chacha20_ietf_KEYBYTES + i] =
# STATE_INONCE(state)[i];
# }
$new_key_and_inonce .= ParagonIE_Sodium_Core32_Util::substR($st->getNonce(), 0, 8);
# crypto_stream_chacha20_ietf_xor(new_key_and_inonce, new_key_and_inonce,
# sizeof new_key_and_inonce,
# state->nonce, state->k);
$st->rekey(ParagonIE_Sodium_Core32_ChaCha20::ietfStreamXorIc(
$new_key_and_inonce,
$st->getCombinedNonce(),
$st->getKey(),
ParagonIE_Sodium_Core32_Util::store64_le(0)
));
# for (i = 0U; i < crypto_stream_chacha20_ietf_KEYBYTES; i++) {
# state->k[i] = new_key_and_inonce[i];
# }
# for (i = 0U; i < crypto_secretstream_xchacha20poly1305_INONCEBYTES; i++) {
# STATE_INONCE(state)[i] =
# new_key_and_inonce[crypto_stream_chacha20_ietf_KEYBYTES + i];
# }
# _crypto_secretstream_xchacha20poly1305_counter_reset(state);
$st->counterReset();
$state = $st->toString();
}
/**
* Detached Ed25519 signature.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign_detached($message, $sk)
{
return ParagonIE_Sodium_Core32_Ed25519::sign_detached($message, $sk);
}
/**
* Attached Ed25519 signature. (Returns a signed message.)
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $message
* @param string $sk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign($message, $sk)
{
return ParagonIE_Sodium_Core32_Ed25519::sign($message, $sk);
}
/**
* Opens a signed message. If valid, returns the message.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $signedMessage
* @param string $pk
* @return string
* @throws SodiumException
* @throws TypeError
*/
public static function sign_open($signedMessage, $pk)
{
return ParagonIE_Sodium_Core32_Ed25519::sign_open($signedMessage, $pk);
}
/**
* Verify a detached signature of a given message and public key.
*
* @internal Do not use this directly. Use ParagonIE_Sodium_Compat.
*
* @param string $signature
* @param string $message
* @param string $pk
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function sign_verify_detached($signature, $message, $pk)
{
return ParagonIE_Sodium_Core32_Ed25519::verify_detached($signature, $message, $pk);
}
}
File.php 0000644 00000147501 15153427537 0006160 0 ustar 00 <?php
if (class_exists('ParagonIE_Sodium_File', false)) {
return;
}
/**
* Class ParagonIE_Sodium_File
*/
class ParagonIE_Sodium_File extends ParagonIE_Sodium_Core_Util
{
/* PHP's default buffer size is 8192 for fread()/fwrite(). */
const BUFFER_SIZE = 8192;
/**
* Box a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_box(), but produces
* the same result.
*
* @param string $inputFile Absolute path to a file on the filesystem
* @param string $outputFile Absolute path to a file on the filesystem
* @param string $nonce Number to be used only once
* @param string $keyPair ECDH secret key and ECDH public key concatenated
*
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function box($inputFile, $outputFile, $nonce, $keyPair)
{
/* Type checks: */
if (!is_string($inputFile)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($inputFile) . ' given.');
}
if (!is_string($outputFile)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($outputFile) . ' given.');
}
if (!is_string($nonce)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($nonce) . ' given.');
}
/* Input validation: */
if (!is_string($keyPair)) {
throw new TypeError('Argument 4 must be a string, ' . gettype($keyPair) . ' given.');
}
if (self::strlen($nonce) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_NONCEBYTES) {
throw new TypeError('Argument 3 must be CRYPTO_BOX_NONCEBYTES bytes');
}
if (self::strlen($keyPair) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES) {
throw new TypeError('Argument 4 must be CRYPTO_BOX_KEYPAIRBYTES bytes');
}
/** @var int $size */
$size = filesize($inputFile);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $ifp */
$ifp = fopen($inputFile, 'rb');
if (!is_resource($ifp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $ofp */
$ofp = fopen($outputFile, 'wb');
if (!is_resource($ofp)) {
fclose($ifp);
throw new SodiumException('Could not open output file for writing');
}
$res = self::box_encrypt($ifp, $ofp, $size, $nonce, $keyPair);
fclose($ifp);
fclose($ofp);
return $res;
}
/**
* Open a boxed file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_box_open(), but produces
* the same result.
*
* Warning: Does not protect against TOCTOU attacks. You should
* just load the file into memory and use crypto_box_open() if
* you are worried about those.
*
* @param string $inputFile
* @param string $outputFile
* @param string $nonce
* @param string $keypair
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function box_open($inputFile, $outputFile, $nonce, $keypair)
{
/* Type checks: */
if (!is_string($inputFile)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($inputFile) . ' given.');
}
if (!is_string($outputFile)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($outputFile) . ' given.');
}
if (!is_string($nonce)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($nonce) . ' given.');
}
if (!is_string($keypair)) {
throw new TypeError('Argument 4 must be a string, ' . gettype($keypair) . ' given.');
}
/* Input validation: */
if (self::strlen($nonce) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_NONCEBYTES) {
throw new TypeError('Argument 4 must be CRYPTO_BOX_NONCEBYTES bytes');
}
if (self::strlen($keypair) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES) {
throw new TypeError('Argument 4 must be CRYPTO_BOX_KEYPAIRBYTES bytes');
}
/** @var int $size */
$size = filesize($inputFile);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $ifp */
$ifp = fopen($inputFile, 'rb');
if (!is_resource($ifp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $ofp */
$ofp = fopen($outputFile, 'wb');
if (!is_resource($ofp)) {
fclose($ifp);
throw new SodiumException('Could not open output file for writing');
}
$res = self::box_decrypt($ifp, $ofp, $size, $nonce, $keypair);
fclose($ifp);
fclose($ofp);
try {
ParagonIE_Sodium_Compat::memzero($nonce);
ParagonIE_Sodium_Compat::memzero($ephKeypair);
} catch (SodiumException $ex) {
if (isset($ephKeypair)) {
unset($ephKeypair);
}
}
return $res;
}
/**
* Seal a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_box_seal(), but produces
* the same result.
*
* @param string $inputFile Absolute path to a file on the filesystem
* @param string $outputFile Absolute path to a file on the filesystem
* @param string $publicKey ECDH public key
*
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function box_seal($inputFile, $outputFile, $publicKey)
{
/* Type checks: */
if (!is_string($inputFile)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($inputFile) . ' given.');
}
if (!is_string($outputFile)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($outputFile) . ' given.');
}
if (!is_string($publicKey)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($publicKey) . ' given.');
}
/* Input validation: */
if (self::strlen($publicKey) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_PUBLICKEYBYTES) {
throw new TypeError('Argument 3 must be CRYPTO_BOX_PUBLICKEYBYTES bytes');
}
/** @var int $size */
$size = filesize($inputFile);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $ifp */
$ifp = fopen($inputFile, 'rb');
if (!is_resource($ifp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $ofp */
$ofp = fopen($outputFile, 'wb');
if (!is_resource($ofp)) {
fclose($ifp);
throw new SodiumException('Could not open output file for writing');
}
/** @var string $ephKeypair */
$ephKeypair = ParagonIE_Sodium_Compat::crypto_box_keypair();
/** @var string $msgKeypair */
$msgKeypair = ParagonIE_Sodium_Compat::crypto_box_keypair_from_secretkey_and_publickey(
ParagonIE_Sodium_Compat::crypto_box_secretkey($ephKeypair),
$publicKey
);
/** @var string $ephemeralPK */
$ephemeralPK = ParagonIE_Sodium_Compat::crypto_box_publickey($ephKeypair);
/** @var string $nonce */
$nonce = ParagonIE_Sodium_Compat::crypto_generichash(
$ephemeralPK . $publicKey,
'',
24
);
/** @var int $firstWrite */
$firstWrite = fwrite(
$ofp,
$ephemeralPK,
ParagonIE_Sodium_Compat::CRYPTO_BOX_PUBLICKEYBYTES
);
if (!is_int($firstWrite)) {
fclose($ifp);
fclose($ofp);
ParagonIE_Sodium_Compat::memzero($ephKeypair);
throw new SodiumException('Could not write to output file');
}
if ($firstWrite !== ParagonIE_Sodium_Compat::CRYPTO_BOX_PUBLICKEYBYTES) {
ParagonIE_Sodium_Compat::memzero($ephKeypair);
fclose($ifp);
fclose($ofp);
throw new SodiumException('Error writing public key to output file');
}
$res = self::box_encrypt($ifp, $ofp, $size, $nonce, $msgKeypair);
fclose($ifp);
fclose($ofp);
try {
ParagonIE_Sodium_Compat::memzero($nonce);
ParagonIE_Sodium_Compat::memzero($ephKeypair);
} catch (SodiumException $ex) {
/** @psalm-suppress PossiblyUndefinedVariable */
unset($ephKeypair);
}
return $res;
}
/**
* Open a sealed file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_box_seal_open(), but produces
* the same result.
*
* Warning: Does not protect against TOCTOU attacks. You should
* just load the file into memory and use crypto_box_seal_open() if
* you are worried about those.
*
* @param string $inputFile
* @param string $outputFile
* @param string $ecdhKeypair
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function box_seal_open($inputFile, $outputFile, $ecdhKeypair)
{
/* Type checks: */
if (!is_string($inputFile)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($inputFile) . ' given.');
}
if (!is_string($outputFile)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($outputFile) . ' given.');
}
if (!is_string($ecdhKeypair)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($ecdhKeypair) . ' given.');
}
/* Input validation: */
if (self::strlen($ecdhKeypair) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_KEYPAIRBYTES) {
throw new TypeError('Argument 3 must be CRYPTO_BOX_KEYPAIRBYTES bytes');
}
$publicKey = ParagonIE_Sodium_Compat::crypto_box_publickey($ecdhKeypair);
/** @var int $size */
$size = filesize($inputFile);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $ifp */
$ifp = fopen($inputFile, 'rb');
if (!is_resource($ifp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $ofp */
$ofp = fopen($outputFile, 'wb');
if (!is_resource($ofp)) {
fclose($ifp);
throw new SodiumException('Could not open output file for writing');
}
$ephemeralPK = fread($ifp, ParagonIE_Sodium_Compat::CRYPTO_BOX_PUBLICKEYBYTES);
if (!is_string($ephemeralPK)) {
throw new SodiumException('Could not read input file');
}
if (self::strlen($ephemeralPK) !== ParagonIE_Sodium_Compat::CRYPTO_BOX_PUBLICKEYBYTES) {
fclose($ifp);
fclose($ofp);
throw new SodiumException('Could not read public key from sealed file');
}
$nonce = ParagonIE_Sodium_Compat::crypto_generichash(
$ephemeralPK . $publicKey,
'',
24
);
$msgKeypair = ParagonIE_Sodium_Compat::crypto_box_keypair_from_secretkey_and_publickey(
ParagonIE_Sodium_Compat::crypto_box_secretkey($ecdhKeypair),
$ephemeralPK
);
$res = self::box_decrypt($ifp, $ofp, $size, $nonce, $msgKeypair);
fclose($ifp);
fclose($ofp);
try {
ParagonIE_Sodium_Compat::memzero($nonce);
ParagonIE_Sodium_Compat::memzero($ephKeypair);
} catch (SodiumException $ex) {
if (isset($ephKeypair)) {
unset($ephKeypair);
}
}
return $res;
}
/**
* Calculate the BLAKE2b hash of a file.
*
* @param string $filePath Absolute path to a file on the filesystem
* @param string|null $key BLAKE2b key
* @param int $outputLength Length of hash output
*
* @return string BLAKE2b hash
* @throws SodiumException
* @throws TypeError
* @psalm-suppress FailedTypeResolution
*/
public static function generichash($filePath, $key = '', $outputLength = 32)
{
/* Type checks: */
if (!is_string($filePath)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($filePath) . ' given.');
}
if (!is_string($key)) {
if (is_null($key)) {
$key = '';
} else {
throw new TypeError('Argument 2 must be a string, ' . gettype($key) . ' given.');
}
}
if (!is_int($outputLength)) {
if (!is_numeric($outputLength)) {
throw new TypeError('Argument 3 must be an integer, ' . gettype($outputLength) . ' given.');
}
$outputLength = (int) $outputLength;
}
/* Input validation: */
if (!empty($key)) {
if (self::strlen($key) < ParagonIE_Sodium_Compat::CRYPTO_GENERICHASH_KEYBYTES_MIN) {
throw new TypeError('Argument 2 must be at least CRYPTO_GENERICHASH_KEYBYTES_MIN bytes');
}
if (self::strlen($key) > ParagonIE_Sodium_Compat::CRYPTO_GENERICHASH_KEYBYTES_MAX) {
throw new TypeError('Argument 2 must be at most CRYPTO_GENERICHASH_KEYBYTES_MAX bytes');
}
}
if ($outputLength < ParagonIE_Sodium_Compat::CRYPTO_GENERICHASH_BYTES_MIN) {
throw new SodiumException('Argument 3 must be at least CRYPTO_GENERICHASH_BYTES_MIN');
}
if ($outputLength > ParagonIE_Sodium_Compat::CRYPTO_GENERICHASH_BYTES_MAX) {
throw new SodiumException('Argument 3 must be at least CRYPTO_GENERICHASH_BYTES_MAX');
}
/** @var int $size */
$size = filesize($filePath);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $fp */
$fp = fopen($filePath, 'rb');
if (!is_resource($fp)) {
throw new SodiumException('Could not open input file for reading');
}
$ctx = ParagonIE_Sodium_Compat::crypto_generichash_init($key, $outputLength);
while ($size > 0) {
$blockSize = $size > 64
? 64
: $size;
$read = fread($fp, $blockSize);
if (!is_string($read)) {
throw new SodiumException('Could not read input file');
}
ParagonIE_Sodium_Compat::crypto_generichash_update($ctx, $read);
$size -= $blockSize;
}
fclose($fp);
return ParagonIE_Sodium_Compat::crypto_generichash_final($ctx, $outputLength);
}
/**
* Encrypt a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_secretbox(), but produces
* the same result.
*
* @param string $inputFile Absolute path to a file on the filesystem
* @param string $outputFile Absolute path to a file on the filesystem
* @param string $nonce Number to be used only once
* @param string $key Encryption key
*
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox($inputFile, $outputFile, $nonce, $key)
{
/* Type checks: */
if (!is_string($inputFile)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($inputFile) . ' given..');
}
if (!is_string($outputFile)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($outputFile) . ' given.');
}
if (!is_string($nonce)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($nonce) . ' given.');
}
/* Input validation: */
if (self::strlen($nonce) !== ParagonIE_Sodium_Compat::CRYPTO_SECRETBOX_NONCEBYTES) {
throw new TypeError('Argument 3 must be CRYPTO_SECRETBOX_NONCEBYTES bytes');
}
if (!is_string($key)) {
throw new TypeError('Argument 4 must be a string, ' . gettype($key) . ' given.');
}
if (self::strlen($key) !== ParagonIE_Sodium_Compat::CRYPTO_SECRETBOX_KEYBYTES) {
throw new TypeError('Argument 4 must be CRYPTO_SECRETBOX_KEYBYTES bytes');
}
/** @var int $size */
$size = filesize($inputFile);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $ifp */
$ifp = fopen($inputFile, 'rb');
if (!is_resource($ifp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $ofp */
$ofp = fopen($outputFile, 'wb');
if (!is_resource($ofp)) {
fclose($ifp);
throw new SodiumException('Could not open output file for writing');
}
$res = self::secretbox_encrypt($ifp, $ofp, $size, $nonce, $key);
fclose($ifp);
fclose($ofp);
return $res;
}
/**
* Seal a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_secretbox_open(), but produces
* the same result.
*
* Warning: Does not protect against TOCTOU attacks. You should
* just load the file into memory and use crypto_secretbox_open() if
* you are worried about those.
*
* @param string $inputFile
* @param string $outputFile
* @param string $nonce
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
public static function secretbox_open($inputFile, $outputFile, $nonce, $key)
{
/* Type checks: */
if (!is_string($inputFile)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($inputFile) . ' given.');
}
if (!is_string($outputFile)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($outputFile) . ' given.');
}
if (!is_string($nonce)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($nonce) . ' given.');
}
if (!is_string($key)) {
throw new TypeError('Argument 4 must be a string, ' . gettype($key) . ' given.');
}
/* Input validation: */
if (self::strlen($nonce) !== ParagonIE_Sodium_Compat::CRYPTO_SECRETBOX_NONCEBYTES) {
throw new TypeError('Argument 4 must be CRYPTO_SECRETBOX_NONCEBYTES bytes');
}
if (self::strlen($key) !== ParagonIE_Sodium_Compat::CRYPTO_SECRETBOX_KEYBYTES) {
throw new TypeError('Argument 4 must be CRYPTO_SECRETBOXBOX_KEYBYTES bytes');
}
/** @var int $size */
$size = filesize($inputFile);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $ifp */
$ifp = fopen($inputFile, 'rb');
if (!is_resource($ifp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $ofp */
$ofp = fopen($outputFile, 'wb');
if (!is_resource($ofp)) {
fclose($ifp);
throw new SodiumException('Could not open output file for writing');
}
$res = self::secretbox_decrypt($ifp, $ofp, $size, $nonce, $key);
fclose($ifp);
fclose($ofp);
try {
ParagonIE_Sodium_Compat::memzero($key);
} catch (SodiumException $ex) {
/** @psalm-suppress PossiblyUndefinedVariable */
unset($key);
}
return $res;
}
/**
* Sign a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_sign_detached(), but produces
* the same result.
*
* @param string $filePath Absolute path to a file on the filesystem
* @param string $secretKey Secret signing key
*
* @return string Ed25519 signature
* @throws SodiumException
* @throws TypeError
*/
public static function sign($filePath, $secretKey)
{
/* Type checks: */
if (!is_string($filePath)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($filePath) . ' given.');
}
if (!is_string($secretKey)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($secretKey) . ' given.');
}
/* Input validation: */
if (self::strlen($secretKey) !== ParagonIE_Sodium_Compat::CRYPTO_SIGN_SECRETKEYBYTES) {
throw new TypeError('Argument 2 must be CRYPTO_SIGN_SECRETKEYBYTES bytes');
}
if (PHP_INT_SIZE === 4) {
return self::sign_core32($filePath, $secretKey);
}
/** @var int $size */
$size = filesize($filePath);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $fp */
$fp = fopen($filePath, 'rb');
if (!is_resource($fp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var string $az */
$az = hash('sha512', self::substr($secretKey, 0, 32), true);
$az[0] = self::intToChr(self::chrToInt($az[0]) & 248);
$az[31] = self::intToChr((self::chrToInt($az[31]) & 63) | 64);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($az, 32, 32));
/** @var resource $hs */
$hs = self::updateHashWithFile($hs, $fp, $size);
/** @var string $nonceHash */
$nonceHash = hash_final($hs, true);
/** @var string $pk */
$pk = self::substr($secretKey, 32, 32);
/** @var string $nonce */
$nonce = ParagonIE_Sodium_Core_Ed25519::sc_reduce($nonceHash) . self::substr($nonceHash, 32);
/** @var string $sig */
$sig = ParagonIE_Sodium_Core_Ed25519::ge_p3_tobytes(
ParagonIE_Sodium_Core_Ed25519::ge_scalarmult_base($nonce)
);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($sig, 0, 32));
self::hash_update($hs, self::substr($pk, 0, 32));
/** @var resource $hs */
$hs = self::updateHashWithFile($hs, $fp, $size);
/** @var string $hramHash */
$hramHash = hash_final($hs, true);
/** @var string $hram */
$hram = ParagonIE_Sodium_Core_Ed25519::sc_reduce($hramHash);
/** @var string $sigAfter */
$sigAfter = ParagonIE_Sodium_Core_Ed25519::sc_muladd($hram, $az, $nonce);
/** @var string $sig */
$sig = self::substr($sig, 0, 32) . self::substr($sigAfter, 0, 32);
try {
ParagonIE_Sodium_Compat::memzero($az);
} catch (SodiumException $ex) {
$az = null;
}
fclose($fp);
return $sig;
}
/**
* Verify a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_sign_verify_detached(), but
* produces the same result.
*
* @param string $sig Ed25519 signature
* @param string $filePath Absolute path to a file on the filesystem
* @param string $publicKey Signing public key
*
* @return bool
* @throws SodiumException
* @throws TypeError
* @throws Exception
*/
public static function verify($sig, $filePath, $publicKey)
{
/* Type checks: */
if (!is_string($sig)) {
throw new TypeError('Argument 1 must be a string, ' . gettype($sig) . ' given.');
}
if (!is_string($filePath)) {
throw new TypeError('Argument 2 must be a string, ' . gettype($filePath) . ' given.');
}
if (!is_string($publicKey)) {
throw new TypeError('Argument 3 must be a string, ' . gettype($publicKey) . ' given.');
}
/* Input validation: */
if (self::strlen($sig) !== ParagonIE_Sodium_Compat::CRYPTO_SIGN_BYTES) {
throw new TypeError('Argument 1 must be CRYPTO_SIGN_BYTES bytes');
}
if (self::strlen($publicKey) !== ParagonIE_Sodium_Compat::CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new TypeError('Argument 3 must be CRYPTO_SIGN_PUBLICKEYBYTES bytes');
}
if (self::strlen($sig) < 64) {
throw new SodiumException('Signature is too short');
}
if (PHP_INT_SIZE === 4) {
return self::verify_core32($sig, $filePath, $publicKey);
}
/* Security checks */
if (
(ParagonIE_Sodium_Core_Ed25519::chrToInt($sig[63]) & 240)
&&
ParagonIE_Sodium_Core_Ed25519::check_S_lt_L(self::substr($sig, 32, 32))
) {
throw new SodiumException('S < L - Invalid signature');
}
if (ParagonIE_Sodium_Core_Ed25519::small_order($sig)) {
throw new SodiumException('Signature is on too small of an order');
}
if ((self::chrToInt($sig[63]) & 224) !== 0) {
throw new SodiumException('Invalid signature');
}
$d = 0;
for ($i = 0; $i < 32; ++$i) {
$d |= self::chrToInt($publicKey[$i]);
}
if ($d === 0) {
throw new SodiumException('All zero public key');
}
/** @var int $size */
$size = filesize($filePath);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var resource $fp */
$fp = fopen($filePath, 'rb');
if (!is_resource($fp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var bool The original value of ParagonIE_Sodium_Compat::$fastMult */
$orig = ParagonIE_Sodium_Compat::$fastMult;
// Set ParagonIE_Sodium_Compat::$fastMult to true to speed up verification.
ParagonIE_Sodium_Compat::$fastMult = true;
/** @var ParagonIE_Sodium_Core_Curve25519_Ge_P3 $A */
$A = ParagonIE_Sodium_Core_Ed25519::ge_frombytes_negate_vartime($publicKey);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($sig, 0, 32));
self::hash_update($hs, self::substr($publicKey, 0, 32));
/** @var resource $hs */
$hs = self::updateHashWithFile($hs, $fp, $size);
/** @var string $hDigest */
$hDigest = hash_final($hs, true);
/** @var string $h */
$h = ParagonIE_Sodium_Core_Ed25519::sc_reduce($hDigest) . self::substr($hDigest, 32);
/** @var ParagonIE_Sodium_Core_Curve25519_Ge_P2 $R */
$R = ParagonIE_Sodium_Core_Ed25519::ge_double_scalarmult_vartime(
$h,
$A,
self::substr($sig, 32)
);
/** @var string $rcheck */
$rcheck = ParagonIE_Sodium_Core_Ed25519::ge_tobytes($R);
// Close the file handle
fclose($fp);
// Reset ParagonIE_Sodium_Compat::$fastMult to what it was before.
ParagonIE_Sodium_Compat::$fastMult = $orig;
return self::verify_32($rcheck, self::substr($sig, 0, 32));
}
/**
* @param resource $ifp
* @param resource $ofp
* @param int $mlen
* @param string $nonce
* @param string $boxKeypair
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function box_encrypt($ifp, $ofp, $mlen, $nonce, $boxKeypair)
{
if (PHP_INT_SIZE === 4) {
return self::secretbox_encrypt(
$ifp,
$ofp,
$mlen,
$nonce,
ParagonIE_Sodium_Crypto32::box_beforenm(
ParagonIE_Sodium_Crypto32::box_secretkey($boxKeypair),
ParagonIE_Sodium_Crypto32::box_publickey($boxKeypair)
)
);
}
return self::secretbox_encrypt(
$ifp,
$ofp,
$mlen,
$nonce,
ParagonIE_Sodium_Crypto::box_beforenm(
ParagonIE_Sodium_Crypto::box_secretkey($boxKeypair),
ParagonIE_Sodium_Crypto::box_publickey($boxKeypair)
)
);
}
/**
* @param resource $ifp
* @param resource $ofp
* @param int $mlen
* @param string $nonce
* @param string $boxKeypair
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function box_decrypt($ifp, $ofp, $mlen, $nonce, $boxKeypair)
{
if (PHP_INT_SIZE === 4) {
return self::secretbox_decrypt(
$ifp,
$ofp,
$mlen,
$nonce,
ParagonIE_Sodium_Crypto32::box_beforenm(
ParagonIE_Sodium_Crypto32::box_secretkey($boxKeypair),
ParagonIE_Sodium_Crypto32::box_publickey($boxKeypair)
)
);
}
return self::secretbox_decrypt(
$ifp,
$ofp,
$mlen,
$nonce,
ParagonIE_Sodium_Crypto::box_beforenm(
ParagonIE_Sodium_Crypto::box_secretkey($boxKeypair),
ParagonIE_Sodium_Crypto::box_publickey($boxKeypair)
)
);
}
/**
* Encrypt a file
*
* @param resource $ifp
* @param resource $ofp
* @param int $mlen
* @param string $nonce
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function secretbox_encrypt($ifp, $ofp, $mlen, $nonce, $key)
{
if (PHP_INT_SIZE === 4) {
return self::secretbox_encrypt_core32($ifp, $ofp, $mlen, $nonce, $key);
}
$plaintext = fread($ifp, 32);
if (!is_string($plaintext)) {
throw new SodiumException('Could not read input file');
}
$first32 = self::ftell($ifp);
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core_HSalsa20::hsalsa20($nonce, $key);
/** @var string $realNonce */
$realNonce = ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8);
/** @var string $block0 */
$block0 = str_repeat("\x00", 32);
/** @var int $mlen - Length of the plaintext message */
$mlen0 = $mlen;
if ($mlen0 > 64 - ParagonIE_Sodium_Crypto::secretbox_xsalsa20poly1305_ZEROBYTES) {
$mlen0 = 64 - ParagonIE_Sodium_Crypto::secretbox_xsalsa20poly1305_ZEROBYTES;
}
$block0 .= ParagonIE_Sodium_Core_Util::substr($plaintext, 0, $mlen0);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core_Salsa20::salsa20_xor(
$block0,
$realNonce,
$subkey
);
$state = new ParagonIE_Sodium_Core_Poly1305_State(
ParagonIE_Sodium_Core_Util::substr(
$block0,
0,
ParagonIE_Sodium_Crypto::onetimeauth_poly1305_KEYBYTES
)
);
// Pre-write 16 blank bytes for the Poly1305 tag
$start = self::ftell($ofp);
fwrite($ofp, str_repeat("\x00", 16));
/** @var string $c */
$cBlock = ParagonIE_Sodium_Core_Util::substr(
$block0,
ParagonIE_Sodium_Crypto::secretbox_xsalsa20poly1305_ZEROBYTES
);
$state->update($cBlock);
fwrite($ofp, $cBlock);
$mlen -= 32;
/** @var int $iter */
$iter = 1;
/** @var int $incr */
$incr = self::BUFFER_SIZE >> 6;
/*
* Set the cursor to the end of the first half-block. All future bytes will
* generated from salsa20_xor_ic, starting from 1 (second block).
*/
fseek($ifp, $first32, SEEK_SET);
while ($mlen > 0) {
$blockSize = $mlen > self::BUFFER_SIZE
? self::BUFFER_SIZE
: $mlen;
$plaintext = fread($ifp, $blockSize);
if (!is_string($plaintext)) {
throw new SodiumException('Could not read input file');
}
$cBlock = ParagonIE_Sodium_Core_Salsa20::salsa20_xor_ic(
$plaintext,
$realNonce,
$iter,
$subkey
);
fwrite($ofp, $cBlock, $blockSize);
$state->update($cBlock);
$mlen -= $blockSize;
$iter += $incr;
}
try {
ParagonIE_Sodium_Compat::memzero($block0);
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$block0 = null;
$subkey = null;
}
$end = self::ftell($ofp);
/*
* Write the Poly1305 authentication tag that provides integrity
* over the ciphertext (encrypt-then-MAC)
*/
fseek($ofp, $start, SEEK_SET);
fwrite($ofp, $state->finish(), ParagonIE_Sodium_Compat::CRYPTO_SECRETBOX_MACBYTES);
fseek($ofp, $end, SEEK_SET);
unset($state);
return true;
}
/**
* Decrypt a file
*
* @param resource $ifp
* @param resource $ofp
* @param int $mlen
* @param string $nonce
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function secretbox_decrypt($ifp, $ofp, $mlen, $nonce, $key)
{
if (PHP_INT_SIZE === 4) {
return self::secretbox_decrypt_core32($ifp, $ofp, $mlen, $nonce, $key);
}
$tag = fread($ifp, 16);
if (!is_string($tag)) {
throw new SodiumException('Could not read input file');
}
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core_HSalsa20::hsalsa20($nonce, $key);
/** @var string $realNonce */
$realNonce = ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core_Salsa20::salsa20(
64,
ParagonIE_Sodium_Core_Util::substr($nonce, 16, 8),
$subkey
);
/* Verify the Poly1305 MAC -before- attempting to decrypt! */
$state = new ParagonIE_Sodium_Core_Poly1305_State(self::substr($block0, 0, 32));
if (!self::onetimeauth_verify($state, $ifp, $tag, $mlen)) {
throw new SodiumException('Invalid MAC');
}
/*
* Set the cursor to the end of the first half-block. All future bytes will
* generated from salsa20_xor_ic, starting from 1 (second block).
*/
$first32 = fread($ifp, 32);
if (!is_string($first32)) {
throw new SodiumException('Could not read input file');
}
$first32len = self::strlen($first32);
fwrite(
$ofp,
self::xorStrings(
self::substr($block0, 32, $first32len),
self::substr($first32, 0, $first32len)
)
);
$mlen -= 32;
/** @var int $iter */
$iter = 1;
/** @var int $incr */
$incr = self::BUFFER_SIZE >> 6;
/* Decrypts ciphertext, writes to output file. */
while ($mlen > 0) {
$blockSize = $mlen > self::BUFFER_SIZE
? self::BUFFER_SIZE
: $mlen;
$ciphertext = fread($ifp, $blockSize);
if (!is_string($ciphertext)) {
throw new SodiumException('Could not read input file');
}
$pBlock = ParagonIE_Sodium_Core_Salsa20::salsa20_xor_ic(
$ciphertext,
$realNonce,
$iter,
$subkey
);
fwrite($ofp, $pBlock, $blockSize);
$mlen -= $blockSize;
$iter += $incr;
}
return true;
}
/**
* @param ParagonIE_Sodium_Core_Poly1305_State $state
* @param resource $ifp
* @param string $tag
* @param int $mlen
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function onetimeauth_verify(
ParagonIE_Sodium_Core_Poly1305_State $state,
$ifp,
$tag = '',
$mlen = 0
) {
/** @var int $pos */
$pos = self::ftell($ifp);
/** @var int $iter */
$iter = 1;
/** @var int $incr */
$incr = self::BUFFER_SIZE >> 6;
while ($mlen > 0) {
$blockSize = $mlen > self::BUFFER_SIZE
? self::BUFFER_SIZE
: $mlen;
$ciphertext = fread($ifp, $blockSize);
if (!is_string($ciphertext)) {
throw new SodiumException('Could not read input file');
}
$state->update($ciphertext);
$mlen -= $blockSize;
$iter += $incr;
}
$res = ParagonIE_Sodium_Core_Util::verify_16($tag, $state->finish());
fseek($ifp, $pos, SEEK_SET);
return $res;
}
/**
* Update a hash context with the contents of a file, without
* loading the entire file into memory.
*
* @param resource|HashContext $hash
* @param resource $fp
* @param int $size
* @return resource|object Resource on PHP < 7.2, HashContext object on PHP >= 7.2
* @throws SodiumException
* @throws TypeError
* @psalm-suppress PossiblyInvalidArgument
* PHP 7.2 changes from a resource to an object,
* which causes Psalm to complain about an error.
* @psalm-suppress TypeCoercion
* Ditto.
*/
public static function updateHashWithFile($hash, $fp, $size = 0)
{
/* Type checks: */
if (PHP_VERSION_ID < 70200) {
if (!is_resource($hash)) {
throw new TypeError('Argument 1 must be a resource, ' . gettype($hash) . ' given.');
}
} else {
if (!is_object($hash)) {
throw new TypeError('Argument 1 must be an object (PHP 7.2+), ' . gettype($hash) . ' given.');
}
}
if (!is_resource($fp)) {
throw new TypeError('Argument 2 must be a resource, ' . gettype($fp) . ' given.');
}
if (!is_int($size)) {
throw new TypeError('Argument 3 must be an integer, ' . gettype($size) . ' given.');
}
/** @var int $originalPosition */
$originalPosition = self::ftell($fp);
// Move file pointer to beginning of file
fseek($fp, 0, SEEK_SET);
for ($i = 0; $i < $size; $i += self::BUFFER_SIZE) {
/** @var string|bool $message */
$message = fread(
$fp,
($size - $i) > self::BUFFER_SIZE
? $size - $i
: self::BUFFER_SIZE
);
if (!is_string($message)) {
throw new SodiumException('Unexpected error reading from file.');
}
/** @var string $message */
/** @psalm-suppress InvalidArgument */
self::hash_update($hash, $message);
}
// Reset file pointer's position
fseek($fp, $originalPosition, SEEK_SET);
return $hash;
}
/**
* Sign a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_sign_detached(), but produces
* the same result. (32-bit)
*
* @param string $filePath Absolute path to a file on the filesystem
* @param string $secretKey Secret signing key
*
* @return string Ed25519 signature
* @throws SodiumException
* @throws TypeError
*/
private static function sign_core32($filePath, $secretKey)
{
$size = filesize($filePath);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
$fp = fopen($filePath, 'rb');
if (!is_resource($fp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var string $az */
$az = hash('sha512', self::substr($secretKey, 0, 32), true);
$az[0] = self::intToChr(self::chrToInt($az[0]) & 248);
$az[31] = self::intToChr((self::chrToInt($az[31]) & 63) | 64);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($az, 32, 32));
/** @var resource $hs */
$hs = self::updateHashWithFile($hs, $fp, $size);
$nonceHash = hash_final($hs, true);
$pk = self::substr($secretKey, 32, 32);
$nonce = ParagonIE_Sodium_Core32_Ed25519::sc_reduce($nonceHash) . self::substr($nonceHash, 32);
$sig = ParagonIE_Sodium_Core32_Ed25519::ge_p3_tobytes(
ParagonIE_Sodium_Core32_Ed25519::ge_scalarmult_base($nonce)
);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($sig, 0, 32));
self::hash_update($hs, self::substr($pk, 0, 32));
/** @var resource $hs */
$hs = self::updateHashWithFile($hs, $fp, $size);
$hramHash = hash_final($hs, true);
$hram = ParagonIE_Sodium_Core32_Ed25519::sc_reduce($hramHash);
$sigAfter = ParagonIE_Sodium_Core32_Ed25519::sc_muladd($hram, $az, $nonce);
/** @var string $sig */
$sig = self::substr($sig, 0, 32) . self::substr($sigAfter, 0, 32);
try {
ParagonIE_Sodium_Compat::memzero($az);
} catch (SodiumException $ex) {
$az = null;
}
fclose($fp);
return $sig;
}
/**
*
* Verify a file (rather than a string). Uses less memory than
* ParagonIE_Sodium_Compat::crypto_sign_verify_detached(), but
* produces the same result. (32-bit)
*
* @param string $sig Ed25519 signature
* @param string $filePath Absolute path to a file on the filesystem
* @param string $publicKey Signing public key
*
* @return bool
* @throws SodiumException
* @throws Exception
*/
public static function verify_core32($sig, $filePath, $publicKey)
{
/* Security checks */
if (ParagonIE_Sodium_Core32_Ed25519::check_S_lt_L(self::substr($sig, 32, 32))) {
throw new SodiumException('S < L - Invalid signature');
}
if (ParagonIE_Sodium_Core32_Ed25519::small_order($sig)) {
throw new SodiumException('Signature is on too small of an order');
}
if ((self::chrToInt($sig[63]) & 224) !== 0) {
throw new SodiumException('Invalid signature');
}
$d = 0;
for ($i = 0; $i < 32; ++$i) {
$d |= self::chrToInt($publicKey[$i]);
}
if ($d === 0) {
throw new SodiumException('All zero public key');
}
/** @var int|bool $size */
$size = filesize($filePath);
if (!is_int($size)) {
throw new SodiumException('Could not obtain the file size');
}
/** @var int $size */
/** @var resource|bool $fp */
$fp = fopen($filePath, 'rb');
if (!is_resource($fp)) {
throw new SodiumException('Could not open input file for reading');
}
/** @var resource $fp */
/** @var bool The original value of ParagonIE_Sodium_Compat::$fastMult */
$orig = ParagonIE_Sodium_Compat::$fastMult;
// Set ParagonIE_Sodium_Compat::$fastMult to true to speed up verification.
ParagonIE_Sodium_Compat::$fastMult = true;
/** @var ParagonIE_Sodium_Core32_Curve25519_Ge_P3 $A */
$A = ParagonIE_Sodium_Core32_Ed25519::ge_frombytes_negate_vartime($publicKey);
$hs = hash_init('sha512');
self::hash_update($hs, self::substr($sig, 0, 32));
self::hash_update($hs, self::substr($publicKey, 0, 32));
/** @var resource $hs */
$hs = self::updateHashWithFile($hs, $fp, $size);
/** @var string $hDigest */
$hDigest = hash_final($hs, true);
/** @var string $h */
$h = ParagonIE_Sodium_Core32_Ed25519::sc_reduce($hDigest) . self::substr($hDigest, 32);
/** @var ParagonIE_Sodium_Core32_Curve25519_Ge_P2 $R */
$R = ParagonIE_Sodium_Core32_Ed25519::ge_double_scalarmult_vartime(
$h,
$A,
self::substr($sig, 32)
);
/** @var string $rcheck */
$rcheck = ParagonIE_Sodium_Core32_Ed25519::ge_tobytes($R);
// Close the file handle
fclose($fp);
// Reset ParagonIE_Sodium_Compat::$fastMult to what it was before.
ParagonIE_Sodium_Compat::$fastMult = $orig;
return self::verify_32($rcheck, self::substr($sig, 0, 32));
}
/**
* Encrypt a file (32-bit)
*
* @param resource $ifp
* @param resource $ofp
* @param int $mlen
* @param string $nonce
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function secretbox_encrypt_core32($ifp, $ofp, $mlen, $nonce, $key)
{
$plaintext = fread($ifp, 32);
if (!is_string($plaintext)) {
throw new SodiumException('Could not read input file');
}
$first32 = self::ftell($ifp);
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core32_HSalsa20::hsalsa20($nonce, $key);
/** @var string $realNonce */
$realNonce = ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8);
/** @var string $block0 */
$block0 = str_repeat("\x00", 32);
/** @var int $mlen - Length of the plaintext message */
$mlen0 = $mlen;
if ($mlen0 > 64 - ParagonIE_Sodium_Crypto::secretbox_xsalsa20poly1305_ZEROBYTES) {
$mlen0 = 64 - ParagonIE_Sodium_Crypto::secretbox_xsalsa20poly1305_ZEROBYTES;
}
$block0 .= ParagonIE_Sodium_Core32_Util::substr($plaintext, 0, $mlen0);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core32_Salsa20::salsa20_xor(
$block0,
$realNonce,
$subkey
);
$state = new ParagonIE_Sodium_Core32_Poly1305_State(
ParagonIE_Sodium_Core32_Util::substr(
$block0,
0,
ParagonIE_Sodium_Crypto::onetimeauth_poly1305_KEYBYTES
)
);
// Pre-write 16 blank bytes for the Poly1305 tag
$start = self::ftell($ofp);
fwrite($ofp, str_repeat("\x00", 16));
/** @var string $c */
$cBlock = ParagonIE_Sodium_Core32_Util::substr(
$block0,
ParagonIE_Sodium_Crypto::secretbox_xsalsa20poly1305_ZEROBYTES
);
$state->update($cBlock);
fwrite($ofp, $cBlock);
$mlen -= 32;
/** @var int $iter */
$iter = 1;
/** @var int $incr */
$incr = self::BUFFER_SIZE >> 6;
/*
* Set the cursor to the end of the first half-block. All future bytes will
* generated from salsa20_xor_ic, starting from 1 (second block).
*/
fseek($ifp, $first32, SEEK_SET);
while ($mlen > 0) {
$blockSize = $mlen > self::BUFFER_SIZE
? self::BUFFER_SIZE
: $mlen;
$plaintext = fread($ifp, $blockSize);
if (!is_string($plaintext)) {
throw new SodiumException('Could not read input file');
}
$cBlock = ParagonIE_Sodium_Core32_Salsa20::salsa20_xor_ic(
$plaintext,
$realNonce,
$iter,
$subkey
);
fwrite($ofp, $cBlock, $blockSize);
$state->update($cBlock);
$mlen -= $blockSize;
$iter += $incr;
}
try {
ParagonIE_Sodium_Compat::memzero($block0);
ParagonIE_Sodium_Compat::memzero($subkey);
} catch (SodiumException $ex) {
$block0 = null;
$subkey = null;
}
$end = self::ftell($ofp);
/*
* Write the Poly1305 authentication tag that provides integrity
* over the ciphertext (encrypt-then-MAC)
*/
fseek($ofp, $start, SEEK_SET);
fwrite($ofp, $state->finish(), ParagonIE_Sodium_Compat::CRYPTO_SECRETBOX_MACBYTES);
fseek($ofp, $end, SEEK_SET);
unset($state);
return true;
}
/**
* Decrypt a file (32-bit)
*
* @param resource $ifp
* @param resource $ofp
* @param int $mlen
* @param string $nonce
* @param string $key
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function secretbox_decrypt_core32($ifp, $ofp, $mlen, $nonce, $key)
{
$tag = fread($ifp, 16);
if (!is_string($tag)) {
throw new SodiumException('Could not read input file');
}
/** @var string $subkey */
$subkey = ParagonIE_Sodium_Core32_HSalsa20::hsalsa20($nonce, $key);
/** @var string $realNonce */
$realNonce = ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8);
/** @var string $block0 */
$block0 = ParagonIE_Sodium_Core32_Salsa20::salsa20(
64,
ParagonIE_Sodium_Core32_Util::substr($nonce, 16, 8),
$subkey
);
/* Verify the Poly1305 MAC -before- attempting to decrypt! */
$state = new ParagonIE_Sodium_Core32_Poly1305_State(self::substr($block0, 0, 32));
if (!self::onetimeauth_verify_core32($state, $ifp, $tag, $mlen)) {
throw new SodiumException('Invalid MAC');
}
/*
* Set the cursor to the end of the first half-block. All future bytes will
* generated from salsa20_xor_ic, starting from 1 (second block).
*/
$first32 = fread($ifp, 32);
if (!is_string($first32)) {
throw new SodiumException('Could not read input file');
}
$first32len = self::strlen($first32);
fwrite(
$ofp,
self::xorStrings(
self::substr($block0, 32, $first32len),
self::substr($first32, 0, $first32len)
)
);
$mlen -= 32;
/** @var int $iter */
$iter = 1;
/** @var int $incr */
$incr = self::BUFFER_SIZE >> 6;
/* Decrypts ciphertext, writes to output file. */
while ($mlen > 0) {
$blockSize = $mlen > self::BUFFER_SIZE
? self::BUFFER_SIZE
: $mlen;
$ciphertext = fread($ifp, $blockSize);
if (!is_string($ciphertext)) {
throw new SodiumException('Could not read input file');
}
$pBlock = ParagonIE_Sodium_Core32_Salsa20::salsa20_xor_ic(
$ciphertext,
$realNonce,
$iter,
$subkey
);
fwrite($ofp, $pBlock, $blockSize);
$mlen -= $blockSize;
$iter += $incr;
}
return true;
}
/**
* One-time message authentication for 32-bit systems
*
* @param ParagonIE_Sodium_Core32_Poly1305_State $state
* @param resource $ifp
* @param string $tag
* @param int $mlen
* @return bool
* @throws SodiumException
* @throws TypeError
*/
protected static function onetimeauth_verify_core32(
ParagonIE_Sodium_Core32_Poly1305_State $state,
$ifp,
$tag = '',
$mlen = 0
) {
/** @var int $pos */
$pos = self::ftell($ifp);
while ($mlen > 0) {
$blockSize = $mlen > self::BUFFER_SIZE
? self::BUFFER_SIZE
: $mlen;
$ciphertext = fread($ifp, $blockSize);
if (!is_string($ciphertext)) {
throw new SodiumException('Could not read input file');
}
$state->update($ciphertext);
$mlen -= $blockSize;
}
$res = ParagonIE_Sodium_Core32_Util::verify_16($tag, $state->finish());
fseek($ifp, $pos, SEEK_SET);
return $res;
}
/**
* @param resource $resource
* @return int
* @throws SodiumException
*/
private static function ftell($resource)
{
$return = ftell($resource);
if (!is_int($return)) {
throw new SodiumException('ftell() returned false');
}
return (int) $return;
}
}
PHP52/SplFixedArray.php 0000644 00000010024 15153427537 0010601 0 ustar 00 <?php
if (class_exists('SplFixedArray')) {
return;
}
/**
* The SplFixedArray class provides the main functionalities of array. The
* main differences between a SplFixedArray and a normal PHP array is that
* the SplFixedArray is of fixed length and allows only integers within
* the range as indexes. The advantage is that it allows a faster array
* implementation.
*/
class SplFixedArray implements Iterator, ArrayAccess, Countable
{
/** @var array<int, mixed> */
private $internalArray = array();
/** @var int $size */
private $size = 0;
/**
* SplFixedArray constructor.
* @param int $size
*/
public function __construct($size = 0)
{
$this->size = $size;
$this->internalArray = array();
}
/**
* @return int
*/
public function count()
{
return count($this->internalArray);
}
/**
* @return array
*/
public function toArray()
{
ksort($this->internalArray);
return (array) $this->internalArray;
}
/**
* @param array $array
* @param bool $save_indexes
* @return SplFixedArray
* @psalm-suppress MixedAssignment
*/
public static function fromArray(array $array, $save_indexes = true)
{
$self = new SplFixedArray(count($array));
if($save_indexes) {
foreach($array as $key => $value) {
$self[(int) $key] = $value;
}
} else {
$i = 0;
foreach (array_values($array) as $value) {
$self[$i] = $value;
$i++;
}
}
return $self;
}
/**
* @return int
*/
public function getSize()
{
return $this->size;
}
/**
* @param int $size
* @return bool
*/
public function setSize($size)
{
$this->size = $size;
return true;
}
/**
* @param string|int $index
* @return bool
*/
public function offsetExists($index)
{
return array_key_exists((int) $index, $this->internalArray);
}
/**
* @param string|int $index
* @return mixed
*/
public function offsetGet($index)
{
/** @psalm-suppress MixedReturnStatement */
return $this->internalArray[(int) $index];
}
/**
* @param string|int $index
* @param mixed $newval
* @psalm-suppress MixedAssignment
*/
public function offsetSet($index, $newval)
{
$this->internalArray[(int) $index] = $newval;
}
/**
* @param string|int $index
*/
public function offsetUnset($index)
{
unset($this->internalArray[(int) $index]);
}
/**
* Rewind iterator back to the start
* @link https://php.net/manual/en/splfixedarray.rewind.php
* @return void
* @since 5.3.0
*/
public function rewind()
{
reset($this->internalArray);
}
/**
* Return current array entry
* @link https://php.net/manual/en/splfixedarray.current.php
* @return mixed The current element value.
* @since 5.3.0
*/
public function current()
{
/** @psalm-suppress MixedReturnStatement */
return current($this->internalArray);
}
/**
* Return current array index
* @return int The current array index.
*/
public function key()
{
return key($this->internalArray);
}
/**
* @return void
*/
public function next()
{
next($this->internalArray);
}
/**
* Check whether the array contains more elements
* @link https://php.net/manual/en/splfixedarray.valid.php
* @return bool true if the array contains any more elements, false otherwise.
*/
public function valid()
{
if (empty($this->internalArray)) {
return false;
}
$result = next($this->internalArray) !== false;
prev($this->internalArray);
return $result;
}
/**
* Do nothing.
*/
public function __wakeup()
{
// NOP
}
} SodiumException.php 0000644 00000000236 15153427537 0010411 0 ustar 00 <?php
if (!class_exists('SodiumException', false)) {
/**
* Class SodiumException
*/
class SodiumException extends Exception
{
}
}
Admin/API/Coupons.php 0000644 00000004232 15153704476 0010361 0 ustar 00 <?php
/**
* REST API Coupons Controller
*
* Handles requests to /coupons/*
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Coupons controller.
*
* @internal
* @extends WC_REST_Coupons_Controller
*/
class Coupons extends \WC_REST_Coupons_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['search'] = array(
'description' => __( 'Limit results to coupons with codes matching a given string.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Add coupon code searching to the WC API.
*
* @param WP_REST_Request $request Request data.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = parent::prepare_objects_query( $request );
if ( ! empty( $request['search'] ) ) {
$args['search'] = $request['search'];
$args['s'] = false;
}
return $args;
}
/**
* Get a collection of posts and add the code search option to WP_Query.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10, 2 );
$response = parent::get_items( $request );
remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10 );
return $response;
}
/**
* Add code searching to the WP Query
*
* @internal
* @param string $where Where clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_search_code_filter( $where, $wp_query ) {
global $wpdb;
$search = $wp_query->get( 'search' );
if ( $search ) {
$code_like = '%' . $wpdb->esc_like( $search ) . '%';
$where .= $wpdb->prepare( "AND {$wpdb->posts}.post_title LIKE %s", $code_like );
}
return $where;
}
}
Admin/API/CustomAttributeTraits.php 0000644 00000006634 15153704476 0013270 0 ustar 00 <?php
/**
* Traits for handling custom product attributes and their terms.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* CustomAttributeTraits class.
*
* @internal
*/
trait CustomAttributeTraits {
/**
* Get a single attribute by its slug.
*
* @internal
* @param string $slug The attribute slug.
* @return WP_Error|object The matching attribute object or WP_Error if not found.
*/
public function get_custom_attribute_by_slug( $slug ) {
$matching_attributes = $this->get_custom_attributes( array( 'slug' => $slug ) );
if ( empty( $matching_attributes ) ) {
return new \WP_Error(
'woocommerce_rest_product_attribute_not_found',
__( 'No product attribute with that slug was found.', 'woocommerce' ),
array( 'status' => 404 )
);
}
foreach ( $matching_attributes as $attribute_key => $attribute_value ) {
return array( $attribute_key => $attribute_value );
}
}
/**
* Query custom attributes by name or slug.
*
* @param string $args Search arguments, either name or slug.
* @return array Matching attributes, formatted for response.
*/
protected function get_custom_attributes( $args ) {
global $wpdb;
$args = wp_parse_args(
$args,
array(
'name' => '',
'slug' => '',
)
);
if ( empty( $args['name'] ) && empty( $args['slug'] ) ) {
return array();
}
$mode = $args['name'] ? 'name' : 'slug';
if ( 'name' === $mode ) {
$name = $args['name'];
// Get as close as we can to matching the name property of custom attributes using SQL.
$like = '%"name";s:%:"%' . $wpdb->esc_like( $name ) . '%"%';
} else {
$slug = sanitize_title_for_query( $args['slug'] );
// Get as close as we can to matching the slug property of custom attributes using SQL.
$like = '%s:' . strlen( $slug ) . ':"' . $slug . '";a:6:{%';
}
// Find all serialized product attributes with names like the search string.
$query_results = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_value
FROM {$wpdb->postmeta}
WHERE meta_key = '_product_attributes'
AND meta_value LIKE %s
LIMIT 100",
$like
),
ARRAY_A
);
$custom_attributes = array();
foreach ( $query_results as $raw_product_attributes ) {
$meta_attributes = maybe_unserialize( $raw_product_attributes['meta_value'] );
if ( empty( $meta_attributes ) || ! is_array( $meta_attributes ) ) {
continue;
}
foreach ( $meta_attributes as $meta_attribute_key => $meta_attribute_value ) {
$meta_value = array_merge(
array(
'name' => '',
'is_taxonomy' => 0,
),
(array) $meta_attribute_value
);
// Skip non-custom attributes.
if ( ! empty( $meta_value['is_taxonomy'] ) ) {
continue;
}
// Skip custom attributes that didn't match the query.
// (There can be any number of attributes in the meta value).
if ( ( 'name' === $mode ) && ( false === stripos( $meta_value['name'], $name ) ) ) {
continue;
}
if ( ( 'slug' === $mode ) && ( $meta_attribute_key !== $slug ) ) {
continue;
}
// Combine all values when there are multiple matching custom attributes.
if ( isset( $custom_attributes[ $meta_attribute_key ] ) ) {
$custom_attributes[ $meta_attribute_key ]['value'] .= ' ' . WC_DELIMITER . ' ' . $meta_value['value'];
} else {
$custom_attributes[ $meta_attribute_key ] = $meta_attribute_value;
}
}
}
return $custom_attributes;
}
}
Admin/API/Customers.php 0000644 00000004163 15153704476 0010722 0 ustar 00 <?php
/**
* REST API Customers Controller
*
* Handles requests to /customers/*
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Customers controller.
*
* @internal
* @extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller
*/
class Customers extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'customers';
/**
* Register the routes for customers.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d-]+)',
array(
'args' => array(
'id' => array(
'description' => __( 'Unique ID for the resource.', 'woocommerce' ),
'type' => 'integer',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = parent::prepare_reports_query( $request );
$args['customers'] = $request['include'];
return $args;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['include'] = $params['customers'];
unset( $params['customers'] );
return $params;
}
}
Admin/API/Data.php 0000644 00000001653 15153704476 0007610 0 ustar 00 <?php
/**
* REST API Data Controller
*
* Handles requests to /data
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Data controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Data extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Return the list of data resources.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
$response = parent::get_items( $request );
$response->data[] = $this->prepare_response_for_collection(
$this->prepare_item_for_response(
(object) array(
'slug' => 'download-ips',
'description' => __( 'An endpoint used for searching download logs for a specific IP address.', 'woocommerce' ),
),
$request
)
);
return $response;
}
}
Admin/API/DataCountries.php 0000644 00000002175 15153704476 0011504 0 ustar 00 <?php
/**
* REST API Data countries controller.
*
* Handles requests to the /data/countries endpoint.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* REST API Data countries controller class.
*
* @internal
* @extends WC_REST_Data_Countries_Controller
*/
class DataCountries extends \WC_REST_Data_Countries_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Register routes.
*
* @since 3.5.0
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/locales',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_locales' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
parent::register_routes();
}
/**
* Get country fields.
*
* @return array
*/
public function get_locales() {
$locales = WC()->countries->get_country_locale();
return rest_ensure_response( $locales );
}
}
Admin/API/DataDownloadIPs.php 0000644 00000010230 15153704476 0011703 0 ustar 00 <?php
/**
* REST API Data Download IP Controller
*
* Handles requests to /data/download-ips
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Data Download IP controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class DataDownloadIPs extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'data/download-ips';
/**
* Register routes.
*
* @since 3.5.0
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Return the download IPs matching the passed parameters.
*
* @since 3.5.0
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
global $wpdb;
if ( isset( $request['match'] ) ) {
$downloads = $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT( user_ip_address ) FROM {$wpdb->prefix}wc_download_log
WHERE user_ip_address LIKE %s
LIMIT 10",
$request['match'] . '%'
)
);
} else {
return new \WP_Error( 'woocommerce_rest_data_download_ips_invalid_request', __( 'Invalid request. Please pass the match parameter.', 'woocommerce' ), array( 'status' => 400 ) );
}
$data = array();
if ( ! empty( $downloads ) ) {
foreach ( $downloads as $download ) {
$response = $this->prepare_item_for_response( $download, $request );
$data[] = $this->prepare_response_for_collection( $response );
}
}
return rest_ensure_response( $data );
}
/**
* Prepare the data object for response.
*
* @since 3.5.0
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $item ) );
/**
* Filter the list returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_data_download_ip', $response, $item, $request );
}
/**
* Prepare links for the request.
*
* @param object $item Data object.
* @return array Links for the given object.
*/
protected function prepare_links( $item ) {
$links = array(
'collection' => array(
'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
),
);
return $links;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['match'] = array(
'description' => __( 'A partial IP address can be passed and matching results will be returned.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'data_download_ips',
'type' => 'object',
'properties' => array(
'user_ip_address' => array(
'type' => 'string',
'description' => __( 'IP address.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/Experiments.php 0000644 00000003510 15153704476 0011234 0 ustar 00 <?php
/**
* REST API Experiment Controller
*
* Handles requests to /experiment
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Data controller.
*
* @extends WC_REST_Data_Controller
*/
class Experiments extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'experiments';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/assignment',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_assignment' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Forward the experiment request to WP.com and return the WP.com response.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_assignment( $request ) {
$args = $request->get_query_params();
if ( ! isset( $args['experiment_name'] ) ) {
return new \WP_Error(
'woocommerce_rest_experiment_name_required',
__( 'Sorry, experiment_name is required.', 'woocommerce' ),
array( 'status' => 400 )
);
}
unset( $args['rest_route'] );
$abtest = new \WooCommerce\Admin\Experimental_Abtest(
$request->get_param( 'anon_id' ) ?? '',
'woocommerce',
true, // set consent to true here since frontend has checked it already.
true // set true to send request as auth user.
);
$response = $abtest->request_assignment( $args );
if ( is_wp_error( $response ) ) {
return $response;
}
return json_decode( $response['body'], true );
}
}
Admin/API/Features.php 0000644 00000003314 15153704476 0010511 0 ustar 00 <?php
/**
* REST API Features Controller
*
* Handles requests to /features
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Features as FeaturesClass;
/**
* Features Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Features extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'features';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_features' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to read onboarding profile data.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return available payment methods.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_features( $request ) {
return FeaturesClass::get_available_features();
}
}
Admin/API/Init.php 0000644 00000020511 15153704476 0007634 0 ustar 00 <?php
/**
* REST API bootstrap.
*/
namespace Automattic\WooCommerce\Admin\API;
use AllowDynamicProperties;
use Automattic\WooCommerce\Admin\Features\Features;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\Admin\Loader;
/**
* Init class.
*
* @internal
*/
#[AllowDynamicProperties]
class Init {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Bootstrap REST API.
*/
public function __construct() {
// Hook in data stores.
add_filter( 'woocommerce_data_stores', array( __CLASS__, 'add_data_stores' ) );
// REST API extensions init.
add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
// Add currency symbol to orders endpoint response.
add_filter( 'woocommerce_rest_prepare_shop_order_object', array( __CLASS__, 'add_currency_symbol_to_order_response' ) );
}
/**
* Init REST API.
*/
public function rest_api_init() {
$controllers = array(
'Automattic\WooCommerce\Admin\API\Features',
'Automattic\WooCommerce\Admin\API\Notes',
'Automattic\WooCommerce\Admin\API\NoteActions',
'Automattic\WooCommerce\Admin\API\Coupons',
'Automattic\WooCommerce\Admin\API\Data',
'Automattic\WooCommerce\Admin\API\DataCountries',
'Automattic\WooCommerce\Admin\API\DataDownloadIPs',
'Automattic\WooCommerce\Admin\API\Experiments',
'Automattic\WooCommerce\Admin\API\Marketing',
'Automattic\WooCommerce\Admin\API\MarketingOverview',
'Automattic\WooCommerce\Admin\API\MarketingRecommendations',
'Automattic\WooCommerce\Admin\API\MarketingChannels',
'Automattic\WooCommerce\Admin\API\MarketingCampaigns',
'Automattic\WooCommerce\Admin\API\MarketingCampaignTypes',
'Automattic\WooCommerce\Admin\API\Options',
'Automattic\WooCommerce\Admin\API\Orders',
'Automattic\WooCommerce\Admin\API\PaymentGatewaySuggestions',
'Automattic\WooCommerce\Admin\API\Products',
'Automattic\WooCommerce\Admin\API\ProductAttributes',
'Automattic\WooCommerce\Admin\API\ProductAttributeTerms',
'Automattic\WooCommerce\Admin\API\ProductCategories',
'Automattic\WooCommerce\Admin\API\ProductVariations',
'Automattic\WooCommerce\Admin\API\ProductReviews',
'Automattic\WooCommerce\Admin\API\ProductVariations',
'Automattic\WooCommerce\Admin\API\ProductsLowInStock',
'Automattic\WooCommerce\Admin\API\SettingOptions',
'Automattic\WooCommerce\Admin\API\Themes',
'Automattic\WooCommerce\Admin\API\Plugins',
'Automattic\WooCommerce\Admin\API\OnboardingFreeExtensions',
'Automattic\WooCommerce\Admin\API\OnboardingProductTypes',
'Automattic\WooCommerce\Admin\API\OnboardingProfile',
'Automattic\WooCommerce\Admin\API\OnboardingTasks',
'Automattic\WooCommerce\Admin\API\OnboardingThemes',
'Automattic\WooCommerce\Admin\API\OnboardingPlugins',
'Automattic\WooCommerce\Admin\API\NavigationFavorites',
'Automattic\WooCommerce\Admin\API\Taxes',
'Automattic\WooCommerce\Admin\API\MobileAppMagicLink',
'Automattic\WooCommerce\Admin\API\ShippingPartnerSuggestions',
);
$product_form_controllers = array();
if ( Features::is_enabled( 'new-product-management-experience' ) ) {
$product_form_controllers[] = 'Automattic\WooCommerce\Admin\API\ProductForm';
}
if ( Features::is_enabled( 'analytics' ) ) {
$analytics_controllers = array(
'Automattic\WooCommerce\Admin\API\Customers',
'Automattic\WooCommerce\Admin\API\Leaderboards',
'Automattic\WooCommerce\Admin\API\Reports\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Import\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Export\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Products\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Categories\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Coupons\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Stock\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Downloads\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Customers\Controller',
'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Controller',
);
// The performance indicators controller must be registered last, after other /stats endpoints have been registered.
$analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators\Controller';
$controllers = array_merge( $controllers, $analytics_controllers, $product_form_controllers );
}
/**
* Filter for the WooCommerce Admin REST controllers.
*
* @since 3.5.0
* @param array $controllers List of rest API controllers.
*/
$controllers = apply_filters( 'woocommerce_admin_rest_controllers', $controllers );
foreach ( $controllers as $controller ) {
$this->$controller = new $controller();
$this->$controller->register_routes();
}
}
/**
* Adds data stores.
*
* @internal
* @param array $data_stores List of data stores.
* @return array
*/
public static function add_data_stores( $data_stores ) {
return array_merge(
$data_stores,
array(
'report-revenue-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
'report-orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore',
'report-orders-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
'report-products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\DataStore',
'report-variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore',
'report-products-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore',
'report-variations-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\DataStore',
'report-categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore',
'report-taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore',
'report-taxes-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore',
'report-coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore',
'report-coupons-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore',
'report-downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore',
'report-downloads-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\DataStore',
'admin-note' => 'Automattic\WooCommerce\Admin\Notes\DataStore',
'report-customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore',
'report-customers-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore',
'report-stock-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\DataStore',
)
);
}
/**
* Add the currency symbol (in addition to currency code) to each Order
* object in REST API responses. For use in formatAmount().
*
* @internal
* @param {WP_REST_Response} $response REST response object.
* @returns {WP_REST_Response}
*/
public static function add_currency_symbol_to_order_response( $response ) {
$response_data = $response->get_data();
$currency_code = $response_data['currency'];
$currency_symbol = get_woocommerce_currency_symbol( $currency_code );
$response_data['currency_symbol'] = html_entity_decode( $currency_symbol );
$response->set_data( $response_data );
return $response;
}
}
Admin/API/Leaderboards.php 0000644 00000043317 15153704476 0011331 0 ustar 00 <?php
/**
* REST API Leaderboards Controller
*
* Handles requests to /leaderboards
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore as CategoriesDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
/**
* Leaderboards controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Leaderboards extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'leaderboards';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/allowed',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_allowed_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_allowed_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<leaderboard>\w+)',
array(
'args' => array(
'leaderboard' => array(
'type' => 'string',
'enum' => array( 'customers', 'coupons', 'categories', 'products' ),
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get the data for the coupons leaderboard.
*
* @param int $per_page Number of rows.
* @param string $after Items after date.
* @param string $before Items before date.
* @param string $persisted_query URL query string.
*/
protected function get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ) {
$coupons_data_store = new CouponsDataStore();
$coupons_data = $per_page > 0 ? $coupons_data_store->get_data(
apply_filters(
'woocommerce_analytics_coupons_query_args',
array(
'orderby' => 'orders_count',
'order' => 'desc',
'after' => $after,
'before' => $before,
'per_page' => $per_page,
'extended_info' => true,
)
)
)->data : array();
$rows = array();
foreach ( $coupons_data as $coupon ) {
$url_query = wp_parse_args(
array(
'filter' => 'single_coupon',
'coupons' => $coupon['coupon_id'],
),
$persisted_query
);
$coupon_url = wc_admin_url( '/analytics/coupons', $url_query );
$coupon_code = isset( $coupon['extended_info'] ) && isset( $coupon['extended_info']['code'] ) ? $coupon['extended_info']['code'] : '';
$rows[] = array(
array(
'display' => "<a href='{$coupon_url}'>{$coupon_code}</a>",
'value' => $coupon_code,
),
array(
'display' => wc_admin_number_format( $coupon['orders_count'] ),
'value' => $coupon['orders_count'],
),
array(
'display' => wc_price( $coupon['amount'] ),
'value' => $coupon['amount'],
),
);
}
return array(
'id' => 'coupons',
'label' => __( 'Top Coupons - Number of Orders', 'woocommerce' ),
'headers' => array(
array(
'label' => __( 'Coupon code', 'woocommerce' ),
),
array(
'label' => __( 'Orders', 'woocommerce' ),
),
array(
'label' => __( 'Amount discounted', 'woocommerce' ),
),
),
'rows' => $rows,
);
}
/**
* Get the data for the categories leaderboard.
*
* @param int $per_page Number of rows.
* @param string $after Items after date.
* @param string $before Items before date.
* @param string $persisted_query URL query string.
*/
protected function get_categories_leaderboard( $per_page, $after, $before, $persisted_query ) {
$categories_data_store = new CategoriesDataStore();
$categories_data = $per_page > 0 ? $categories_data_store->get_data(
apply_filters(
'woocommerce_analytics_categories_query_args',
array(
'orderby' => 'items_sold',
'order' => 'desc',
'after' => $after,
'before' => $before,
'per_page' => $per_page,
'extended_info' => true,
)
)
)->data : array();
$rows = array();
foreach ( $categories_data as $category ) {
$url_query = wp_parse_args(
array(
'filter' => 'single_category',
'categories' => $category['category_id'],
),
$persisted_query
);
$category_url = wc_admin_url( '/analytics/categories', $url_query );
$category_name = isset( $category['extended_info'] ) && isset( $category['extended_info']['name'] ) ? $category['extended_info']['name'] : '';
$rows[] = array(
array(
'display' => "<a href='{$category_url}'>{$category_name}</a>",
'value' => $category_name,
),
array(
'display' => wc_admin_number_format( $category['items_sold'] ),
'value' => $category['items_sold'],
),
array(
'display' => wc_price( $category['net_revenue'] ),
'value' => $category['net_revenue'],
),
);
}
return array(
'id' => 'categories',
'label' => __( 'Top categories - Items sold', 'woocommerce' ),
'headers' => array(
array(
'label' => __( 'Category', 'woocommerce' ),
),
array(
'label' => __( 'Items sold', 'woocommerce' ),
),
array(
'label' => __( 'Net sales', 'woocommerce' ),
),
),
'rows' => $rows,
);
}
/**
* Get the data for the customers leaderboard.
*
* @param int $per_page Number of rows.
* @param string $after Items after date.
* @param string $before Items before date.
* @param string $persisted_query URL query string.
*/
protected function get_customers_leaderboard( $per_page, $after, $before, $persisted_query ) {
$customers_data_store = new CustomersDataStore();
$customers_data = $per_page > 0 ? $customers_data_store->get_data(
apply_filters(
'woocommerce_analytics_customers_query_args',
array(
'orderby' => 'total_spend',
'order' => 'desc',
'order_after' => $after,
'order_before' => $before,
'per_page' => $per_page,
)
)
)->data : array();
$rows = array();
foreach ( $customers_data as $customer ) {
$url_query = wp_parse_args(
array(
'filter' => 'single_customer',
'customers' => $customer['id'],
),
$persisted_query
);
$customer_url = wc_admin_url( '/analytics/customers', $url_query );
$rows[] = array(
array(
'display' => "<a href='{$customer_url}'>{$customer['name']}</a>",
'value' => $customer['name'],
),
array(
'display' => wc_admin_number_format( $customer['orders_count'] ),
'value' => $customer['orders_count'],
),
array(
'display' => wc_price( $customer['total_spend'] ),
'value' => $customer['total_spend'],
),
);
}
return array(
'id' => 'customers',
'label' => __( 'Top Customers - Total Spend', 'woocommerce' ),
'headers' => array(
array(
'label' => __( 'Customer Name', 'woocommerce' ),
),
array(
'label' => __( 'Orders', 'woocommerce' ),
),
array(
'label' => __( 'Total Spend', 'woocommerce' ),
),
),
'rows' => $rows,
);
}
/**
* Get the data for the products leaderboard.
*
* @param int $per_page Number of rows.
* @param string $after Items after date.
* @param string $before Items before date.
* @param string $persisted_query URL query string.
*/
protected function get_products_leaderboard( $per_page, $after, $before, $persisted_query ) {
$products_data_store = new ProductsDataStore();
$products_data = $per_page > 0 ? $products_data_store->get_data(
apply_filters(
'woocommerce_analytics_products_query_args',
array(
'orderby' => 'items_sold',
'order' => 'desc',
'after' => $after,
'before' => $before,
'per_page' => $per_page,
'extended_info' => true,
)
)
)->data : array();
$rows = array();
foreach ( $products_data as $product ) {
$url_query = wp_parse_args(
array(
'filter' => 'single_product',
'products' => $product['product_id'],
),
$persisted_query
);
$product_url = wc_admin_url( '/analytics/products', $url_query );
$product_name = isset( $product['extended_info'] ) && isset( $product['extended_info']['name'] ) ? $product['extended_info']['name'] : '';
$rows[] = array(
array(
'display' => "<a href='{$product_url}'>{$product_name}</a>",
'value' => $product_name,
),
array(
'display' => wc_admin_number_format( $product['items_sold'] ),
'value' => $product['items_sold'],
),
array(
'display' => wc_price( $product['net_revenue'] ),
'value' => $product['net_revenue'],
),
);
}
return array(
'id' => 'products',
'label' => __( 'Top products - Items sold', 'woocommerce' ),
'headers' => array(
array(
'label' => __( 'Product', 'woocommerce' ),
),
array(
'label' => __( 'Items sold', 'woocommerce' ),
),
array(
'label' => __( 'Net sales', 'woocommerce' ),
),
),
'rows' => $rows,
);
}
/**
* Get an array of all leaderboards.
*
* @param int $per_page Number of rows.
* @param string $after Items after date.
* @param string $before Items before date.
* @param string $persisted_query URL query string.
* @return array
*/
public function get_leaderboards( $per_page, $after, $before, $persisted_query ) {
$leaderboards = array(
$this->get_customers_leaderboard( $per_page, $after, $before, $persisted_query ),
$this->get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ),
$this->get_categories_leaderboard( $per_page, $after, $before, $persisted_query ),
$this->get_products_leaderboard( $per_page, $after, $before, $persisted_query ),
);
return apply_filters( 'woocommerce_leaderboards', $leaderboards, $per_page, $after, $before, $persisted_query );
}
/**
* Return all leaderboards.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
$persisted_query = json_decode( $request['persisted_query'], true );
switch ( $request['leaderboard'] ) {
case 'customers':
$leaderboards = array( $this->get_customers_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
break;
case 'coupons':
$leaderboards = array( $this->get_coupons_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
break;
case 'categories':
$leaderboards = array( $this->get_categories_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
break;
case 'products':
$leaderboards = array( $this->get_products_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
break;
default:
$leaderboards = $this->get_leaderboards( $request['per_page'], $request['after'], $request['before'], $persisted_query );
break;
}
$data = array();
if ( ! empty( $leaderboards ) ) {
foreach ( $leaderboards as $leaderboard ) {
$response = $this->prepare_item_for_response( $leaderboard, $request );
$data[] = $this->prepare_response_for_collection( $response );
}
}
return rest_ensure_response( $data );
}
/**
* Returns a list of allowed leaderboards.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_allowed_items( $request ) {
$leaderboards = $this->get_leaderboards( 0, null, null, null );
$data = array();
foreach ( $leaderboards as $leaderboard ) {
$data[] = (object) array(
'id' => $leaderboard['id'],
'label' => $leaderboard['label'],
'headers' => $leaderboard['headers'],
);
}
$objects = array();
foreach ( $data as $item ) {
$prepared = $this->prepare_item_for_response( $item, $request );
$objects[] = $this->prepare_response_for_collection( $prepared );
}
$response = rest_ensure_response( $objects );
$response->header( 'X-WP-Total', count( $data ) );
$response->header( 'X-WP-TotalPages', 1 );
$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
return $response;
}
/**
* Prepare the data object for response.
*
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
/**
* Filter the list returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_leaderboard', $response, $item, $request );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 5,
'minimum' => 1,
'maximum' => 20,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['persisted_query'] = array(
'description' => __( 'URL query to persist across links.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'leaderboard',
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'string',
'description' => __( 'Leaderboard ID.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
),
'label' => array(
'type' => 'string',
'description' => __( 'Displayed title for the leaderboard.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
),
'headers' => array(
'type' => 'array',
'description' => __( 'Table headers.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'items' => array(
'type' => 'array',
'properties' => array(
'label' => array(
'description' => __( 'Table column header.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
),
),
'rows' => array(
'type' => 'array',
'description' => __( 'Table rows.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'items' => array(
'type' => 'array',
'properties' => array(
'display' => array(
'description' => __( 'Table cell display.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'value' => array(
'description' => __( 'Table cell value.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get schema for the list of allowed leaderboards.
*
* @return array $schema
*/
public function get_public_allowed_item_schema() {
$schema = $this->get_public_item_schema();
unset( $schema['properties']['rows'] );
return $schema;
}
}
Admin/API/Marketing.php 0000644 00000010175 15153704476 0010657 0 ustar 00 <?php
/**
* REST API Marketing Controller
*
* Handles requests to /marketing.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs;
defined( 'ABSPATH' ) || exit;
/**
* Marketing Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Marketing extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'marketing';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/recommended',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_recommended_plugins' ),
'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ),
'args' => array(
'per_page' => $this->get_collection_params()['per_page'],
'category' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_title_with_dashes',
),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/knowledge-base',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_knowledge_base_posts' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => array(
'category' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_title_with_dashes',
),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to install plugins.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_recommended_plugins_permissions_check( $request ) {
if ( ! current_user_can( 'install_plugins' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return installed marketing extensions data.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_recommended_plugins( $request ) {
/**
* MarketingSpecs class.
*
* @var MarketingSpecs $marketing_specs
*/
$marketing_specs = wc_get_container()->get( MarketingSpecs::class );
// Default to marketing category (if no category set).
$category = ( ! empty( $request->get_param( 'category' ) ) ) ? $request->get_param( 'category' ) : 'marketing';
$all_plugins = $marketing_specs->get_recommended_plugins();
$valid_plugins = [];
$per_page = $request->get_param( 'per_page' );
foreach ( $all_plugins as $plugin ) {
// default to marketing if 'categories' is empty on the plugin object (support for legacy api while testing).
$plugin_categories = ( ! empty( $plugin['categories'] ) ) ? $plugin['categories'] : [ 'marketing' ];
if ( ! PluginsHelper::is_plugin_installed( $plugin['plugin'] ) && in_array( $category, $plugin_categories, true ) ) {
$valid_plugins[] = $plugin;
}
}
return rest_ensure_response( array_slice( $valid_plugins, 0, $per_page ) );
}
/**
* Return installed marketing extensions data.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_knowledge_base_posts( $request ) {
/**
* MarketingSpecs class.
*
* @var MarketingSpecs $marketing_specs
*/
$marketing_specs = wc_get_container()->get( MarketingSpecs::class );
$category = $request->get_param( 'category' );
return rest_ensure_response( $marketing_specs->get_knowledge_base_posts( $category ) );
}
}
Admin/API/MarketingCampaignTypes.php 0000644 00000014020 15153704476 0013335 0 ustar 00 <?php
/**
* REST API MarketingCampaignTypes Controller
*
* Handles requests to /marketing/campaign-types.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Marketing\MarketingCampaignType;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService;
use WC_REST_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
/**
* MarketingCampaignTypes Controller.
*
* @internal
* @extends WC_REST_Controller
* @since x.x.x
*/
class MarketingCampaignTypes extends WC_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'marketing/campaign-types';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Retrieves the query params for the collections.
*
* @return array Query parameters for the collection.
*/
public function get_collection_params() {
$params = parent::get_collection_params();
unset( $params['search'] );
return $params;
}
/**
* Check whether a given request has permission to view marketing campaigns.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Returns an aggregated array of marketing campaigns for all active marketing channels.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
/**
* MarketingChannels class.
*
* @var MarketingChannelsService $marketing_channels_service
*/
$marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class );
// Aggregate the supported campaign types from all registered marketing channels.
$responses = [];
foreach ( $marketing_channels_service->get_registered_channels() as $channel ) {
foreach ( $channel->get_supported_campaign_types() as $campaign_type ) {
$response = $this->prepare_item_for_response( $campaign_type, $request );
$responses[] = $this->prepare_response_for_collection( $response );
}
}
return rest_ensure_response( $responses );
}
/**
* Prepares the item for the REST response.
*
* @param MarketingCampaignType $item WordPress representation of the item.
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) {
$data = [
'id' => $item->get_id(),
'name' => $item->get_name(),
'description' => $item->get_description(),
'channel' => [
'slug' => $item->get_channel()->get_slug(),
'name' => $item->get_channel()->get_name(),
],
'create_url' => $item->get_create_url(),
'icon_url' => $item->get_icon_url(),
];
$context = $request['context'] ?? 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the item's schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'marketing_campaign_type',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The unique identifier for the marketing campaign type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the marketing campaign type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'description' => [
'description' => __( 'Description of the marketing campaign type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'channel' => [
'description' => __( 'The marketing channel that this campaign type belongs to.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view' ],
'readonly' => true,
'properties' => [
'slug' => [
'description' => __( 'The unique identifier of the marketing channel that this campaign type belongs to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The name of the marketing channel that this campaign type belongs to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
],
],
'create_url' => [
'description' => __( 'URL to the create campaign page for this campaign type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'icon_url' => [
'description' => __( 'URL to an image/icon for the campaign type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
],
];
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/MarketingCampaigns.php 0000644 00000015256 15153704476 0012507 0 ustar 00 <?php
/**
* REST API MarketingCampaigns Controller
*
* Handles requests to /marketing/campaigns.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Marketing\MarketingCampaign;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService;
use Automattic\WooCommerce\Admin\Marketing\Price;
use WC_REST_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
/**
* MarketingCampaigns Controller.
*
* @internal
* @extends WC_REST_Controller
* @since x.x.x
*/
class MarketingCampaigns extends WC_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'marketing/campaigns';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to view marketing campaigns.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Returns an aggregated array of marketing campaigns for all active marketing channels.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
/**
* MarketingChannels class.
*
* @var MarketingChannelsService $marketing_channels_service
*/
$marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class );
// Aggregate the campaigns from all registered marketing channels.
$responses = [];
foreach ( $marketing_channels_service->get_registered_channels() as $channel ) {
foreach ( $channel->get_campaigns() as $campaign ) {
$response = $this->prepare_item_for_response( $campaign, $request );
$responses[] = $this->prepare_response_for_collection( $response );
}
}
// Pagination.
$page = $request['page'];
$items_per_page = $request['per_page'];
$offset = ( $page - 1 ) * $items_per_page;
$paginated_results = array_slice( $responses, $offset, $items_per_page );
$response = rest_ensure_response( $paginated_results );
$total_campaigns = count( $responses );
$max_pages = ceil( $total_campaigns / $items_per_page );
$response->header( 'X-WP-Total', $total_campaigns );
$response->header( 'X-WP-TotalPages', (int) $max_pages );
// Add previous and next page links to response header.
$request_params = $request->get_query_params();
$base = add_query_arg( urlencode_deep( $request_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
if ( $page > 1 ) {
$prev_page = $page - 1;
if ( $prev_page > $max_pages ) {
$prev_page = $max_pages;
}
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $max_pages > $page ) {
$next_page = $page + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
/**
* Prepares the item for the REST response.
*
* @param MarketingCampaign $item WordPress representation of the item.
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) {
$data = [
'id' => $item->get_id(),
'channel' => $item->get_type()->get_channel()->get_slug(),
'title' => $item->get_title(),
'manage_url' => $item->get_manage_url(),
];
if ( $item->get_cost() instanceof Price ) {
$data['cost'] = [
'value' => wc_format_decimal( $item->get_cost()->get_value() ),
'currency' => $item->get_cost()->get_currency(),
];
}
$context = $request['context'] ?? 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the item's schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'marketing_campaign',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The unique identifier for the marketing campaign.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'channel' => [
'description' => __( 'The unique identifier for the marketing channel that this campaign belongs to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'title' => [
'description' => __( 'Title of the marketing campaign.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'manage_url' => [
'description' => __( 'URL to the campaign management page.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'cost' => [
'description' => __( 'Cost of the marketing campaign.', 'woocommerce' ),
'context' => [ 'view' ],
'readonly' => true,
'type' => 'object',
'properties' => [
'value' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'currency' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
],
],
],
];
return $this->add_additional_fields_schema( $schema );
}
/**
* Retrieves the query params for the collections.
*
* @return array Query parameters for the collection.
*/
public function get_collection_params() {
$params = parent::get_collection_params();
unset( $params['search'] );
return $params;
}
}
Admin/API/MarketingChannels.php 0000644 00000013366 15153704476 0012340 0 ustar 00 <?php
/**
* REST API MarketingChannels Controller
*
* Handles requests to /marketing/channels.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannelInterface;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService;
use WC_REST_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
/**
* MarketingChannels Controller.
*
* @internal
* @extends WC_REST_Controller
* @since x.x.x
*/
class MarketingChannels extends WC_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'marketing/channels';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to view marketing channels.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return installed marketing channels.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
/**
* MarketingChannels class.
*
* @var MarketingChannelsService $marketing_channels_service
*/
$marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class );
$channels = $marketing_channels_service->get_registered_channels();
$responses = [];
foreach ( $channels as $item ) {
$response = $this->prepare_item_for_response( $item, $request );
$responses[] = $this->prepare_response_for_collection( $response );
}
return rest_ensure_response( $responses );
}
/**
* Prepares the item for the REST response.
*
* @param MarketingChannelInterface $item WordPress representation of the item.
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) {
$data = [
'slug' => $item->get_slug(),
'is_setup_completed' => $item->is_setup_completed(),
'settings_url' => $item->get_setup_url(),
'name' => $item->get_name(),
'description' => $item->get_description(),
'product_listings_status' => $item->get_product_listings_status(),
'errors_count' => $item->get_errors_count(),
'icon' => $item->get_icon_url(),
];
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the item's schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'marketing_channel',
'type' => 'object',
'properties' => [
'slug' => [
'description' => __( 'Unique identifier string for the marketing channel extension, also known as the plugin slug.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the marketing channel.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'description' => [
'description' => __( 'Description of the marketing channel.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'icon' => [
'description' => __( 'Path to the channel icon.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'is_setup_completed' => [
'type' => 'boolean',
'description' => __( 'Whether or not the marketing channel is set up.', 'woocommerce' ),
'context' => [ 'view' ],
'readonly' => true,
],
'settings_url' => [
'description' => __( 'URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'product_listings_status' => [
'description' => __( 'Status of the marketing channel\'s product listings.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'errors_count' => [
'description' => __( 'Number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.).', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
],
];
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/MarketingOverview.php 0000644 00000006563 15153704476 0012414 0 ustar 00 <?php
/**
* REST API Marketing Overview Controller
*
* Handles requests to /marketing/overview.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions;
use Automattic\WooCommerce\Admin\PluginsHelper;
defined( 'ABSPATH' ) || exit;
/**
* Marketing Overview Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class MarketingOverview extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'marketing/overview';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/activate-plugin',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'activate_plugin' ),
'permission_callback' => array( $this, 'install_plugins_permissions_check' ),
'args' => array(
'plugin' => array(
'required' => true,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_title_with_dashes',
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/installed-plugins',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_installed_plugins' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Return installed marketing extensions data.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function activate_plugin( $request ) {
$plugin_slug = $request->get_param( 'plugin' );
if ( ! PluginsHelper::is_plugin_installed( $plugin_slug ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'Invalid plugin.', 'woocommerce' ), 404 );
}
$result = activate_plugin( PluginsHelper::get_plugin_path_from_slug( $plugin_slug ) );
if ( ! is_null( $result ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'The plugin could not be activated.', 'woocommerce' ), 500 );
}
// IMPORTANT - Don't return the active plugins data here.
// Instead we will get that data in a separate request to ensure they are loaded.
return rest_ensure_response(
array(
'status' => 'success',
)
);
}
/**
* Check if a given request has access to manage plugins.
*
* @param \WP_REST_Request $request Full details about the request.
*
* @return \WP_Error|boolean
*/
public function install_plugins_permissions_check( $request ) {
if ( ! current_user_can( 'install_plugins' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return installed marketing extensions data.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_installed_plugins( $request ) {
return rest_ensure_response( InstalledExtensions::get_data() );
}
}
Admin/API/MarketingRecommendations.php 0000644 00000014046 15153704476 0013730 0 ustar 00 <?php
/**
* REST API MarketingRecommendations Controller
*
* Handles requests to /marketing/recommendations.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs;
use WC_REST_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
/**
* MarketingRecommendations Controller.
*
* @internal
* @extends WC_REST_Controller
* @since x.x.x
*/
class MarketingRecommendations extends WC_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'marketing/recommendations';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => [
'category' => [
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_title_with_dashes',
'enum' => [ 'channels', 'extensions' ],
'required' => true,
],
],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
/**
* Check whether a given request has permission to view marketing recommendations.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! current_user_can( 'install_plugins' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view marketing channels.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Retrieves a collection of recommendations.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
/**
* MarketingSpecs class.
*
* @var MarketingSpecs $marketing_specs
*/
$marketing_specs = wc_get_container()->get( MarketingSpecs::class );
$category = $request->get_param( 'category' );
if ( 'channels' === $category ) {
$items = $marketing_specs->get_recommended_marketing_channels();
} elseif ( 'extensions' === $category ) {
$items = $marketing_specs->get_recommended_marketing_extensions_excluding_channels();
} else {
return new WP_Error( 'woocommerce_rest_invalid_category', __( 'The specified category for recommendations is invalid. Allowed values: "channels", "extensions".', 'woocommerce' ), array( 'status' => 400 ) );
}
$responses = [];
foreach ( $items as $item ) {
$response = $this->prepare_item_for_response( $item, $request );
$responses[] = $this->prepare_response_for_collection( $response );
}
return rest_ensure_response( $responses );
}
/**
* Prepares the item for the REST response.
*
* @param array $item WordPress representation of the item.
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, $context );
return rest_ensure_response( $data );
}
/**
* Retrieves the item's schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
$schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'marketing_recommendation',
'type' => 'object',
'properties' => [
'title' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'description' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'url' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'direct_install' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'icon' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'product' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'plugin' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'categories' => [
'type' => 'array',
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'string',
],
],
'subcategories' => [
'type' => 'array',
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'object',
'context' => [ 'view' ],
'readonly' => true,
'properties' => [
'slug' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
],
],
],
'tags' => [
'type' => 'array',
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'object',
'context' => [ 'view' ],
'readonly' => true,
'properties' => [
'slug' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'type' => 'string',
'context' => [ 'view' ],
'readonly' => true,
],
],
],
],
],
];
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/MobileAppMagicLink.php 0000644 00000004143 15153704476 0012363 0 ustar 00 <?php
/**
* REST API Data countries controller.
*
* Handles requests to the /mobile-app endpoint.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;
/**
* REST API Data countries controller class.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class MobileAppMagicLink extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'mobile-app';
/**
* Register routes.
*
* @since 7.0.0
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/send-magic-link',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'send_magic_link' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
parent::register_routes();
}
/**
* Sends request to generate magic link email.
*
* @return \WP_REST_Response|\WP_Error
*/
public function send_magic_link() {
// Attempt to get email from Jetpack.
if ( class_exists( Jetpack_Connection_Manager::class ) ) {
$jetpack_connection_manager = new Jetpack_Connection_Manager();
if ( $jetpack_connection_manager->is_active() ) {
if ( class_exists( 'Jetpack_IXR_Client' ) ) {
$xml = new \Jetpack_IXR_Client(
array(
'user_id' => get_current_user_id(),
)
);
$xml->query( 'jetpack.sendMobileMagicLink', array( 'app' => 'woocommerce' ) );
if ( $xml->isError() ) {
return new \WP_Error(
'error_sending_mobile_magic_link',
sprintf(
'%s: %s',
$xml->getErrorCode(),
$xml->getErrorMessage()
)
);
}
return rest_ensure_response(
array(
'code' => 'success',
)
);
}
}
}
return new \WP_Error( 'jetpack_not_connected', __( 'Jetpack is not connected.', 'woocommerce' ) );
}
}
Admin/API/NavigationFavorites.php 0000644 00000011515 15153704476 0012717 0 ustar 00 <?php
/**
* REST API Navigation Favorites controller
*
* Handles requests to the navigation favorites endpoint
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Navigation\Favorites;
/**
* REST API Favorites controller class.
*
* @internal
* @extends WC_REST_CRUD_Controller
*/
class NavigationFavorites extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'navigation/favorites';
/**
* Error code to status code mapping.
*
* @var array
*/
protected $error_to_status_map = array(
'woocommerce_favorites_invalid_request' => 400,
'woocommerce_favorites_already_exists' => 409,
'woocommerce_favorites_does_not_exist' => 404,
'woocommerce_favorites_invalid_user' => 400,
'woocommerce_favorites_unauthenticated' => 401,
);
/**
* Register the routes
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/me',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'current_user_permissions_check' ),
),
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'add_item' ),
'permission_callback' => array( $this, 'current_user_permissions_check' ),
'args' => array(
'item_id' => array(
'required' => true,
),
),
),
array(
'methods' => \WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'current_user_permissions_check' ),
'args' => array(
'item_id' => array(
'required' => true,
),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get all favorites.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response
*/
public function get_items( $request ) {
$response = Favorites::get_all( get_current_user_id() );
if ( is_wp_error( $response ) || ! $response ) {
return rest_ensure_response( $this->prepare_error( $response ) );
}
return rest_ensure_response(
array_map( 'stripslashes', $response )
);
}
/**
* Add a favorite.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response
*/
public function add_item( $request ) {
$user_id = get_current_user_id();
$fav_id = $request->get_param( 'item_id' );
$user = get_userdata( $user_id );
if ( false === $user ) {
return $this->prepare_error(
new \WP_Error(
'woocommerce_favorites_invalid_user',
__( 'Invalid user_id provided', 'woocommerce' )
)
);
}
$response = Favorites::add_item( $fav_id, $user_id );
if ( is_wp_error( $response ) || ! $response ) {
return rest_ensure_response( $this->prepare_error( $response ) );
}
return rest_ensure_response( Favorites::get_all( $user_id ) );
}
/**
* Delete a favorite.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response
*/
public function delete_item( $request ) {
$user_id = get_current_user_id();
$fav_id = $request->get_param( 'item_id' );
$response = Favorites::remove_item( $fav_id, $user_id );
if ( is_wp_error( $response ) || ! $response ) {
return rest_ensure_response( $this->prepare_error( $response ) );
}
return rest_ensure_response( Favorites::get_all( $user_id ) );
}
/**
* Check whether a given request has permission to create favorites.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function add_item_permissions_check( $request ) {
return current_user_can( 'edit_users' );
}
/**
* Check whether a given request has permission to delete notes.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function delete_item_permissions_check( $request ) {
return current_user_can( 'edit_users' );
}
/**
* Always allow for operations that only impact current user
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function current_user_permissions_check( $request ) {
return true;
}
/**
* Accept an instance of WP_Error and add the appropriate data for REST transit.
*
* @param WP_Error $error Error to prepare.
* @return WP_Error
*/
protected function prepare_error( $error ) {
if ( ! is_wp_error( $error ) ) {
return $error;
}
$error->add_data(
array(
'status' => $this->error_to_status_map[ $error->get_error_code() ] ?? 500,
)
);
return $error;
}
}
Admin/API/NoteActions.php 0000644 00000004621 15153704476 0011163 0 ustar 00 <?php
/**
* REST API Admin Note Action controller
*
* Handles requests to the admin note action endpoint.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes as NotesFactory;
/**
* REST API Admin Note Action controller class.
*
* @internal
* @extends WC_REST_CRUD_Controller
*/
class NoteActions extends Notes {
/**
* Register the routes for admin notes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<note_id>[\d-]+)/action/(?P<action_id>[\d-]+)',
array(
'args' => array(
'note_id' => array(
'description' => __( 'Unique ID for the Note.', 'woocommerce' ),
'type' => 'integer',
),
'action_id' => array(
'description' => __( 'Unique ID for the Note Action.', 'woocommerce' ),
'type' => 'integer',
),
),
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'trigger_note_action' ),
// @todo - double check these permissions for taking note actions.
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Trigger a note action.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function trigger_note_action( $request ) {
$note = NotesFactory::get_note( $request->get_param( 'note_id' ) );
if ( ! $note ) {
return new \WP_Error(
'woocommerce_note_invalid_id',
__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
array( 'status' => 404 )
);
}
$note->set_is_read( true );
$note->save();
$triggered_action = NotesFactory::get_action_by_id( $note, $request->get_param( 'action_id' ) );
if ( ! $triggered_action ) {
return new \WP_Error(
'woocommerce_note_action_invalid_id',
__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
array( 'status' => 404 )
);
}
$triggered_note = NotesFactory::trigger_note_action( $note, $triggered_action );
$data = $triggered_note->get_data();
$data = $this->prepare_item_for_response( $data, $request );
$data = $this->prepare_response_for_collection( $data );
return rest_ensure_response( $data );
}
}
Admin/API/Notes.php 0000644 00000063453 15153704476 0010035 0 ustar 00 <?php
/**
* REST API Admin Notes controller
*
* Handles requests to the admin notes endpoint.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes as NotesRepository;
/**
* REST API Admin Notes controller class.
*
* @internal
* @extends WC_REST_CRUD_Controller
*/
class Notes extends \WC_REST_CRUD_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'admin/notes';
/**
* Register the routes for admin notes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d-]+)',
array(
'args' => array(
'id' => array(
'description' => __( 'Unique ID for the resource.', 'woocommerce' ),
'type' => 'integer',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'update_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/delete/(?P<id>[\d-]+)',
array(
array(
'methods' => \WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'update_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/delete/all',
array(
array(
'methods' => \WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_all_items' ),
'permission_callback' => array( $this, 'update_items_permissions_check' ),
'args' => array(
'status' => array(
'description' => __( 'Status of note.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => Note::get_allowed_statuses(),
'type' => 'string',
),
),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/tracker/(?P<note_id>[\d-]+)/user/(?P<user_id>[\d-]+)',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'track_opened_email' ),
'permission_callback' => '__return_true',
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/update',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'batch_update_items' ),
'permission_callback' => array( $this, 'update_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/experimental-activate-promo/(?P<promo_note_name>[\w-]+)',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'activate_promo_note' ),
'permission_callback' => array( $this, 'update_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get a single note.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response|WP_Error
*/
public function get_item( $request ) {
$note = NotesRepository::get_note( $request->get_param( 'id' ) );
if ( ! $note ) {
return new \WP_Error(
'woocommerce_note_invalid_id',
__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
array( 'status' => 404 )
);
}
if ( is_wp_error( $note ) ) {
return $note;
}
$data = $this->prepare_note_data_for_response( $note, $request );
return rest_ensure_response( $data );
}
/**
* Get all notes.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response
*/
public function get_items( $request ) {
$query_args = $this->prepare_objects_query( $request );
$notes = NotesRepository::get_notes( 'edit', $query_args );
$data = array();
foreach ( (array) $notes as $note_obj ) {
$note = $this->prepare_item_for_response( $note_obj, $request );
$note = $this->prepare_response_for_collection( $note );
$data[] = $note;
}
$response = rest_ensure_response( $data );
$response->header( 'X-WP-Total', count( $data ) );
return $response;
}
/**
* Checks if user is in tasklist experiment.
*
* @return bool Whether remote inbox notifications are enabled.
*/
private function is_tasklist_experiment_assigned_treatment() {
$anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : '';
$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking' );
$abtest = new \WooCommerce\Admin\Experimental_Abtest(
$anon_id,
'woocommerce',
$allow_tracking
);
$date = new \DateTime();
$date->setTimeZone( new \DateTimeZone( 'UTC' ) );
$experiment_name = sprintf(
'woocommerce_tasklist_progression_headercard_%s_%s',
$date->format( 'Y' ),
$date->format( 'm' )
);
$experiment_name_2col = sprintf(
'woocommerce_tasklist_progression_headercard_2col_%s_%s',
$date->format( 'Y' ),
$date->format( 'm' )
);
return $abtest->get_variation( $experiment_name ) === 'treatment' ||
$abtest->get_variation( $experiment_name_2col ) === 'treatment';
}
/**
* Prepare objects query.
*
* @param WP_REST_Request $request Full details about the request.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = array();
$args['order'] = $request['order'];
$args['orderby'] = $request['orderby'];
$args['per_page'] = $request['per_page'];
$args['page'] = $request['page'];
$args['type'] = isset( $request['type'] ) ? $request['type'] : array();
$args['status'] = isset( $request['status'] ) ? $request['status'] : array();
$args['source'] = isset( $request['source'] ) ? $request['source'] : array();
$args['is_deleted'] = 0;
if ( isset( $request['is_read'] ) ) {
$args['is_read'] = filter_var( $request['is_read'], FILTER_VALIDATE_BOOLEAN );
}
if ( 'date' === $args['orderby'] ) {
$args['orderby'] = 'date_created';
}
/**
* Filter the query arguments for a request.
*
* Enables adding extra arguments or setting defaults for a post
* collection request.
*
* @param array $args Key value array of query var to query value.
* @param WP_REST_Request $request The request used.
* @since 3.9.0
*/
$args = apply_filters( 'woocommerce_rest_notes_object_query', $args, $request );
return $args;
}
/**
* Check whether a given request has permission to read a single note.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_item_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check whether a given request has permission to read notes.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Update a single note.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function update_item( $request ) {
$note = NotesRepository::get_note( $request->get_param( 'id' ) );
if ( ! $note ) {
return new \WP_Error(
'woocommerce_note_invalid_id',
__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
array( 'status' => 404 )
);
}
NotesRepository::update_note( $note, $this->get_requested_updates( $request ) );
return $this->get_item( $request );
}
/**
* Delete a single note.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function delete_item( $request ) {
$note = NotesRepository::get_note( $request->get_param( 'id' ) );
if ( ! $note ) {
return new \WP_Error(
'woocommerce_note_invalid_id',
__( 'Sorry, there is no note with that ID.', 'woocommerce' ),
array( 'status' => 404 )
);
}
NotesRepository::delete_note( $note );
$data = $this->prepare_note_data_for_response( $note, $request );
return rest_ensure_response( $data );
}
/**
* Delete all notes.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Request|WP_Error
*/
public function delete_all_items( $request ) {
$args = array();
if ( isset( $request['status'] ) ) {
$args['status'] = $request['status'];
}
$notes = NotesRepository::delete_all_notes( $args );
$data = array();
foreach ( (array) $notes as $note_obj ) {
$data[] = $this->prepare_note_data_for_response( $note_obj, $request );
}
$response = rest_ensure_response( $data );
$response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) );
return $response;
}
/**
* Prepare note data.
*
* @param Note $note Note data.
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response $response Response data.
*/
public function prepare_note_data_for_response( $note, $request ) {
$note = $note->get_data();
$note = $this->prepare_item_for_response( $note, $request );
return $this->prepare_response_for_collection( $note );
}
/**
* Prepare an array with the the requested updates.
*
* @param WP_REST_Request $request Request object.
* @return array A list of the requested updates values.
*/
protected function get_requested_updates( $request ) {
$requested_updates = array();
if ( ! is_null( $request->get_param( 'status' ) ) ) {
$requested_updates['status'] = $request->get_param( 'status' );
}
if ( ! is_null( $request->get_param( 'date_reminder' ) ) ) {
$requested_updates['date_reminder'] = $request->get_param( 'date_reminder' );
}
if ( ! is_null( $request->get_param( 'is_deleted' ) ) ) {
$requested_updates['is_deleted'] = $request->get_param( 'is_deleted' );
}
if ( ! is_null( $request->get_param( 'is_read' ) ) ) {
$requested_updates['is_read'] = $request->get_param( 'is_read' );
}
return $requested_updates;
}
/**
* Batch update a set of notes.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Request|WP_Error
*/
public function batch_update_items( $request ) {
$data = array();
$note_ids = $request->get_param( 'noteIds' );
if ( ! isset( $note_ids ) || ! is_array( $note_ids ) ) {
return new \WP_Error(
'woocommerce_note_invalid_ids',
__( 'Please provide an array of IDs through the noteIds param.', 'woocommerce' ),
array( 'status' => 422 )
);
}
foreach ( (array) $note_ids as $note_id ) {
$note = NotesRepository::get_note( (int) $note_id );
if ( $note ) {
NotesRepository::update_note( $note, $this->get_requested_updates( $request ) );
$data[] = $this->prepare_note_data_for_response( $note, $request );
}
}
$response = rest_ensure_response( $data );
$response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) );
return $response;
}
/**
* Activate a promo note, create if not exist.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Request|WP_Error
*/
public function activate_promo_note( $request ) {
/**
* Filter allowed promo notes for experimental-activate-promo.
*
* @param array $promo_notes Array of allowed promo notes.
* @since 7.8.0
*/
$allowed_promo_notes = apply_filters( 'woocommerce_admin_allowed_promo_notes', [] );
$promo_note_name = $request->get_param( 'promo_note_name' );
if ( ! in_array( $promo_note_name, $allowed_promo_notes, true ) ) {
return new \WP_Error(
'woocommerce_note_invalid_promo_note_name',
__( 'Please provide a valid promo note name.', 'woocommerce' ),
array( 'status' => 422 )
);
}
$data_store = NotesRepository::load_data_store();
$note_ids = $data_store->get_notes_with_name( $promo_note_name );
if ( empty( $note_ids ) ) {
// Promo note doesn't exist, this could happen in cases where
// user might have disabled RemoteInboxNotications via disabling
// marketing suggestions. Thus we'd have to manually add the note.
$note = new Note();
$note->set_name( $promo_note_name );
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$data_store->create( $note );
} else {
$note = NotesRepository::get_note( $note_ids[0] );
NotesRepository::update_note(
$note,
[
'status' => Note::E_WC_ADMIN_NOTE_ACTIONED,
]
);
}
return rest_ensure_response(
array(
'success' => true,
)
);
}
/**
* Makes sure the current user has access to WRITE the settings APIs.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_Error|bool
*/
public function update_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Prepare a path or query for serialization to the client.
*
* @param string $query The query, path, or URL to transform.
* @return string A fully formed URL.
*/
public function prepare_query_for_response( $query ) {
if ( empty( $query ) ) {
return $query;
}
if ( 'https://' === substr( $query, 0, 8 ) ) {
return $query;
}
if ( 'http://' === substr( $query, 0, 7 ) ) {
return $query;
}
if ( '?' === substr( $query, 0, 1 ) ) {
return admin_url( 'admin.php' . $query );
}
return admin_url( $query );
}
/**
* Maybe add a nonce to a URL.
*
* @link https://codex.wordpress.org/WordPress_Nonces
*
* @param string $url The URL needing a nonce.
* @param string $action The nonce action.
* @param string $name The nonce anme.
* @return string A fully formed URL.
*/
private function maybe_add_nonce_to_url( string $url, string $action = '', string $name = '' ) : string {
if ( empty( $action ) ) {
return $url;
}
if ( empty( $name ) ) {
// Default paramater name.
$name = '_wpnonce';
}
return add_query_arg( $name, wp_create_nonce( $action ), $url );
}
/**
* Prepare a note object for serialization.
*
* @param array $data Note data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $data, $request ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data['date_created_gmt'] = wc_rest_prepare_date_response( $data['date_created'] );
$data['date_created'] = wc_rest_prepare_date_response( $data['date_created'], false );
$data['date_reminder_gmt'] = wc_rest_prepare_date_response( $data['date_reminder'] );
$data['date_reminder'] = wc_rest_prepare_date_response( $data['date_reminder'], false );
$data['title'] = stripslashes( $data['title'] );
$data['content'] = stripslashes( $data['content'] );
$data['is_snoozable'] = (bool) $data['is_snoozable'];
$data['is_deleted'] = (bool) $data['is_deleted'];
$data['is_read'] = (bool) $data['is_read'];
foreach ( (array) $data['actions'] as $key => $value ) {
$data['actions'][ $key ]->label = stripslashes( $data['actions'][ $key ]->label );
$data['actions'][ $key ]->url = $this->maybe_add_nonce_to_url(
$this->prepare_query_for_response( $data['actions'][ $key ]->query ),
(string) $data['actions'][ $key ]->nonce_action,
(string) $data['actions'][ $key ]->nonce_name
);
$data['actions'][ $key ]->status = stripslashes( $data['actions'][ $key ]->status );
}
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links(
array(
'self' => array(
'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $data['id'] ) ),
),
'collection' => array(
'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
),
)
);
/**
* Filter a note returned from the API.
*
* Allows modification of the note data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param array $data The original note.
* @param WP_REST_Request $request Request used to generate the response.
* @since 3.9.0
*/
return apply_filters( 'woocommerce_rest_prepare_note', $response, $data, $request );
}
/**
* Track opened emails.
*
* @param WP_REST_Request $request Request object.
*/
public function track_opened_email( $request ) {
$note = NotesRepository::get_note( $request->get_param( 'note_id' ) );
if ( ! $note ) {
return;
}
NotesRepository::record_tracks_event_with_user( $request->get_param( 'user_id' ), 'email_note_opened', array( 'note_name' => $note->get_name() ) );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'note_id',
'date',
'type',
'title',
'status',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['type'] = array(
'description' => __( 'Type of note.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => Note::get_allowed_types(),
'type' => 'string',
),
);
$params['status'] = array(
'description' => __( 'Status of note.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => Note::get_allowed_statuses(),
'type' => 'string',
),
);
$params['source'] = array(
'description' => __( 'Source of note.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
/**
* Get the note's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'note',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'ID of the note record.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Name of the note.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'type' => array(
'description' => __( 'The type of the note (e.g. error, warning, etc.).', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'locale' => array(
'description' => __( 'Locale used for the note title and content.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'title' => array(
'description' => __( 'Title of the note.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'content' => array(
'description' => __( 'Content of the note.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'content_data' => array(
'description' => __( 'Content data for the note. JSON string. Available for re-localization.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'status' => array(
'description' => __( 'The status of the note (e.g. unactioned, actioned).', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
'source' => array(
'description' => __( 'Source of the note.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_created' => array(
'description' => __( 'Date the note was created.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_created_gmt' => array(
'description' => __( 'Date the note was created (GMT).', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_reminder' => array(
'description' => __( 'Date after which the user should be reminded of the note, if any.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true, // @todo Allow date_reminder to be updated.
),
'date_reminder_gmt' => array(
'description' => __( 'Date after which the user should be reminded of the note, if any (GMT).', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'is_snoozable' => array(
'description' => __( 'Whether or not a user can request to be reminded about the note.', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'actions' => array(
'description' => __( 'An array of actions, if any, for the note.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'layout' => array(
'description' => __( 'The layout of the note (e.g. banner, thumbnail, plain).', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'image' => array(
'description' => __( 'The image of the note, if any.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'is_deleted' => array(
'description' => __( 'Registers whether the note is deleted or not', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'is_read' => array(
'description' => __( 'Registers whether the note is read or not', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/OnboardingFreeExtensions.php 0000644 00000007403 15153704476 0013702 0 ustar 00 <?php
/**
* REST API Onboarding Free Extensions Controller
*
* Handles requests to /onboarding/free-extensions
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init as RemoteFreeExtensions;
use WC_REST_Data_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Onboarding Payments Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingFreeExtensions extends WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/free-extensions';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_available_extensions' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to read onboarding profile data.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return available payment methods.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_Error|WP_REST_Response
*/
public function get_available_extensions( $request ) {
$extensions = RemoteFreeExtensions::get_extensions();
/**
* Allows removing Jetpack suggestions from WooCommerce Admin when false.
*
* In this instance it is removed from the list of extensions suggested in the Onboarding Profiler. This list is first retrieved from the WooCommerce.com API, then if a plugin with the 'jetpack' slug is found, it is removed.
*
* @since 7.8
*/
if ( false === apply_filters( 'woocommerce_suggest_jetpack', true ) ) {
foreach ( $extensions as &$extension ) {
$extension['plugins'] = array_filter(
$extension['plugins'],
function( $plugin ) {
return 'jetpack' !== $plugin->key;
}
);
}
}
$extensions = $this->replace_jetpack_with_jetpack_boost_for_treatment( $extensions );
return new WP_REST_Response( $extensions );
}
private function replace_jetpack_with_jetpack_boost_for_treatment( array $extensions ) {
$is_treatment = \WooCommerce\Admin\Experimental_Abtest::in_treatment( 'woocommerce_jetpack_copy' );
if ( ! $is_treatment ) {
return $extensions;
}
$has_core_profiler = array_search( 'obw/core-profiler', array_column( $extensions, 'key' ) );
if ( $has_core_profiler === false ) {
return $extensions;
}
$has_jetpack = array_search( 'jetpack', array_column( $extensions[ $has_core_profiler ]['plugins'], 'key' ) );
if ( $has_jetpack === false ) {
return $extensions;
}
$jetpack = &$extensions[ $has_core_profiler ]['plugins'][ $has_jetpack ];
$jetpack->key = 'jetpack-boost';
$jetpack->name = 'Jetpack Boost';
$jetpack->label = __( 'Optimize store performance with Jetpack Boost', 'woocommerce' );
$jetpack->description = __( 'Speed up your store and improve your SEO with performance-boosting tools from Jetpack. Learn more', 'woocommerce' );
$jetpack->learn_more_link = 'https://jetpack.com/boost/';
return $extensions;
}
}
Admin/API/OnboardingPlugins.php 0000644 00000027675 15153704476 0012377 0 ustar 00 <?php
/**
* REST API Onboarding Profile Controller
*
* Handles requests to /onboarding/profile
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use ActionScheduler;
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsynPluginsInstallLogger;
use WC_REST_Data_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
/**
* Onboarding Plugins controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingPlugins extends WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/plugins';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install-and-activate-async',
array(
array(
'methods' => 'POST',
'callback' => array( $this, 'install_and_activate_async' ),
'permission_callback' => array( $this, 'can_install_and_activate_plugins' ),
'args' => array(
'plugins' => array(
'description' => 'A list of plugins to install',
'type' => 'array',
'items' => 'string',
'sanitize_callback' => function ( $value ) {
return array_map(
function ( $value ) {
return sanitize_text_field( $value );
},
$value
);
},
'required' => true,
),
),
),
'schema' => array( $this, 'get_install_async_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install-and-activate',
array(
array(
'methods' => 'POST',
'callback' => array( $this, 'install_and_activate' ),
'permission_callback' => array( $this, 'can_install_and_activate_plugins' ),
),
'schema' => array( $this, 'get_install_activate_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/scheduled-installs/(?P<job_id>\w+)',
array(
array(
'methods' => 'GET',
'callback' => array( $this, 'get_scheduled_installs' ),
'permission_callback' => array( $this, 'can_install_plugins' ),
),
'schema' => array( $this, 'get_install_async_schema' ),
)
);
// This is an experimental endpoint and is subject to change in the future.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/jetpack-authorization-url',
array(
array(
'methods' => 'GET',
'callback' => array( $this, 'get_jetpack_authorization_url' ),
'permission_callback' => array( $this, 'can_install_plugins' ),
'args' => array(
'redirect_url' => array(
'description' => 'The URL to redirect to after authorization',
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'required' => true,
),
'from' => array(
'description' => 'from value for the jetpack authorization page',
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'required' => false,
'default' => 'woocommerce-onboarding',
),
),
),
)
);
/*
* This is a temporary solution to override /jetpack/v4/connection/data endpoint
* registered by Jetpack Connection when Jetpack is not installed.
*
* For more details, see https://github.com/woocommerce/woocommerce/issues/38979
*/
if ( Constants::get_constant( 'JETPACK__VERSION' ) === null && wp_is_mobile() ) {
register_rest_route(
'jetpack/v4',
'/connection/data',
array(
array(
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function() {
return new WP_REST_Response( null, 404 );
},
),
),
true
);
}
add_action( 'woocommerce_plugins_install_error', array( $this, 'log_plugins_install_error' ), 10, 4 );
add_action( 'woocommerce_plugins_install_api_error', array( $this, 'log_plugins_install_api_error' ), 10, 2 );
}
/**
* Install and activate a plugin.
*
* @param WP_REST_Request $request WP Request object.
*
* @return WP_REST_Response
*/
public function install_and_activate( WP_REST_Request $request ) {
$response = array();
$response['install'] = PluginsHelper::install_plugins( $request->get_param( 'plugins' ) );
$response['activate'] = PluginsHelper::activate_plugins( $response['install']['installed'] );
return new WP_REST_Response( $response );
}
/**
* Queue plugin install request.
*
* @param WP_REST_Request $request WP_REST_Request object.
*
* @return array
*/
public function install_and_activate_async( WP_REST_Request $request ) {
$plugins = $request->get_param( 'plugins' );
$job_id = uniqid();
WC()->queue()->add( 'woocommerce_plugins_install_and_activate_async_callback', array( $plugins, $job_id ) );
$plugin_status = array();
foreach ( $plugins as $plugin ) {
$plugin_status[ $plugin ] = array(
'status' => 'pending',
'errors' => array(),
);
}
return array(
'job_id' => $job_id,
'status' => 'pending',
'plugins' => $plugin_status,
);
}
/**
* Returns current status of given job.
*
* @param WP_REST_Request $request WP_REST_Request object.
*
* @return array|WP_REST_Response
*/
public function get_scheduled_installs( WP_REST_Request $request ) {
$job_id = $request->get_param( 'job_id' );
$actions = WC()->queue()->search(
array(
'hook' => 'woocommerce_plugins_install_and_activate_async_callback',
'search' => $job_id,
'orderby' => 'date',
'order' => 'DESC',
)
);
$actions = array_filter(
PluginsHelper::get_action_data( $actions ),
function( $action ) use ( $job_id ) {
return $action['job_id'] === $job_id;
}
);
if ( empty( $actions ) ) {
return new WP_REST_Response( null, 404 );
}
$response = array(
'job_id' => $actions[0]['job_id'],
'status' => $actions[0]['status'],
);
$option = get_option( 'woocommerce_onboarding_plugins_install_and_activate_async_' . $job_id );
if ( isset( $option['plugins'] ) ) {
$response['plugins'] = $option['plugins'];
}
return $response;
}
/**
* Return Jetpack authorization URL.
*
* @param WP_REST_Request $request WP_REST_Request object.
*
* @return array
* @throws \Exception If there is an error registering the site.
*/
public function get_jetpack_authorization_url( WP_REST_Request $request ) {
$manager = new Manager( 'woocommerce' );
$errors = new WP_Error();
// Register the site to wp.com.
if ( ! $manager->is_connected() ) {
$result = $manager->try_registration();
if ( is_wp_error( $result ) ) {
$errors->add( $result->get_error_code(), $result->get_error_message() );
}
}
$redirect_url = $request->get_param( 'redirect_url' );
$calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, [ 'development', 'wpcalypso', 'horizon', 'stage' ], true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production';
return [
'success' => ! $errors->has_errors(),
'errors' => $errors->get_error_messages(),
'url' => add_query_arg(
[
'from' => $request->get_param( 'from' ),
'calypso_env' => $calypso_env,
],
$manager->get_authorization_url( null, $redirect_url )
),
];
}
/**
* Check whether the current user has permission to install plugins
*
* @return WP_Error|boolean
*/
public function can_install_plugins() {
if ( ! current_user_can( 'install_plugins' ) ) {
return new WP_Error(
'woocommerce_rest_cannot_update',
__( 'Sorry, you cannot manage plugins.', 'woocommerce' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Check whether the current user has permission to install and activate plugins
*
* @return WP_Error|boolean
*/
public function can_install_and_activate_plugins() {
if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) {
return new WP_Error(
'woocommerce_rest_cannot_update',
__( 'Sorry, you cannot manage plugins.', 'woocommerce' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* JSON Schema for both install-async and scheduled-installs endpoints.
*
* @return array
*/
public function get_install_async_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'Install Async Schema',
'type' => 'object',
'properties' => array(
'type' => 'object',
'properties' => array(
'job_id' => 'integer',
'status' => array(
'type' => 'string',
'enum' => array( 'pending', 'complete', 'failed' ),
),
),
),
);
}
/**
* JSON Schema for install-and-activate endpoint.
*
* @return array
*/
public function get_install_activate_schema() {
$error_schema = array(
'type' => 'object',
'patternProperties' => array(
'^.*$' => array(
'type' => 'string',
),
),
'items' => array(
'type' => 'string',
),
);
$install_schema = array(
'type' => 'object',
'properties' => array(
'installed' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'results' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'errors' => array(
'type' => 'object',
'properties' => array(
'errors' => $error_schema,
'error_data' => $error_schema,
),
),
),
);
$activate_schema = array(
'type' => 'object',
'properties' => array(
'activated' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'active' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'errors' => array(
'type' => 'object',
'properties' => array(
'errors' => $error_schema,
'error_data' => $error_schema,
),
),
),
);
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'Install and Activate Schema',
'type' => 'object',
'properties' => array(
'type' => 'object',
'properties' => array(
'install' => $install_schema,
'activate' => $activate_schema,
),
),
);
}
public function log_plugins_install_error( $slug, $api, $result, $upgrader ) {
$properties = array(
'error_message' => sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__(
'The requested plugin `%s` could not be installed.',
'woocommerce'
),
$slug
),
'type' => 'plugin_info_api_error',
'slug' => $slug,
'api_version' => $api->version,
'api_download_link' => $api->download_link,
'upgrader_skin_message' => implode( ',', $upgrader->skin->get_upgrade_messages() ),
'result' => is_wp_error( $result ) ? $result->get_error_message() : 'null',
);
wc_admin_record_tracks_event( 'coreprofiler_install_plugin_error', $properties );
}
public function log_plugins_install_api_error( $slug, $api ) {
$properties = array(
'error_message' => sprintf(
// translators: %s: plugin slug (example: woocommerce-services).
__(
'The requested plugin `%s` could not be installed. Plugin API call failed.',
'woocommerce'
),
$slug
),
'type' => 'plugin_install_error',
'api_error_message' => $api->get_error_message(),
'slug' => $slug,
);
wc_admin_record_tracks_event( 'coreprofiler_install_plugin_error', $properties );
}
}
Admin/API/OnboardingProductTypes.php 0000644 00000003460 15153704476 0013405 0 ustar 00 <?php
/**
* REST API Onboarding Product Types Controller
*
* Handles requests to /onboarding/product-types
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts;
defined( 'ABSPATH' ) || exit;
/**
* Onboarding Product Types Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingProductTypes extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/product-types';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_product_types' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to read onboarding profile data.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return available product types.
*
* @param \WP_REST_Request $request Request data.
*
* @return \WP_Error|\WP_REST_Response
*/
public function get_product_types( $request ) {
return OnboardingProducts::get_product_types_with_data();
}
}
Admin/API/OnboardingProfile.php 0000644 00000041334 15153704476 0012342 0 ustar 00 <?php
/**
* REST API Onboarding Profile Controller
*
* Handles requests to /onboarding/profile
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile as Profile;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;
/**
* Onboarding Profile controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingProfile extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/profile';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_items' ),
'permission_callback' => array( $this, 'update_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
// This endpoint is experimental. For internal use only.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/experimental_get_email_prefill',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_email_prefill' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to read onboarding profile data.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check whether a given request has permission to edit onboarding profile data.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function update_items_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return all onboarding profile data.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
$onboarding_data = get_option( Profile::DATA_OPTION, array() );
$onboarding_data['industry'] = isset( $onboarding_data['industry'] ) ? $this->filter_industries( $onboarding_data['industry'] ) : null;
$item_schema = $this->get_item_schema();
$items = array();
foreach ( $item_schema['properties'] as $key => $property_schema ) {
$items[ $key ] = isset( $onboarding_data[ $key ] ) ? $onboarding_data[ $key ] : null;
}
$wccom_auth = \WC_Helper_Options::get( 'auth' );
$items['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true;
$item = $this->prepare_item_for_response( $items, $request );
$data = $this->prepare_response_for_collection( $item );
return rest_ensure_response( $data );
}
/**
* Filter the industries.
*
* @param array $industries list of industries.
* @return array
*/
protected function filter_industries( $industries ) {
return apply_filters(
'woocommerce_admin_onboarding_industries',
$industries
);
}
/**
* Update onboarding profile data.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function update_items( $request ) {
$params = $request->get_json_params();
$query_args = $this->prepare_objects_query( $params );
$onboarding_data = (array) get_option( Profile::DATA_OPTION, array() );
$profile_data = array_merge( $onboarding_data, $query_args );
update_option( Profile::DATA_OPTION, $profile_data );
do_action( 'woocommerce_onboarding_profile_data_updated', $onboarding_data, $query_args );
$result = array(
'status' => 'success',
'message' => __( 'Onboarding profile data has been updated.', 'woocommerce' ),
);
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Returns a default email to be pre-filled in OBW. Prioritizes Jetpack if connected,
* otherwise will default to WordPress general settings.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_email_prefill( $request ) {
$result = array(
'email' => '',
);
// Attempt to get email from Jetpack.
if ( class_exists( Jetpack_Connection_Manager::class ) ) {
$jetpack_connection_manager = new Jetpack_Connection_Manager();
if ( $jetpack_connection_manager->is_active() ) {
$jetpack_user = $jetpack_connection_manager->get_connected_user_data();
$result['email'] = $jetpack_user['email'];
}
}
// Attempt to get email from WordPress general settings.
if ( empty( $result['email'] ) ) {
$result['email'] = get_option( 'admin_email' );
}
return rest_ensure_response( $result );
}
/**
* Prepare objects query.
*
* @param array $params The params sent in the request.
* @return array
*/
protected function prepare_objects_query( $params ) {
$args = array();
$properties = self::get_profile_properties();
foreach ( $properties as $key => $property ) {
if ( isset( $params[ $key ] ) ) {
$args[ $key ] = $params[ $key ];
}
}
/**
* Filter the query arguments for a request.
*
* Enables adding extra arguments or setting defaults for a post
* collection request.
*
* @param array $args Key value array of query var to query value.
* @param array $params The params sent in the request.
*/
$args = apply_filters( 'woocommerce_rest_onboarding_profile_object_query', $args, $params );
return $args;
}
/**
* Prepare the data object for response.
*
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
/**
* Filter the list returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_onboarding_prepare_profile', $response, $item, $request );
}
/**
* Get onboarding profile properties.
*
* @return array
*/
public static function get_profile_properties() {
$properties = array(
'completed' => array(
'type' => 'boolean',
'description' => __( 'Whether or not the profile was completed.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'skipped' => array(
'type' => 'boolean',
'description' => __( 'Whether or not the profile was skipped.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'industry' => array(
'type' => 'array',
'description' => __( 'Industry.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'object',
),
),
'product_types' => array(
'type' => 'array',
'description' => __( 'Types of products sold.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => array_keys( OnboardingProducts::get_allowed_product_types() ),
'type' => 'string',
),
),
'product_count' => array(
'type' => 'string',
'description' => __( 'Number of products to be added.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
'enum' => array(
'0',
'1-10',
'11-100',
'101-1000',
'1000+',
),
),
'selling_venues' => array(
'type' => 'string',
'description' => __( 'Other places the store is selling products.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
'enum' => array(
'no',
'other',
'brick-mortar',
'brick-mortar-other',
'other-woocommerce',
),
),
'number_employees' => array(
'type' => 'string',
'description' => __( 'Number of employees of the store.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
'enum' => array(
'1',
'<10',
'10-50',
'50-250',
'+250',
'not specified',
),
),
'revenue' => array(
'type' => 'string',
'description' => __( 'Current annual revenue of the store.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
'enum' => array(
'none',
'up-to-2500',
'2500-10000',
'10000-50000',
'50000-250000',
'more-than-250000',
'rather-not-say',
),
),
'other_platform' => array(
'type' => 'string',
'description' => __( 'Name of other platform used to sell.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
'enum' => array(
'shopify',
'bigcommerce',
'magento',
'wix',
'amazon',
'ebay',
'etsy',
'squarespace',
'other',
),
),
'other_platform_name' => array(
'type' => 'string',
'description' => __( 'Name of other platform used to sell (not listed).', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'business_extensions' => array(
'type' => 'array',
'description' => __( 'Extra business extensions to install.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => array(
'jetpack',
'jetpack-boost',
'woocommerce-services',
'woocommerce-payments',
'mailchimp-for-woocommerce',
'creative-mail-by-constant-contact',
'facebook-for-woocommerce',
'google-listings-and-ads',
'pinterest-for-woocommerce',
'mailpoet',
'codistoconnect',
'tiktok-for-business',
'tiktok-for-business:alt',
),
'type' => 'string',
),
),
'theme' => array(
'type' => 'string',
'description' => __( 'Selected store theme.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'sanitize_callback' => 'sanitize_title_with_dashes',
'validate_callback' => 'rest_validate_request_arg',
),
'setup_client' => array(
'type' => 'boolean',
'description' => __( 'Whether or not this store was setup for a client.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'wccom_connected' => array(
'type' => 'boolean',
'description' => __( 'Whether or not the store was connected to WooCommerce.com during the extension flow.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'is_agree_marketing' => array(
'type' => 'boolean',
'description' => __( 'Whether or not this store agreed to receiving marketing contents from WooCommerce.com.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'store_email' => array(
'type' => 'string',
'description' => __( 'Store email address.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => array( __CLASS__, 'rest_validate_marketing_email' ),
),
'is_store_country_set' => array(
'type' => 'boolean',
'description' => __( 'Whether or not this store country is set via onboarding profiler.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
'is_plugins_page_skipped' => array(
'type' => 'boolean',
'description' => __( 'Whether or not plugins step in core profiler was skipped.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
'validate_callback' => 'rest_validate_request_arg',
),
);
return apply_filters( 'woocommerce_rest_onboarding_profile_properties', $properties );
}
/**
* Optionally validates email if user agreed to marketing or if email is not empty.
*
* @param mixed $value Email value.
* @param WP_REST_Request $request Request object.
* @param string $param Parameter name.
* @return true|WP_Error
*/
public static function rest_validate_marketing_email( $value, $request, $param ) {
$is_agree_marketing = $request->get_param( 'is_agree_marketing' );
if (
( $is_agree_marketing || ! empty( $value ) ) &&
! is_email( $value ) ) {
return new \WP_Error( 'rest_invalid_email', __( 'Invalid email address', 'woocommerce' ) );
};
return true;
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
// Unset properties used for collection params.
$properties = self::get_profile_properties();
foreach ( $properties as $key => $property ) {
unset( $properties[ $key ]['default'] );
unset( $properties[ $key ]['items'] );
unset( $properties[ $key ]['validate_callback'] );
unset( $properties[ $key ]['sanitize_callback'] );
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'onboarding_profile',
'type' => 'object',
'properties' => $properties,
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
// Unset properties used for item schema.
$params = self::get_profile_properties();
foreach ( $params as $key => $param ) {
unset( $params[ $key ]['context'] );
unset( $params[ $key ]['readonly'] );
}
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
return apply_filters( 'woocommerce_rest_onboarding_profile_collection_params', $params );
}
}
Admin/API/OnboardingTasks.php 0000644 00000077675 15153704476 0012050 0 ustar 00 <?php
/**
* REST API Onboarding Tasks Controller
*
* Handles requests to complete various onboarding tasks.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask;
defined( 'ABSPATH' ) || exit;
/**
* Onboarding Tasks Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingTasks extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/tasks';
/**
* Duration to milisecond mapping.
*
* @var array
*/
protected $duration_to_ms = array(
'day' => DAY_IN_SECONDS * 1000,
'hour' => HOUR_IN_SECONDS * 1000,
'week' => WEEK_IN_SECONDS * 1000,
);
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/import_sample_products',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'import_sample_products' ),
'permission_callback' => array( $this, 'create_products_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/create_homepage',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_homepage' ),
'permission_callback' => array( $this, 'create_pages_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/create_product_from_template',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_product_from_template' ),
'permission_callback' => array( $this, 'create_products_permission_check' ),
'args' => array_merge(
$this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
array(
'template_name' => array(
'required' => true,
'type' => 'string',
'description' => __( 'Product template name.', 'woocommerce' ),
),
)
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_tasks' ),
'permission_callback' => array( $this, 'get_tasks_permission_check' ),
'args' => array(
'ids' => array(
'description' => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => TaskLists::get_list_ids(),
'type' => 'string',
),
),
),
),
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'get_tasks' ),
'permission_callback' => array( $this, 'get_tasks_permission_check' ),
'args' => $this->get_task_list_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/hide',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'hide_task_list' ),
'permission_callback' => array( $this, 'hide_task_list_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/unhide',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'unhide_task_list' ),
'permission_callback' => array( $this, 'hide_task_list_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/dismiss',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'dismiss_task' ),
'permission_callback' => array( $this, 'get_tasks_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_dismiss',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'undo_dismiss_task' ),
'permission_callback' => array( $this, 'get_tasks_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_-]+)/snooze',
array(
'args' => array(
'duration' => array(
'description' => __( 'Time period to snooze the task.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => function( $param, $request, $key ) {
return in_array( $param, array_keys( $this->duration_to_ms ), true );
},
),
'task_list_id' => array(
'description' => __( 'Optional parameter to query specific task list.', 'woocommerce' ),
'type' => 'string',
),
),
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'snooze_task' ),
'permission_callback' => array( $this, 'snooze_task_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/action',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'action_task' ),
'permission_callback' => array( $this, 'get_tasks_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_snooze',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'undo_snooze_task' ),
'permission_callback' => array( $this, 'snooze_task_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check if a given request has access to create a product.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function create_products_permission_check( $request ) {
if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check if a given request has access to create a product.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function create_pages_permission_check( $request ) {
if ( ! wc_rest_check_post_permissions( 'page', 'create' ) || ! current_user_can( 'manage_options' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create new pages.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check if a given request has access to manage woocommerce.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_tasks_permission_check( $request ) {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check if a given request has permission to hide task lists.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function hide_task_list_permission_check( $request ) {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you are not allowed to hide task lists.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check if a given request has access to manage woocommerce.
*
* @deprecated 7.8.0 snooze task is deprecated.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function snooze_task_permissions_check( $request ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to snooze onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Import sample products from given CSV path.
*
* @param string $csv_file CSV file path.
* @return WP_Error|WP_REST_Response
*/
public static function import_sample_products_from_csv( $csv_file ) {
include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
if ( file_exists( $csv_file ) && class_exists( 'WC_Product_CSV_Importer' ) ) {
// Override locale so we can return mappings from WooCommerce in English language stores.
add_filter( 'locale', '__return_false', 9999 );
$importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' );
$args = array(
'parse' => true,
'mapping' => self::get_header_mappings( $csv_file ),
);
$args = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class );
$importer = new $importer_class( $csv_file, $args );
$import = $importer->import();
return $import;
} else {
return new \WP_Error( 'woocommerce_rest_import_error', __( 'Sorry, the sample products data file was not found.', 'woocommerce' ) );
}
}
/**
* Import sample products from WooCommerce sample CSV.
*
* @internal
* @return WP_Error|WP_REST_Response
*/
public static function import_sample_products() {
$sample_csv_file = Features::is_enabled( 'experimental-fashion-sample-products' ) ? WC_ABSPATH . 'sample-data/experimental_fashion_sample_9_products.csv' :
WC_ABSPATH . 'sample-data/experimental_sample_9_products.csv';
$import = self::import_sample_products_from_csv( $sample_csv_file );
return rest_ensure_response( $import );
}
/**
* Creates a product from a template name passed in through the template_name param.
*
* @internal
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response|WP_Error
*/
public static function create_product_from_template( $request ) {
$template_name = basename( $request->get_param( 'template_name' ) );
$template_path = __DIR__ . '/Templates/' . $template_name . '_product.csv';
$template_path = apply_filters( 'woocommerce_product_template_csv_file_path', $template_path, $template_name );
$import = self::import_sample_products_from_csv( $template_path );
if ( is_wp_error( $import ) || 0 === count( $import['imported'] ) ) {
return new \WP_Error(
'woocommerce_rest_product_creation_error',
/* translators: %s is template name */
__( 'Sorry, creating the product with template failed.', 'woocommerce' ),
array( 'status' => 500 )
);
}
$product = wc_get_product( $import['imported'][0] );
$product->set_status( 'auto-draft' );
$product->save();
return rest_ensure_response(
array(
'id' => $product->get_id(),
)
);
}
/**
* Get header mappings from CSV columns.
*
* @internal
* @param string $file File path.
* @return array Mapped headers.
*/
public static function get_header_mappings( $file ) {
include_once WC_ABSPATH . 'includes/admin/importers/mappings/mappings.php';
$importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' );
$importer = new $importer_class( $file, array() );
$raw_headers = $importer->get_raw_keys();
$default_columns = wc_importer_default_english_mappings( array() );
$special_columns = wc_importer_default_special_english_mappings( array() );
$headers = array();
foreach ( $raw_headers as $key => $field ) {
$index = $field;
$headers[ $index ] = $field;
if ( isset( $default_columns[ $field ] ) ) {
$headers[ $index ] = $default_columns[ $field ];
} else {
foreach ( $special_columns as $regex => $special_key ) {
if ( preg_match( self::sanitize_special_column_name_regex( $regex ), $field, $matches ) ) {
$headers[ $index ] = $special_key . $matches[1];
break;
}
}
}
}
return $headers;
}
/**
* Sanitize special column name regex.
*
* @internal
* @param string $value Raw special column name.
* @return string
*/
public static function sanitize_special_column_name_regex( $value ) {
return '/' . str_replace( array( '%d', '%s' ), '(.*)', trim( quotemeta( $value ) ) ) . '/';
}
/**
* Returns a valid cover block with an image, if one exists, or background as a fallback.
*
* @internal
* @param array $image Image to use for the cover block. Should contain a media ID and image URL.
* @return string Block content.
*/
private static function get_homepage_cover_block( $image ) {
$shop_url = get_permalink( wc_get_page_id( 'shop' ) );
if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) {
return '<!-- wp:cover {"url":"' . esc_url( $image['url'] ) . '","id":' . intval( $image['id'] ) . ',"dimRatio":0} -->
<div class="wp-block-cover" style="background-image:url(' . esc_url( $image['url'] ) . ')"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} -->
<p class="has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"align":"center","textColor":"white"} -->
<p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons"><!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons --></div></div>
<!-- /wp:cover -->';
}
return '<!-- wp:cover {"dimRatio":0} -->
<div class="wp-block-cover"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} -->
<p class="has-text-color has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph {"align":"center","textColor":"white"} -->
<p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons"><!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons --></div></div>
<!-- /wp:cover -->';
}
/**
* Returns a valid media block with an image, if one exists, or a uninitialized media block the user can set.
*
* @internal
* @param array $image Image to use for the cover block. Should contain a media ID and image URL.
* @param string $align If the image should be aligned to the left or right.
* @return string Block content.
*/
private static function get_homepage_media_block( $image, $align = 'left' ) {
$media_position = 'right' === $align ? '"mediaPosition":"right",' : '';
$css_class = 'right' === $align ? ' has-media-on-the-right' : '';
if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) {
return '<!-- wp:media-text {' . $media_position . '"mediaId":' . intval( $image['id'] ) . ',"mediaType":"image"} -->
<div class="wp-block-media-text alignwide' . $css_class . '""><figure class="wp-block-media-text__media"><img src="' . esc_url( $image['url'] ) . '" alt="" class="wp-image-' . intval( $image['id'] ) . '"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} -->
<p class="has-large-font-size"></p>
<!-- /wp:paragraph --></div></div>
<!-- /wp:media-text -->';
}
return '<!-- wp:media-text {' . $media_position . '} -->
<div class="wp-block-media-text alignwide' . $css_class . '"><figure class="wp-block-media-text__media"></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} -->
<p class="has-large-font-size"></p>
<!-- /wp:paragraph --></div></div>
<!-- /wp:media-text -->';
}
/**
* Returns a homepage template to be inserted into a post. A different template will be used depending on the number of products.
*
* @internal
* @param int $post_id ID of the homepage template.
* @return string Template contents.
*/
private static function get_homepage_template( $post_id ) {
$products = wp_count_posts( 'product' );
if ( $products->publish >= 4 ) {
$images = self::sideload_homepage_images( $post_id, 1 );
$image_1 = ! empty( $images[0] ) ? $images[0] : '';
$template = self::get_homepage_cover_block( $image_1 ) . '
<!-- wp:heading {"align":"center"} -->
<h2 style="text-align:center">' . __( 'Shop by Category', 'woocommerce' ) . '</h2>
<!-- /wp:heading -->
<!-- wp:shortcode -->
[product_categories number="0" parent="0"]
<!-- /wp:shortcode -->
<!-- wp:heading {"align":"center"} -->
<h2 style="text-align:center">' . __( 'New In', 'woocommerce' ) . '</h2>
<!-- /wp:heading -->
<!-- wp:woocommerce/product-new {"columns":4} /-->
<!-- wp:heading {"align":"center"} -->
<h2 style="text-align:center">' . __( 'Fan Favorites', 'woocommerce' ) . '</h2>
<!-- /wp:heading -->
<!-- wp:woocommerce/product-top-rated {"columns":4} /-->
<!-- wp:heading {"align":"center"} -->
<h2 style="text-align:center">' . __( 'On Sale', 'woocommerce' ) . '</h2>
<!-- /wp:heading -->
<!-- wp:woocommerce/product-on-sale {"columns":4} /-->
<!-- wp:heading {"align":"center"} -->
<h2 style="text-align:center">' . __( 'Best Sellers', 'woocommerce' ) . '</h2>
<!-- /wp:heading -->
<!-- wp:woocommerce/product-best-sellers {"columns":4} /-->
';
/**
* Modify the template/content of the default homepage.
*
* @param string $template The default homepage template.
*/
return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template );
}
$images = self::sideload_homepage_images( $post_id, 3 );
$image_1 = ! empty( $images[0] ) ? $images[0] : '';
$image_2 = ! empty( $images[1] ) ? $images[1] : '';
$image_3 = ! empty( $images[2] ) ? $images[2] : '';
$template = self::get_homepage_cover_block( $image_1 ) . '
<!-- wp:heading {"align":"center"} -->
<h2 style="text-align:center">' . __( 'New Products', 'woocommerce' ) . '</h2>
<!-- /wp:heading -->
<!-- wp:woocommerce/product-new /--> ' .
self::get_homepage_media_block( $image_1, 'right' ) .
self::get_homepage_media_block( $image_2, 'left' ) .
self::get_homepage_media_block( $image_3, 'right' ) . '
<!-- wp:woocommerce/featured-product /-->';
/** This filter is documented in src/API/OnboardingTasks.php. */
return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template );
}
/**
* Gets the possible industry images from the plugin folder for sideloading. If an image doesn't exist, other.jpg is used a fallback.
*
* @internal
* @return array An array of images by industry.
*/
private static function get_available_homepage_images() {
$industry_images = array();
$industries = OnboardingIndustries::get_allowed_industries();
foreach ( $industries as $industry_slug => $label ) {
$industry_images[ $industry_slug ] = apply_filters( 'woocommerce_admin_onboarding_industry_image', WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/other-small.jpg', $industry_slug );
}
return $industry_images;
}
/**
* Uploads a number of images to a homepage template, depending on the selected industry from the profile wizard.
*
* @internal
* @param int $post_id ID of the homepage template.
* @param int $number_of_images The number of images that should be sideloaded (depending on how many media slots are in the template).
* @return array An array of images that have been attached to the post.
*/
private static function sideload_homepage_images( $post_id, $number_of_images ) {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
$images_to_sideload = array();
$available_images = self::get_available_homepage_images();
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
if ( ! empty( $profile['industry'] ) ) {
foreach ( $profile['industry'] as $selected_industry ) {
if ( is_string( $selected_industry ) ) {
$industry_slug = $selected_industry;
} elseif ( is_array( $selected_industry ) && ! empty( $selected_industry['slug'] ) ) {
$industry_slug = $selected_industry['slug'];
} else {
continue;
}
// Capture the first industry for use in our minimum images logic.
$first_industry = isset( $first_industry ) ? $first_industry : $industry_slug;
$images_to_sideload[] = ! empty( $available_images[ $industry_slug ] ) ? $available_images[ $industry_slug ] : $available_images['other'];
}
}
// Make sure we have at least {$number_of_images} images.
if ( count( $images_to_sideload ) < $number_of_images ) {
for ( $i = count( $images_to_sideload ); $i < $number_of_images; $i++ ) {
// Fill up missing image slots with the first selected industry, or other.
$industry = isset( $first_industry ) ? $first_industry : 'other';
$images_to_sideload[] = empty( $available_images[ $industry ] ) ? $available_images['other'] : $available_images[ $industry ];
}
}
$already_sideloaded = array();
$images_for_post = array();
foreach ( $images_to_sideload as $image ) {
// Avoid uploading two of the same image, if an image is repeated.
if ( ! empty( $already_sideloaded[ $image ] ) ) {
$images_for_post[] = $already_sideloaded[ $image ];
continue;
}
$sideload_id = \media_sideload_image( $image, $post_id, null, 'id' );
if ( ! is_wp_error( $sideload_id ) ) {
$sideload_url = wp_get_attachment_url( $sideload_id );
$already_sideloaded[ $image ] = array(
'id' => $sideload_id,
'url' => $sideload_url,
);
$images_for_post[] = $already_sideloaded[ $image ];
}
}
return $images_for_post;
}
/**
* Create a homepage from a template.
*
* @return WP_Error|array
*/
public static function create_homepage() {
$post_id = wp_insert_post(
array(
'post_title' => __( 'Homepage', 'woocommerce' ),
'post_type' => 'page',
'post_status' => 'publish',
'post_content' => '', // Template content is updated below, so images can be attached to the post.
)
);
if ( ! is_wp_error( $post_id ) && 0 < $post_id ) {
$template = self::get_homepage_template( $post_id );
wp_update_post(
array(
'ID' => $post_id,
'post_content' => $template,
)
);
update_option( 'show_on_front', 'page' );
update_option( 'page_on_front', $post_id );
update_option( 'woocommerce_onboarding_homepage_post_id', $post_id );
// Use the full width template on stores using Storefront.
if ( 'storefront' === get_stylesheet() ) {
update_post_meta( $post_id, '_wp_page_template', 'template-fullwidth.php' );
}
return array(
'status' => 'success',
'message' => __( 'Homepage created', 'woocommerce' ),
'post_id' => $post_id,
'edit_post_link' => htmlspecialchars_decode( get_edit_post_link( $post_id ) ),
);
} else {
return $post_id;
}
}
/**
* Get the query params for task lists.
*
* @return array
*/
public function get_task_list_params() {
$params = array();
$params['ids'] = array(
'description' => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => TaskLists::get_list_ids(),
'type' => 'string',
),
);
$params['extended_tasks'] = array(
'description' => __( 'List of extended deprecated tasks from the client side filter.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => function( $param, $request, $key ) {
$has_valid_keys = true;
foreach ( $param as $task ) {
if ( $has_valid_keys ) {
$has_valid_keys = array_key_exists( 'list_id', $task ) && array_key_exists( 'id', $task );
}
}
return $has_valid_keys;
},
);
return $params;
}
/**
* Get the onboarding tasks.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error
*/
public function get_tasks( $request ) {
$extended_tasks = $request->get_param( 'extended_tasks' );
$task_list_ids = $request->get_param( 'ids' );
TaskLists::maybe_add_extended_tasks( $extended_tasks );
$lists = is_array( $task_list_ids ) && count( $task_list_ids ) > 0 ? TaskLists::get_lists_by_ids( $task_list_ids ) : TaskLists::get_lists();
$json = array_map(
function( $list ) {
return $list->sort_tasks()->get_json();
},
$lists
);
return rest_ensure_response( array_values( apply_filters( 'woocommerce_admin_onboarding_tasks', $json ) ) );
}
/**
* Dismiss a single task.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function dismiss_task( $request ) {
$id = $request->get_param( 'id' );
$task = TaskLists::get_task( $id );
if ( ! $task && $id ) {
$task = new DeprecatedExtendedTask(
null,
array(
'id' => $id,
'is_dismissable' => true,
)
);
}
if ( ! $task || ! $task->is_dismissable() ) {
return new \WP_Error(
'woocommerce_rest_invalid_task',
__( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$task->dismiss();
return rest_ensure_response( $task->get_json() );
}
/**
* Undo dismissal of a single task.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function undo_dismiss_task( $request ) {
$id = $request->get_param( 'id' );
$task = TaskLists::get_task( $id );
if ( ! $task && $id ) {
$task = new DeprecatedExtendedTask(
null,
array(
'id' => $id,
'is_dismissable' => true,
)
);
}
if ( ! $task || ! $task->is_dismissable() ) {
return new \WP_Error(
'woocommerce_rest_invalid_task',
__( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$task->undo_dismiss();
return rest_ensure_response( $task->get_json() );
}
/**
* Snooze an onboarding task.
*
* @deprecated 7.8.0 snooze task is deprecated.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_REST_Response|WP_Error
*/
public function snooze_task( $request ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' );
$task_id = $request->get_param( 'id' );
$task_list_id = $request->get_param( 'task_list_id' );
$duration = $request->get_param( 'duration' );
$task = TaskLists::get_task( $task_id, $task_list_id );
if ( ! $task && $task_id ) {
$task = new DeprecatedExtendedTask(
null,
array(
'id' => $task_id,
'is_snoozeable' => true,
)
);
}
if ( ! $task || ! $task->is_snoozeable() ) {
return new \WP_Error(
'woocommerce_rest_invalid_task',
__( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$task->snooze( isset( $duration ) ? $duration : 'day' );
return rest_ensure_response( $task->get_json() );
}
/**
* Undo snooze of a single task.
*
* @deprecated 7.8.0 undo snooze task is deprecated.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function undo_snooze_task( $request ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' );
$id = $request->get_param( 'id' );
$task = TaskLists::get_task( $id );
if ( ! $task && $id ) {
$task = new DeprecatedExtendedTask(
null,
array(
'id' => $id,
'is_snoozeable' => true,
)
);
}
if ( ! $task || ! $task->is_snoozeable() ) {
return new \WP_Error(
'woocommerce_rest_invalid_task',
__( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$task->undo_snooze();
return rest_ensure_response( $task->get_json() );
}
/**
* Hide a task list.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_REST_Response|WP_Error
*/
public function hide_task_list( $request ) {
$id = $request->get_param( 'id' );
$task_list = TaskLists::get_list( $id );
if ( ! $task_list ) {
return new \WP_Error(
'woocommerce_rest_invalid_task_list',
__( 'Sorry, that task list was not found', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$update = $task_list->hide();
$json = $task_list->get_json();
return rest_ensure_response( $json );
}
/**
* Unhide a task list.
*
* @param WP_REST_Request $request Request data.
*
* @return WP_REST_Response|WP_Error
*/
public function unhide_task_list( $request ) {
$id = $request->get_param( 'id' );
$task_list = TaskLists::get_list( $id );
if ( ! $task_list ) {
return new \WP_Error(
'woocommerce_tasks_invalid_task_list',
__( 'Sorry, that task list was not found', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$update = $task_list->unhide();
$json = $task_list->get_json();
return rest_ensure_response( $json );
}
/**
* Action a single task.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function action_task( $request ) {
$id = $request->get_param( 'id' );
$task = TaskLists::get_task( $id );
if ( ! $task && $id ) {
$task = new DeprecatedExtendedTask(
null,
array(
'id' => $id,
)
);
}
if ( ! $task ) {
return new \WP_Error(
'woocommerce_rest_invalid_task',
__( 'Sorry, no task with that ID was found.', 'woocommerce' ),
array(
'status' => 404,
)
);
}
$task->mark_actioned();
return rest_ensure_response( $task->get_json() );
}
}
Admin/API/OnboardingThemes.php 0000644 00000014007 15153704476 0012164 0 ustar 00 <?php
/**
* REST API Onboarding Themes Controller
*
* Handles requests to install and activate themes.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes as Themes;
defined( 'ABSPATH' ) || exit;
/**
* Onboarding Themes Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingThemes extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/themes';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'install_theme' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/activate',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'activate_theme' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Check if a given request has access to manage themes.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function update_item_permissions_check( $request ) {
if ( ! current_user_can( 'switch_themes' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage themes.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Installs the requested theme.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array Theme installation status.
*/
public function install_theme( $request ) {
$allowed_themes = Themes::get_allowed_themes();
$theme = sanitize_text_field( $request['theme'] );
if ( ! in_array( $theme, $allowed_themes, true ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_theme', __( 'Invalid theme.', 'woocommerce' ), 404 );
}
$installed_themes = wp_get_themes();
if ( in_array( $theme, array_keys( $installed_themes ), true ) ) {
return( array(
'slug' => $theme,
'name' => $installed_themes[ $theme ]->get( 'Name' ),
'status' => 'success',
) );
}
include_once ABSPATH . '/wp-admin/includes/admin.php';
include_once ABSPATH . '/wp-admin/includes/theme-install.php';
include_once ABSPATH . '/wp-admin/includes/theme.php';
include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php';
include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php';
$api = themes_api(
'theme_information',
array(
'slug' => $theme,
'fields' => array(
'sections' => false,
),
)
);
if ( is_wp_error( $api ) ) {
return new \WP_Error(
'woocommerce_rest_theme_install',
sprintf(
/* translators: %s: theme slug (example: woocommerce-services) */
__( 'The requested theme `%s` could not be installed. Theme API call failed.', 'woocommerce' ),
$theme
),
500
);
}
$upgrader = new \Theme_Upgrader( new \Automatic_Upgrader_Skin() );
$result = $upgrader->install( $api->download_link );
if ( is_wp_error( $result ) || is_null( $result ) ) {
return new \WP_Error(
'woocommerce_rest_theme_install',
sprintf(
/* translators: %s: theme slug (example: woocommerce-services) */
__( 'The requested theme `%s` could not be installed.', 'woocommerce' ),
$theme
),
500
);
}
return array(
'slug' => $theme,
'name' => $api->name,
'status' => 'success',
);
}
/**
* Activate the requested theme.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array Theme activation status.
*/
public function activate_theme( $request ) {
$allowed_themes = Themes::get_allowed_themes();
$theme = sanitize_text_field( $request['theme'] );
if ( ! in_array( $theme, $allowed_themes, true ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_theme', __( 'Invalid theme.', 'woocommerce' ), 404 );
}
require_once ABSPATH . 'wp-admin/includes/theme.php';
$installed_themes = wp_get_themes();
if ( ! in_array( $theme, array_keys( $installed_themes ), true ) ) {
/* translators: %s: theme slug (example: woocommerce-services) */
return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'Invalid theme %s.', 'woocommerce' ), $theme ), 404 );
}
$result = switch_theme( $theme );
if ( ! is_null( $result ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'The requested theme could not be activated.', 'woocommerce' ), $theme ), 500 );
}
return( array(
'slug' => $theme,
'name' => $installed_themes[ $theme ]->get( 'Name' ),
'status' => 'success',
) );
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'onboarding_theme',
'type' => 'object',
'properties' => array(
'slug' => array(
'description' => __( 'Theme slug.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Theme name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'status' => array(
'description' => __( 'Theme status.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/Options.php 0000644 00000022366 15153704476 0010376 0 ustar 00 <?php
/**
* REST API Options Controller
*
* Handles requests to get and update options in the wp_options table.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Options Controller.
*
* @deprecated since 6.2.0
*
* @extends WC_REST_Data_Controller
*/
class Options extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'options';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_options' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_options' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Check if a given request has access to get options.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_item_permissions_check( $request ) {
$params = ( isset( $request['options'] ) && is_string( $request['options'] ) ) ? explode( ',', $request['options'] ) : array();
if ( ! $params ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'You must supply an array of options.', 'woocommerce' ), 500 );
}
foreach ( $params as $option ) {
if ( ! $this->user_has_permission( $option, $request ) ) {
if ( 'production' !== wp_get_environment_type() ) {
return new \WP_Error(
'woocommerce_rest_cannot_view',
__( 'Sorry, you cannot view these options, please remember to update the option permissions in Options API to allow viewing these options in non-production environments.', 'woocommerce' ),
array( 'status' => rest_authorization_required_code() )
);
}
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
}
return true;
}
/**
* Check if the user has permission given an option name.
*
* @param string $option Option name.
* @param WP_REST_Request $request Full details about the request.
* @param bool $is_update If the request is to update the option.
* @return boolean
*/
public function user_has_permission( $option, $request, $is_update = false ) {
$permissions = $this->get_option_permissions( $request );
if ( isset( $permissions[ $option ] ) ) {
return $permissions[ $option ];
}
// Don't allow to update options in non-production environments if the option is not whitelisted. This is to force developers to update the option permissions when adding new options.
if ( 'production' !== wp_get_environment_type() ) {
return false;
}
wc_deprecated_function( 'Automattic\WooCommerce\Admin\API\Options::' . ( $is_update ? 'update_options' : 'get_options' ), '6.3' );
return current_user_can( 'manage_options' );
}
/**
* Check if a given request has access to update options.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function update_item_permissions_check( $request ) {
$params = $request->get_json_params();
if ( ! is_array( $params ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'You must supply an array of options and values.', 'woocommerce' ), 500 );
}
foreach ( $params as $option_name => $option_value ) {
if ( ! $this->user_has_permission( $option_name, $request, true ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
}
return true;
}
/**
* Get an array of options and respective permissions for the current user.
*
* @param WP_REST_Request $request Full details about the request.
* @return array
*/
public function get_option_permissions( $request ) {
$permissions = self::get_default_option_permissions();
return apply_filters_deprecated( 'woocommerce_rest_api_option_permissions', array( $permissions, $request ), '6.3.0' );
}
/**
* Get the default available option permissions.
*
* @return array
*/
public static function get_default_option_permissions() {
$is_woocommerce_admin = \Automattic\WooCommerce\Internal\Admin\Homescreen::is_admin_user();
$woocommerce_permissions = array(
'woocommerce_setup_jetpack_opted_in',
'woocommerce_stripe_settings',
'woocommerce-ppcp-settings',
'woocommerce_ppcp-gateway_setting',
'woocommerce_demo_store',
'woocommerce_demo_store_notice',
'woocommerce_ces_tracks_queue',
'woocommerce_navigation_intro_modal_dismissed',
'woocommerce_shipping_dismissed_timestamp',
'woocommerce_allow_tracking',
'woocommerce_task_list_keep_completed',
'woocommerce_task_list_prompt_shown',
'woocommerce_default_homepage_layout',
'woocommerce_setup_jetpack_opted_in',
'woocommerce_no_sales_tax',
'woocommerce_calc_taxes',
'woocommerce_bacs_settings',
'woocommerce_bacs_accounts',
'woocommerce_task_list_prompt_shown',
'woocommerce_settings_shipping_recommendations_hidden',
'woocommerce_task_list_dismissed_tasks',
'woocommerce_setting_payments_recommendations_hidden',
'woocommerce_navigation_favorites_tooltip_hidden',
'woocommerce_admin_transient_notices_queue',
'woocommerce_task_list_welcome_modal_dismissed',
'woocommerce_welcome_from_calypso_modal_dismissed',
'woocommerce_task_list_hidden',
'woocommerce_task_list_complete',
'woocommerce_extended_task_list_hidden',
'woocommerce_ces_shown_for_actions',
'woocommerce_clear_ces_tracks_queue_for_page',
'woocommerce_admin_install_timestamp',
'woocommerce_task_list_tracked_completed_tasks',
'woocommerce_show_marketplace_suggestions',
'woocommerce_task_list_reminder_bar_hidden',
'wc_connect_options',
'woocommerce_admin_created_default_shipping_zones',
'woocommerce_admin_reviewed_default_shipping_zones',
'woocommerce_admin_reviewed_store_location_settings',
'woocommerce_ces_product_feedback_shown',
'woocommerce_marketing_overview_multichannel_banner_dismissed',
'woocommerce_dimension_unit',
'woocommerce_weight_unit',
'woocommerce_product_editor_show_feedback_bar',
'woocommerce_product_tour_modal_hidden',
'woocommerce_block_product_tour_shown',
'woocommerce_revenue_report_date_tour_shown',
'woocommerce_date_type',
'date_format',
'time_format',
'woocommerce_onboarding_profile',
'woocommerce_default_country',
'blogname',
'wcpay_welcome_page_incentives_dismissed',
'wcpay_welcome_page_viewed_timestamp',
'wcpay_welcome_page_exit_survey_more_info_needed_timestamp',
'woocommerce_customize_store_onboarding_tour_hidden',
'woocommerce_admin_customize_store_completed',
// WC Test helper options.
'wc-admin-test-helper-rest-api-filters',
'wc_admin_helper_feature_values',
);
$theme_permissions = array(
'theme_mods_' . get_stylesheet() => current_user_can( 'edit_theme_options' ),
'stylesheet' => current_user_can( 'edit_theme_options' ),
);
return array_merge(
array_fill_keys( $theme_permissions, current_user_can( 'edit_theme_options' ) ),
array_fill_keys( $woocommerce_permissions, $is_woocommerce_admin )
);
}
/**
* Gets an array of options and respective values.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Options object with option values.
*/
public function get_options( $request ) {
$options = array();
if ( empty( $request['options'] ) || ! is_string( $request['options'] ) ) {
return $options;
}
$params = explode( ',', $request['options'] );
foreach ( $params as $option ) {
$options[ $option ] = get_option( $option );
}
return $options;
}
/**
* Updates an array of objects.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Options object with a boolean if the option was updated.
*/
public function update_options( $request ) {
$params = $request->get_json_params();
$updated = array();
if ( ! is_array( $params ) ) {
return array();
}
foreach ( $params as $key => $value ) {
$updated[ $key ] = update_option( $key, $value );
}
return $updated;
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'options',
'type' => 'object',
'properties' => array(
'options' => array(
'type' => 'array',
'description' => __( 'Array of options with associated values.', 'woocommerce' ),
'context' => array( 'view' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/Orders.php 0000644 00000024260 15153704476 0010174 0 ustar 00 <?php
/**
* REST API Orders Controller
*
* Handles requests to /orders/*
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Orders controller.
*
* @internal
* @extends WC_REST_Orders_Controller
*/
class Orders extends \WC_REST_Orders_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
// This needs to remain a string to support extensions that filter Order Number.
$params['number'] = array(
'description' => __( 'Limit result set to orders matching part of an order number.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
// Fix the default 'status' value until it can be patched in core.
$params['status']['default'] = array( 'any' );
// Analytics settings may affect the allowed status list.
$params['status']['items']['enum'] = ReportsController::get_order_statuses();
return $params;
}
/**
* Prepare objects query.
*
* @param WP_REST_Request $request Full details about the request.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = parent::prepare_objects_query( $request );
if ( ! empty( $request['number'] ) ) {
$args = $this->search_partial_order_number( $request['number'], $args );
}
return $args;
}
/**
* Helper method to allow searching by partial order number.
*
* @param int $number Partial order number match.
* @param array $args List of arguments for the request.
*
* @return array Modified args with partial order search included.
*/
private function search_partial_order_number( $number, $args ) {
global $wpdb;
$partial_number = trim( $number );
$limit = intval( $args['posts_per_page'] );
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
$order_table_name = OrdersTableDataStore::get_orders_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orders_table_name is hardcoded.
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT id
FROM $order_table_name
WHERE type = 'shop_order'
AND id LIKE %s
LIMIT %d",
$wpdb->esc_like( absint( $partial_number ) ) . '%',
$limit
)
);
// phpcs:enable
} else {
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID
FROM {$wpdb->prefix}posts
WHERE post_type = 'shop_order'
AND ID LIKE %s
LIMIT %d",
$wpdb->esc_like( absint( $partial_number ) ) . '%',
$limit
)
);
}
// Force WP_Query return empty if don't found any order.
$order_ids = empty( $order_ids ) ? array( 0 ) : $order_ids;
$args['post__in'] = $order_ids;
return $args;
}
/**
* Get product IDs, names, and quantity from order ID.
*
* @param array $order_id ID of order.
* @return array
*/
protected function get_products_by_order_id( $order_id ) {
global $wpdb;
$order_items_table = $wpdb->prefix . 'woocommerce_order_items';
$order_itemmeta_table = $wpdb->prefix . 'woocommerce_order_itemmeta';
$products = $wpdb->get_results(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT
order_id,
order_itemmeta.meta_value as product_id,
order_itemmeta_2.meta_value as product_quantity,
order_itemmeta_3.meta_value as variation_id,
{$wpdb->posts}.post_title as product_name
FROM {$order_items_table} order_items
LEFT JOIN {$order_itemmeta_table} order_itemmeta on order_items.order_item_id = order_itemmeta.order_item_id
LEFT JOIN {$order_itemmeta_table} order_itemmeta_2 on order_items.order_item_id = order_itemmeta_2.order_item_id
LEFT JOIN {$order_itemmeta_table} order_itemmeta_3 on order_items.order_item_id = order_itemmeta_3.order_item_id
LEFT JOIN {$wpdb->posts} on {$wpdb->posts}.ID = order_itemmeta.meta_value
WHERE
order_id = ( %d )
AND order_itemmeta.meta_key = '_product_id'
AND order_itemmeta_2.meta_key = '_qty'
AND order_itemmeta_3.meta_key = '_variation_id'
GROUP BY product_id
", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$order_id
),
ARRAY_A
);
return $products;
}
/**
* Get customer data from customer_id.
*
* @param array $customer_id ID of customer.
* @return array
*/
protected function get_customer_by_id( $customer_id ) {
global $wpdb;
$customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup';
$customer = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$customer_lookup_table} WHERE customer_id = ( %d )",
$customer_id
),
ARRAY_A
);
return $customer;
}
/**
* Get formatted item data.
*
* @param WC_Data $object WC_Data instance.
* @return array
*/
protected function get_formatted_item_data( $object ) {
$extra_fields = array( 'customer', 'products' );
$fields = false;
// Determine if the response fields were specified.
if ( ! empty( $this->request['_fields'] ) ) {
$fields = wp_parse_list( $this->request['_fields'] );
if ( 0 === count( $fields ) ) {
$fields = false;
} else {
$fields = array_map( 'trim', $fields );
}
}
// Initially skip line items if we can.
$using_order_class_override = is_a( $object, '\Automattic\WooCommerce\Admin\Overrides\Order' );
if ( $using_order_class_override ) {
$data = $object->get_data_without_line_items();
} else {
$data = $object->get_data();
}
$extra_fields = false === $fields ? array() : array_intersect( $extra_fields, $fields );
$format_decimal = array( 'discount_total', 'discount_tax', 'shipping_total', 'shipping_tax', 'shipping_total', 'shipping_tax', 'cart_tax', 'total', 'total_tax' );
$format_date = array( 'date_created', 'date_modified', 'date_completed', 'date_paid' );
$format_line_items = array( 'line_items', 'tax_lines', 'shipping_lines', 'fee_lines', 'coupon_lines' );
// Add extra data as necessary.
$extra_data = array();
foreach ( $extra_fields as $field ) {
switch ( $field ) {
case 'customer':
$extra_data['customer'] = $this->get_customer_by_id( $data['customer_id'] );
break;
case 'products':
$extra_data['products'] = $this->get_products_by_order_id( $object->get_id() );
break;
}
}
// Format decimal values.
foreach ( $format_decimal as $key ) {
$data[ $key ] = wc_format_decimal( $data[ $key ], $this->request['dp'] );
}
// format total with order currency.
if ( $object instanceof \WC_Order ) {
$data['total_formatted'] = wp_strip_all_tags( html_entity_decode( $object->get_formatted_order_total() ), true );
}
// Format date values.
foreach ( $format_date as $key ) {
$datetime = $data[ $key ];
$data[ $key ] = wc_rest_prepare_date_response( $datetime, false );
$data[ $key . '_gmt' ] = wc_rest_prepare_date_response( $datetime );
}
// Format the order status.
$data['status'] = 'wc-' === substr( $data['status'], 0, 3 ) ? substr( $data['status'], 3 ) : $data['status'];
// Format requested line items.
$formatted_line_items = array();
foreach ( $format_line_items as $key ) {
if ( false === $fields || in_array( $key, $fields, true ) ) {
if ( $using_order_class_override ) {
$line_item_data = $object->get_line_item_data( $key );
} else {
$line_item_data = $data[ $key ];
}
$formatted_line_items[ $key ] = array_values( array_map( array( $this, 'get_order_item_data' ), $line_item_data ) );
}
}
// Refunds.
$data['refunds'] = array();
foreach ( $object->get_refunds() as $refund ) {
$data['refunds'][] = array(
'id' => $refund->get_id(),
'reason' => $refund->get_reason() ? $refund->get_reason() : '',
'total' => '-' . wc_format_decimal( $refund->get_amount(), $this->request['dp'] ),
);
}
return array_merge(
array(
'id' => $object->get_id(),
'parent_id' => $data['parent_id'],
'number' => $data['number'],
'order_key' => $data['order_key'],
'created_via' => $data['created_via'],
'version' => $data['version'],
'status' => $data['status'],
'currency' => $data['currency'],
'date_created' => $data['date_created'],
'date_created_gmt' => $data['date_created_gmt'],
'date_modified' => $data['date_modified'],
'date_modified_gmt' => $data['date_modified_gmt'],
'discount_total' => $data['discount_total'],
'discount_tax' => $data['discount_tax'],
'shipping_total' => $data['shipping_total'],
'shipping_tax' => $data['shipping_tax'],
'cart_tax' => $data['cart_tax'],
'total' => $data['total'],
'total_formatted' => isset( $data['total_formatted'] ) ? $data['total_formatted'] : $data['total'],
'total_tax' => $data['total_tax'],
'prices_include_tax' => $data['prices_include_tax'],
'customer_id' => $data['customer_id'],
'customer_ip_address' => $data['customer_ip_address'],
'customer_user_agent' => $data['customer_user_agent'],
'customer_note' => $data['customer_note'],
'billing' => $data['billing'],
'shipping' => $data['shipping'],
'payment_method' => $data['payment_method'],
'payment_method_title' => $data['payment_method_title'],
'transaction_id' => $data['transaction_id'],
'date_paid' => $data['date_paid'],
'date_paid_gmt' => $data['date_paid_gmt'],
'date_completed' => $data['date_completed'],
'date_completed_gmt' => $data['date_completed_gmt'],
'cart_hash' => $data['cart_hash'],
'meta_data' => $data['meta_data'],
'refunds' => $data['refunds'],
),
$formatted_line_items,
$extra_data
);
}
}
Admin/API/PaymentGatewaySuggestions.php 0000644 00000012707 15153704476 0014133 0 ustar 00 <?php
/**
* REST API Payment Gateway Suggestions Controller
*
* Handles requests to install and activate depedent plugins.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions;
defined( 'ABSPATH' ) || exit;
/**
* PaymentGatewaySuggetsions Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class PaymentGatewaySuggestions extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'payment-gateway-suggestions';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_suggestions' ),
'permission_callback' => array( $this, 'get_permission_check' ),
'args' => array(
'force_default_suggestions' => array(
'type' => 'boolean',
'description' => __( 'Return the default payment suggestions when woocommerce_show_marketplace_suggestions and woocommerce_setting_payments_recommendations_hidden options are set to no', 'woocommerce' ),
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/dismiss',
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'dismiss_payment_gateway_suggestion' ),
'permission_callback' => array( $this, 'get_permission_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Check if a given request has access to manage plugins.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_permission_check( $request ) {
if ( ! current_user_can( 'install_plugins' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Return suggested payment gateways.
*
* @param WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
*/
public function get_suggestions( $request ) {
$should_display = Suggestions::should_display();
$force_default = $request->get_param( 'force_default_suggestions' );
if ( $should_display ) {
return Suggestions::get_suggestions();
} elseif ( false === $should_display && true === $force_default ) {
return rest_ensure_response( Suggestions::get_suggestions( DefaultPaymentGateways::get_all() ) );
}
return rest_ensure_response( array() );
}
/**
* Dismisses suggested payment gateways.
*
* @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
*/
public function dismiss_payment_gateway_suggestion() {
$success = Suggestions::dismiss();
return rest_ensure_response( $success );
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'payment-gateway-suggestions',
'type' => 'object',
'properties' => array(
'content' => array(
'description' => __( 'Suggestion description.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'id' => array(
'description' => __( 'Suggestion ID.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'image' => array(
'description' => __( 'Gateway image.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'is_visible' => array(
'description' => __( 'Suggestion visibility.', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'plugins' => array(
'description' => __( 'Array of plugin slugs.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'recommendation_priority' => array(
'description' => __( 'Priority of recommendation.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'title' => array(
'description' => __( 'Gateway title.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'transaction_processors' => array(
'description' => __( 'Array of transaction processors and their images.', 'woocommerce' ),
'type' => 'object',
'addtionalProperties' => array(
'type' => 'string',
'format' => 'uri',
),
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}
Admin/API/Plugins.php 0000644 00000047571 15153704476 0010371 0 ustar 00 <?php
/**
* REST API Plugins Controller
*
* Handles requests to install and activate depedent plugins.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\PaymentMethodSuggestionsDataSourcePoller;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
defined( 'ABSPATH' ) || exit;
/**
* Plugins Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Plugins extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'plugins';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'install_plugins' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install/status',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_installation_status' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install/status/(?P<job_id>[a-z0-9_\-]+)',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_job_installation_status' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/active',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'active_plugins' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/installed',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'installed_plugins' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/activate',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'activate_plugins' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/activate/status',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_activation_status' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/activate/status/(?P<job_id>[a-z0-9_\-]+)',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_job_activation_status' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/connect-jetpack',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'connect_jetpack' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_connect_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/request-wccom-connect',
array(
array(
'methods' => 'POST',
'callback' => array( $this, 'request_wccom_connect' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_connect_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/finish-wccom-connect',
array(
array(
'methods' => 'POST',
'callback' => array( $this, 'finish_wccom_connect' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_connect_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/connect-wcpay',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'connect_wcpay' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_connect_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/connect-square',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'connect_square' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_connect_schema' ),
)
);
}
/**
* Check if a given request has access to manage plugins.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function update_item_permissions_check( $request ) {
if ( ! current_user_can( 'install_plugins' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Install the requested plugin.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array Plugin Status
*/
public function install_plugin( $request ) {
wc_deprecated_function( 'install_plugin', '4.3', '\Automattic\WooCommerce\Admin\API\Plugins()->install_plugins' );
// This method expects a `plugin` argument to be sent, install plugins requires plugins.
$request['plugins'] = $request['plugin'];
return self::install_plugins( $request );
}
/**
* Installs the requested plugins.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array Plugin Status
*/
public function install_plugins( $request ) {
$plugins = explode( ',', $request['plugins'] );
if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
}
if ( isset( $request['async'] ) && $request['async'] ) {
$job_id = PluginsHelper::schedule_install_plugins( $plugins );
return array(
'data' => array(
'job_id' => $job_id,
'plugins' => $plugins,
),
'message' => __( 'Plugin installation has been scheduled.', 'woocommerce' ),
);
}
$data = PluginsHelper::install_plugins( $plugins );
return array(
'data' => array(
'installed' => $data['installed'],
'results' => $data['results'],
'install_time' => $data['time'],
),
'errors' => $data['errors'],
'success' => count( $data['errors']->errors ) === 0,
'message' => count( $data['errors']->errors ) === 0
? __( 'Plugins were successfully installed.', 'woocommerce' )
: __( 'There was a problem installing some of the requested plugins.', 'woocommerce' ),
);
}
/**
* Returns a list of recently scheduled installation jobs.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Jobs.
*/
public function get_installation_status( $request ) {
return PluginsHelper::get_installation_status();
}
/**
* Returns a list of recently scheduled installation jobs.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Job.
*/
public function get_job_installation_status( $request ) {
$job_id = $request->get_param( 'job_id' );
$jobs = PluginsHelper::get_installation_status( $job_id );
return reset( $jobs );
}
/**
* Returns a list of active plugins in API format.
*
* @return array Active plugins
*/
public static function active_plugins() {
return( array(
'plugins' => array_values( PluginsHelper::get_active_plugin_slugs() ),
) );
}
/**
* Returns a list of active plugins.
*
* @internal
* @return array Active plugins
*/
public static function get_active_plugins() {
$data = self::active_plugins();
return $data['plugins'];
}
/**
* Returns a list of installed plugins.
*
* @return array Installed plugins
*/
public function installed_plugins() {
return( array(
'plugins' => PluginsHelper::get_installed_plugin_slugs(),
) );
}
/**
* Activate the requested plugin.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array Plugin Status
*/
public function activate_plugins( $request ) {
$plugins = explode( ',', $request['plugins'] );
if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) {
return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
}
if ( isset( $request['async'] ) && $request['async'] ) {
$job_id = PluginsHelper::schedule_activate_plugins( $plugins );
return array(
'data' => array(
'job_id' => $job_id,
'plugins' => $plugins,
),
'message' => __( 'Plugin activation has been scheduled.', 'woocommerce' ),
);
}
$data = PluginsHelper::activate_plugins( $plugins );
return( array(
'data' => array(
'activated' => $data['activated'],
'active' => $data['active'],
),
'errors' => $data['errors'],
'success' => count( $data['errors']->errors ) === 0,
'message' => count( $data['errors']->errors ) === 0
? __( 'Plugins were successfully activated.', 'woocommerce' )
: __( 'There was a problem activating some of the requested plugins.', 'woocommerce' ),
) );
}
/**
* Returns a list of recently scheduled activation jobs.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Job.
*/
public function get_activation_status( $request ) {
return PluginsHelper::get_activation_status();
}
/**
* Returns a list of recently scheduled activation jobs.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Jobs.
*/
public function get_job_activation_status( $request ) {
$job_id = $request->get_param( 'job_id' );
$jobs = PluginsHelper::get_activation_status( $job_id );
return reset( $jobs );
}
/**
* Generates a Jetpack Connect URL.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array Connection URL for Jetpack
*/
public function connect_jetpack( $request ) {
if ( ! class_exists( '\Jetpack' ) ) {
return new \WP_Error( 'woocommerce_rest_jetpack_not_active', __( 'Jetpack is not installed or active.', 'woocommerce' ), 404 );
}
$redirect_url = apply_filters( 'woocommerce_admin_onboarding_jetpack_connect_redirect_url', esc_url_raw( $request['redirect_url'] ) );
$connect_url = \Jetpack::init()->build_connect_url( true, $redirect_url, 'woocommerce-onboarding' );
$calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production';
$connect_url = add_query_arg( array( 'calypso_env' => $calypso_env ), $connect_url );
return( array(
'slug' => 'jetpack',
'name' => __( 'Jetpack', 'woocommerce' ),
'connectAction' => $connect_url,
) );
}
/**
* Kicks off the WCCOM Connect process.
*
* @return WP_Error|array Connection URL for WooCommerce.com
*/
public function request_wccom_connect() {
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php';
if ( ! class_exists( 'WC_Helper_API' ) ) {
return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 );
}
$redirect_uri = wc_admin_url( '&task=connect&wccom-connected=1' );
$request = \WC_Helper_API::post(
'oauth/request_token',
array(
'body' => array(
'home_url' => home_url(),
'redirect_uri' => $redirect_uri,
),
)
);
$code = wp_remote_retrieve_response_code( $request );
if ( 200 !== $code ) {
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
}
$secret = json_decode( wp_remote_retrieve_body( $request ) );
if ( empty( $secret ) ) {
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
}
do_action( 'woocommerce_helper_connect_start' );
$connect_url = add_query_arg(
array(
'home_url' => rawurlencode( home_url() ),
'redirect_uri' => rawurlencode( $redirect_uri ),
'secret' => rawurlencode( $secret ),
'wccom-from' => 'onboarding',
),
\WC_Helper_API::url( 'oauth/authorize' )
);
if ( defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ) {
$connect_url = add_query_arg(
array(
'calypso_env' => WOOCOMMERCE_CALYPSO_ENVIRONMENT,
),
$connect_url
);
}
return( array(
'connectAction' => $connect_url,
) );
}
/**
* Finishes connecting to WooCommerce.com.
*
* @param object $rest_request Request details.
* @return WP_Error|array Contains success status.
*/
public function finish_wccom_connect( $rest_request ) {
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper.php';
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php';
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-updater.php';
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
if ( ! class_exists( 'WC_Helper_API' ) ) {
return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 );
}
// Obtain an access token.
$request = \WC_Helper_API::post(
'oauth/access_token',
array(
'body' => array(
'request_token' => wp_unslash( $rest_request['request_token'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
'home_url' => home_url(),
),
)
);
$code = wp_remote_retrieve_response_code( $request );
if ( 200 !== $code ) {
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
}
$access_token = json_decode( wp_remote_retrieve_body( $request ), true );
if ( ! $access_token ) {
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
}
\WC_Helper_Options::update(
'auth',
array(
'access_token' => $access_token['access_token'],
'access_token_secret' => $access_token['access_token_secret'],
'site_id' => $access_token['site_id'],
'user_id' => get_current_user_id(),
'updated' => time(),
)
);
if ( ! \WC_Helper::_flush_authentication_cache() ) {
\WC_Helper_Options::update( 'auth', array() );
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
}
delete_transient( '_woocommerce_helper_subscriptions' );
\WC_Helper_Updater::flush_updates_cache();
do_action( 'woocommerce_helper_connected' );
return array(
'success' => true,
);
}
/**
* Returns a URL that can be used to connect to Square.
*
* @return WP_Error|array Connect URL.
*/
public function connect_square() {
if ( ! class_exists( '\WooCommerce\Square\Handlers\Connection' ) ) {
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to Square.', 'woocommerce' ), 500 );
}
if ( 'US' === WC()->countries->get_base_country() ) {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! empty( $profile['industry'] ) ) {
$has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true );
}
}
if ( $has_cbd_industry ) {
$url = 'https://squareup.com/t/f_partnerships/d_referrals/p_woocommerce/c_general/o_none/l_us/dt_alldevice/pr_payments/?route=/solutions/cbd';
} else {
$url = \WooCommerce\Square\Handlers\Connection::CONNECT_URL_PRODUCTION;
}
$redirect_url = wp_nonce_url( wc_admin_url( '&task=payments&method=square&square-connect-finish=1' ), 'wc_square_connected' );
$args = array(
'redirect' => rawurlencode( rawurlencode( $redirect_url ) ),
'scopes' => implode(
',',
array(
'MERCHANT_PROFILE_READ',
'PAYMENTS_READ',
'PAYMENTS_WRITE',
'ORDERS_READ',
'ORDERS_WRITE',
'CUSTOMERS_READ',
'CUSTOMERS_WRITE',
'SETTLEMENTS_READ',
'ITEMS_READ',
'ITEMS_WRITE',
'INVENTORY_READ',
'INVENTORY_WRITE',
)
),
);
$connect_url = add_query_arg( $args, $url );
return( array(
'connectUrl' => $connect_url,
) );
}
/**
* Returns a URL that can be used to by WCPay to verify business details with Stripe.
*
* @return WP_Error|array Connect URL.
*/
public function connect_wcpay() {
if ( ! class_exists( 'WC_Payments_Account' ) ) {
return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error communicating with the WooPayments plugin.', 'woocommerce' ), 500 );
}
$connect_url = add_query_arg(
array(
'wcpay-connect' => 'WCADMIN_PAYMENT_TASK',
'_wpnonce' => wp_create_nonce( 'wcpay-connect' ),
),
admin_url()
);
return( array(
'connectUrl' => $connect_url,
) );
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'plugins',
'type' => 'object',
'properties' => array(
'slug' => array(
'description' => __( 'Plugin slug.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Plugin name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'status' => array(
'description' => __( 'Plugin status.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_connect_schema() {
$schema = $this->get_item_schema();
unset( $schema['properties']['status'] );
$schema['properties']['connectAction'] = array(
'description' => __( 'Action that should be completed to connect Jetpack.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
);
return $schema;
}
}
Admin/API/ProductAttributeTerms.php 0000644 00000010563 15153704476 0013256 0 ustar 00 <?php
/**
* REST API Product Attribute Terms Controller
*
* Handles requests to /products/attributes/<slug>/terms
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Product attribute terms controller.
*
* @internal
* @extends WC_REST_Product_Attribute_Terms_Controller
*/
class ProductAttributeTerms extends \WC_REST_Product_Attribute_Terms_Controller {
use CustomAttributeTraits;
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Register the routes for custom product attributes.
*/
public function register_routes() {
parent::register_routes();
register_rest_route(
$this->namespace,
'products/attributes/(?P<slug>[a-z0-9_\-]+)/terms',
array(
'args' => array(
'slug' => array(
'description' => __( 'Slug identifier for the resource.', 'woocommerce' ),
'type' => 'string',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item_by_slug' ),
'permission_callback' => array( $this, 'get_custom_attribute_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check if a given request has access to read a custom attribute.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_custom_attribute_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'attributes', 'read' ) ) {
return new WP_Error(
'woocommerce_rest_cannot_view',
__( 'Sorry, you cannot view this resource.', 'woocommerce' ),
array(
'status' => rest_authorization_required_code(),
)
);
}
return true;
}
/**
* Get the Attribute's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
// Custom attributes substitute slugs for numeric IDs.
$schema['properties']['id']['type'] = array( 'integer', 'string' );
return $schema;
}
/**
* Query custom attribute values by slug.
*
* @param string $slug Attribute slug.
* @return array Attribute values, formatted for response.
*/
protected function get_custom_attribute_values( $slug ) {
global $wpdb;
if ( empty( $slug ) ) {
return array();
}
$attribute_values = array();
// Get the attribute properties.
$attribute = $this->get_custom_attribute_by_slug( $slug );
if ( is_wp_error( $attribute ) ) {
return $attribute;
}
// Find all attribute values assigned to products.
$query_results = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_value, COUNT(meta_id) AS product_count
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND meta_value != ''
GROUP BY meta_value",
'attribute_' . esc_sql( $slug )
),
OBJECT_K
);
// Ensure all defined properties are in the response.
$defined_values = wc_get_text_attributes( $attribute[ $slug ]['value'] );
foreach ( $defined_values as $defined_value ) {
if ( array_key_exists( $defined_value, $query_results ) ) {
continue;
}
$query_results[ $defined_value ] = (object) array(
'meta_value' => $defined_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'product_count' => 0,
);
}
foreach ( $query_results as $term_value => $term ) {
// Mimic the structure of a taxonomy-backed attribute values for response.
$data = array(
'id' => $term_value,
'name' => $term_value,
'slug' => $term_value,
'description' => '',
'menu_order' => 0,
'count' => (int) $term->product_count,
);
$response = rest_ensure_response( $data );
$response->add_links(
array(
'collection' => array(
'href' => rest_url(
$this->namespace . '/products/attributes/' . $slug . '/terms'
),
),
)
);
$response = $this->prepare_response_for_collection( $response );
$attribute_values[ $term_value ] = $response;
}
return array_values( $attribute_values );
}
/**
* Get a single custom attribute.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Request|WP_Error
*/
public function get_item_by_slug( $request ) {
return $this->get_custom_attribute_values( $request['slug'] );
}
}
Admin/API/ProductAttributes.php 0000644 00000010730 15153704476 0012422 0 ustar 00 <?php
/**
* REST API Product Attributes Controller
*
* Handles requests to /products/attributes.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Product categories controller.
*
* @internal
* @extends WC_REST_Product_Attributes_Controller
*/
class ProductAttributes extends \WC_REST_Product_Attributes_Controller {
use CustomAttributeTraits;
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Register the routes for custom product attributes.
*/
public function register_routes() {
parent::register_routes();
register_rest_route(
$this->namespace,
'products/attributes/(?P<slug>[a-z0-9_\-]+)',
array(
'args' => array(
'slug' => array(
'description' => __( 'Slug identifier for the resource.', 'woocommerce' ),
'type' => 'string',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item_by_slug' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get the query params for collections
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['search'] = array(
'description' => __( 'Search by similar attribute name.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the Attribute's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
// Custom attributes substitute slugs for numeric IDs.
$schema['properties']['id']['type'] = array( 'integer', 'string' );
return $schema;
}
/**
* Get a single attribute by it's slug.
*
* @param WP_REST_Request $request The API request.
* @return WP_REST_Response
*/
public function get_item_by_slug( $request ) {
if ( empty( $request['slug'] ) ) {
return array();
}
$attributes = $this->get_custom_attribute_by_slug( $request['slug'] );
if ( is_wp_error( $attributes ) ) {
return $attributes;
}
$response_items = $this->format_custom_attribute_items_for_response( $attributes );
return reset( $response_items );
}
/**
* Format custom attribute items for response (mimic the structure of a taxonomy - backed attribute).
*
* @param array $custom_attributes - CustomAttributeTraits::get_custom_attributes().
* @return array
*/
protected function format_custom_attribute_items_for_response( $custom_attributes ) {
$response = array();
foreach ( $custom_attributes as $attribute_key => $attribute_value ) {
$data = array(
'id' => $attribute_key,
'name' => $attribute_value['name'],
'slug' => $attribute_key,
'type' => 'select',
'order_by' => 'menu_order',
'has_archives' => false,
);
$item_response = rest_ensure_response( $data );
$item_response->add_links( $this->prepare_links( (object) array( 'attribute_id' => $attribute_key ) ) );
$item_response = $this->prepare_response_for_collection(
$item_response
);
$response[] = $item_response;
}
return $response;
}
/**
* Get all attributes, with support for searching (which includes custom attributes).
*
* @param WP_REST_Request $request The API request.
* @return WP_REST_Response
*/
public function get_items( $request ) {
if ( empty( $request['search'] ) ) {
return parent::get_items( $request );
}
$search_string = $request['search'];
$custom_attributes = $this->get_custom_attributes( array( 'name' => $search_string ) );
$matching_attributes = $this->format_custom_attribute_items_for_response( $custom_attributes );
$taxonomy_attributes = wc_get_attribute_taxonomies();
foreach ( $taxonomy_attributes as $attribute_obj ) {
// Skip taxonomy attributes that didn't match the query.
if ( false === stripos( $attribute_obj->attribute_label, $search_string ) ) {
continue;
}
$attribute = $this->prepare_item_for_response( $attribute_obj, $request );
$matching_attributes[] = $this->prepare_response_for_collection( $attribute );
}
$response = rest_ensure_response( $matching_attributes );
$response->header( 'X-WP-Total', count( $matching_attributes ) );
$response->header( 'X-WP-TotalPages', 1 );
return $response;
}
}
Admin/API/ProductCategories.php 0000644 00000000712 15153704476 0012360 0 ustar 00 <?php
/**
* REST API Product Categories Controller
*
* Handles requests to /products/categories.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Product categories controller.
*
* @internal
* @extends WC_REST_Product_Categories_Controller
*/
class ProductCategories extends \WC_REST_Product_Categories_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
}
Admin/API/ProductForm.php 0000644 00000006101 15153704476 0011174 0 ustar 00 <?php
/**
* REST API Product Form Controller
*
* Handles requests to retrieve product form data.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Internal\Admin\ProductForm\FormFactory;
defined( 'ABSPATH' ) || exit;
/**
* ProductForm Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class ProductForm extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'product-form';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_form_config' ),
'permission_callback' => array( $this, 'get_product_form_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/fields',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_fields' ),
'permission_callback' => array( $this, 'get_product_form_permission_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check if a given request has access to manage woocommerce.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_product_form_permission_check( $request ) {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve product form data.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Get the form fields.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error
*/
public function get_fields( $request ) {
$json = array_map(
function( $field ) {
return $field->get_json();
},
FormFactory::get_fields()
);
return rest_ensure_response( $json );
}
/**
* Get the form config.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error
*/
public function get_form_config( $request ) {
$fields = array_map(
function( $field ) {
return $field->get_json();
},
FormFactory::get_fields()
);
$subsections = array_map(
function( $subsection ) {
return $subsection->get_json();
},
FormFactory::get_subsections()
);
$sections = array_map(
function( $section ) {
return $section->get_json();
},
FormFactory::get_sections()
);
$tabs = array_map(
function( $tab ) {
return $tab->get_json();
},
FormFactory::get_tabs()
);
return rest_ensure_response(
array(
'fields' => $fields,
'subsections' => $subsections,
'sections' => $sections,
'tabs' => $tabs,
)
);
}
}
Admin/API/ProductReviews.php 0000644 00000002462 15153704476 0011723 0 ustar 00 <?php
/**
* REST API Product Reviews Controller
*
* Handles requests to /products/reviews.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Product reviews controller.
*
* @internal
* @extends WC_REST_Product_Reviews_Controller
*/
class ProductReviews extends \WC_REST_Product_Reviews_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Prepare links for the request.
*
* @param WP_Comment $review Product review object.
* @return array Links for the given product review.
*/
protected function prepare_links( $review ) {
$links = array(
'self' => array(
'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $review->comment_ID ) ),
),
'collection' => array(
'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
),
);
if ( 0 !== (int) $review->comment_post_ID ) {
$links['up'] = array(
'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $review->comment_post_ID ) ),
'embeddable' => true,
);
}
if ( 0 !== (int) $review->user_id ) {
$links['reviewer'] = array(
'href' => rest_url( 'wp/v2/users/' . $review->user_id ),
'embeddable' => true,
);
}
return $links;
}
}
Admin/API/ProductVariations.php 0000644 00000013735 15153704476 0012423 0 ustar 00 <?php
/**
* REST API Product Variations Controller
*
* Handles requests to /products/variations.
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Product variations controller.
*
* @internal
* @extends WC_REST_Product_Variations_Controller
*/
class ProductVariations extends \WC_REST_Product_Variations_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Register the routes for products.
*/
public function register_routes() {
parent::register_routes();
// Add a route for listing variations without specifying the parent product ID.
register_rest_route(
$this->namespace,
'/variations',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['search'] = array(
'description' => __( 'Search by similar product name, sku, or attribute value.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Add in conditional search filters for variations.
*
* @internal
* @param string $where Where clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_filter( $where, $wp_query ) {
global $wpdb;
$search = $wp_query->get( 'search' );
if ( $search ) {
$like = '%' . $wpdb->esc_like( $search ) . '%';
$conditions = array(
$wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( 'attr_search_meta.meta_value LIKE %s', $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
if ( wc_product_sku_enabled() ) {
$conditions[] = $wpdb->prepare( 'wc_product_meta_lookup.sku LIKE %s', $like );
}
$where .= ' AND (' . implode( ' OR ', $conditions ) . ')';
}
return $where;
}
/**
* Join posts meta tables when variation search query is present.
*
* @internal
* @param string $join Join clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_join( $join, $wp_query ) {
global $wpdb;
$search = $wp_query->get( 'search' );
if ( $search ) {
$join .= " LEFT JOIN {$wpdb->postmeta} AS attr_search_meta
ON {$wpdb->posts}.ID = attr_search_meta.post_id
AND attr_search_meta.meta_key LIKE 'attribute_%' ";
}
if ( wc_product_sku_enabled() && ! strstr( $join, 'wc_product_meta_lookup' ) ) {
$join .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup
ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
}
return $join;
}
/**
* Add product name and sku filtering to the WC API.
*
* @param WP_REST_Request $request Request data.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = parent::prepare_objects_query( $request );
if ( ! empty( $request['search'] ) ) {
$args['search'] = $request['search'];
unset( $args['s'] );
}
// Retrieve variations without specifying a parent product.
if ( "/{$this->namespace}/variations" === $request->get_route() ) {
unset( $args['post_parent'] );
}
return $args;
}
/**
* Get a collection of posts and add the post title filter option to WP_Query.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
add_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10, 2 );
$response = parent::get_items( $request );
remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
remove_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10 );
return $response;
}
/**
* Get the Product's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$schema['properties']['name'] = array(
'description' => __( 'Product parent name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
);
$schema['properties']['type'] = array(
'description' => __( 'Product type.', 'woocommerce' ),
'type' => 'string',
'default' => 'variation',
'enum' => array( 'variation' ),
'context' => array( 'view', 'edit' ),
);
$schema['properties']['parent_id'] = array(
'description' => __( 'Product parent ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
);
return $schema;
}
/**
* Prepare a single variation output for response.
*
* @param WC_Data $object Object data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_object_for_response( $object, $request ) {
$context = empty( $request['context'] ) ? 'view' : $request['context'];
$response = parent::prepare_object_for_response( $object, $request );
$data = $response->get_data();
$data['name'] = $object->get_name( $context );
$data['type'] = $object->get_type();
$data['parent_id'] = $object->get_parent_id( $context );
$response->set_data( $data );
return $response;
}
}
Admin/API/Products.php 0000644 00000023315 15153704476 0010541 0 ustar 00 <?php
/**
* REST API Products Controller
*
* Handles requests to /products/*
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Products controller.
*
* @internal
* @extends WC_REST_Products_Controller
*/
class Products extends \WC_REST_Products_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Local cache of last order dates by ID.
*
* @var array
*/
protected $last_order_dates = array();
/**
* Adds properties that can be embed via ?_embed=1.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$properties_to_embed = array(
'id',
'name',
'slug',
'permalink',
'images',
'description',
'short_description',
);
foreach ( $properties_to_embed as $property ) {
$schema['properties'][ $property ]['context'][] = 'embed';
}
$schema['properties']['last_order_date'] = array(
'description' => __( "The date the last order for this product was placed, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
);
return $schema;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['low_in_stock'] = array(
'description' => __( 'Limit result set to products that are low or out of stock. (Deprecated)', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
);
$params['search'] = array(
'description' => __( 'Search by similar product name or sku.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Add product name and sku filtering to the WC API.
*
* @param WP_REST_Request $request Request data.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = parent::prepare_objects_query( $request );
if ( ! empty( $request['search'] ) ) {
$args['search'] = trim( $request['search'] );
unset( $args['s'] );
}
if ( ! empty( $request['low_in_stock'] ) ) {
$args['low_in_stock'] = $request['low_in_stock'];
$args['post_type'] = array( 'product', 'product_variation' );
}
return $args;
}
/**
* Get a collection of posts and add the post title filter option to WP_Query.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
add_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10, 2 );
add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 );
$response = parent::get_items( $request );
remove_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10 );
remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 );
/**
* The low stock query caused performance issues in WooCommerce 5.5.1
* due to a) being slow, and b) multiple requests being made to this endpoint
* from WC Admin.
*
* This is a temporary measure to trigger the user’s browser to cache the
* endpoint response for 1 minute, limiting the amount of requests overall.
*
* https://github.com/woocommerce/woocommerce-admin/issues/7358
*/
if ( $this->is_low_in_stock_request( $request ) ) {
$response->header( 'Cache-Control', 'max-age=300' );
}
return $response;
}
/**
* Check whether the request is for products low in stock.
*
* It matches requests with parameters:
*
* low_in_stock = true
* page = 1
* fields[0] = id
*
* @param string $request WP REST API request.
* @return boolean Whether the request matches.
*/
private function is_low_in_stock_request( $request ) {
if (
$request->get_param( 'low_in_stock' ) === true &&
$request->get_param( 'page' ) === 1 &&
is_array( $request->get_param( '_fields' ) ) &&
count( $request->get_param( '_fields' ) ) === 1 &&
in_array( 'id', $request->get_param( '_fields' ), true )
) {
return true;
}
return false;
}
/**
* Hang onto last order date since it will get removed by wc_get_product().
*
* @param stdClass $object_data Single row from query results.
* @return WC_Data
*/
public function get_object( $object_data ) {
if ( isset( $object_data->last_order_date ) ) {
$this->last_order_dates[ $object_data->ID ] = $object_data->last_order_date;
}
return parent::get_object( $object_data );
}
/**
* Add `low_stock_amount` property to product data
*
* @param WC_Data $object Object data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_object_for_response( $object, $request ) {
$data = parent::prepare_object_for_response( $object, $request );
$object_data = $object->get_data();
$product_id = $object_data['id'];
if ( $request->get_param( 'low_in_stock' ) ) {
if ( is_numeric( $object_data['low_stock_amount'] ) ) {
$data->data['low_stock_amount'] = $object_data['low_stock_amount'];
}
if ( isset( $this->last_order_dates[ $product_id ] ) ) {
$data->data['last_order_date'] = wc_rest_prepare_date_response( $this->last_order_dates[ $product_id ] );
}
}
if ( isset( $data->data['name'] ) ) {
$data->data['name'] = wp_strip_all_tags( $data->data['name'] );
}
return $data;
}
/**
* Add in conditional select fields to the query.
*
* @internal
* @param string $select Select clause used to select fields from the query.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_fields( $select, $wp_query ) {
if ( $wp_query->get( 'low_in_stock' ) ) {
$fields = array(
'low_stock_amount_meta.meta_value AS low_stock_amount',
'MAX( product_lookup.date_created ) AS last_order_date',
);
$select .= ', ' . implode( ', ', $fields );
}
return $select;
}
/**
* Add in conditional search filters for products.
*
* @internal
* @param string $where Where clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_filter( $where, $wp_query ) {
global $wpdb;
$search = $wp_query->get( 'search' );
if ( $search ) {
$title_like = '%' . $wpdb->esc_like( $search ) . '%';
$where .= $wpdb->prepare( " AND ({$wpdb->posts}.post_title LIKE %s", $title_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$where .= wc_product_sku_enabled() ? $wpdb->prepare( ' OR wc_product_meta_lookup.sku LIKE %s)', $search ) : ')';
}
if ( $wp_query->get( 'low_in_stock' ) ) {
$low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
$where .= "
AND wc_product_meta_lookup.stock_quantity IS NOT NULL
AND wc_product_meta_lookup.stock_status IN('instock','outofstock')
AND (
(
low_stock_amount_meta.meta_value > ''
AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED)
)
OR (
(
low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= ''
)
AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount}
)
)";
}
return $where;
}
/**
* Join posts meta tables when product search or low stock query is present.
*
* @internal
* @param string $join Join clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_join( $join, $wp_query ) {
global $wpdb;
$search = $wp_query->get( 'search' );
if ( $search && wc_product_sku_enabled() ) {
$join = self::append_product_sorting_table_join( $join );
}
if ( $wp_query->get( 'low_in_stock' ) ) {
$product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
$join = self::append_product_sorting_table_join( $join );
$join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' ";
$join .= " LEFT JOIN {$product_lookup_table} product_lookup ON {$wpdb->posts}.ID = CASE
WHEN {$wpdb->posts}.post_type = 'product' THEN product_lookup.product_id
WHEN {$wpdb->posts}.post_type = 'product_variation' THEN product_lookup.variation_id
END";
}
return $join;
}
/**
* Join wc_product_meta_lookup to posts if not already joined.
*
* @internal
* @param string $sql SQL join.
* @return string
*/
protected static function append_product_sorting_table_join( $sql ) {
global $wpdb;
if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
}
return $sql;
}
/**
* Group by post ID to prevent duplicates.
*
* @internal
* @param string $groupby Group by clause used to organize posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_group_by( $groupby, $wp_query ) {
global $wpdb;
$search = $wp_query->get( 'search' );
$low_in_stock = $wp_query->get( 'low_in_stock' );
if ( empty( $groupby ) && ( $search || $low_in_stock ) ) {
$groupby = $wpdb->posts . '.ID';
}
return $groupby;
}
}
Admin/API/ProductsLowInStock.php 0000644 00000023012 15153704476 0012510 0 ustar 00 <?php
/**
* REST API ProductsLowInStock Controller
*
* Handles request to /products/low-in-stock
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* ProductsLowInStock controller.
*
* @internal
* @extends WC_REST_Products_Controller
*/
final class ProductsLowInStock extends \WC_REST_Products_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'products/low-in-stock',
array(
'args' => array(),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Get low in stock products.
*
* @param WP_REST_Request $request request object.
*
* @return WP_REST_Response|WP_ERROR
*/
public function get_items( $request ) {
$query_results = $this->get_low_in_stock_products(
$request->get_param( 'page' ),
$request->get_param( 'per_page' ),
$request->get_param( 'status' )
);
// set images and attributes.
$query_results['results'] = array_map(
function( $query_result ) {
$product = wc_get_product( $query_result );
$query_result->images = $this->get_images( $product );
$query_result->attributes = $this->get_attributes( $product );
return $query_result;
},
$query_results['results']
);
// set last_order_date.
$query_results['results'] = $this->set_last_order_date( $query_results['results'] );
// convert the post data to the expected API response for the backward compatibility.
$query_results['results'] = array_map( array( $this, 'transform_post_to_api_response' ), $query_results['results'] );
$response = rest_ensure_response( array_values( $query_results['results'] ) );
$response->header( 'X-WP-Total', $query_results['total'] );
$response->header( 'X-WP-TotalPages', $query_results['pages'] );
return $response;
}
/**
* Set the last order date for each data.
*
* @param array $results query result from get_low_in_stock_products.
*
* @return mixed
*/
protected function set_last_order_date( $results = array() ) {
global $wpdb;
if ( 0 === count( $results ) ) {
return $results;
}
$wheres = array();
foreach ( $results as $result ) {
'product_variation' === $result->post_type ?
array_push( $wheres, "(product_id={$result->post_parent} and variation_id={$result->ID})" )
: array_push( $wheres, "product_id={$result->ID}" );
}
count( $wheres ) ? $where_clause = implode( ' or ', $wheres ) : $where_clause = $wheres[0];
$product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
$query_string = "
select
product_id,
variation_id,
MAX( wc_order_product_lookup.date_created ) AS last_order_date
from {$product_lookup_table} wc_order_product_lookup
where {$where_clause}
group by product_id
order by date_created desc
";
// phpcs:ignore -- ignore prepare() warning as we're not using any user input here.
$last_order_dates = $wpdb->get_results( $query_string );
$last_order_dates_index = array();
// Make an index with product_id_variation_id as a key
// so that it can be referenced back without looping the whole array.
foreach ( $last_order_dates as $last_order_date ) {
$last_order_dates_index[ $last_order_date->product_id . '_' . $last_order_date->variation_id ] = $last_order_date;
}
foreach ( $results as &$result ) {
'product_variation' === $result->post_type ?
$index_key = $result->post_parent . '_' . $result->ID
: $index_key = $result->ID . '_' . $result->post_parent;
if ( isset( $last_order_dates_index[ $index_key ] ) ) {
$result->last_order_date = $last_order_dates_index[ $index_key ]->last_order_date;
}
}
return $results;
}
/**
* Get low in stock products data.
*
* @param int $page current page.
* @param int $per_page items per page.
* @param string $status post status.
*
* @return array
*/
protected function get_low_in_stock_products( $page = 1, $per_page = 1, $status = 'publish' ) {
global $wpdb;
$offset = ( $page - 1 ) * $per_page;
$low_stock_threshold = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
$query_string = $this->get_query( $this->is_using_sitewide_stock_threshold_only() );
$query_results = $wpdb->get_results(
// phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here.
$wpdb->prepare( $query_string, $status, $low_stock_threshold, $offset, $per_page ),
OBJECT_K
);
$total_results = $wpdb->get_var( 'SELECT FOUND_ROWS()' );
return array(
'results' => $query_results,
'total' => (int) $total_results,
'pages' => (int) ceil( $total_results / (int) $per_page ),
);
}
/**
* Check to see if store is using sitewide threshold only. Meaning that it does not have any custom
* stock threshold for a product.
*
* @return bool
*/
protected function is_using_sitewide_stock_threshold_only() {
global $wpdb;
$count = $wpdb->get_var( "select count(*) as total from {$wpdb->postmeta} where meta_key='_low_stock_amount'" );
return 0 === (int) $count;
}
/**
* Transform post object to expected API response.
*
* @param object $query_result a row of query result from get_low_in_stock_products().
*
* @return array
*/
protected function transform_post_to_api_response( $query_result ) {
$low_stock_amount = null;
if ( isset( $query_result->low_stock_amount ) ) {
$low_stock_amount = (int) $query_result->low_stock_amount;
}
if ( ! isset( $query_result->last_order_date ) ) {
$query_result->last_order_date = null;
}
return array(
'id' => (int) $query_result->ID,
'images' => $query_result->images,
'attributes' => $query_result->attributes,
'low_stock_amount' => $low_stock_amount,
'last_order_date' => wc_rest_prepare_date_response( $query_result->last_order_date ),
'name' => $query_result->post_title,
'parent_id' => (int) $query_result->post_parent,
'stock_quantity' => (int) $query_result->stock_quantity,
'type' => 'product_variation' === $query_result->post_type ? 'variation' : 'simple',
);
}
/**
* Generate a query.
*
* @param bool $siteside_only generates a query for sitewide low stock threshold only query.
*
* @return string
*/
protected function get_query( $siteside_only = false ) {
global $wpdb;
$query = "
SELECT
SQL_CALC_FOUND_ROWS wp_posts.*,
:postmeta_select
wc_product_meta_lookup.stock_quantity
FROM
{$wpdb->wc_product_meta_lookup} wc_product_meta_lookup
LEFT JOIN {$wpdb->posts} wp_posts ON wp_posts.ID = wc_product_meta_lookup.product_id
:postmeta_join
WHERE
wp_posts.post_type IN ('product', 'product_variation')
AND wp_posts.post_status = %s
AND wc_product_meta_lookup.stock_quantity IS NOT NULL
AND wc_product_meta_lookup.stock_status IN('instock', 'outofstock')
:postmeta_wheres
order by wc_product_meta_lookup.product_id DESC
limit %d, %d
";
$postmeta = array(
'select' => '',
'join' => '',
'wheres' => 'AND wc_product_meta_lookup.stock_quantity <= %d',
);
if ( ! $siteside_only ) {
$postmeta['select'] = 'meta.meta_value AS low_stock_amount,';
$postmeta['join'] = "LEFT JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id
AND meta.meta_key = '_low_stock_amount'";
$postmeta['wheres'] = "AND (
(
meta.meta_value > ''
AND wc_product_meta_lookup.stock_quantity <= CAST(
meta.meta_value AS SIGNED
)
)
OR (
(
meta.meta_value IS NULL
OR meta.meta_value <= ''
)
AND wc_product_meta_lookup.stock_quantity <= %d
)
)";
}
return strtr(
$query,
array(
':postmeta_select' => $postmeta['select'],
':postmeta_join' => $postmeta['join'],
':postmeta_wheres' => $postmeta['wheres'],
)
);
}
/**
* Get the query params for collections of attachments.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param();
$params['context']['default'] = 'view';
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['status'] = array(
'default' => 'publish',
'description' => __( 'Limit result set to products assigned a specific status.', 'woocommerce' ),
'type' => 'string',
'enum' => array_merge( array_keys( get_post_statuses() ), array( 'future' ) ),
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}
Admin/API/Reports/Cache.php 0000644 00000002753 15153704476 0011402 0 ustar 00 <?php
/**
* REST API Reports Cache.
*
* Handles report data object caching.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
/**
* REST API Reports Cache class.
*/
class Cache {
/**
* Cache version. Used to invalidate all cached values.
*/
const VERSION_OPTION = 'woocommerce_reports';
/**
* Invalidate cache.
*/
public static function invalidate() {
\WC_Cache_Helper::get_transient_version( self::VERSION_OPTION, true );
}
/**
* Get cache version number.
*
* @return string
*/
public static function get_version() {
$version = \WC_Cache_Helper::get_transient_version( self::VERSION_OPTION );
return $version;
}
/**
* Get cached value.
*
* @param string $key Cache key.
* @return mixed
*/
public static function get( $key ) {
$transient_version = self::get_version();
$transient_value = get_transient( $key );
if (
isset( $transient_value['value'], $transient_value['version'] ) &&
$transient_value['version'] === $transient_version
) {
return $transient_value['value'];
}
return false;
}
/**
* Update cached value.
*
* @param string $key Cache key.
* @param mixed $value New value.
* @return bool
*/
public static function set( $key, $value ) {
$transient_version = self::get_version();
$transient_value = array(
'version' => $transient_version,
'value' => $value,
);
$result = set_transient( $key, $transient_value, WEEK_IN_SECONDS );
return $result;
}
}
Admin/API/Reports/Categories/Controller.php 0000644 00000026677 15153704476 0014622 0 ustar 00 <?php
/**
* REST API Reports categories controller
*
* Handles requests to the /reports/categories endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Categories;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
/**
* REST API Reports categories controller class.
*
* @internal
* @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
*/
class Controller extends ReportsController implements ExportableInterface {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/categories';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['extended_info'] = $request['extended_info'];
$args['category_includes'] = (array) $request['categories'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$categories_query = new Query( $query_args );
$report_data = $categories_query->get_data();
if ( is_wp_error( $report_data ) ) {
return $report_data;
}
if ( ! isset( $report_data->data ) || ! isset( $report_data->page_no ) || ! isset( $report_data->pages ) ) {
return new \WP_Error( 'woocommerce_rest_reports_categories_invalid_response', __( 'Invalid response from data store.', 'woocommerce' ), array( 'status' => 500 ) );
}
$out_data = array();
foreach ( $report_data->data as $datum ) {
$item = $this->prepare_item_for_response( $datum, $request );
$out_data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param stdClass $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_categories', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param \Automattic\WooCommerce\Admin\API\Reports\Query $object Object data.
* @return array
*/
protected function prepare_links( $object ) {
$links = array(
'category' => array(
'href' => rest_url( sprintf( '/%s/products/categories/%d', $this->namespace, $object['category_id'] ) ),
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_categories',
'type' => 'object',
'properties' => array(
'category_id' => array(
'description' => __( 'Category ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'items_sold' => array(
'description' => __( 'Amount of items sold.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'net_revenue' => array(
'description' => __( 'Total sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'products_count' => array(
'description' => __( 'Amount of products.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'extended_info' => array(
'name' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Category name.', 'woocommerce' ),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'category_id',
'enum' => array(
'category_id',
'items_sold',
'net_revenue',
'orders_count',
'products_count',
'category',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['interval'] = array(
'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ),
'type' => 'string',
'default' => 'week',
'enum' => array(
'hour',
'day',
'week',
'month',
'quarter',
'year',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['status_is'] = array(
'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => self::get_order_statuses(),
'type' => 'string',
),
);
$params['status_is_not'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => self::get_order_statuses(),
'type' => 'string',
),
);
$params['categories'] = array(
'description' => __( 'Limit result set to all items that have the specified term assigned in the categories taxonomy.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each category to the report.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'category' => __( 'Category', 'woocommerce' ),
'items_sold' => __( 'Items sold', 'woocommerce' ),
'net_revenue' => __( 'Net Revenue', 'woocommerce' ),
'products_count' => __( 'Products', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
);
/**
* Filter to add or remove column names from the categories report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_categories_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$export_item = array(
'category' => $item['extended_info']['name'],
'items_sold' => $item['items_sold'],
'net_revenue' => $item['net_revenue'],
'products_count' => $item['products_count'],
'orders_count' => $item['orders_count'],
);
/**
* Filter to prepare extra columns in the export item for the
* categories export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_categories_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Categories/DataStore.php 0000644 00000025215 15153704476 0014350 0 ustar 00 <?php
/**
* API\Reports\Categories\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Categories;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Categories\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_product_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'categories';
/**
* Order by setting used for sorting categories data.
*
* @var string
*/
private $order_by = '';
/**
* Order setting used for sorting categories data.
*
* @var string
*/
private $order = '';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'category_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'products_count' => 'intval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'categories';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
'products_count' => "COUNT(DISTINCT {$table_name}.product_id) as products_count",
);
}
/**
* Return the database query with parameters used for Categories report: time span and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_product_lookup_table = self::get_db_table_name();
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
// join wp_order_product_lookup_table with relationships and taxonomies
// @todo How to handle custom product tables?
$this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$wpdb->term_relationships} ON {$order_product_lookup_table}.product_id = {$wpdb->term_relationships}.object_id" );
// Adding this (inner) JOIN as a LEFT JOIN for ordering purposes. See comment in add_order_by_params().
$this->subquery->add_sql_clause( 'left_join', "JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id" );
$included_categories = $this->get_included_categories( $query_args );
if ( $included_categories ) {
$this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_relationships}.term_taxonomy_id IN ({$included_categories})" );
// Limit is left out here so that the grouping in code by PHP can be applied correctly.
// This also needs to be put after the term_taxonomy JOIN so that we can match the correct term name.
$this->add_order_by_params( $query_args, 'outer', 'default_results.category_id' );
} else {
$this->add_order_by_params( $query_args, 'inner', "{$wpdb->term_relationships}.term_taxonomy_id" );
}
$this->add_order_status_clause( $query_args, $order_product_lookup_table, $this->subquery );
$this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_taxonomy}.taxonomy = 'product_cat'" );
}
/**
* Fills ORDER BY clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @param string $from_arg Target of the JOIN sql param.
* @param string $id_cell ID cell identifier, like `table_name.id_column_name`.
*/
protected function add_order_by_params( $query_args, $from_arg, $id_cell ) {
global $wpdb;
// Sanitize input: guarantee that the id cell in the join is quoted with backticks.
$id_cell_segments = explode( '.', str_replace( '`', '', $id_cell ) );
$id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`';
$lookup_table = self::get_db_table_name();
$order_by_clause = $this->add_order_by_clause( $query_args, $this );
$this->add_orderby_order_clause( $query_args, $this );
if ( false !== strpos( $order_by_clause, '_terms' ) ) {
$join = "JOIN {$wpdb->terms} AS _terms ON {$id_cell_identifier} = _terms.term_id";
if ( 'inner' === $from_arg ) {
// Even though this is an (inner) JOIN, we're adding it as a `left_join` to
// affect its order in the query statement. The SqlQuery::$sql_filters variable
// determines the order in which joins are concatenated.
// See: https://github.com/woocommerce/woocommerce-admin/blob/1f261998e7287b77bc13c3d4ee2e84b717da7957/src/API/Reports/SqlQuery.php#L46-L50.
$this->subquery->add_sql_clause( 'left_join', $join );
} else {
$this->add_sql_clause( 'join', $join );
}
}
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
if ( 'category' === $order_by ) {
return '_terms.name';
}
return $order_by;
}
/**
* Returns an array of ids of included categories, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_categories_array( $query_args ) {
if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) {
return $query_args['category_includes'];
}
return array();
}
/**
* Returns the page of data according to page number and items per page.
*
* @param array $data Data to paginate.
* @param integer $page_no Page number.
* @param integer $items_per_page Number of items per page.
* @return array
*/
protected function page_records( $data, $page_no, $items_per_page ) {
$offset = ( $page_no - 1 ) * $items_per_page;
return array_slice( $data, $offset, $items_per_page );
}
/**
* Enriches the category data.
*
* @param array $categories_data Categories data.
* @param array $query_args Query parameters.
*/
protected function include_extended_info( &$categories_data, $query_args ) {
foreach ( $categories_data as $key => $category_data ) {
$extended_info = new \ArrayObject();
if ( $query_args['extended_info'] ) {
$extended_info['name'] = get_the_category_by_ID( $category_data['category_id'] );
}
$categories_data[ $key ]['extended_info'] = $extended_info;
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
$included_categories = $this->get_included_categories_array( $query_args );
$this->add_sql_query_params( $query_args );
if ( count( $included_categories ) > 0 ) {
$fields = $this->get_fields( $query_args );
$ids_table = $this->get_ids_table( $included_categories, 'category_id' );
$this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) );
$this->add_sql_clause( 'from', '(' );
$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
$this->add_sql_clause( 'from', ") AS {$table_name}" );
$this->add_sql_clause(
'right_join',
"RIGHT JOIN ( {$ids_table} ) AS default_results
ON default_results.category_id = {$table_name}.category_id"
);
$categories_query = $this->get_query_statement();
} else {
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$categories_query = $this->subquery->get_query_statement();
}
$categories_data = $wpdb->get_results(
$categories_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $categories_data ) {
return new \WP_Error( 'woocommerce_analytics_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ), array( 'status' => 500 ) );
}
$record_count = count( $categories_data );
$total_pages = (int) ceil( $record_count / $query_args['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$categories_data = $this->page_records( $categories_data, $query_args['page'], $query_args['per_page'] );
$this->include_extended_info( $categories_data, $query_args );
$categories_data = array_map( array( $this, 'cast_numbers' ), $categories_data );
$data = (object) array(
'data' => $categories_data,
'total' => $record_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
global $wpdb;
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'select', "{$wpdb->term_taxonomy}.term_id as category_id," );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
$this->subquery->add_sql_clause( 'group_by', "{$wpdb->term_taxonomy}.term_id" );
}
}
Admin/API/Reports/Categories/Query.php 0000644 00000002367 15153704476 0013572 0 ustar 00 <?php
/**
* Class for parameter-based Categories Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'order' => 'desc',
* 'orderby' => 'items_sold',
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Categories\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Categories;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Query
*/
class Query extends ReportsQuery {
const REPORT_NAME = 'report-categories';
/**
* Valid fields for Categories report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get categories data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_categories_query_args', $this->get_query_vars() );
$results = \WC_Data_Store::load( self::REPORT_NAME )->get_data( $args );
return apply_filters( 'woocommerce_analytics_categories_select_query', $results, $args );
}
}
Admin/API/Reports/Controller.php 0000644 00000022026 15153704476 0012515 0 ustar 00 <?php
/**
* REST API Reports controller extended by WC Admin plugin.
*
* Handles requests to the reports endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
/**
* REST API Reports controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController {
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$data = array();
$reports = array(
array(
'slug' => 'performance-indicators',
'description' => __( 'Batch endpoint for getting specific performance indicators from `stats` endpoints.', 'woocommerce' ),
),
array(
'slug' => 'revenue/stats',
'description' => __( 'Stats about revenue.', 'woocommerce' ),
),
array(
'slug' => 'orders/stats',
'description' => __( 'Stats about orders.', 'woocommerce' ),
),
array(
'slug' => 'products',
'description' => __( 'Products detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'products/stats',
'description' => __( 'Stats about products.', 'woocommerce' ),
),
array(
'slug' => 'variations',
'description' => __( 'Variations detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'variations/stats',
'description' => __( 'Stats about variations.', 'woocommerce' ),
),
array(
'slug' => 'categories',
'description' => __( 'Product categories detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'categories/stats',
'description' => __( 'Stats about product categories.', 'woocommerce' ),
),
array(
'slug' => 'coupons',
'description' => __( 'Coupons detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'coupons/stats',
'description' => __( 'Stats about coupons.', 'woocommerce' ),
),
array(
'slug' => 'taxes',
'description' => __( 'Taxes detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'taxes/stats',
'description' => __( 'Stats about taxes.', 'woocommerce' ),
),
array(
'slug' => 'downloads',
'description' => __( 'Product downloads detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'downloads/files',
'description' => __( 'Product download files detailed reports.', 'woocommerce' ),
),
array(
'slug' => 'downloads/stats',
'description' => __( 'Stats about product downloads.', 'woocommerce' ),
),
array(
'slug' => 'customers',
'description' => __( 'Customers detailed reports.', 'woocommerce' ),
),
);
/**
* Filter the list of allowed reports, so that data can be loaded from third party extensions in addition to WooCommerce core.
* Array items should be in format of array( 'slug' => 'downloads/stats', 'description' => '',
* 'url' => '', and 'path' => '/wc-ext/v1/...'.
*
* @param array $endpoints The list of allowed reports..
*/
$reports = apply_filters( 'woocommerce_admin_reports', $reports );
foreach ( $reports as $report ) {
if ( empty( $report['slug'] ) ) {
continue;
}
if ( empty( $report['path'] ) ) {
$report['path'] = '/' . $this->namespace . '/reports/' . $report['slug'];
}
// Allows a different admin page to be loaded here,
// or allows an empty url if no report exists for a set of performance indicators.
if ( ! isset( $report['url'] ) ) {
if ( '/stats' === substr( $report['slug'], -6 ) ) {
$url_slug = substr( $report['slug'], 0, -6 );
} else {
$url_slug = $report['slug'];
}
$report['url'] = '/analytics/' . $url_slug;
}
$item = $this->prepare_item_for_response( (object) $report, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return rest_ensure_response( $data );
}
/**
* Get the order number for an order. If no filter is present for `woocommerce_order_number`, we can just return the ID.
* Returns the parent order number if the order is actually a refund.
*
* @param int $order_id Order ID.
* @return string
*/
protected function get_order_number( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order && ! $order instanceof \WC_Order_Refund ) {
return null;
}
if ( 'shop_order_refund' === $order->get_type() ) {
$order = wc_get_order( $order->get_parent_id() );
}
if ( ! has_filter( 'woocommerce_order_number' ) ) {
return $order->get_id();
}
return $order->get_order_number();
}
/**
* Get the order total with the related currency formatting.
* Returns the parent order total if the order is actually a refund.
*
* @param int $order_id Order ID.
* @return string
*/
protected function get_total_formatted( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order && ! $order instanceof \WC_Order_Refund ) {
return null;
}
if ( 'shop_order_refund' === $order->get_type() ) {
$order = wc_get_order( $order->get_parent_id() );
}
return wp_strip_all_tags( html_entity_decode( $order->get_formatted_order_total() ), true );
}
/**
* Prepare a report object for serialization.
*
* @param stdClass $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = array(
'slug' => $report->slug,
'description' => $report->description,
'path' => $report->path,
);
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links(
array(
'self' => array(
'href' => rest_url( $report->path ),
),
'report' => array(
'href' => $report->url,
),
'collection' => array(
'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
),
)
);
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report', $response, $report, $request );
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report',
'type' => 'object',
'properties' => array(
'slug' => array(
'description' => __( 'An alphanumeric identifier for the resource.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view' ),
'readonly' => true,
),
'description' => array(
'description' => __( 'A human-readable description of the resource.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view' ),
'readonly' => true,
),
'path' => array(
'description' => __( 'API path.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
return array(
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
);
}
/**
* Get order statuses without prefixes.
* Includes unregistered statuses that have been marked "actionable".
*
* @internal
* @return array
*/
public static function get_order_statuses() {
// Allow all statuses selected as "actionable" - this may include unregistered statuses.
// See: https://github.com/woocommerce/woocommerce-admin/issues/5592.
$actionable_statuses = get_option( 'woocommerce_actionable_order_statuses', array() );
// See WC_REST_Orders_V2_Controller::get_collection_params() re: any/trash statuses.
$registered_statuses = array_merge( array( 'any', 'trash' ), array_keys( self::get_order_status_labels() ) );
// Merge the status arrays (using flip to avoid array_unique()).
$allowed_statuses = array_keys( array_merge( array_flip( $registered_statuses ), array_flip( $actionable_statuses ) ) );
return $allowed_statuses;
}
/**
* Get order statuses (and labels) without prefixes.
*
* @internal
* @return array
*/
public static function get_order_status_labels() {
$order_statuses = array();
foreach ( wc_get_order_statuses() as $key => $label ) {
$new_key = str_replace( 'wc-', '', $key );
$order_statuses[ $new_key ] = $label;
}
return $order_statuses;
}
}
Admin/API/Reports/Coupons/Controller.php 0000644 00000020242 15153704476 0014141 0 ustar 00 <?php
/**
* REST API Reports coupons controller
*
* Handles requests to the /reports/coupons endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports coupons controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController implements ExportableInterface {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/coupons';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['coupons'] = (array) $request['coupons'];
$args['extended_info'] = $request['extended_info'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$coupons_query = new Query( $query_args );
$report_data = $coupons_query->get_data();
$data = array();
foreach ( $report_data->data as $coupons_data ) {
$item = $this->prepare_item_for_response( $coupons_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_coupons', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param WC_Reports_Query $object Object data.
* @return array
*/
protected function prepare_links( $object ) {
$links = array(
'coupon' => array(
'href' => rest_url( sprintf( '/%s/coupons/%d', $this->namespace, $object['coupon_id'] ) ),
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_coupons',
'type' => 'object',
'properties' => array(
'coupon_id' => array(
'description' => __( 'Coupon ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'amount' => array(
'description' => __( 'Net discount amount.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'extended_info' => array(
'code' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Coupon code.', 'woocommerce' ),
),
'date_created' => array(
'type' => 'date-time',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Coupon creation date.', 'woocommerce' ),
),
'date_created_gmt' => array(
'type' => 'date-time',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Coupon creation date in GMT.', 'woocommerce' ),
),
'date_expires' => array(
'type' => 'date-time',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Coupon expiration date.', 'woocommerce' ),
),
'date_expires_gmt' => array(
'type' => 'date-time',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Coupon expiration date in GMT.', 'woocommerce' ),
),
'discount_type' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'enum' => array_keys( wc_get_coupon_types() ),
'description' => __( 'Coupon discount type.', 'woocommerce' ),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['default'] = 'coupon_id';
$params['orderby']['enum'] = array(
'coupon_id',
'code',
'amount',
'orders_count',
);
$params['coupons'] = array(
'description' => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'code' => __( 'Coupon code', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
'amount' => __( 'Amount discounted', 'woocommerce' ),
'created' => __( 'Created', 'woocommerce' ),
'expires' => __( 'Expires', 'woocommerce' ),
'type' => __( 'Type', 'woocommerce' ),
);
/**
* Filter to add or remove column names from the coupons report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_coupons_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$date_expires = empty( $item['extended_info']['date_expires'] )
? __( 'N/A', 'woocommerce' )
: $item['extended_info']['date_expires'];
$export_item = array(
'code' => $item['extended_info']['code'],
'orders_count' => $item['orders_count'],
'amount' => $item['amount'],
'created' => $item['extended_info']['date_created'],
'expires' => $date_expires,
'type' => $item['extended_info']['discount_type'],
);
/**
* Filter to prepare extra columns in the export item for the coupons
* report.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_coupons_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Coupons/DataStore.php 0000644 00000036734 15153704476 0013721 0 ustar 00 <?php
/**
* API\Reports\Coupons\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
/**
* API\Reports\Coupons\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_coupon_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'coupons';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'coupon_id' => 'intval',
'amount' => 'floatval',
'orders_count' => 'intval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'coupons';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'coupon_id' => 'coupon_id',
'amount' => 'SUM(discount_amount) as amount',
'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
);
}
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 );
}
/**
* Returns an array of ids of included coupons, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_included_coupons_array( $query_args ) {
if ( isset( $query_args['coupons'] ) && is_array( $query_args['coupons'] ) && count( $query_args['coupons'] ) > 0 ) {
return $query_args['coupons'];
}
return array();
}
/**
* Updates the database query with parameters used for Products report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_coupon_lookup_table = self::get_db_table_name();
$this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table );
$this->get_limit_sql_params( $query_args );
$included_coupons = $this->get_included_coupons( $query_args, 'coupons' );
if ( $included_coupons ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})" );
$this->add_order_by_params( $query_args, 'outer', 'default_results.coupon_id' );
} else {
$this->add_order_by_params( $query_args, 'inner', "{$order_coupon_lookup_table}.coupon_id" );
}
$this->add_order_status_clause( $query_args, $order_coupon_lookup_table, $this->subquery );
}
/**
* Fills ORDER BY clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @param string $from_arg Target of the JOIN sql param.
* @param string $id_cell ID cell identifier, like `table_name.id_column_name`.
*/
protected function add_order_by_params( $query_args, $from_arg, $id_cell ) {
global $wpdb;
// Sanitize input: guarantee that the id cell in the join is quoted with backticks.
$id_cell_segments = explode( '.', str_replace( '`', '', $id_cell ) );
$id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`';
$lookup_table = self::get_db_table_name();
$order_by_clause = $this->add_order_by_clause( $query_args, $this );
$join = "JOIN {$wpdb->posts} AS _coupons ON {$id_cell_identifier} = _coupons.ID";
$this->add_orderby_order_clause( $query_args, $this );
if ( 'inner' === $from_arg ) {
$this->subquery->clear_sql_clause( 'join' );
if ( false !== strpos( $order_by_clause, '_coupons' ) ) {
$this->subquery->add_sql_clause( 'join', $join );
}
} else {
$this->clear_sql_clause( 'join' );
if ( false !== strpos( $order_by_clause, '_coupons' ) ) {
$this->add_sql_clause( 'join', $join );
}
}
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
if ( 'code' === $order_by ) {
return '_coupons.post_title';
}
return $order_by;
}
/**
* Enriches the coupon data with extra attributes.
*
* @param array $coupon_data Coupon data.
* @param array $query_args Query parameters.
*/
protected function include_extended_info( &$coupon_data, $query_args ) {
foreach ( $coupon_data as $idx => $coupon_datum ) {
$extended_info = new \ArrayObject();
if ( $query_args['extended_info'] ) {
$coupon_id = $coupon_datum['coupon_id'];
$coupon = new \WC_Coupon( $coupon_id );
if ( 0 === $coupon->get_id() ) {
// Deleted or otherwise invalid coupon.
$extended_info = array(
'code' => __( '(Deleted)', 'woocommerce' ),
'date_created' => '',
'date_created_gmt' => '',
'date_expires' => '',
'date_expires_gmt' => '',
'discount_type' => __( 'N/A', 'woocommerce' ),
);
} else {
$gmt_timzone = new \DateTimeZone( 'UTC' );
$date_expires = $coupon->get_date_expires();
if ( is_a( $date_expires, 'DateTime' ) ) {
$date_expires = $date_expires->format( TimeInterval::$iso_datetime_format );
$date_expires_gmt = new \DateTime( $date_expires );
$date_expires_gmt->setTimezone( $gmt_timzone );
$date_expires_gmt = $date_expires_gmt->format( TimeInterval::$iso_datetime_format );
} else {
$date_expires = '';
$date_expires_gmt = '';
}
$date_created = $coupon->get_date_created();
if ( is_a( $date_created, 'DateTime' ) ) {
$date_created = $date_created->format( TimeInterval::$iso_datetime_format );
$date_created_gmt = new \DateTime( $date_created );
$date_created_gmt->setTimezone( $gmt_timzone );
$date_created_gmt = $date_created_gmt->format( TimeInterval::$iso_datetime_format );
} else {
$date_created = '';
$date_created_gmt = '';
}
$extended_info = array(
'code' => $coupon->get_code(),
'date_created' => $date_created,
'date_created_gmt' => $date_created_gmt,
'date_expires' => $date_expires,
'date_expires_gmt' => $date_expires_gmt,
'discount_type' => $coupon->get_discount_type(),
);
}
}
$coupon_data[ $idx ]['extended_info'] = $extended_info;
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'coupon_id',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'coupons' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$included_coupons = $this->get_included_coupons_array( $query_args );
$limit_params = $this->get_limit_params( $query_args );
$this->subquery->add_sql_clause( 'select', $selections );
$this->add_sql_query_params( $query_args );
if ( count( $included_coupons ) > 0 ) {
$total_results = count( $included_coupons );
$total_pages = (int) ceil( $total_results / $limit_params['per_page'] );
$fields = $this->get_fields( $query_args );
$ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' );
$this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) );
$this->add_sql_clause( 'from', '(' );
$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
$this->add_sql_clause( 'from', ") AS {$table_name}" );
$this->add_sql_clause(
'right_join',
"RIGHT JOIN ( {$ids_table} ) AS default_results
ON default_results.coupon_id = {$table_name}.coupon_id"
);
$coupons_query = $this->get_query_statement();
} else {
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$coupons_query = $this->subquery->get_query_statement();
$this->subquery->clear_sql_clause( array( 'select', 'order_by', 'limit' ) );
$this->subquery->add_sql_clause( 'select', 'coupon_id' );
$coupon_subquery = "SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) AS tt";
$db_records_count = (int) $wpdb->get_var(
$coupon_subquery // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$total_results = $db_records_count;
$total_pages = (int) ceil( $db_records_count / $limit_params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
}
$coupon_data = $wpdb->get_results(
$coupons_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $coupon_data ) {
return $data;
}
$this->include_extended_info( $coupon_data, $query_args );
$coupon_data = array_map( array( $this, 'cast_numbers' ), $coupon_data );
$data = (object) array(
'data' => $coupon_data,
'total' => $total_results,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Get coupon ID for an order.
*
* Tries to get the ID from order item meta, then falls back to a query of published coupons.
*
* @param \WC_Order_Item_Coupon $coupon_item The coupon order item object.
* @return int Coupon ID on success, 0 on failure.
*/
public static function get_coupon_id( \WC_Order_Item_Coupon $coupon_item ) {
// First attempt to get coupon ID from order item data.
$coupon_data = $coupon_item->get_meta( 'coupon_data', true );
// Normal checkout orders should have this data.
// See: https://github.com/woocommerce/woocommerce/blob/3dc7df7af9f7ca0c0aa34ede74493e856f276abe/includes/abstracts/abstract-wc-order.php#L1206.
if ( isset( $coupon_data['id'] ) ) {
return $coupon_data['id'];
}
// Try to get the coupon ID using the code.
return wc_get_coupon_id_by_code( $coupon_item->get_code() );
}
/**
* Create or update an an entry in the wc_order_coupon_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
*/
public static function sync_order_coupons( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return -1;
}
// Refunds don't affect coupon stats so return successfully if one is called here.
if ( 'shop_order_refund' === $order->get_type() ) {
return true;
}
$table_name = self::get_db_table_name();
$existing_items = $wpdb->get_col(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT coupon_id FROM {$table_name} WHERE order_id = %d",
$order_id
)
);
$existing_items = array_flip( $existing_items );
$coupon_items = $order->get_items( 'coupon' );
$coupon_items_count = count( $coupon_items );
$num_updated = 0;
$num_deleted = 0;
foreach ( $coupon_items as $coupon_item ) {
$coupon_id = self::get_coupon_id( $coupon_item );
unset( $existing_items[ $coupon_id ] );
if ( ! $coupon_id ) {
// Insert a unique, but obviously invalid ID for this deleted coupon.
$num_deleted++;
$coupon_id = -1 * $num_deleted;
}
$result = $wpdb->replace(
self::get_db_table_name(),
array(
'order_id' => $order_id,
'coupon_id' => $coupon_id,
'discount_amount' => $coupon_item->get_discount(),
'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
),
array(
'%d',
'%d',
'%f',
'%s',
)
);
/**
* Fires when coupon's reports are updated.
*
* @param int $coupon_id Coupon ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_update_coupon', $coupon_id, $order_id );
// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
}
if ( ! empty( $existing_items ) ) {
$existing_items = array_flip( $existing_items );
$format = array_fill( 0, count( $existing_items ), '%d' );
$format = implode( ',', $format );
array_unshift( $existing_items, $order_id );
$wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"DELETE FROM {$table_name} WHERE order_id = %d AND coupon_id in ({$format})",
$existing_items
)
);
}
return ( $coupon_items_count === $num_updated );
}
/**
* Clean coupons data when an order is deleted.
*
* @param int $order_id Order ID.
*/
public static function sync_on_order_delete( $order_id ) {
global $wpdb;
$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
/**
* Fires when coupon's reports are removed from database.
*
* @param int $coupon_id Coupon ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_delete_coupon', 0, $order_id );
ReportsCache::invalidate();
}
/**
* Gets coupons based on the provided arguments.
*
* @todo Upon core merge, including this in core's `class-wc-coupon-data-store-cpt.php` might make more sense.
* @param array $args Array of args to filter the query by. Supports `include`.
* @return array Array of results.
*/
public function get_coupons( $args ) {
global $wpdb;
$query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type='shop_coupon'";
$included_coupons = $this->get_included_coupons( $args, 'include' );
if ( ! empty( $included_coupons ) ) {
$query .= " AND ID IN ({$included_coupons})";
}
return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
$this->subquery->add_sql_clause( 'group_by', 'coupon_id' );
}
}
Admin/API/Reports/Coupons/Query.php 0000644 00000002245 15153704476 0013126 0 ustar 00 <?php
/**
* Class for parameter-based Coupons Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'coupons' => array(5, 120),
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Coupons\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_coupons_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-coupons' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args );
}
}
Admin/API/Reports/Coupons/Stats/Controller.php 0000644 00000013425 15153704476 0015244 0 ustar 00 <?php
/**
* REST API Reports coupons stats controller
*
* Handles requests to the /reports/coupons/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports coupons stats controller class.
*
* @internal
* @extends GenericStatsController
*/
class Controller extends GenericStatsController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/coupons/stats';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['coupons'] = (array) $request['coupons'];
$args['segmentby'] = $request['segmentby'];
$args['fields'] = $request['fields'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$coupons_query = new Query( $query_args );
try {
$report_data = $coupons_query->get_data();
} catch ( ParameterException $e ) {
return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( (object) $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param stdClass $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = get_object_vars( $report );
$response = parent::prepare_item_for_response( $data, $request );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_coupons_stats', $response, $report, $request );
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
protected function get_item_properties_schema() {
return array(
'amount' => array(
'description' => __( 'Net discount amount.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'coupons_count' => array(
'description' => __( 'Number of coupons.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'orders_count' => array(
'title' => __( 'Discounted orders', 'woocommerce' ),
'description' => __( 'Number of discounted orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$schema['title'] = 'report_coupons_stats';
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array(
'date',
'amount',
'coupons_count',
'orders_count',
);
$params['coupons'] = array(
'description' => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'product',
'variation',
'category',
'coupon',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
}
Admin/API/Reports/Coupons/Stats/DataStore.php 0000644 00000021305 15153704476 0015003 0 ustar 00 <?php
/**
* API\Reports\Coupons\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Coupons\Stats\DataStore.
*/
class DataStore extends CouponsDataStore implements DataStoreInterface {
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'date_start_gmt' => 'strval',
'date_end_gmt' => 'strval',
'amount' => 'floatval',
'coupons_count' => 'intval',
'orders_count' => 'intval',
);
/**
* SQL columns to select in the db query.
*
* @var array
*/
protected $report_columns;
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'coupons_stats';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'coupons_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'amount' => 'SUM(discount_amount) as amount',
'coupons_count' => 'COUNT(DISTINCT coupon_id) as coupons_count',
'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
);
}
/**
* Updates the database query with parameters used for Products Stats report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function update_sql_query_params( $query_args ) {
global $wpdb;
$clauses = array(
'where' => '',
'join' => '',
);
$order_coupon_lookup_table = self::get_db_table_name();
$included_coupons = $this->get_included_coupons( $query_args, 'coupons' );
if ( $included_coupons ) {
$clauses['where'] .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})";
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$clauses['join'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
$clauses['where'] .= " AND ( {$order_status_filter} )";
}
$this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table );
$this->add_intervals_sql_params( $query_args, $order_coupon_lookup_table );
$clauses['where_time'] = $this->get_sql_clause( 'where_time' );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) );
$this->interval_query->add_sql_clause( 'select', 'AS time_interval' );
foreach ( array( 'join', 'where_time', 'where' ) as $clause ) {
$this->interval_query->add_sql_clause( $clause, $clauses[ $clause ] );
$this->total_query->add_sql_clause( $clause, $clauses[ $clause ] );
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @since 3.5.0
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'interval' => 'week',
'coupons' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$totals_query = array();
$intervals_query = array();
$limit_params = $this->get_limit_sql_params( $query_args );
$this->update_sql_query_params( $query_args, $totals_query, $intervals_query );
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$db_interval_count = count( $db_intervals );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $limit_params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->total_query->add_sql_clause( 'select', $selections );
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $totals ) {
return $data;
}
// @todo remove these assignements when refactoring segmenter classes to use query objects.
$totals_query = array(
'from_clause' => $this->total_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->total_query->get_sql_clause( 'where' ),
);
$intervals_query = array(
'select_clause' => $this->get_sql_clause( 'select' ),
'from_clause' => $this->interval_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->interval_query->get_sql_clause( 'where' ),
'limit' => $this->get_sql_clause( 'limit' ),
);
$segmenter = new Segmenter( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
$totals = (object) $this->cast_numbers( $totals[0] );
// Intervals.
$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $intervals ) {
return $data;
}
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $limit_params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $limit_params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}
Admin/API/Reports/Coupons/Stats/Query.php 0000644 00000002304 15153704476 0014220 0 ustar 00 <?php
/**
* Class for parameter-based Products Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'coupons' => array(5, 120),
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Coupons\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_coupons_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-coupons-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args );
}
}
Admin/API/Reports/Coupons/Stats/Segmenter.php 0000644 00000036141 15153704476 0015052 0 ustar 00 <?php
/**
* Class for adding segmenting support to coupons/stats without cluttering the data store.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter extends ReportsSegmenter {
/**
* Returns column => query mapping to be used for product-related product-level segmenting query
* (e.g. coupon discount amount for product X when segmenting by product id or category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'amount' => "SUM($products_table.coupon_amount) as amount",
);
return $columns_mapping;
}
/**
* Returns column => query mapping to be used for order-related product-level segmenting query
* (e.g. orders_count when segmented by category).
*
* @param string $coupons_lookup_table Name of SQL table containing the order-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_order_level( $coupons_lookup_table ) {
$columns_mapping = array(
'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count",
'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count",
);
return $columns_mapping;
}
/**
* Returns column => query mapping to be used for order-level segmenting query
* (e.g. discount amount when segmented by coupons).
*
* @param string $coupons_lookup_table Name of SQL table containing the order-level info.
* @param array $overrides Array of overrides for default column calculations.
*
* @return array Column => SELECT query mapping.
*/
protected function segment_selections_orders( $coupons_lookup_table, $overrides = array() ) {
$columns_mapping = array(
'amount' => "SUM($coupons_lookup_table.discount_amount) as amount",
'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count",
'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count",
);
if ( $overrides ) {
$columns_mapping = array_merge( $columns_mapping, $overrides );
}
return $columns_mapping;
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
// Product-level numbers and order-level numbers can be fetched by the same query.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
{$segmenting_selections['order_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
// LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments.
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
// Product-level numbers and order-level numbers can be fetched by the same query.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
{$segmenting_selections['order_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
return $intervals_segments;
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
global $wpdb;
$totals_segments = $wpdb->get_results(
"SELECT
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
return $totals_segments;
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
global $wpdb;
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
$intervals_segments = $wpdb->get_results(
"SELECT
MAX($table_name.date_created) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = '';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'product' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$order_level_columns = $this->get_segment_selections_order_level( $table_name );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
'order_level' => $this->prepare_selections( $order_level_columns ),
);
$this->report_columns = array_merge( $product_level_columns, $order_level_columns );
$segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_groupby = $product_segmenting_table . '.product_id';
$segmenting_dimension_name = 'product_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) );
}
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$order_level_columns = $this->get_segment_selections_order_level( $table_name );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
'order_level' => $this->prepare_selections( $order_level_columns ),
);
$this->report_columns = array_merge( $product_level_columns, $order_level_columns );
$segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$order_level_columns = $this->get_segment_selections_order_level( $table_name );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
'order_level' => $this->prepare_selections( $order_level_columns ),
);
$this->report_columns = array_merge( $product_level_columns, $order_level_columns );
$segmenting_from = "
INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id
JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id
LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id
";
$segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
$segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id";
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
$coupon_level_columns = $this->segment_selections_orders( $table_name );
$segmenting_selections = $this->prepare_selections( $coupon_level_columns );
$this->report_columns = $coupon_level_columns;
$segmenting_from = '';
$segmenting_groupby = "$table_name.coupon_id";
$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
return $segments;
}
}
Admin/API/Reports/Customers/Controller.php 0000644 00000056564 15153704476 0014517 0 ustar 00 <?php
/**
* REST API Reports customers controller
*
* Handles requests to the /reports/customers endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Customers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
/**
* REST API Reports customers controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController implements ExportableInterface {
/**
* Exportable traits.
*/
use ExportableTraits;
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/customers';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['registered_before'] = $request['registered_before'];
$args['registered_after'] = $request['registered_after'];
$args['order_before'] = $request['before'];
$args['order_after'] = $request['after'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['order'] = $request['order'];
$args['orderby'] = $request['orderby'];
$args['match'] = $request['match'];
$args['search'] = $request['search'];
$args['searchby'] = $request['searchby'];
$args['name_includes'] = $request['name_includes'];
$args['name_excludes'] = $request['name_excludes'];
$args['username_includes'] = $request['username_includes'];
$args['username_excludes'] = $request['username_excludes'];
$args['email_includes'] = $request['email_includes'];
$args['email_excludes'] = $request['email_excludes'];
$args['country_includes'] = $request['country_includes'];
$args['country_excludes'] = $request['country_excludes'];
$args['last_active_before'] = $request['last_active_before'];
$args['last_active_after'] = $request['last_active_after'];
$args['orders_count_min'] = $request['orders_count_min'];
$args['orders_count_max'] = $request['orders_count_max'];
$args['total_spend_min'] = $request['total_spend_min'];
$args['total_spend_max'] = $request['total_spend_max'];
$args['avg_order_value_min'] = $request['avg_order_value_min'];
$args['avg_order_value_max'] = $request['avg_order_value_max'];
$args['last_order_before'] = $request['last_order_before'];
$args['last_order_after'] = $request['last_order_after'];
$args['customers'] = $request['customers'];
$args['users'] = $request['users'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
$args['filter_empty'] = $request['filter_empty'];
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false );
$between_params_date = array( 'last_active', 'registered' );
$normalized_params_date = TimeInterval::normalize_between_params( $request, $between_params_date, true );
$args = array_merge( $args, $normalized_params_numeric, $normalized_params_date );
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$customers_query = new Query( $query_args );
$report_data = $customers_query->get_data();
$data = array();
foreach ( $report_data->data as $customer_data ) {
$item = $this->prepare_item_for_response( $customer_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Get one report.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_item( $request ) {
$query_args = $this->prepare_reports_query( $request );
$query_args['customers'] = array( $request->get_param( 'id' ) );
$customers_query = new Query( $query_args );
$report_data = $customers_query->get_data();
$data = array();
foreach ( $report_data->data as $customer_data ) {
$item = $this->prepare_item_for_response( $customer_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
$response = rest_ensure_response( $data );
$response->header( 'X-WP-Total', (int) $report_data->total );
$response->header( 'X-WP-TotalPages', (int) $report_data->pages );
return $response;
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $report, $request );
// Registered date is UTC.
$data['date_registered_gmt'] = wc_rest_prepare_date_response( $data['date_registered'] );
$data['date_registered'] = wc_rest_prepare_date_response( $data['date_registered'], false );
// Last active date is local time.
$data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false );
$data['date_last_active'] = wc_rest_prepare_date_response( $data['date_last_active'] );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
* @since 4.0.0
*/
return apply_filters( 'woocommerce_rest_prepare_report_customers', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param array $object Object data.
* @return array
*/
protected function prepare_links( $object ) {
if ( empty( $object['user_id'] ) ) {
return array();
}
return array(
'customer' => array(
'href' => rest_url( sprintf( '/%s/customers/%d', $this->namespace, $object['id'] ) ),
),
'collection' => array(
'href' => rest_url( sprintf( '/%s/customers', $this->namespace ) ),
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_customers',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Customer ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'user_id' => array(
'description' => __( 'User ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'username' => array(
'description' => __( 'Username.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'country' => array(
'description' => __( 'Country / Region.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'city' => array(
'description' => __( 'City.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'state' => array(
'description' => __( 'Region.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'postcode' => array(
'description' => __( 'Postal code.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_registered' => array(
'description' => __( 'Date registered.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_registered_gmt' => array(
'description' => __( 'Date registered GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_last_active' => array(
'description' => __( 'Date last active.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_last_active_gmt' => array(
'description' => __( 'Date last active GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'orders_count' => array(
'description' => __( 'Order count.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'total_spend' => array(
'description' => __( 'Total spend.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'avg_order_value' => array(
'description' => __( 'Avg order value.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['registered_before'] = array(
'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_after'] = array(
'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby']['default'] = 'date_registered';
$params['orderby']['enum'] = array(
'username',
'name',
'country',
'city',
'state',
'postcode',
'date_registered',
'date_last_active',
'orders_count',
'total_spend',
'avg_order_value',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['search'] = array(
'description' => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['searchby'] = array(
'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.',
'type' => 'string',
'default' => 'name',
'enum' => array(
'name',
'username',
'email',
'all',
),
);
$params['name_includes'] = array(
'description' => __( 'Limit response to objects with specific names.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['name_excludes'] = array(
'description' => __( 'Limit response to objects excluding specific names.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['username_includes'] = array(
'description' => __( 'Limit response to objects with specific usernames.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['username_excludes'] = array(
'description' => __( 'Limit response to objects excluding specific usernames.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['email_includes'] = array(
'description' => __( 'Limit response to objects including emails.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['email_excludes'] = array(
'description' => __( 'Limit response to objects excluding emails.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['country_includes'] = array(
'description' => __( 'Limit response to objects with specific countries.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['country_excludes'] = array(
'description' => __( 'Limit response to objects excluding specific countries.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_active_before'] = array(
'description' => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_active_after'] = array(
'description' => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_active_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
'items' => array(
'type' => 'string',
),
);
$params['registered_before'] = array(
'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_after'] = array(
'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
'items' => array(
'type' => 'string',
),
);
$params['orders_count_min'] = array(
'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_max'] = array(
'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_between'] = array(
'description' => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
'items' => array(
'type' => 'integer',
),
);
$params['total_spend_min'] = array(
'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['total_spend_max'] = array(
'description' => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['total_spend_between'] = array(
'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
'items' => array(
'type' => 'integer',
),
);
$params['avg_order_value_min'] = array(
'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['avg_order_value_max'] = array(
'description' => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['avg_order_value_between'] = array(
'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
'items' => array(
'type' => 'integer',
),
);
$params['last_order_before'] = array(
'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_order_after'] = array(
'description' => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['customers'] = array(
'description' => __( 'Limit result to items with specified customer ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['users'] = array(
'description' => __( 'Limit result to items with specified user ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['filter_empty'] = array(
'description' => __( 'Filter out results where any of the passed fields are empty', 'woocommerce' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
'enum' => array(
'email',
'name',
'country',
'city',
'state',
'postcode',
),
),
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'name' => __( 'Name', 'woocommerce' ),
'username' => __( 'Username', 'woocommerce' ),
'last_active' => __( 'Last Active', 'woocommerce' ),
'registered' => __( 'Sign Up', 'woocommerce' ),
'email' => __( 'Email', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
'total_spend' => __( 'Total Spend', 'woocommerce' ),
'avg_order_value' => __( 'AOV', 'woocommerce' ),
'country' => __( 'Country / Region', 'woocommerce' ),
'city' => __( 'City', 'woocommerce' ),
'region' => __( 'Region', 'woocommerce' ),
'postcode' => __( 'Postal Code', 'woocommerce' ),
);
/**
* Filter to add or remove column names from the customers report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_customers_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$export_item = array(
'name' => $item['name'],
'username' => $item['username'],
'last_active' => $item['date_last_active'],
'registered' => $item['date_registered'],
'email' => $item['email'],
'orders_count' => $item['orders_count'],
'total_spend' => self::csv_number_format( $item['total_spend'] ),
'avg_order_value' => self::csv_number_format( $item['avg_order_value'] ),
'country' => $item['country'],
'city' => $item['city'],
'region' => $item['state'],
'postcode' => $item['postcode'],
);
/**
* Filter the column values of an item being exported.
*
* @param object $export_item Key value pair of Column ID => Row Value.
* @param object $item Single report item/row.
* @since 4.0.0
*/
return apply_filters(
'woocommerce_report_customers_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Customers/DataStore.php 0000644 00000072124 15153704476 0014250 0 ustar 00 <?php
/**
* Admin\API\Reports\Customers\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Customers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Admin\API\Reports\Customers\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_customer_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'customers';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'id' => 'intval',
'user_id' => 'intval',
'orders_count' => 'intval',
'total_spend' => 'floatval',
'avg_order_value' => 'floatval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'customers';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
global $wpdb;
$table_name = self::get_db_table_name();
$orders_count = 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END )';
$total_spend = 'SUM( total_sales )';
$this->report_columns = array(
'id' => "{$table_name}.customer_id as id",
'user_id' => 'user_id',
'username' => 'username',
'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @xxx: What does this mean for RTL?
'email' => 'email',
'country' => 'country',
'city' => 'city',
'state' => 'state',
'postcode' => 'postcode',
'date_registered' => 'date_registered',
'date_last_active' => 'IF( date_last_active <= "0000-00-00 00:00:00", NULL, date_last_active ) AS date_last_active',
'date_last_order' => "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order",
'orders_count' => "{$orders_count} as orders_count",
'total_spend' => "{$total_spend} as total_spend",
'avg_order_value' => "CASE WHEN {$orders_count} = 0 THEN NULL ELSE {$total_spend} / {$orders_count} END AS avg_order_value",
);
}
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'woocommerce_new_customer', array( __CLASS__, 'update_registered_customer' ) );
add_action( 'woocommerce_update_customer', array( __CLASS__, 'update_registered_customer' ) );
add_action( 'profile_update', array( __CLASS__, 'update_registered_customer' ) );
add_action( 'added_user_meta', array( __CLASS__, 'update_registered_customer_via_last_active' ), 10, 3 );
add_action( 'updated_user_meta', array( __CLASS__, 'update_registered_customer_via_last_active' ), 10, 3 );
add_action( 'delete_user', array( __CLASS__, 'delete_customer_by_user_id' ) );
add_action( 'remove_user_from_blog', array( __CLASS__, 'delete_customer_by_user_id' ) );
add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'anonymize_customer' ) );
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15, 2 );
}
/**
* Sync customers data after an order was deleted.
*
* When an order is deleted, the customer record is deleted from the
* table if the customer has no other orders.
*
* @param int $order_id Order ID.
* @param int $customer_id Customer ID.
*/
public static function sync_on_order_delete( $order_id, $customer_id ) {
$customer_id = absint( $customer_id );
if ( 0 === $customer_id ) {
return;
}
// Calculate the amount of orders remaining for this customer.
$order_count = self::get_order_count( $customer_id );
if ( 0 === $order_count ) {
self::delete_customer( $customer_id );
}
}
/**
* Sync customers data after an order was updated.
*
* Only updates customer if it is the customers last order.
*
* @param int $post_id of order.
* @return true|-1
*/
public static function sync_order_customer( $post_id ) {
global $wpdb;
if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) {
return -1;
}
$order = wc_get_order( $post_id );
$customer_id = self::get_existing_customer_id_from_order( $order );
if ( false === $customer_id ) {
return -1;
}
$last_order = self::get_last_order( $customer_id );
if ( ! $last_order || $order->get_id() !== $last_order->get_id() ) {
return -1;
}
list($data, $format) = self::get_customer_order_data_and_format( $order );
$result = $wpdb->update( self::get_db_table_name(), $data, array( 'customer_id' => $customer_id ), $format );
/**
* Fires when a customer is updated.
*
* @param int $customer_id Customer ID.
* @since 4.0.0
*/
do_action( 'woocommerce_analytics_update_customer', $customer_id );
return 1 === $result;
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'name' === $order_by ) {
return "CONCAT_WS( ' ', first_name, last_name )";
}
return $order_by;
}
/**
* Fills WHERE clause of SQL request with date-related constraints.
*
* @param array $query_args Parameters supplied by the user.
* @param string $table_name Name of the db table relevant for the date constraint.
*/
protected function add_time_period_sql_params( $query_args, $table_name ) {
global $wpdb;
$this->clear_sql_clause( array( 'where', 'where_time', 'having' ) );
$date_param_mapping = array(
'registered' => array(
'clause' => 'where',
'column' => $table_name . '.date_registered',
),
'order' => array(
'clause' => 'where',
'column' => $wpdb->prefix . 'wc_order_stats.date_created',
),
'last_active' => array(
'clause' => 'where',
'column' => $table_name . '.date_last_active',
),
'last_order' => array(
'clause' => 'having',
'column' => "MAX( {$wpdb->prefix}wc_order_stats.date_created )",
),
);
$match_operator = $this->get_match_operator( $query_args );
$where_time_clauses = array();
$having_time_clauses = array();
foreach ( $date_param_mapping as $query_param => $param_info ) {
$subclauses = array();
$before_arg = $query_param . '_before';
$after_arg = $query_param . '_after';
$column_name = $param_info['column'];
if ( ! empty( $query_args[ $before_arg ] ) ) {
$datetime = new \DateTime( $query_args[ $before_arg ] );
$datetime_str = $datetime->format( TimeInterval::$sql_datetime_format );
$subclauses[] = "{$column_name} <= '$datetime_str'";
}
if ( ! empty( $query_args[ $after_arg ] ) ) {
$datetime = new \DateTime( $query_args[ $after_arg ] );
$datetime_str = $datetime->format( TimeInterval::$sql_datetime_format );
$subclauses[] = "{$column_name} >= '$datetime_str'";
}
if ( $subclauses && ( 'where' === $param_info['clause'] ) ) {
$where_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')';
}
if ( $subclauses && ( 'having' === $param_info['clause'] ) ) {
$having_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')';
}
}
if ( $where_time_clauses ) {
$this->subquery->add_sql_clause( 'where_time', 'AND ' . implode( " {$match_operator} ", $where_time_clauses ) );
}
if ( $having_time_clauses ) {
$this->subquery->add_sql_clause( 'having', 'AND ' . implode( " {$match_operator} ", $having_time_clauses ) );
}
}
/**
* Updates the database query with parameters used for Customers report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$customer_lookup_table = self::get_db_table_name();
$order_stats_table_name = $wpdb->prefix . 'wc_order_stats';
$this->add_time_period_sql_params( $query_args, $customer_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
$this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$order_stats_table_name} ON {$customer_lookup_table}.customer_id = {$order_stats_table_name}.customer_id" );
$match_operator = $this->get_match_operator( $query_args );
$where_clauses = array();
$having_clauses = array();
$exact_match_params = array(
'name',
'username',
'email',
'country',
);
foreach ( $exact_match_params as $exact_match_param ) {
if ( ! empty( $query_args[ $exact_match_param . '_includes' ] ) ) {
$exact_match_arguments = $query_args[ $exact_match_param . '_includes' ];
$exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) );
$included = implode( "','", $exact_match_arguments_escaped );
// 'country_includes' is a list of country codes, the others will be a list of customer ids.
$table_column = 'country' === $exact_match_param ? $exact_match_param : 'customer_id';
$where_clauses[] = "{$customer_lookup_table}.{$table_column} IN ('{$included}')";
}
if ( ! empty( $query_args[ $exact_match_param . '_excludes' ] ) ) {
$exact_match_arguments = $query_args[ $exact_match_param . '_excludes' ];
$exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) );
$excluded = implode( "','", $exact_match_arguments_escaped );
// 'country_includes' is a list of country codes, the others will be a list of customer ids.
$table_column = 'country' === $exact_match_param ? $exact_match_param : 'customer_id';
$where_clauses[] = "{$customer_lookup_table}.{$table_column} NOT IN ('{$excluded}')";
}
}
$search_params = array(
'name',
'username',
'email',
'all',
);
if ( ! empty( $query_args['search'] ) ) {
$name_like = '%' . $wpdb->esc_like( $query_args['search'] ) . '%';
if ( empty( $query_args['searchby'] ) || 'name' === $query_args['searchby'] || ! in_array( $query_args['searchby'], $search_params, true ) ) {
$searchby = "CONCAT_WS( ' ', first_name, last_name )";
} elseif ( 'all' === $query_args['searchby'] ) {
$searchby = "CONCAT_WS( ' ', first_name, last_name, username, email )";
} else {
$searchby = $query_args['searchby'];
}
$where_clauses[] = $wpdb->prepare( "{$searchby} LIKE %s", $name_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
$filter_empty_params = array(
'email',
'name',
'country',
'city',
'state',
'postcode',
);
if ( ! empty( $query_args['filter_empty'] ) ) {
$fields_to_filter_by = array_intersect( $query_args['filter_empty'], $filter_empty_params );
if ( in_array( 'name', $fields_to_filter_by, true ) ) {
$fields_to_filter_by = array_diff( $fields_to_filter_by, array( 'name' ) );
$fields_to_filter_by[] = "CONCAT_WS( ' ', first_name, last_name )";
}
$fields_with_not_condition = array_map(
function ( $field ) {
return $field . ' <> \'\'';
},
$fields_to_filter_by
);
$where_clauses[] = '(' . implode( ' AND ', $fields_with_not_condition ) . ')';
}
// Allow a list of customer IDs to be specified.
if ( ! empty( $query_args['customers'] ) ) {
$included_customers = $this->get_filtered_ids( $query_args, 'customers' );
$where_clauses[] = "{$customer_lookup_table}.customer_id IN ({$included_customers})";
}
// Allow a list of user IDs to be specified.
if ( ! empty( $query_args['users'] ) ) {
$included_users = $this->get_filtered_ids( $query_args, 'users' );
$where_clauses[] = "{$customer_lookup_table}.user_id IN ({$included_users})";
}
$numeric_params = array(
'orders_count' => array(
'column' => 'COUNT( order_id )',
'format' => '%d',
),
'total_spend' => array(
'column' => 'SUM( total_sales )',
'format' => '%f',
),
'avg_order_value' => array(
'column' => '( SUM( total_sales ) / COUNT( order_id ) )',
'format' => '%f',
),
);
foreach ( $numeric_params as $numeric_param => $param_info ) {
$subclauses = array();
$min_param = $numeric_param . '_min';
$max_param = $numeric_param . '_max';
$or_equal = isset( $query_args[ $min_param ] ) && isset( $query_args[ $max_param ] ) ? '=' : '';
if ( isset( $query_args[ $min_param ] ) ) {
$subclauses[] = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
"{$param_info['column']} >{$or_equal} {$param_info['format']}",
$query_args[ $min_param ]
);
}
if ( isset( $query_args[ $max_param ] ) ) {
$subclauses[] = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
"{$param_info['column']} <{$or_equal} {$param_info['format']}",
$query_args[ $max_param ]
);
}
if ( $subclauses ) {
$having_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')';
}
}
if ( $where_clauses ) {
$preceding_match = empty( $this->get_sql_clause( 'where_time' ) ) ? ' AND ' : " {$match_operator} ";
$this->subquery->add_sql_clause( 'where', $preceding_match . implode( " {$match_operator} ", $where_clauses ) );
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'left_join', "AND ( {$order_status_filter} )" );
}
if ( $having_clauses ) {
$preceding_match = empty( $this->get_sql_clause( 'having' ) ) ? ' AND ' : " {$match_operator} ";
$this->subquery->add_sql_clause( 'having', $preceding_match . implode( " {$match_operator} ", $having_clauses ) );
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$customers_table_name = self::get_db_table_name();
$order_stats_table_name = $wpdb->prefix . 'wc_order_stats';
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date_registered',
'order_before' => TimeInterval::default_before(),
'order_after' => TimeInterval::default_after(),
'fields' => '*',
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$sql_query_params = $this->add_sql_query_params( $query_args );
$count_query = "SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) as tt
";
$db_records_count = (int) $wpdb->get_var(
$count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$params = $this->get_limit_params( $query_args );
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$customer_data = $wpdb->get_results(
$this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $customer_data ) {
return $data;
}
$customer_data = array_map( array( $this, 'cast_numbers' ), $customer_data );
$data = (object) array(
'data' => $customer_data,
'total' => $db_records_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Returns an existing customer ID for an order if one exists.
*
* @param object $order WC Order.
* @return int|bool
*/
public static function get_existing_customer_id_from_order( $order ) {
global $wpdb;
if ( ! is_a( $order, 'WC_Order' ) ) {
return false;
}
$user_id = $order->get_customer_id();
if ( 0 === $user_id ) {
$customer_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
$order->get_id()
)
);
if ( $customer_id ) {
return $customer_id;
}
$email = $order->get_billing_email( 'edit' );
if ( $email ) {
return self::get_guest_id_by_email( $email );
} else {
return false;
}
} else {
return self::get_customer_id_by_user_id( $user_id );
}
}
/**
* Get or create a customer from a given order.
*
* @param object $order WC Order.
* @return int|bool
*/
public static function get_or_create_customer_from_order( $order ) {
if ( ! $order ) {
return false;
}
global $wpdb;
if ( ! is_a( $order, 'WC_Order' ) ) {
return false;
}
$returning_customer_id = self::get_existing_customer_id_from_order( $order );
if ( $returning_customer_id ) {
return $returning_customer_id;
}
list($data, $format) = self::get_customer_order_data_and_format( $order );
$result = $wpdb->insert( self::get_db_table_name(), $data, $format );
$customer_id = $wpdb->insert_id;
/**
* Fires when a new report customer is created.
*
* @param int $customer_id Customer ID.
* @since 4.0.0
*/
do_action( 'woocommerce_analytics_new_customer', $customer_id );
return $result ? $customer_id : false;
}
/**
* Returns a data object and format object of the customers data coming from the order.
*
* @param object $order WC_Order where we get customer info from.
* @param object|null $customer_user WC_Customer registered customer WP user.
* @return array ($data, $format)
*/
public static function get_customer_order_data_and_format( $order, $customer_user = null ) {
$data = array(
'first_name' => $order->get_customer_first_name(),
'last_name' => $order->get_customer_last_name(),
'email' => $order->get_billing_email( 'edit' ),
'city' => $order->get_billing_city( 'edit' ),
'state' => $order->get_billing_state( 'edit' ),
'postcode' => $order->get_billing_postcode( 'edit' ),
'country' => $order->get_billing_country( 'edit' ),
'date_last_active' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
);
$format = array(
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
);
// Add registered customer data.
if ( 0 !== $order->get_user_id() ) {
$user_id = $order->get_user_id();
if ( is_null( $customer_user ) ) {
$customer_user = new \WC_Customer( $user_id );
}
$data['user_id'] = $user_id;
$data['username'] = $customer_user->get_username( 'edit' );
$data['date_registered'] = $customer_user->get_date_created( 'edit' ) ? $customer_user->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null;
$format[] = '%d';
$format[] = '%s';
$format[] = '%s';
}
return array( $data, $format );
}
/**
* Retrieve a guest ID (when user_id is null) by email.
*
* @param string $email Email address.
* @return false|array Customer array if found, boolean false if not.
*/
public static function get_guest_id_by_email( $email ) {
global $wpdb;
$table_name = self::get_db_table_name();
$customer_id = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT customer_id FROM {$table_name} WHERE email = %s AND user_id IS NULL LIMIT 1",
$email
)
);
return $customer_id ? (int) $customer_id : false;
}
/**
* Retrieve a registered customer row id by user_id.
*
* @param string|int $user_id User ID.
* @return false|int Customer ID if found, boolean false if not.
*/
public static function get_customer_id_by_user_id( $user_id ) {
global $wpdb;
$table_name = self::get_db_table_name();
$customer_id = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT customer_id FROM {$table_name} WHERE user_id = %d LIMIT 1",
$user_id
)
);
return $customer_id ? (int) $customer_id : false;
}
/**
* Retrieve the last order made by a customer.
*
* @param int $customer_id Customer ID.
* @return object WC_Order|false.
*/
public static function get_last_order( $customer_id ) {
global $wpdb;
$orders_table = $wpdb->prefix . 'wc_order_stats';
$last_order = $wpdb->get_var(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT order_id, date_created_gmt FROM {$orders_table}
WHERE customer_id = %d
ORDER BY date_created_gmt DESC, order_id DESC LIMIT 1",
// phpcs:enable
$customer_id
)
);
if ( ! $last_order ) {
return false;
}
return wc_get_order( absint( $last_order ) );
}
/**
* Retrieve the oldest orders made by a customer.
*
* @param int $customer_id Customer ID.
* @return array Orders.
*/
public static function get_oldest_orders( $customer_id ) {
global $wpdb;
$orders_table = $wpdb->prefix . 'wc_order_stats';
$excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() );
$excluded_statuses_condition = '';
if ( ! empty( $excluded_statuses ) ) {
$excluded_statuses_str = implode( "','", $excluded_statuses );
$excluded_statuses_condition = "AND status NOT IN ('{$excluded_statuses_str}')";
}
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2",
$customer_id
)
);
}
/**
* Retrieve the amount of orders made by a customer.
*
* @param int $customer_id Customer ID.
* @return int|null Amount of orders for customer or null on failure.
*/
public static function get_order_count( $customer_id ) {
global $wpdb;
$customer_id = absint( $customer_id );
if ( 0 === $customer_id ) {
return null;
}
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT( order_id ) FROM {$wpdb->prefix}wc_order_stats WHERE customer_id = %d",
$customer_id
)
);
if ( is_null( $result ) ) {
return null;
}
return (int) $result;
}
/**
* Update the database with customer data.
*
* @param int $user_id WP User ID to update customer data for.
* @return int|bool|null Number or rows modified or false on failure.
*/
public static function update_registered_customer( $user_id ) {
global $wpdb;
$customer = new \WC_Customer( $user_id );
if ( ! self::is_valid_customer( $user_id ) ) {
return false;
}
$first_name = $customer->get_first_name();
$last_name = $customer->get_last_name();
if ( empty( $first_name ) ) {
$first_name = $customer->get_billing_first_name();
}
if ( empty( $last_name ) ) {
$last_name = $customer->get_billing_last_name();
}
$last_active = $customer->get_meta( 'wc_last_active', true, 'edit' );
$data = array(
'user_id' => $user_id,
'username' => $customer->get_username( 'edit' ),
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $customer->get_email( 'edit' ),
'city' => $customer->get_billing_city( 'edit' ),
'state' => $customer->get_billing_state( 'edit' ),
'postcode' => $customer->get_billing_postcode( 'edit' ),
'country' => $customer->get_billing_country( 'edit' ),
'date_registered' => $customer->get_date_created( 'edit' ) ? $customer->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null,
'date_last_active' => $last_active ? gmdate( 'Y-m-d H:i:s', $last_active ) : null,
);
$format = array(
'%d',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
);
$customer_id = self::get_customer_id_by_user_id( $user_id );
if ( $customer_id ) {
// Preserve customer_id for existing user_id.
$data['customer_id'] = $customer_id;
$format[] = '%d';
}
$results = $wpdb->replace( self::get_db_table_name(), $data, $format );
/**
* Fires when customser's reports are updated.
*
* @param int $customer_id Customer ID.
* @since 4.0.0
*/
do_action( 'woocommerce_analytics_update_customer', $customer_id );
ReportsCache::invalidate();
return $results;
}
/**
* Update the database if the "last active" meta value was changed.
* Function expects to be hooked into the `added_user_meta` and `updated_user_meta` actions.
*
* @param int $meta_id ID of updated metadata entry.
* @param int $user_id ID of the user being updated.
* @param string $meta_key Meta key being updated.
*/
public static function update_registered_customer_via_last_active( $meta_id, $user_id, $meta_key ) {
if ( 'wc_last_active' === $meta_key ) {
self::update_registered_customer( $user_id );
}
}
/**
* Check if a user ID is a valid customer or other user role with past orders.
*
* @param int $user_id User ID.
* @return bool
*/
protected static function is_valid_customer( $user_id ) {
$user = new \WP_User( $user_id );
if ( (int) $user_id !== $user->ID ) {
return false;
}
/**
* Filter the customer roles, used to check if the user is a customer.
*
* @param array List of customer roles.
* @since 4.0.0
*/
$customer_roles = (array) apply_filters( 'woocommerce_analytics_customer_roles', array( 'customer' ) );
if ( empty( $user->roles ) || empty( array_intersect( $user->roles, $customer_roles ) ) ) {
return false;
}
return true;
}
/**
* Delete a customer lookup row.
*
* @param int $customer_id Customer ID.
*/
public static function delete_customer( $customer_id ) {
global $wpdb;
$customer_id = (int) $customer_id;
$num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'customer_id' => $customer_id ) );
if ( $num_deleted ) {
/**
* Fires when a customer is deleted.
*
* @param int $order_id Order ID.
* @since 4.0.0
*/
do_action( 'woocommerce_analytics_delete_customer', $customer_id );
ReportsCache::invalidate();
}
}
/**
* Delete a customer lookup row by WordPress User ID.
*
* @param int $user_id WordPress User ID.
*/
public static function delete_customer_by_user_id( $user_id ) {
global $wpdb;
if ( (int) $user_id < 1 || doing_action( 'wp_uninitialize_site' ) ) {
// Skip the deletion.
return;
}
$user_id = (int) $user_id;
$num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'user_id' => $user_id ) );
if ( $num_deleted ) {
ReportsCache::invalidate();
}
}
/**
* Anonymize the customer data for a single order.
*
* @internal
* @param int $order_id Order id.
* @return void
*/
public static function anonymize_customer( $order_id ) {
global $wpdb;
$customer_id = $wpdb->get_var(
$wpdb->prepare( "SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id )
);
if ( ! $customer_id ) {
return;
}
// Long form query because $wpdb->update rejects [deleted].
$deleted_text = __( '[deleted]', 'woocommerce' );
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}wc_customer_lookup
SET
user_id = NULL,
username = %s,
first_name = %s,
last_name = %s,
email = %s,
country = '',
postcode = %s,
city = %s,
state = %s
WHERE
customer_id = %d",
array(
$deleted_text,
$deleted_text,
$deleted_text,
'deleted@site.invalid',
$deleted_text,
$deleted_text,
$deleted_text,
$customer_id,
)
)
);
// If the customer row was anonymized, flush the cache.
if ( $updated ) {
ReportsCache::invalidate();
}
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$table_name = self::get_db_table_name();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'from', $table_name );
$this->subquery->add_sql_clause( 'select', "{$table_name}.customer_id" );
$this->subquery->add_sql_clause( 'group_by', "{$table_name}.customer_id" );
}
}
Admin/API/Reports/Customers/Query.php 0000644 00000002712 15153704476 0013463 0 ustar 00 <?php
/**
* Class for parameter-based Customers Report querying
*
* Example usage:
* $args = array(
* 'registered_before' => '2018-07-19 00:00:00',
* 'registered_after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'avg_order_value_min' => 100,
* 'country' => 'GB',
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Customers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Customers\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Customers report.
*
* @return array
*/
protected function get_default_query_vars() {
return array(
'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default.
'page' => 1,
'order' => 'DESC',
'orderby' => 'date_registered',
'fields' => '*',
);
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_customers_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-customers' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_customers_select_query', $results, $args );
}
}
Admin/API/Reports/Customers/Stats/Controller.php 0000644 00000040555 15153704476 0015606 0 ustar 00 <?php
/**
* REST API Reports customers stats controller
*
* Handles requests to the /reports/customers/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
/**
* REST API Reports customers stats controller class.
*
* @internal
* @extends WC_REST_Reports_Controller
*/
class Controller extends \WC_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/customers/stats';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['registered_before'] = $request['registered_before'];
$args['registered_after'] = $request['registered_after'];
$args['match'] = $request['match'];
$args['search'] = $request['search'];
$args['name_includes'] = $request['name_includes'];
$args['name_excludes'] = $request['name_excludes'];
$args['username_includes'] = $request['username_includes'];
$args['username_excludes'] = $request['username_excludes'];
$args['email_includes'] = $request['email_includes'];
$args['email_excludes'] = $request['email_excludes'];
$args['country_includes'] = $request['country_includes'];
$args['country_excludes'] = $request['country_excludes'];
$args['last_active_before'] = $request['last_active_before'];
$args['last_active_after'] = $request['last_active_after'];
$args['orders_count_min'] = $request['orders_count_min'];
$args['orders_count_max'] = $request['orders_count_max'];
$args['total_spend_min'] = $request['total_spend_min'];
$args['total_spend_max'] = $request['total_spend_max'];
$args['avg_order_value_min'] = $request['avg_order_value_min'];
$args['avg_order_value_max'] = $request['avg_order_value_max'];
$args['last_order_before'] = $request['last_order_before'];
$args['last_order_after'] = $request['last_order_after'];
$args['customers'] = $request['customers'];
$args['fields'] = $request['fields'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false );
$between_params_date = array( 'last_active', 'registered' );
$normalized_params_date = TimeInterval::normalize_between_params( $request, $between_params_date, true );
$args = array_merge( $args, $normalized_params_numeric, $normalized_params_date );
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$customers_query = new Query( $query_args );
$report_data = $customers_query->get_data();
$out_data = array(
'totals' => $report_data,
);
return rest_ensure_response( $out_data );
}
/**
* Prepare a report object for serialization.
*
* @param Array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_customers_stats', $response, $report, $request );
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
// @todo Should any of these be 'indicator's?
$totals = array(
'customers_count' => array(
'description' => __( 'Number of customers.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'avg_orders_count' => array(
'description' => __( 'Average number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'avg_total_spend' => array(
'description' => __( 'Average total spend per customer.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'avg_avg_order_value' => array(
'description' => __( 'Average AOV per customer.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
);
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_customers_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['registered_before'] = array(
'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_after'] = array(
'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['search'] = array(
'description' => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['searchby'] = array(
'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.',
'type' => 'string',
'default' => 'name',
'enum' => array(
'name',
'username',
'email',
'all',
),
);
$params['name_includes'] = array(
'description' => __( 'Limit response to objects with specific names.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['name_excludes'] = array(
'description' => __( 'Limit response to objects excluding specific names.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['username_includes'] = array(
'description' => __( 'Limit response to objects with specific usernames.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['username_excludes'] = array(
'description' => __( 'Limit response to objects excluding specific usernames.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['email_includes'] = array(
'description' => __( 'Limit response to objects including emails.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['email_excludes'] = array(
'description' => __( 'Limit response to objects excluding emails.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['country_includes'] = array(
'description' => __( 'Limit response to objects with specific countries.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['country_excludes'] = array(
'description' => __( 'Limit response to objects excluding specific countries.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_active_before'] = array(
'description' => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_active_after'] = array(
'description' => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_active_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
'items' => array(
'type' => 'string',
),
);
$params['registered_before'] = array(
'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_after'] = array(
'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
'items' => array(
'type' => 'string',
),
);
$params['orders_count_min'] = array(
'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_max'] = array(
'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_between'] = array(
'description' => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
'items' => array(
'type' => 'integer',
),
);
$params['total_spend_min'] = array(
'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['total_spend_max'] = array(
'description' => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['total_spend_between'] = array(
'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
'items' => array(
'type' => 'integer',
),
);
$params['avg_order_value_min'] = array(
'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['avg_order_value_max'] = array(
'description' => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ),
'type' => 'number',
'validate_callback' => 'rest_validate_request_arg',
);
$params['avg_order_value_between'] = array(
'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
'items' => array(
'type' => 'integer',
),
);
$params['last_order_before'] = array(
'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['last_order_after'] = array(
'description' => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['customers'] = array(
'description' => __( 'Limit result to items with specified customer ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}
Admin/API/Reports/Customers/Stats/DataStore.php 0000644 00000007230 15153704476 0015342 0 ustar 00 <?php
/**
* API\Reports\Customers\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
/**
* API\Reports\Customers\Stats\DataStore.
*/
class DataStore extends CustomersDataStore implements DataStoreInterface {
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'customers_count' => 'intval',
'avg_orders_count' => 'floatval',
'avg_total_spend' => 'floatval',
'avg_avg_order_value' => 'floatval',
);
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'customers_stats';
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'customers_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$this->report_columns = array(
'customers_count' => 'COUNT( * ) as customers_count',
'avg_orders_count' => 'AVG( orders_count ) as avg_orders_count',
'avg_total_spend' => 'AVG( total_spend ) as avg_total_spend',
'avg_avg_order_value' => 'AVG( avg_order_value ) as avg_avg_order_value',
);
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$customers_table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date_registered',
'fields' => '*',
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'customers_count' => 0,
'avg_orders_count' => 0,
'avg_total_spend' => 0.0,
'avg_avg_order_value' => 0.0,
);
$selections = $this->selected_columns( $query_args );
$this->add_sql_query_params( $query_args );
// Clear SQL clauses set for parent class queries that are different here.
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' );
$this->subquery->add_sql_clause(
'select',
'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,'
);
$this->subquery->add_sql_clause(
'select',
'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value'
);
$this->clear_sql_clause( array( 'order_by', 'limit' ) );
$this->add_sql_clause( 'select', $selections );
$this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" );
$report_data = $wpdb->get_results(
$this->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $report_data ) {
return $data;
}
$data = (object) $this->cast_numbers( $report_data[0] );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
}
Admin/API/Reports/Customers/Stats/Query.php 0000644 00000003005 15153704476 0014555 0 ustar 00 <?php
/**
* Class for parameter-based Customers Report Stats querying
*
* Example usage:
* $args = array(
* 'registered_before' => '2018-07-19 00:00:00',
* 'registered_after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'avg_order_value_min' => 100,
* 'country' => 'GB',
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Customers\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Customers report.
*
* @return array
*/
protected function get_default_query_vars() {
return array(
'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default.
'page' => 1,
'order' => 'DESC',
'orderby' => 'date_registered',
'fields' => '*', // @todo Needed?
);
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_customers_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-customers-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_customers_stats_select_query', $results, $args );
}
}
Admin/API/Reports/DataStore.php 0000644 00000142667 15153704476 0012276 0 ustar 00 <?php
/**
* Admin\API\Reports\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
/**
* Admin\API\Reports\DataStore: Common parent for custom report data stores.
*/
class DataStore extends SqlQuery {
/**
* Cache group for the reports.
*
* @var string
*/
protected $cache_group = 'reports';
/**
* Time out for the cache.
*
* @var int
*/
protected $cache_timeout = 3600;
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = '';
/**
* Table used as a data store for this report.
*
* @var string
*/
protected static $table_name = '';
/**
* Date field name.
*
* @var string
*/
protected $date_column_name = 'date_created';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array();
/**
* SQL columns to select in the db query.
*
* @var array
*/
protected $report_columns = array();
// @todo This does not really belong here, maybe factor out the comparison as separate class?
/**
* Order by property, used in the cmp function.
*
* @var string
*/
private $order_by = '';
/**
* Order property, used in the cmp function.
*
* @var string
*/
private $order = '';
/**
* Query limit parameters.
*
* @var array
*/
private $limit_parameters = array();
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'reports';
/**
* Subquery object for query nesting.
*
* @var SqlQuery
*/
protected $subquery;
/**
* Totals query object.
*
* @var SqlQuery
*/
protected $total_query;
/**
* Intervals query object.
*
* @var SqlQuery
*/
protected $interval_query;
/**
* Refresh the cache for the current query when true.
*
* @var bool
*/
protected $force_cache_refresh = false;
/**
* Include debugging information in the returned data when true.
*
* @var bool
*/
protected $debug_cache = true;
/**
* Debugging information to include in the returned data.
*
* @var array
*/
protected $debug_cache_data = array();
/**
* Class constructor.
*/
public function __construct() {
self::set_db_table_name();
$this->assign_report_columns();
if ( $this->report_columns ) {
$this->report_columns = apply_filters(
'woocommerce_admin_report_columns',
$this->report_columns,
$this->context,
self::get_db_table_name()
);
}
// Utilize enveloped responses to include debugging info.
// See https://querymonitor.com/blog/2021/05/debugging-wordpress-rest-api-requests/
if ( isset( $_GET['_envelope'] ) ) {
$this->debug_cache = true;
add_filter( 'rest_envelope_response', array( $this, 'add_debug_cache_to_envelope' ), 999, 2 );
}
}
/**
* Get table name from database class.
*/
public static function get_db_table_name() {
global $wpdb;
return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name;
}
/**
* Set table name from database class.
*/
protected static function set_db_table_name() {
global $wpdb;
if ( static::$table_name && ! isset( $wpdb->{static::$table_name} ) ) {
$wpdb->{static::$table_name} = $wpdb->prefix . static::$table_name;
}
}
/**
* Whether or not the report should use the caching layer.
*
* Provides an opportunity for plugins to prevent reports from using cache.
*
* @return boolean Whether or not to utilize caching.
*/
protected function should_use_cache() {
/**
* Determines if a report will utilize caching.
*
* @param bool $use_cache Whether or not to use cache.
* @param string $cache_key The report's cache key. Used to identify the report.
*/
return (bool) apply_filters( 'woocommerce_analytics_report_should_use_cache', true, $this->cache_key );
}
/**
* Returns string to be used as cache key for the data.
*
* @param array $params Query parameters.
* @return string
*/
protected function get_cache_key( $params ) {
if ( isset( $params['force_cache_refresh'] ) ) {
if ( true === $params['force_cache_refresh'] ) {
$this->force_cache_refresh = true;
}
// We don't want this param in the key.
unset( $params['force_cache_refresh'] );
}
if ( true === $this->debug_cache ) {
$this->debug_cache_data['query_args'] = $params;
}
return implode(
'_',
array(
'wc_report',
$this->cache_key,
md5( wp_json_encode( $params ) ),
)
);
}
/**
* Wrapper around Cache::get().
*
* @param string $cache_key Cache key.
* @return mixed
*/
protected function get_cached_data( $cache_key ) {
if ( true === $this->debug_cache ) {
$this->debug_cache_data['should_use_cache'] = $this->should_use_cache();
$this->debug_cache_data['force_cache_refresh'] = $this->force_cache_refresh;
$this->debug_cache_data['cache_hit'] = false;
}
if ( $this->should_use_cache() && false === $this->force_cache_refresh ) {
$cached_data = Cache::get( $cache_key );
$cache_hit = false !== $cached_data;
if ( true === $this->debug_cache ) {
$this->debug_cache_data['cache_hit'] = $cache_hit;
}
return $cached_data;
}
// Cached item has now functionally been refreshed. Reset the option.
$this->force_cache_refresh = false;
return false;
}
/**
* Wrapper around Cache::set().
*
* @param string $cache_key Cache key.
* @param mixed $value New value.
* @return bool
*/
protected function set_cached_data( $cache_key, $value ) {
if ( $this->should_use_cache() ) {
return Cache::set( $cache_key, $value );
}
return true;
}
/**
* Add cache debugging information to an enveloped API response.
*
* @param array $envelope
* @param \WP_REST_Response $response
*
* @return array
*/
public function add_debug_cache_to_envelope( $envelope, $response ) {
if ( 0 !== strncmp( '/wc-analytics', $response->get_matched_route(), 13 ) ) {
return $envelope;
}
if ( ! empty( $this->debug_cache_data ) ) {
$envelope['debug_cache'] = $this->debug_cache_data;
}
return $envelope;
}
/**
* Compares two report data objects by pre-defined object property and ASC/DESC ordering.
*
* @param stdClass $a Object a.
* @param stdClass $b Object b.
* @return string
*/
private function interval_cmp( $a, $b ) {
if ( '' === $this->order_by || '' === $this->order ) {
return 0;
// @todo Should return WP_Error here perhaps?
}
if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) {
// As relative order is undefined in case of equality in usort, second-level sorting by date needs to be enforced
// so that paging is stable.
if ( $a['time_interval'] === $b['time_interval'] ) {
return 0; // This should never happen.
} elseif ( $a['time_interval'] > $b['time_interval'] ) {
return 1;
} elseif ( $a['time_interval'] < $b['time_interval'] ) {
return -1;
}
} elseif ( $a[ $this->order_by ] > $b[ $this->order_by ] ) {
return strtolower( $this->order ) === 'desc' ? -1 : 1;
} elseif ( $a[ $this->order_by ] < $b[ $this->order_by ] ) {
return strtolower( $this->order ) === 'desc' ? 1 : -1;
}
}
/**
* Sorts intervals according to user's request.
*
* They are pre-sorted in SQL, but after adding gaps, they need to be sorted including the added ones.
*
* @param stdClass $data Data object, must contain an array under $data->intervals.
* @param string $sort_by Ordering property.
* @param string $direction DESC/ASC.
*/
protected function sort_intervals( &$data, $sort_by, $direction ) {
$this->sort_array( $data->intervals, $sort_by, $direction );
}
/**
* Sorts array of arrays based on subarray key $sort_by.
*
* @param array $arr Array to sort.
* @param string $sort_by Ordering property.
* @param string $direction DESC/ASC.
*/
protected function sort_array( &$arr, $sort_by, $direction ) {
$this->order_by = $this->normalize_order_by( $sort_by );
$this->order = $direction;
usort( $arr, array( $this, 'interval_cmp' ) );
}
/**
* Fills in interval gaps from DB with 0-filled objects.
*
* @param array $db_intervals Array of all intervals present in the db.
* @param DateTime $start_datetime Start date.
* @param DateTime $end_datetime End date.
* @param string $time_interval Time interval, e.g. day, week, month.
* @param stdClass $data Data with SQL extracted intervals.
* @return stdClass
*/
protected function fill_in_missing_intervals( $db_intervals, $start_datetime, $end_datetime, $time_interval, &$data ) {
// @todo This is ugly and messy.
$local_tz = new \DateTimeZone( wc_timezone_string() );
// At this point, we don't know when we can stop iterating, as the ordering can be based on any value.
$time_ids = array_flip( wp_list_pluck( $data->intervals, 'time_interval' ) );
$db_intervals = array_flip( $db_intervals );
// Totals object used to get all needed properties.
$totals_arr = get_object_vars( $data->totals );
foreach ( $totals_arr as $key => $val ) {
$totals_arr[ $key ] = 0;
}
// @todo Should 'products' be in intervals?
unset( $totals_arr['products'] );
while ( $start_datetime <= $end_datetime ) {
$next_start = TimeInterval::iterate( $start_datetime, $time_interval );
$time_id = TimeInterval::time_interval_id( $time_interval, $start_datetime );
// Either create fill-zero interval or use data from db.
if ( $next_start > $end_datetime ) {
$interval_end = $end_datetime->format( 'Y-m-d H:i:s' );
} else {
$prev_end_timestamp = (int) $next_start->format( 'U' ) - 1;
$prev_end = new \DateTime();
$prev_end->setTimestamp( $prev_end_timestamp );
$prev_end->setTimezone( $local_tz );
$interval_end = $prev_end->format( 'Y-m-d H:i:s' );
}
if ( array_key_exists( $time_id, $time_ids ) ) {
// For interval present in the db for this time frame, just fill in dates.
$record = &$data->intervals[ $time_ids[ $time_id ] ];
$record['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' );
$record['date_end'] = $interval_end;
} elseif ( ! array_key_exists( $time_id, $db_intervals ) ) {
// For intervals present in the db outside of this time frame, do nothing.
// For intervals not present in the db, fabricate it.
$record_arr = array();
$record_arr['time_interval'] = $time_id;
$record_arr['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' );
$record_arr['date_end'] = $interval_end;
$data->intervals[] = array_merge( $record_arr, $totals_arr );
}
$start_datetime = $next_start;
}
return $data;
}
/**
* Converts input datetime parameters to local timezone. If there are no inputs from the user in query_args,
* uses default from $defaults.
*
* @param array $query_args Array of query arguments.
* @param array $defaults Array of default values.
*/
protected function normalize_timezones( &$query_args, $defaults ) {
$local_tz = new \DateTimeZone( wc_timezone_string() );
foreach ( array( 'before', 'after' ) as $query_arg_key ) {
if ( isset( $query_args[ $query_arg_key ] ) && is_string( $query_args[ $query_arg_key ] ) ) {
// Assume that unspecified timezone is a local timezone.
$datetime = new \DateTime( $query_args[ $query_arg_key ], $local_tz );
// In case timezone was forced by using +HH:MM, convert to local timezone.
$datetime->setTimezone( $local_tz );
$query_args[ $query_arg_key ] = $datetime;
} elseif ( isset( $query_args[ $query_arg_key ] ) && is_a( $query_args[ $query_arg_key ], 'DateTime' ) ) {
// In case timezone is in other timezone, convert to local timezone.
$query_args[ $query_arg_key ]->setTimezone( $local_tz );
} else {
$query_args[ $query_arg_key ] = isset( $defaults[ $query_arg_key ] ) ? $defaults[ $query_arg_key ] : null;
}
}
}
/**
* Removes extra records from intervals so that only requested number of records get returned.
*
* @param stdClass $data Data from whose intervals the records get removed.
* @param int $page_no Offset requested by the user.
* @param int $items_per_page Number of records requested by the user.
* @param int $db_interval_count Database interval count.
* @param int $expected_interval_count Expected interval count on the output.
* @param string $order_by Order by field.
* @param string $order ASC or DESC.
*/
protected function remove_extra_records( &$data, $page_no, $items_per_page, $db_interval_count, $expected_interval_count, $order_by, $order ) {
if ( 'date' === strtolower( $order_by ) ) {
$offset = 0;
} else {
if ( 'asc' === strtolower( $order ) ) {
$offset = ( $page_no - 1 ) * $items_per_page;
} else {
$offset = ( $page_no - 1 ) * $items_per_page - $db_interval_count;
}
$offset = $offset < 0 ? 0 : $offset;
}
$count = $expected_interval_count - ( $page_no - 1 ) * $items_per_page;
if ( $count < 0 ) {
$count = 0;
} elseif ( $count > $items_per_page ) {
$count = $items_per_page;
}
$data->intervals = array_slice( $data->intervals, $offset, $count );
}
/**
* Returns expected number of items on the page in case of date ordering.
*
* @param int $expected_interval_count Expected number of intervals in total.
* @param int $items_per_page Number of items per page.
* @param int $page_no Page number.
*
* @return float|int
*/
protected function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) {
$total_pages = (int) ceil( $expected_interval_count / $items_per_page );
if ( $page_no < $total_pages ) {
return $items_per_page;
} elseif ( $page_no === $total_pages ) {
return $expected_interval_count - ( $page_no - 1 ) * $items_per_page;
} else {
return 0;
}
}
/**
* Returns true if there are any intervals that need to be filled in the response.
*
* @param int $expected_interval_count Expected number of intervals in total.
* @param int $db_records Total number of records for given period in the database.
* @param int $items_per_page Number of items per page.
* @param int $page_no Page number.
* @param string $order asc or desc.
* @param string $order_by Column by which the result will be sorted.
* @param int $intervals_count Number of records for given (possibly shortened) time interval.
*
* @return bool
*/
protected function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) {
if ( $expected_interval_count <= $db_records ) {
return false;
}
if ( 'date' === $order_by ) {
$expected_intervals_on_page = $this->expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no );
return $intervals_count < $expected_intervals_on_page;
}
if ( 'desc' === $order ) {
return $page_no > floor( $db_records / $items_per_page );
}
if ( 'asc' === $order ) {
return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page );
}
// Invalid ordering.
return false;
}
/**
* Updates the LIMIT query part for Intervals query of the report.
*
* If there are less records in the database than time intervals, then we need to remap offset in SQL query
* to fetch correct records.
*
* @param array $query_args Query arguments.
* @param int $db_interval_count Database interval count.
* @param int $expected_interval_count Expected interval count on the output.
* @param string $table_name Name of the db table relevant for the date constraint.
*/
protected function update_intervals_sql_params( &$query_args, $db_interval_count, $expected_interval_count, $table_name ) {
if ( $db_interval_count === $expected_interval_count ) {
return;
}
$params = $this->get_limit_params( $query_args );
$local_tz = new \DateTimeZone( wc_timezone_string() );
if ( 'date' === strtolower( $query_args['orderby'] ) ) {
// page X in request translates to slightly different dates in the db, in case some
// records are missing from the db.
$start_iteration = 0;
$end_iteration = 0;
if ( 'asc' === strtolower( $query_args['order'] ) ) {
// ORDER BY date ASC.
$new_start_date = $query_args['after'];
$intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page'];
$latest_end_date = $query_args['before'];
for ( $i = 0; $i < $intervals_to_skip; $i++ ) {
if ( $new_start_date > $latest_end_date ) {
$new_start_date = $latest_end_date;
$start_iteration = 0;
break;
}
$new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'] );
$start_iteration ++;
}
$new_end_date = clone $new_start_date;
for ( $i = 0; $i < $params['per_page']; $i++ ) {
if ( $new_end_date > $latest_end_date ) {
break;
}
$new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'] );
$end_iteration ++;
}
if ( $new_end_date > $latest_end_date ) {
$new_end_date = $latest_end_date;
$end_iteration = 0;
}
if ( $end_iteration ) {
$new_end_date_timestamp = (int) $new_end_date->format( 'U' ) - 1;
$new_end_date->setTimestamp( $new_end_date_timestamp );
}
} else {
// ORDER BY date DESC.
$new_end_date = $query_args['before'];
$intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page'];
$earliest_start_date = $query_args['after'];
for ( $i = 0; $i < $intervals_to_skip; $i++ ) {
if ( $new_end_date < $earliest_start_date ) {
$new_end_date = $earliest_start_date;
$end_iteration = 0;
break;
}
$new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'], true );
$end_iteration ++;
}
$new_start_date = clone $new_end_date;
for ( $i = 0; $i < $params['per_page']; $i++ ) {
if ( $new_start_date < $earliest_start_date ) {
break;
}
$new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'], true );
$start_iteration ++;
}
if ( $new_start_date < $earliest_start_date ) {
$new_start_date = $earliest_start_date;
$start_iteration = 0;
}
if ( $start_iteration ) {
// @todo Is this correct? should it only be added if iterate runs? other two iterate instances, too?
$new_start_date_timestamp = (int) $new_start_date->format( 'U' ) + 1;
$new_start_date->setTimestamp( $new_start_date_timestamp );
}
}
// @todo - Do this without modifying $query_args?
$query_args['adj_after'] = $new_start_date;
$query_args['adj_before'] = $new_end_date;
$adj_after = $new_start_date->format( TimeInterval::$sql_datetime_format );
$adj_before = $new_end_date->format( TimeInterval::$sql_datetime_format );
$this->interval_query->clear_sql_clause( array( 'where_time', 'limit' ) );
$this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$adj_before'" );
$this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$adj_after'" );
$this->clear_sql_clause( 'limit' );
$this->add_sql_clause( 'limit', 'LIMIT 0,' . $params['per_page'] );
} else {
if ( 'asc' === $query_args['order'] ) {
$offset = ( ( $query_args['page'] - 1 ) * $params['per_page'] ) - ( $expected_interval_count - $db_interval_count );
$offset = $offset < 0 ? 0 : $offset;
$count = $query_args['page'] * $params['per_page'] - ( $expected_interval_count - $db_interval_count );
if ( $count < 0 ) {
$count = 0;
} elseif ( $count > $params['per_page'] ) {
$count = $params['per_page'];
}
$this->clear_sql_clause( 'limit' );
$this->add_sql_clause( 'limit', 'LIMIT ' . $offset . ',' . $count );
}
// Otherwise no change in limit clause.
// @todo - Do this without modifying $query_args?
$query_args['adj_after'] = $query_args['after'];
$query_args['adj_before'] = $query_args['before'];
}
}
/**
* Casts strings returned from the database to appropriate data types for output.
*
* @param array $array Associative array of values extracted from the database.
* @return array|WP_Error
*/
protected function cast_numbers( $array ) {
$retyped_array = array();
$column_types = apply_filters( 'woocommerce_rest_reports_column_types', $this->column_types, $array );
foreach ( $array as $column_name => $value ) {
if ( is_array( $value ) ) {
$value = $this->cast_numbers( $value );
}
if ( isset( $column_types[ $column_name ] ) ) {
$retyped_array[ $column_name ] = $column_types[ $column_name ]( $value );
} else {
$retyped_array[ $column_name ] = $value;
}
}
return $retyped_array;
}
/**
* Returns a list of columns selected by the query_args formatted as a comma separated string.
*
* @param array $query_args User-supplied options.
* @return string
*/
protected function selected_columns( $query_args ) {
$selections = $this->report_columns;
if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) {
$keep = array();
foreach ( $query_args['fields'] as $field ) {
if ( isset( $selections[ $field ] ) ) {
$keep[ $field ] = $selections[ $field ];
}
}
$selections = implode( ', ', $keep );
} else {
$selections = implode( ', ', $selections );
}
return $selections;
}
/**
* Get the excluded order statuses used when calculating reports.
*
* @return array
*/
protected static function get_excluded_report_order_statuses() {
$excluded_statuses = \WC_Admin_Settings::get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) );
$excluded_statuses = array_merge( array( 'auto-draft', 'trash' ), array_map( 'esc_sql', $excluded_statuses ) );
return apply_filters( 'woocommerce_analytics_excluded_order_statuses', $excluded_statuses );
}
/**
* Maps order status provided by the user to the one used in the database.
*
* @param string $status Order status.
* @return string
*/
protected static function normalize_order_status( $status ) {
$status = trim( $status );
return 'wc-' . $status;
}
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requested by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
/**
* Updates start and end dates for intervals so that they represent intervals' borders, not times when data in db were recorded.
*
* E.g. if there are db records for only Tuesday and Thursday this week, the actual week interval is [Mon, Sun], not [Tue, Thu].
*
* @param DateTime $start_datetime Start date.
* @param DateTime $end_datetime End date.
* @param string $time_interval Time interval, e.g. day, week, month.
* @param array $intervals Array of intervals extracted from SQL db.
*/
protected function update_interval_boundary_dates( $start_datetime, $end_datetime, $time_interval, &$intervals ) {
$local_tz = new \DateTimeZone( wc_timezone_string() );
foreach ( $intervals as $key => $interval ) {
$datetime = new \DateTime( $interval['datetime_anchor'], $local_tz );
$prev_start = TimeInterval::iterate( $datetime, $time_interval, true );
// @todo Not sure if the +1/-1 here are correct, especially as they are applied before the ?: below.
$prev_start_timestamp = (int) $prev_start->format( 'U' ) + 1;
$prev_start->setTimestamp( $prev_start_timestamp );
if ( $start_datetime ) {
$date_start = $prev_start < $start_datetime ? $start_datetime : $prev_start;
$intervals[ $key ]['date_start'] = $date_start->format( 'Y-m-d H:i:s' );
} else {
$intervals[ $key ]['date_start'] = $prev_start->format( 'Y-m-d H:i:s' );
}
$next_end = TimeInterval::iterate( $datetime, $time_interval );
$next_end_timestamp = (int) $next_end->format( 'U' ) - 1;
$next_end->setTimestamp( $next_end_timestamp );
if ( $end_datetime ) {
$date_end = $next_end > $end_datetime ? $end_datetime : $next_end;
$intervals[ $key ]['date_end'] = $date_end->format( 'Y-m-d H:i:s' );
} else {
$intervals[ $key ]['date_end'] = $next_end->format( 'Y-m-d H:i:s' );
}
$intervals[ $key ]['interval'] = $time_interval;
}
}
/**
* Change structure of intervals to form a correct response.
*
* Also converts local datetimes to GMT and adds them to the intervals.
*
* @param array $intervals Time interval, e.g. day, week, month.
*/
protected function create_interval_subtotals( &$intervals ) {
foreach ( $intervals as $key => $interval ) {
$start_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_start'] );
$end_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_end'] );
// Move intervals result to subtotals object.
$intervals[ $key ] = array(
'interval' => $interval['time_interval'],
'date_start' => $interval['date_start'],
'date_start_gmt' => $start_gmt->format( TimeInterval::$sql_datetime_format ),
'date_end' => $interval['date_end'],
'date_end_gmt' => $end_gmt->format( TimeInterval::$sql_datetime_format ),
);
unset( $interval['interval'] );
unset( $interval['date_start'] );
unset( $interval['date_end'] );
unset( $interval['datetime_anchor'] );
unset( $interval['time_interval'] );
$intervals[ $key ]['subtotals'] = (object) $this->cast_numbers( $interval );
}
}
/**
* Fills WHERE clause of SQL request with date-related constraints.
*
* @param array $query_args Parameters supplied by the user.
* @param string $table_name Name of the db table relevant for the date constraint.
*/
protected function add_time_period_sql_params( $query_args, $table_name ) {
$this->clear_sql_clause( array( 'from', 'where_time', 'where' ) );
if ( isset( $this->subquery ) ) {
$this->subquery->clear_sql_clause( 'where_time' );
}
if ( isset( $query_args['before'] ) && '' !== $query_args['before'] ) {
if ( is_a( $query_args['before'], 'WC_DateTime' ) ) {
$datetime_str = $query_args['before']->date( TimeInterval::$sql_datetime_format );
} else {
$datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format );
}
if ( isset( $this->subquery ) ) {
$this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" );
} else {
$this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" );
}
}
if ( isset( $query_args['after'] ) && '' !== $query_args['after'] ) {
if ( is_a( $query_args['after'], 'WC_DateTime' ) ) {
$datetime_str = $query_args['after']->date( TimeInterval::$sql_datetime_format );
} else {
$datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format );
}
if ( isset( $this->subquery ) ) {
$this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" );
} else {
$this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" );
}
}
}
/**
* Fills LIMIT clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_limit_sql_params( $query_args ) {
global $wpdb;
$params = $this->get_limit_params( $query_args );
$this->clear_sql_clause( 'limit' );
$this->add_sql_clause( 'limit', $wpdb->prepare( 'LIMIT %d, %d', $params['offset'], $params['per_page'] ) );
return $params;
}
/**
* Fills LIMIT parameters of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_limit_params( $query_args = array() ) {
if ( isset( $query_args['per_page'] ) && is_numeric( $query_args['per_page'] ) ) {
$this->limit_parameters['per_page'] = (int) $query_args['per_page'];
} else {
$this->limit_parameters['per_page'] = get_option( 'posts_per_page' );
}
$this->limit_parameters['offset'] = 0;
if ( isset( $query_args['page'] ) ) {
$this->limit_parameters['offset'] = ( (int) $query_args['page'] - 1 ) * $this->limit_parameters['per_page'];
}
return $this->limit_parameters;
}
/**
* Generates a virtual table given a list of IDs.
*
* @param array $ids Array of IDs.
* @param array $id_field Name of the ID field.
* @param array $other_values Other values that must be contained in the virtual table.
* @return array
*/
protected function get_ids_table( $ids, $id_field, $other_values = array() ) {
global $wpdb;
$selects = array();
foreach ( $ids as $id ) {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$new_select = $wpdb->prepare( "SELECT %s AS {$id_field}", $id );
foreach ( $other_values as $key => $value ) {
$new_select .= $wpdb->prepare( ", %s AS {$key}", $value );
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
array_push( $selects, $new_select );
}
return join( ' UNION ', $selects );
}
/**
* Returns a comma separated list of the fields in the `query_args`, if there aren't, returns `report_columns` keys.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_fields( $query_args ) {
if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) {
return $query_args['fields'];
}
return array_keys( $this->report_columns );
}
/**
* Returns a comma separated list of the field names prepared to be used for a selection after a join with `default_results`.
*
* @param array $fields Array of fields name.
* @param array $default_results_fields Fields to load from `default_results` table.
* @param array $outer_selections Array of fields that are not selected in the inner query.
* @return string
*/
protected function format_join_selections( $fields, $default_results_fields, $outer_selections = array() ) {
foreach ( $fields as $i => $field ) {
foreach ( $default_results_fields as $default_results_field ) {
if ( $field === $default_results_field ) {
$field = esc_sql( $field );
$fields[ $i ] = "default_results.{$field} AS {$field}";
}
}
if ( in_array( $field, $outer_selections, true ) && array_key_exists( $field, $this->report_columns ) ) {
$fields[ $i ] = $this->report_columns[ $field ];
}
}
return implode( ', ', $fields );
}
/**
* Fills ORDER BY clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
*/
protected function add_order_by_sql_params( $query_args ) {
if ( isset( $query_args['orderby'] ) ) {
$order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
} else {
$order_by_clause = '';
}
$this->clear_sql_clause( 'order_by' );
$this->add_sql_clause( 'order_by', $order_by_clause );
$this->add_orderby_order_clause( $query_args, $this );
}
/**
* Fills FROM and WHERE clauses of SQL request for 'Intervals' section of data response based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @param string $table_name Name of the db table relevant for the date constraint.
*/
protected function add_intervals_sql_params( $query_args, $table_name ) {
$this->clear_sql_clause( array( 'from', 'where_time', 'where' ) );
$this->add_time_period_sql_params( $query_args, $table_name );
if ( isset( $query_args['interval'] ) && '' !== $query_args['interval'] ) {
$interval = $query_args['interval'];
$this->clear_sql_clause( 'select' );
$this->add_sql_clause( 'select', TimeInterval::db_datetime_format( $interval, $table_name, $this->date_column_name ) );
}
}
/**
* Get join and where clauses for refunds based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_refund_subquery( $query_args ) {
global $wpdb;
$table_name = $wpdb->prefix . 'wc_order_stats';
$sql_query = array(
'where_clause' => '',
'from_clause' => '',
);
if ( ! isset( $query_args['refunds'] ) ) {
return $sql_query;
}
if ( 'all' === $query_args['refunds'] ) {
$sql_query['where_clause'] .= 'parent_id != 0';
}
if ( 'none' === $query_args['refunds'] ) {
$sql_query['where_clause'] .= 'parent_id = 0';
}
if ( 'full' === $query_args['refunds'] || 'partial' === $query_args['refunds'] ) {
$operator = 'full' === $query_args['refunds'] ? '=' : '!=';
$sql_query['from_clause'] .= " JOIN {$table_name} parent_order_stats ON {$table_name}.parent_id = parent_order_stats.order_id";
$sql_query['where_clause'] .= "parent_order_stats.status {$operator} '{$this->normalize_order_status( 'refunded' )}'";
}
return $sql_query;
}
/**
* Returns an array of products belonging to given categories.
*
* @param array $categories List of categories IDs.
* @return array|stdClass
*/
protected function get_products_by_cat_ids( $categories ) {
$terms = get_terms(
array(
'taxonomy' => 'product_cat',
'include' => $categories,
)
);
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return array();
}
$args = array(
'category' => wc_list_pluck( $terms, 'slug' ),
'limit' => -1,
'return' => 'ids',
);
return wc_get_products( $args );
}
/**
* Get WHERE filter by object ids subquery.
*
* @param string $select_table Select table name.
* @param string $select_field Select table object ID field name.
* @param string $filter_table Lookup table name.
* @param string $filter_field Lookup table object ID field name.
* @param string $compare Comparison string (IN|NOT IN).
* @param string $id_list Comma separated ID list.
*
* @return string
*/
protected function get_object_where_filter( $select_table, $select_field, $filter_table, $filter_field, $compare, $id_list ) {
global $wpdb;
if ( empty( $id_list ) ) {
return '';
}
$lookup_name = isset( $wpdb->$filter_table ) ? $wpdb->$filter_table : $wpdb->prefix . $filter_table;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return " {$select_table}.{$select_field} {$compare} (
SELECT
DISTINCT {$filter_table}.{$select_field}
FROM
{$filter_table}
WHERE
{$filter_table}.{$filter_field} IN ({$id_list})
)";
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Returns an array of ids of allowed products, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_included_products_array( $query_args ) {
$included_products = array();
$operator = $this->get_match_operator( $query_args );
if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) {
$included_products = $this->get_products_by_cat_ids( $query_args['category_includes'] );
// If no products were found in the specified categories, we will force an empty set
// by matching a product ID of -1, unless the filters are OR/any and products are specified.
if ( empty( $included_products ) ) {
$included_products = array( '-1' );
}
}
if ( isset( $query_args['product_includes'] ) && is_array( $query_args['product_includes'] ) && count( $query_args['product_includes'] ) > 0 ) {
if ( count( $included_products ) > 0 ) {
if ( 'AND' === $operator ) {
// AND results in an intersection between products from selected categories and manually included products.
$included_products = array_intersect( $included_products, $query_args['product_includes'] );
} elseif ( 'OR' === $operator ) {
// OR results in a union of products from selected categories and manually included products.
$included_products = array_merge( $included_products, $query_args['product_includes'] );
}
} else {
$included_products = $query_args['product_includes'];
}
}
return $included_products;
}
/**
* Returns comma separated ids of allowed products, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_products( $query_args ) {
$included_products = $this->get_included_products_array( $query_args );
return implode( ',', $included_products );
}
/**
* Returns comma separated ids of allowed variations, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_variations( $query_args ) {
return $this->get_filtered_ids( $query_args, 'variation_includes' );
}
/**
* Returns comma separated ids of excluded variations, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_variations( $query_args ) {
return $this->get_filtered_ids( $query_args, 'variation_excludes' );
}
/**
* Returns an array of ids of disallowed products, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_excluded_products_array( $query_args ) {
$excluded_products = array();
$operator = $this->get_match_operator( $query_args );
if ( isset( $query_args['category_excludes'] ) && is_array( $query_args['category_excludes'] ) && count( $query_args['category_excludes'] ) > 0 ) {
$excluded_products = $this->get_products_by_cat_ids( $query_args['category_excludes'] );
}
if ( isset( $query_args['product_excludes'] ) && is_array( $query_args['product_excludes'] ) && count( $query_args['product_excludes'] ) > 0 ) {
$excluded_products = array_merge( $excluded_products, $query_args['product_excludes'] );
}
return $excluded_products;
}
/**
* Returns comma separated ids of excluded products, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_products( $query_args ) {
$excluded_products = $this->get_excluded_products_array( $query_args );
return implode( ',', $excluded_products );
}
/**
* Returns comma separated ids of included categories, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_categories( $query_args ) {
return $this->get_filtered_ids( $query_args, 'category_includes' );
}
/**
* Returns comma separated ids of included coupons, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @param string $field Field name in the parameter list.
* @return string
*/
protected function get_included_coupons( $query_args, $field = 'coupon_includes' ) {
return $this->get_filtered_ids( $query_args, $field );
}
/**
* Returns comma separated ids of excluded coupons, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_coupons( $query_args ) {
return $this->get_filtered_ids( $query_args, 'coupon_excludes' );
}
/**
* Returns comma separated ids of included orders, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_orders( $query_args ) {
return $this->get_filtered_ids( $query_args, 'order_includes' );
}
/**
* Returns comma separated ids of excluded orders, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_orders( $query_args ) {
return $this->get_filtered_ids( $query_args, 'order_excludes' );
}
/**
* Returns comma separated ids of included users, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_users( $query_args ) {
return $this->get_filtered_ids( $query_args, 'user_includes' );
}
/**
* Returns comma separated ids of excluded users, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_users( $query_args ) {
return $this->get_filtered_ids( $query_args, 'user_excludes' );
}
/**
* Returns order status subquery to be used in WHERE SQL query, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @param string $operator AND or OR, based on match query argument.
* @return string
*/
protected function get_status_subquery( $query_args, $operator = 'AND' ) {
global $wpdb;
$subqueries = array();
$excluded_statuses = array();
if ( isset( $query_args['status_is'] ) && is_array( $query_args['status_is'] ) && count( $query_args['status_is'] ) > 0 ) {
$allowed_statuses = array_map( array( $this, 'normalize_order_status' ), esc_sql( $query_args['status_is'] ) );
if ( $allowed_statuses ) {
$subqueries[] = "{$wpdb->prefix}wc_order_stats.status IN ( '" . implode( "','", $allowed_statuses ) . "' )";
}
}
if ( isset( $query_args['status_is_not'] ) && is_array( $query_args['status_is_not'] ) && count( $query_args['status_is_not'] ) > 0 ) {
$excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $query_args['status_is_not'] );
}
if ( ( ! isset( $query_args['status_is'] ) || empty( $query_args['status_is'] ) )
&& ( ! isset( $query_args['status_is_not'] ) || empty( $query_args['status_is_not'] ) )
) {
$excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $this->get_excluded_report_order_statuses() );
}
if ( $excluded_statuses ) {
$subqueries[] = "{$wpdb->prefix}wc_order_stats.status NOT IN ( '" . implode( "','", $excluded_statuses ) . "' )";
}
return implode( " $operator ", $subqueries );
}
/**
* Add order status SQL clauses if included in query.
*
* @param array $query_args Parameters supplied by the user.
* @param string $table_name Database table name.
* @param SqlQuery $sql_query Query object.
*/
protected function add_order_status_clause( $query_args, $table_name, &$sql_query ) {
global $wpdb;
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$sql_query->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
$sql_query->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
}
}
/**
* Add order by SQL clause if included in query.
*
* @param array $query_args Parameters supplied by the user.
* @param SqlQuery $sql_query Query object.
* @return string Order by clause.
*/
protected function add_order_by_clause( $query_args, &$sql_query ) {
$order_by_clause = '';
$sql_query->clear_sql_clause( array( 'order_by' ) );
if ( isset( $query_args['orderby'] ) ) {
$order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
$sql_query->add_sql_clause( 'order_by', $order_by_clause );
}
// Return ORDER BY clause to allow adding the sort field(s) to query via a JOIN.
return $order_by_clause;
}
/**
* Add order by order SQL clause.
*
* @param array $query_args Parameters supplied by the user.
* @param SqlQuery $sql_query Query object.
*/
protected function add_orderby_order_clause( $query_args, &$sql_query ) {
if ( isset( $query_args['order'] ) ) {
$sql_query->add_sql_clause( 'order_by', esc_sql( $query_args['order'] ) );
} else {
$sql_query->add_sql_clause( 'order_by', 'DESC' );
}
}
/**
* Returns customer subquery to be used in WHERE SQL query, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_customer_subquery( $query_args ) {
global $wpdb;
$customer_filter = '';
if ( isset( $query_args['customer_type'] ) ) {
if ( 'new' === strtolower( $query_args['customer_type'] ) ) {
$customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 0";
} elseif ( 'returning' === strtolower( $query_args['customer_type'] ) ) {
$customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 1";
}
}
return $customer_filter;
}
/**
* Returns product attribute subquery elements used in JOIN and WHERE clauses,
* based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_attribute_subqueries( $query_args ) {
global $wpdb;
$sql_clauses = array(
'join' => array(),
'where' => array(),
);
$match_operator = $this->get_match_operator( $query_args );
$post_meta_comparators = array(
'=' => 'attribute_is',
'!=' => 'attribute_is_not',
);
foreach ( $post_meta_comparators as $comparator => $arg ) {
if ( ! isset( $query_args[ $arg ] ) || ! is_array( $query_args[ $arg ] ) ) {
continue;
}
foreach ( $query_args[ $arg ] as $attribute_term ) {
// We expect tuples.
if ( ! is_array( $attribute_term ) || 2 !== count( $attribute_term ) ) {
continue;
}
// If the tuple is numeric, assume these are IDs.
if ( is_numeric( $attribute_term[0] ) && is_numeric( $attribute_term[1] ) ) {
$attribute_id = intval( $attribute_term[0] );
$term_id = intval( $attribute_term[1] );
// Invalid IDs.
if ( 0 === $attribute_id || 0 === $term_id ) {
continue;
}
// @todo: Use wc_get_attribute () instead ?
$attr_taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id );
// Invalid attribute ID.
if ( empty( $attr_taxonomy ) ) {
continue;
}
$attr_term = get_term_by( 'id', $term_id, $attr_taxonomy );
// Invalid term ID.
if ( false === $attr_term ) {
continue;
}
$meta_key = sanitize_title( $attr_taxonomy );
$meta_value = $attr_term->slug;
} else {
// Assume these are a custom attribute slug/value pair.
$meta_key = esc_sql( $attribute_term[0] );
$meta_value = esc_sql( $attribute_term[1] );
}
$join_alias = 'orderitemmeta1';
$table_to_join_on = "{$wpdb->prefix}wc_order_product_lookup";
if ( empty( $sql_clauses['join'] ) ) {
$sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_items orderitems ON orderitems.order_id = {$table_to_join_on}.order_id";
}
// If we're matching all filters (AND), we'll need multiple JOINs on postmeta.
// If not, just one.
if ( 'AND' === $match_operator || 1 === count( $sql_clauses['join'] ) ) {
$join_idx = count( $sql_clauses['join'] );
$join_alias = 'orderitemmeta' . $join_idx;
$sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_itemmeta as {$join_alias} ON {$join_alias}.order_item_id = {$table_to_join_on}.order_item_id";
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$sql_clauses['where'][] = $wpdb->prepare( "( {$join_alias}.meta_key = %s AND {$join_alias}.meta_value {$comparator} %s )", $meta_key, $meta_value );
}
}
// If we're matching multiple attributes and all filters (AND), make sure
// we're matching attributes on the same product.
$num_attribute_filters = count( $sql_clauses['join'] );
for ( $i = 2; $i < $num_attribute_filters; $i++ ) {
$join_alias = 'orderitemmeta' . $i;
$sql_clauses['join'][] = "AND orderitemmeta1.order_item_id = {$join_alias}.order_item_id";
}
return $sql_clauses;
}
/**
* Returns logic operator for WHERE subclause based on 'match' query argument.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_match_operator( $query_args ) {
$operator = 'AND';
if ( ! isset( $query_args['match'] ) ) {
return $operator;
}
if ( 'all' === strtolower( $query_args['match'] ) ) {
$operator = 'AND';
} elseif ( 'any' === strtolower( $query_args['match'] ) ) {
$operator = 'OR';
}
return $operator;
}
/**
* Returns filtered comma separated ids, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @param string $field Query field to filter.
* @param string $separator Field separator.
* @return string
*/
protected function get_filtered_ids( $query_args, $field, $separator = ',' ) {
global $wpdb;
$ids_str = '';
$ids = isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) ? $query_args[ $field ] : array();
/**
* Filter the IDs before retrieving report data.
*
* Allows filtering of the objects included or excluded from reports.
*
* @param array $ids List of object Ids.
* @param array $query_args The original arguments for the request.
* @param string $field The object type.
* @param string $context The data store context.
*/
$ids = apply_filters( 'woocommerce_analytics_' . $field, $ids, $query_args, $field, $this->context );
if ( ! empty( $ids ) ) {
$placeholders = implode( $separator, array_fill( 0, count( $ids ), '%d' ) );
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$ids_str = $wpdb->prepare( "{$placeholders}", $ids );
/* phpcs:enable */
}
return $ids_str;
}
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {}
}
Admin/API/Reports/DataStoreInterface.php 0000644 00000000621 15153704476 0014076 0 ustar 00 <?php
/**
* Reports Data Store Interface
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WooCommerce Reports data store interface.
*
* @since 3.5.0
*/
interface DataStoreInterface {
/**
* Get the data based on args.
*
* @param array $args Query parameters.
* @return stdClass|WP_Error
*/
public function get_data( $args );
}
Admin/API/Reports/Downloads/Controller.php 0000644 00000033675 15153704476 0014463 0 ustar 00 <?php
/**
* REST API Reports downloads controller
*
* Handles requests to the /reports/downloads endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
/**
* REST API Reports downloads controller class.
*
* @internal
* @extends Automattic\WooCommerce\Admin\API\Reports\Controller
*/
class Controller extends ReportsController implements ExportableInterface {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/downloads';
/**
* Get items.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$args = array();
$registered = array_keys( $this->get_collection_params() );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
$args[ $param_name ] = $request[ $param_name ];
}
}
$reports = new Query( $args );
$downloads_data = $reports->get_data();
$data = array();
foreach ( $downloads_data->data as $download_data ) {
$item = $this->prepare_item_for_response( $download_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $downloads_data->total,
(int) $downloads_data->page_no,
(int) $downloads_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param Array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $report ) );
$response->data['date'] = get_date_from_gmt( $data['date_gmt'], 'Y-m-d H:i:s' );
// Figure out file name.
// Matches https://github.com/woocommerce/woocommerce/blob/4be0018c092e617c5d2b8c46b800eb71ece9ddef/includes/class-wc-download-handler.php#L197.
$product_id = intval( $data['product_id'] );
$_product = wc_get_product( $product_id );
// Make sure the product hasn't been deleted.
if ( $_product ) {
$file_path = $_product->get_file_download_path( $data['download_id'] );
$filename = basename( $file_path );
$response->data['file_name'] = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id );
$response->data['file_path'] = $file_path;
} else {
$response->data['file_name'] = '';
$response->data['file_path'] = '';
}
$customer = new \WC_Customer( $data['user_id'] );
$response->data['username'] = $customer->get_username();
$response->data['order_number'] = $this->get_order_number( $data['order_id'] );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_downloads', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param Array $object Object data.
* @return array Links for the given post.
*/
protected function prepare_links( $object ) {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ),
'embeddable' => true,
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_downloads',
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'ID.', 'woocommerce' ),
),
'product_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product ID.', 'woocommerce' ),
),
'date' => array(
'description' => __( "The date of the download, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_gmt' => array(
'description' => __( 'The date of the download, as GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'download_id' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Download ID.', 'woocommerce' ),
),
'file_name' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'File name.', 'woocommerce' ),
),
'file_path' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'File URL.', 'woocommerce' ),
),
'order_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Order ID.', 'woocommerce' ),
),
'order_number' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Order Number.', 'woocommerce' ),
),
'user_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'User ID for the downloader.', 'woocommerce' ),
),
'username' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'User name of the downloader.', 'woocommerce' ),
),
'ip_address' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'IP address for the downloader.', 'woocommerce' ),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'product',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: products, orders, username, ip_address.', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['order_includes'] = array(
'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['order_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['customer_includes'] = array(
'description' => __( 'Limit response to objects that have the specified user ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['customer_excludes'] = array(
'description' => __( 'Limit response to objects that don\'t have the specified user ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['ip_address_includes'] = array(
'description' => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['ip_address_excludes'] = array(
'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'date' => __( 'Date', 'woocommerce' ),
'product' => __( 'Product title', 'woocommerce' ),
'file_name' => __( 'File name', 'woocommerce' ),
'order_number' => __( 'Order #', 'woocommerce' ),
'user_id' => __( 'User Name', 'woocommerce' ),
'ip_address' => __( 'IP', 'woocommerce' ),
);
/**
* Filter to add or remove column names from the downloads report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_filter_downloads_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$export_item = array(
'date' => $item['date'],
'product' => $item['_embedded']['product'][0]['name'],
'file_name' => $item['file_name'],
'order_number' => $item['order_number'],
'user_id' => $item['username'],
'ip_address' => $item['ip_address'],
);
/**
* Filter to prepare extra columns in the export item for the downloads
* report.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_downloads_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Downloads/DataStore.php 0000644 00000030623 15153704476 0014214 0 ustar 00 <?php
/**
* API\Reports\Downloads\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Downloads\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_download_log';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'downloads';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'id' => 'intval',
'date' => 'strval',
'date_gmt' => 'strval',
'download_id' => 'strval', // String because this can sometimes be a hash.
'file_name' => 'strval',
'product_id' => 'intval',
'order_id' => 'intval',
'user_id' => 'intval',
'ip_address' => 'strval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'downloads';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$this->report_columns = array(
'id' => 'download_log_id as id',
'date' => 'timestamp as date_gmt',
'download_id' => 'product_permissions.download_id',
'product_id' => 'product_permissions.product_id',
'order_id' => 'product_permissions.order_id',
'user_id' => 'product_permissions.user_id',
'ip_address' => 'user_ip_address as ip_address',
);
}
/**
* Updates the database query with parameters used for downloads report.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$lookup_table = self::get_db_table_name();
$permission_table = $wpdb->prefix . 'woocommerce_downloadable_product_permissions';
$operator = $this->get_match_operator( $query_args );
$where_filters = array();
$join = "JOIN {$permission_table} as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id";
$where_time = $this->add_time_period_sql_params( $query_args, $lookup_table );
if ( $where_time ) {
if ( isset( $this->subquery ) ) {
$this->subquery->add_sql_clause( 'where_time', $where_time );
} else {
$this->interval_query->add_sql_clause( 'where_time', $where_time );
}
}
$this->get_limit_sql_params( $query_args );
$where_filters[] = $this->get_object_where_filter(
$lookup_table,
'permission_id',
$permission_table,
'product_id',
'IN',
$this->get_included_products( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$lookup_table,
'permission_id',
$permission_table,
'product_id',
'NOT IN',
$this->get_excluded_products( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$lookup_table,
'permission_id',
$permission_table,
'order_id',
'IN',
$this->get_included_orders( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$lookup_table,
'permission_id',
$permission_table,
'order_id',
'NOT IN',
$this->get_excluded_orders( $query_args )
);
$customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup';
$customer_lookup = "SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN (%s)";
$included_customers = $this->get_included_customers( $query_args );
$excluded_customers = $this->get_excluded_customers( $query_args );
if ( $included_customers ) {
$where_filters[] = $this->get_object_where_filter(
$lookup_table,
'permission_id',
$permission_table,
'user_id',
'IN',
sprintf( $customer_lookup, $included_customers )
);
}
if ( $excluded_customers ) {
$where_filters[] = $this->get_object_where_filter(
$lookup_table,
'permission_id',
$permission_table,
'user_id',
'NOT IN',
sprintf( $customer_lookup, $excluded_customers )
);
}
$included_ip_addresses = $this->get_included_ip_addresses( $query_args );
$excluded_ip_addresses = $this->get_excluded_ip_addresses( $query_args );
if ( $included_ip_addresses ) {
$where_filters[] = "{$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')";
}
if ( $excluded_ip_addresses ) {
$where_filters[] = "{$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')";
}
$where_filters = array_filter( $where_filters );
$where_subclause = implode( " $operator ", $where_filters );
if ( $where_subclause ) {
if ( isset( $this->subquery ) ) {
$this->subquery->add_sql_clause( 'where', "AND ( $where_subclause )" );
} else {
$this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" );
}
}
if ( isset( $this->subquery ) ) {
$this->subquery->add_sql_clause( 'join', $join );
} else {
$this->interval_query->add_sql_clause( 'join', $join );
}
$this->add_order_by( $query_args );
}
/**
* Returns comma separated ids of included ip address, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_ip_addresses( $query_args ) {
return $this->get_filtered_ip_addresses( $query_args, 'ip_address_includes' );
}
/**
* Returns comma separated ids of excluded ip address, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_ip_addresses( $query_args ) {
return $this->get_filtered_ip_addresses( $query_args, 'ip_address_excludes' );
}
/**
* Returns filtered comma separated ids, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @param string $field Query field to filter.
* @return string
*/
protected function get_filtered_ip_addresses( $query_args, $field ) {
if ( isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) && count( $query_args[ $field ] ) > 0 ) {
$ip_addresses = array_map( 'esc_sql', $query_args[ $field ] );
/**
* Filter the IDs before retrieving report data.
*
* Allows filtering of the objects included or excluded from reports.
*
* @param array $ids List of object Ids.
* @param array $query_args The original arguments for the request.
* @param string $field The object type.
* @param string $context The data store context.
*/
$ip_addresses = apply_filters( 'woocommerce_analytics_' . $field, $ip_addresses, $query_args, $field, $this->context );
return implode( "','", $ip_addresses );
}
return '';
}
/**
* Returns comma separated ids of included customers, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_customers( $query_args ) {
return self::get_filtered_ids( $query_args, 'customer_includes' );
}
/**
* Returns comma separated ids of excluded customers, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_customers( $query_args ) {
return self::get_filtered_ids( $query_args, 'customer_excludes' );
}
/**
* Gets WHERE time clause of SQL request with date-related constraints.
*
* @param array $query_args Parameters supplied by the user.
* @param string $table_name Name of the db table relevant for the date constraint.
* @return string
*/
protected function add_time_period_sql_params( $query_args, $table_name ) {
$where_time = '';
if ( $query_args['before'] ) {
$datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format );
$where_time .= " AND {$table_name}.timestamp <= '$datetime_str'";
}
if ( $query_args['after'] ) {
$datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format );
$where_time .= " AND {$table_name}.timestamp >= '$datetime_str'";
}
return $where_time;
}
/**
* Fills ORDER BY clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
*/
protected function add_order_by( $query_args ) {
global $wpdb;
$this->clear_sql_clause( 'order_by' );
$order_by = '';
if ( isset( $query_args['orderby'] ) ) {
$order_by = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
$this->add_sql_clause( 'order_by', $order_by );
}
if ( false !== strpos( $order_by, '_products' ) ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->posts} AS _products ON product_permissions.product_id = _products.ID" );
}
$this->add_orderby_order_clause( $query_args, $this );
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'timestamp',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$this->add_sql_query_params( $query_args );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$db_records_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) AS tt"
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$params = $this->get_limit_params( $query_args );
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$download_data = $wpdb->get_results(
$this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $download_data ) {
return $data;
}
$download_data = array_map( array( $this, 'cast_numbers' ), $download_data );
$data = (object) array(
'data' => $download_data,
'total' => $db_records_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
global $wpdb;
if ( 'date' === $order_by ) {
return $wpdb->prefix . 'wc_download_log.timestamp';
}
if ( 'product' === $order_by ) {
return '_products.post_title';
}
return $order_by;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$table_name = self::get_db_table_name();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'from', $table_name );
$this->subquery->add_sql_clause( 'select', "{$table_name}.download_log_id" );
$this->subquery->add_sql_clause( 'group_by', "{$table_name}.download_log_id" );
}
}
Admin/API/Reports/Downloads/Files/Controller.php 0000644 00000001122 15153704476 0015503 0 ustar 00 <?php
/**
* REST API Reports downloads files controller
*
* Handles requests to the /reports/downloads/files endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Files;
defined( 'ABSPATH' ) || exit;
/**
* REST API Reports downloads files controller class.
*
* @internal
* @extends WC_REST_Reports_Controller
*/
class Controller extends \WC_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/downloads/files';
}
Admin/API/Reports/Downloads/Query.php 0000644 00000002260 15153704476 0013427 0 ustar 00 <?php
/**
* Class for parameter-based downloads report querying.
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'products' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Downloads\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Downloads\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for downloads report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get downloads data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_downloads_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-downloads' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_downloads_select_query', $results, $args );
}
}
Admin/API/Reports/Downloads/Stats/Controller.php 0000644 00000024630 15153704476 0015550 0 ustar 00 <?php
/**
* REST API Reports downloads stats controller
*
* Handles requests to the /reports/downloads/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports downloads stats controller class.
*
* @internal
* @extends GenericStatsController
*/
class Controller extends GenericStatsController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/downloads/stats';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['match'] = $request['match'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['customer_includes'] = (array) $request['customer_includes'];
$args['customer_excludes'] = (array) $request['customer_excludes'];
$args['order_includes'] = (array) $request['order_includes'];
$args['order_excludes'] = (array) $request['order_excludes'];
$args['ip_address_includes'] = (array) $request['ip_address_includes'];
$args['ip_address_excludes'] = (array) $request['ip_address_excludes'];
$args['fields'] = $request['fields'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$downloads_query = new Query( $query_args );
$report_data = $downloads_query->get_data();
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_downloads_stats', $response, $report, $request );
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
protected function get_item_properties_schema() {
return array(
'download_count' => array(
'title' => __( 'Downloads', 'woocommerce' ),
'description' => __( 'Number of downloads.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
* It does not have the segments as in GenericStatsController.
*
* @return array
*/
public function get_item_schema() {
$totals = $this->get_item_properties_schema();
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_orders_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
'intervals' => array(
'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'interval' => array(
'description' => __( 'Type of interval.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'date_start' => array(
'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_start_gmt' => array(
'description' => __( 'The date the report start, as GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end' => array(
'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end_gmt' => array(
'description' => __( 'The date the report end, as GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array(
'date',
'download_count',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['order_includes'] = array(
'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['order_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['customer_includes'] = array(
'description' => __( 'Limit response to objects that have the specified customer ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['customer_excludes'] = array(
'description' => __( 'Limit response to objects that don\'t have the specified customer ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['ip_address_includes'] = array(
'description' => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['ip_address_excludes'] = array(
'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
}
Admin/API/Reports/Downloads/Stats/DataStore.php 0000644 00000015317 15153704476 0015315 0 ustar 00 <?php
/**
* API\Reports\Downloads\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore as DownloadsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Downloads\Stats\DataStore.
*/
class DataStore extends DownloadsDataStore implements DataStoreInterface {
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'download_count' => 'intval',
);
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'downloads_stats';
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'downloads_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$this->report_columns = array(
'download_count' => 'COUNT(DISTINCT download_log_id) as download_count',
);
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'fields' => '*',
'interval' => 'week',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$selections = $this->selected_columns( $query_args );
$this->add_sql_query_params( $query_args );
$where_time = $this->add_time_period_sql_params( $query_args, $table_name );
$this->add_intervals_sql_params( $query_args, $table_name );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
$this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' );
$this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' );
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
$db_records_count = count( $db_intervals );
$params = $this->get_limit_params( $query_args );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return array();
}
$this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name );
$this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' );
$this->total_query->add_sql_clause( 'select', $selections );
$this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) );
if ( $where_time ) {
$this->total_query->add_sql_clause( 'where_time', $where_time );
}
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
if ( null === $totals ) {
return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) );
}
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$this->interval_query->add_sql_clause( 'select', ', MAX(timestamp) AS datetime_anchor' );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
if ( null === $intervals ) {
return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) );
}
$totals = (object) $this->cast_numbers( $totals[0] );
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$this->create_interval_subtotals( $data->intervals );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}
Admin/API/Reports/Downloads/Stats/Query.php 0000644 00000001577 15153704476 0014537 0 ustar 00 <?php
/**
* Class for parameter-based downloads Reports querying
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Downloads\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Orders report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get revenue data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_downloads_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-downloads-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_downloads_stats_select_query', $results, $args );
}
}
Admin/API/Reports/Export/Controller.php 0000644 00000015431 15153704476 0014000 0 ustar 00 <?php
/**
* REST API Reports Export Controller
*
* Handles requests to:
* - /reports/[report]/export
* - /reports/[report]/export/[id]/status
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Export;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\ReportExporter;
/**
* Reports Export controller.
*
* @internal
* @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
*/
class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/(?P<type>[a-z]+)/export';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'export_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_export_collection_params(),
),
'schema' => array( $this, 'get_export_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<export_id>[a-z0-9]+)/status',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'export_status' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_export_status_public_schema' ),
)
);
}
/**
* Get the query params for collections.
*
* @return array
*/
protected function get_export_collection_params() {
$params = array();
$params['report_args'] = array(
'description' => __( 'Parameters to pass on to the exported report.', 'woocommerce' ),
'type' => 'object',
'validate_callback' => 'rest_validate_request_arg', // @todo: use each controller's schema?
);
$params['email'] = array(
'description' => __( 'When true, email a link to download the export to the requesting user.', 'woocommerce' ),
'type' => 'boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the Report Export's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_export_public_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_export',
'type' => 'object',
'properties' => array(
'status' => array(
'description' => __( 'Export status.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'message' => array(
'description' => __( 'Export status message.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'export_id' => array(
'description' => __( 'Export ID.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the Export status schema, conforming to JSON Schema.
*
* @return array
*/
public function get_export_status_public_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_export_status',
'type' => 'object',
'properties' => array(
'percent_complete' => array(
'description' => __( 'Percentage complete.', 'woocommerce' ),
'type' => 'int',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'download_url' => array(
'description' => __( 'Export download URL.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Export data based on user request params.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function export_items( $request ) {
$report_type = $request['type'];
$report_args = empty( $request['report_args'] ) ? array() : $request['report_args'];
$send_email = isset( $request['email'] ) ? $request['email'] : false;
$default_export_id = str_replace( '.', '', microtime( true ) );
$export_id = apply_filters( 'woocommerce_admin_export_id', $default_export_id );
$export_id = (string) sanitize_file_name( $export_id );
$total_rows = ReportExporter::queue_report_export( $export_id, $report_type, $report_args, $send_email );
if ( 0 === $total_rows ) {
return rest_ensure_response(
array(
'message' => __( 'There is no data to export for the given request.', 'woocommerce' ),
)
);
}
ReportExporter::update_export_percentage_complete( $report_type, $export_id, 0 );
$response = rest_ensure_response(
array(
'message' => __( 'Your report file is being generated.', 'woocommerce' ),
'export_id' => $export_id,
)
);
// Include a link to the export status endpoint.
$response->add_links(
array(
'status' => array(
'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ),
),
)
);
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Export status based on user request params.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function export_status( $request ) {
$report_type = $request['type'];
$export_id = $request['export_id'];
$percentage = ReportExporter::get_export_percentage_complete( $report_type, $export_id );
if ( false === $percentage ) {
return new \WP_Error(
'woocommerce_admin_reports_export_invalid_id',
__( 'Sorry, there is no export with that ID.', 'woocommerce' ),
array( 'status' => 404 )
);
}
$result = array(
'percent_complete' => $percentage,
);
// @todo - add thing in the links below instead?
if ( 100 === $percentage ) {
$query_args = array(
'action' => ReportExporter::DOWNLOAD_EXPORT_ACTION,
'filename' => "wc-{$report_type}-report-export-{$export_id}",
);
$result['download_url'] = add_query_arg( $query_args, admin_url() );
}
// Wrap the data in a response object.
$response = rest_ensure_response( $result );
// Include a link to the export status endpoint.
$response->add_links(
array(
'self' => array(
'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ),
),
)
);
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
}
Admin/API/Reports/ExportableInterface.php 0000644 00000001160 15153704476 0014314 0 ustar 00 <?php
/**
* Reports Exportable Controller Interface
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WooCommerce Reports exportable controller interface.
*
* @since 3.5.0
*/
interface ExportableInterface {
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns();
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Value.
*/
public function prepare_item_for_export( $item );
}
Admin/API/Reports/ExportableTraits.php 0000644 00000001160 15153704476 0013662 0 ustar 00 <?php
/**
* REST API Reports exportable traits
*
* Collection of utility methods for exportable reports.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
/**
* ExportableTraits class.
*/
trait ExportableTraits {
/**
* Format numbers for CSV using store precision setting.
*
* @param string|float $value Numeric value.
* @return string Formatted value.
*/
public static function csv_number_format( $value ) {
$decimals = wc_get_price_decimals();
// See: @woocommerce/currency: getCurrencyFormatDecimal().
return number_format( $value, $decimals, '.', '' );
}
}
Admin/API/Reports/GenericController.php 0000644 00000011211 15153704476 0014004 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
use WP_REST_Request;
use WP_REST_Response;
/**
* WC REST API Reports controller extended
* to be shared as a generic base for all Analytics controllers.
*
* @internal
* @extends WC_REST_Reports_Controller
*/
abstract class GenericController extends \WC_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Add pagination headers and links.
*
* @param WP_REST_Request $request Request data.
* @param WP_REST_Response|array $response Response data.
* @param int $total Total results.
* @param int $page Current page.
* @param int $max_pages Total amount of pages.
* @return WP_REST_Response
*/
public function add_pagination_headers( $request, $response, int $total, int $page, int $max_pages ) {
$response = rest_ensure_response( $response );
$response->header( 'X-WP-Total', $total );
$response->header( 'X-WP-TotalPages', $max_pages );
$base = add_query_arg(
$request->get_query_params(),
rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) )
);
if ( $page > 1 ) {
$prev_page = $page - 1;
if ( $prev_page > $max_pages ) {
$prev_page = $max_pages;
}
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $max_pages > $page ) {
$next_page = $page + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
return rest_ensure_response( $data );
}
}
Admin/API/Reports/GenericStatsController.php 0000644 00000011245 15153704476 0015032 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
/**
* Generic base for all Stats controllers.
*
* @internal
* @extends GenericController
*/
abstract class GenericStatsController extends GenericController {
/**
* Get the query params for collections.
* Adds intervals to the generic list.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['interval'] = array(
'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ),
'type' => 'string',
'default' => 'week',
'enum' => array(
'hour',
'day',
'week',
'month',
'quarter',
'year',
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
abstract protected function get_item_properties_schema();
/**
* Get the Report's schema, conforming to JSON Schema.
*
* Please note, it does not call add_additional_fields_schema,
* as you may want to update the `title` first.
*
* @return array
*/
public function get_item_schema() {
$data_values = $this->get_item_properties_schema();
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
'intervals' => array(
'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'interval' => array(
'description' => __( 'Type of interval.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'date_start' => array(
'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_start_gmt' => array(
'description' => __( 'The date the report start, as GMT.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end' => array(
'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end_gmt' => array(
'description' => __( 'The date the report end, as GMT.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
),
),
),
);
}
}
Admin/API/Reports/Import/Controller.php 0000644 00000021110 15153704476 0013760 0 ustar 00 <?php
/**
* REST API Reports Import Controller
*
* Handles requests to /reports/import
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Import;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\ReportsSync;
/**
* Reports Imports controller.
*
* @internal
* @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
*/
class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/import';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'import_items' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
'args' => $this->get_import_collection_params(),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/cancel',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'cancel_import' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/delete',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'delete_imported_items' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/status',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_import_status' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/totals',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_import_totals' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
'args' => $this->get_import_collection_params(),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
}
/**
* Makes sure the current user has access to WRITE the settings APIs.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_Error|bool
*/
public function import_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Import data based on user request params.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function import_items( $request ) {
$query_args = $this->prepare_objects_query( $request );
$import = ReportsSync::regenerate_report_data( $query_args['days'], $query_args['skip_existing'] );
if ( is_wp_error( $import ) ) {
$result = array(
'status' => 'error',
'message' => $import->get_error_message(),
);
} else {
$result = array(
'status' => 'success',
'message' => $import,
);
}
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Prepare request object as query args.
*
* @param WP_REST_Request $request Request data.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = array();
$args['skip_existing'] = $request['skip_existing'];
$args['days'] = $request['days'];
return $args;
}
/**
* Prepare the data object for response.
*
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
/**
* Filter the list returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_reports_import', $response, $item, $request );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_import_collection_params() {
$params = array();
$params['days'] = array(
'description' => __( 'Number of days to import.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 0,
);
$params['skip_existing'] = array(
'description' => __( 'Skip importing existing order data.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_import_public_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_import',
'type' => 'object',
'properties' => array(
'status' => array(
'description' => __( 'Regeneration status.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'message' => array(
'description' => __( 'Regenerate data message.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Cancel all queued import actions.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function cancel_import( $request ) {
ReportsSync::clear_queued_actions();
$result = array(
'status' => 'success',
'message' => __( 'All pending and in-progress import actions have been cancelled.', 'woocommerce' ),
);
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Delete all imported items.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function delete_imported_items( $request ) {
$delete = ReportsSync::delete_report_data();
if ( is_wp_error( $delete ) ) {
$result = array(
'status' => 'error',
'message' => $delete->get_error_message(),
);
} else {
$result = array(
'status' => 'success',
'message' => $delete,
);
}
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Get the status of the current import.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_import_status( $request ) {
$result = ReportsSync::get_import_stats();
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Get the total orders and customers based on user supplied params.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_import_totals( $request ) {
$query_args = $this->prepare_objects_query( $request );
$totals = ReportsSync::get_import_totals( $query_args['days'], $query_args['skip_existing'] );
$response = $this->prepare_item_for_response( $totals, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
}
Admin/API/Reports/Orders/Controller.php 0000644 00000047477 15153704476 0013774 0 ustar 00 <?php
/**
* REST API Reports orders controller
*
* Handles requests to the /reports/orders endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
/**
* REST API Reports orders controller class.
*
* @internal
* @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
*/
class Controller extends ReportsController implements ExportableInterface {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/orders';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['variation_includes'] = (array) $request['variation_includes'];
$args['variation_excludes'] = (array) $request['variation_excludes'];
$args['coupon_includes'] = (array) $request['coupon_includes'];
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['tax_rate_includes'] = (array) $request['tax_rate_includes'];
$args['tax_rate_excludes'] = (array) $request['tax_rate_excludes'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['customer_type'] = $request['customer_type'];
$args['extended_info'] = $request['extended_info'];
$args['refunds'] = $request['refunds'];
$args['match'] = $request['match'];
$args['order_includes'] = $request['order_includes'];
$args['order_excludes'] = $request['order_excludes'];
$args['attribute_is'] = (array) $request['attribute_is'];
$args['attribute_is_not'] = (array) $request['attribute_is_not'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$orders_query = new Query( $query_args );
$report_data = $orders_query->get_data();
$data = array();
foreach ( $report_data->data as $orders_data ) {
$orders_data['order_number'] = $this->get_order_number( $orders_data['order_id'] );
$orders_data['total_formatted'] = $this->get_total_formatted( $orders_data['order_id'] );
$item = $this->prepare_item_for_response( $orders_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param stdClass $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_orders', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param WC_Reports_Query $object Object data.
* @return array
*/
protected function prepare_links( $object ) {
$links = array(
'order' => array(
'href' => rest_url( sprintf( '/%s/orders/%d', $this->namespace, $object['order_id'] ) ),
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_orders',
'type' => 'object',
'properties' => array(
'order_id' => array(
'description' => __( 'Order ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'order_number' => array(
'description' => __( 'Order Number.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_created' => array(
'description' => __( "Date the order was created, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_created_gmt' => array(
'description' => __( 'Date the order was created, as GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'status' => array(
'description' => __( 'Order status.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'customer_id' => array(
'description' => __( 'Customer ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'num_items_sold' => array(
'description' => __( 'Number of items sold.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'net_total' => array(
'description' => __( 'Net total revenue.', 'woocommerce' ),
'type' => 'float',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'total_formatted' => array(
'description' => __( 'Net total revenue (formatted).', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'customer_type' => array(
'description' => __( 'Returning or new customer.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'extended_info' => array(
'products' => array(
'type' => 'array',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'List of order product IDs, names, quantities.', 'woocommerce' ),
),
'coupons' => array(
'type' => 'array',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'List of order coupons.', 'woocommerce' ),
),
'customer' => array(
'type' => 'object',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Order customer information.', 'woocommerce' ),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 0,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'num_items_sold',
'net_total',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['variation_includes'] = array(
'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['variation_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['coupon_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['tax_rate_includes'] = array(
'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['tax_rate_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['status_is'] = array(
'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => self::get_order_statuses(),
'type' => 'string',
),
);
$params['status_is_not'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => self::get_order_statuses(),
'type' => 'string',
),
);
$params['customer_type'] = array(
'description' => __( 'Limit result set to returning or new customers.', 'woocommerce' ),
'type' => 'string',
'default' => '',
'enum' => array(
'',
'returning',
'new',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['refunds'] = array(
'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ),
'type' => 'string',
'default' => '',
'enum' => array(
'',
'all',
'partial',
'full',
'none',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order_includes'] = array(
'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['order_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['attribute_is'] = array(
'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get customer name column export value.
*
* @param array $customer Customer from report row.
* @return string
*/
protected function get_customer_name( $customer ) {
return $customer['first_name'] . ' ' . $customer['last_name'];
}
/**
* Get products column export value.
*
* @param array $products Products from report row.
* @return string
*/
protected function get_products( $products ) {
$products_list = array();
foreach ( $products as $product ) {
$products_list[] = sprintf(
/* translators: 1: numeric product quantity, 2: name of product */
__( '%1$s× %2$s', 'woocommerce' ),
$product['quantity'],
$product['name']
);
}
return implode( ', ', $products_list );
}
/**
* Get coupons column export value.
*
* @param array $coupons Coupons from report row.
* @return string
*/
protected function get_coupons( $coupons ) {
return implode( ', ', wp_list_pluck( $coupons, 'code' ) );
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'date_created' => __( 'Date', 'woocommerce' ),
'order_number' => __( 'Order #', 'woocommerce' ),
'total_formatted' => __( 'N. Revenue (formatted)', 'woocommerce' ),
'status' => __( 'Status', 'woocommerce' ),
'customer_name' => __( 'Customer', 'woocommerce' ),
'customer_type' => __( 'Customer type', 'woocommerce' ),
'products' => __( 'Product(s)', 'woocommerce' ),
'num_items_sold' => __( 'Items sold', 'woocommerce' ),
'coupons' => __( 'Coupon(s)', 'woocommerce' ),
'net_total' => __( 'N. Revenue', 'woocommerce' ),
);
/**
* Filter to add or remove column names from the orders report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_orders_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$export_item = array(
'date_created' => $item['date_created'],
'order_number' => $item['order_number'],
'total_formatted' => $item['total_formatted'],
'status' => $item['status'],
'customer_name' => isset( $item['extended_info']['customer'] ) ? $this->get_customer_name( $item['extended_info']['customer'] ) : null,
'customer_type' => $item['customer_type'],
'products' => isset( $item['extended_info']['products'] ) ? $this->get_products( $item['extended_info']['products'] ) : null,
'num_items_sold' => $item['num_items_sold'],
'coupons' => isset( $item['extended_info']['coupons'] ) ? $this->get_coupons( $item['extended_info']['coupons'] ) : null,
'net_total' => $item['net_total'],
);
/**
* Filter to prepare extra columns in the export item for the orders
* report.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_orders_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Orders/DataStore.php 0000644 00000045535 15153704476 0013530 0 ustar 00 <?php
/**
* API\Reports\Orders\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
/**
* API\Reports\Orders\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Dynamically sets the date column name based on configuration
*/
public function __construct() {
$this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' );
parent::__construct();
}
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_stats';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'orders';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'order_id' => 'intval',
'parent_id' => 'intval',
'date_created' => 'strval',
'date_created_gmt' => 'strval',
'status' => 'strval',
'customer_id' => 'intval',
'net_total' => 'floatval',
'total_sales' => 'floatval',
'num_items_sold' => 'intval',
'customer_type' => 'strval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'orders';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
// Avoid ambigious columns in SQL query.
$this->report_columns = array(
'order_id' => "DISTINCT {$table_name}.order_id",
'parent_id' => "{$table_name}.parent_id",
// Add 'date' field based on date type setting.
'date' => "{$table_name}.{$this->date_column_name} AS date",
'date_created' => "{$table_name}.date_created",
'date_created_gmt' => "{$table_name}.date_created_gmt",
'status' => "REPLACE({$table_name}.status, 'wc-', '') as status",
'customer_id' => "{$table_name}.customer_id",
'net_total' => "{$table_name}.net_total",
'total_sales' => "{$table_name}.total_sales",
'num_items_sold' => "{$table_name}.num_items_sold",
'customer_type' => "(CASE WHEN {$table_name}.returning_customer = 0 THEN 'new' ELSE 'returning' END) as customer_type",
);
}
/**
* Updates the database query with parameters used for orders report: coupons and products filters.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_stats_lookup_table = self::get_db_table_name();
$order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup';
$order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
$order_tax_lookup_table = $wpdb->prefix . 'wc_order_tax_lookup';
$operator = $this->get_match_operator( $query_args );
$where_subquery = array();
$have_joined_products_table = false;
$this->add_time_period_sql_params( $query_args, $order_stats_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
$status_subquery = $this->get_status_subquery( $query_args );
if ( $status_subquery ) {
if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) {
$this->subquery->add_sql_clause( 'where', "AND {$status_subquery}" );
} else {
$where_subquery[] = $status_subquery;
}
}
$included_orders = $this->get_included_orders( $query_args );
if ( $included_orders ) {
$where_subquery[] = "{$order_stats_lookup_table}.order_id IN ({$included_orders})";
}
$excluded_orders = $this->get_excluded_orders( $query_args );
if ( $excluded_orders ) {
$where_subquery[] = "{$order_stats_lookup_table}.order_id NOT IN ({$excluded_orders})";
}
if ( $query_args['customer_type'] ) {
$returning_customer = 'returning' === $query_args['customer_type'] ? 1 : 0;
$where_subquery[] = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}";
}
$refund_subquery = $this->get_refund_subquery( $query_args );
$this->subquery->add_sql_clause( 'from', $refund_subquery['from_clause'] );
if ( $refund_subquery['where_clause'] ) {
$where_subquery[] = $refund_subquery['where_clause'];
}
$included_coupons = $this->get_included_coupons( $query_args );
$excluded_coupons = $this->get_excluded_coupons( $query_args );
if ( $included_coupons || $excluded_coupons ) {
$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_coupon_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_coupon_lookup_table}.order_id" );
}
if ( $included_coupons ) {
$where_subquery[] = "{$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})";
}
if ( $excluded_coupons ) {
$where_subquery[] = "({$order_coupon_lookup_table}.coupon_id IS NULL OR {$order_coupon_lookup_table}.coupon_id NOT IN ({$excluded_coupons}))";
}
$included_products = $this->get_included_products( $query_args );
$excluded_products = $this->get_excluded_products( $query_args );
if ( $included_products || $excluded_products ) {
$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} product_lookup" );
$this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = product_lookup.order_id" );
}
if ( $included_products ) {
$this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$included_products})" );
$where_subquery[] = 'product_lookup.order_id IS NOT NULL';
}
if ( $excluded_products ) {
$this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$excluded_products})" );
$where_subquery[] = 'product_lookup.order_id IS NULL';
}
$included_variations = $this->get_included_variations( $query_args );
$excluded_variations = $this->get_excluded_variations( $query_args );
if ( $included_variations || $excluded_variations ) {
$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} variation_lookup" );
$this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = variation_lookup.order_id" );
}
if ( $included_variations ) {
$this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$included_variations})" );
$where_subquery[] = 'variation_lookup.order_id IS NOT NULL';
}
if ( $excluded_variations ) {
$this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$excluded_variations})" );
$where_subquery[] = 'variation_lookup.order_id IS NULL';
}
$included_tax_rates = ! empty( $query_args['tax_rate_includes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_includes'] ) ) : false;
$excluded_tax_rates = ! empty( $query_args['tax_rate_excludes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_excludes'] ) ) : false;
if ( $included_tax_rates || $excluded_tax_rates ) {
$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_tax_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_tax_lookup_table}.order_id" );
}
if ( $included_tax_rates ) {
$where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id IN ({$included_tax_rates})";
}
if ( $excluded_tax_rates ) {
$where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id NOT IN ({$excluded_tax_rates}) OR {$order_tax_lookup_table}.tax_rate_id IS NULL";
}
$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );
// Add JOINs for matching attributes.
foreach ( $attribute_subqueries['join'] as $attribute_join ) {
$this->subquery->add_sql_clause( 'join', $attribute_join );
}
// Add WHEREs for matching attributes.
$where_subquery = array_merge( $where_subquery, $attribute_subqueries['where'] );
}
if ( 0 < count( $where_subquery ) ) {
$this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' );
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => $this->date_column_name,
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'product_includes' => array(),
'product_excludes' => array(),
'coupon_includes' => array(),
'coupon_excludes' => array(),
'tax_rate_includes' => array(),
'tax_rate_excludes' => array(),
'customer_type' => null,
'status_is' => array(),
'extended_info' => false,
'refunds' => null,
'order_includes' => array(),
'order_excludes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$params = $this->get_limit_params( $query_args );
$this->add_sql_query_params( $query_args );
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$db_records_count = (int) $wpdb->get_var(
"SELECT COUNT( DISTINCT tt.order_id ) FROM (
{$this->subquery->get_query_statement()}
) AS tt"
);
/* phpcs:enable */
if ( 0 === $params['per_page'] ) {
$total_pages = 0;
} else {
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
}
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
$data = (object) array(
'data' => array(),
'total' => $db_records_count,
'pages' => 0,
'page_no' => 0,
);
return $data;
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
$orders_data = $wpdb->get_results(
$this->subquery->get_query_statement(),
ARRAY_A
);
/* phpcs:enable */
if ( null === $orders_data ) {
return $data;
}
if ( $query_args['extended_info'] ) {
$this->include_extended_info( $orders_data, $query_args );
}
$orders_data = array_map( array( $this, 'cast_numbers' ), $orders_data );
$data = (object) array(
'data' => $orders_data,
'total' => $db_records_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return $this->date_column_name;
}
return $order_by;
}
/**
* Enriches the order data.
*
* @param array $orders_data Orders data.
* @param array $query_args Query parameters.
*/
protected function include_extended_info( &$orders_data, $query_args ) {
$mapped_orders = $this->map_array_by_key( $orders_data, 'order_id' );
$related_orders = $this->get_orders_with_parent_id( $mapped_orders );
$order_ids = array_merge( array_keys( $mapped_orders ), array_keys( $related_orders ) );
$products = $this->get_products_by_order_ids( $order_ids );
$coupons = $this->get_coupons_by_order_ids( array_keys( $mapped_orders ) );
$customers = $this->get_customers_by_orders( $orders_data );
$mapped_customers = $this->map_array_by_key( $customers, 'customer_id' );
$mapped_data = array();
foreach ( $products as $product ) {
if ( ! isset( $mapped_data[ $product['order_id'] ] ) ) {
$mapped_data[ $product['order_id'] ]['products'] = array();
}
$is_variation = '0' !== $product['variation_id'];
$product_data = array(
'id' => $is_variation ? $product['variation_id'] : $product['product_id'],
'name' => $product['product_name'],
'quantity' => $product['product_quantity'],
);
if ( $is_variation ) {
$variation = wc_get_product( $product_data['id'] );
/**
* Used to determine the separator for products and their variations titles.
*
* @since 4.0.0
*/
$separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $variation );
if ( false === strpos( $product_data['name'], $separator ) ) {
$attributes = wc_get_formatted_variation( $variation, true, false );
$product_data['name'] .= $separator . $attributes;
}
}
$mapped_data[ $product['order_id'] ]['products'][] = $product_data;
// If this product's order has another related order, it will be added to our mapped_data.
if ( isset( $related_orders [ $product['order_id'] ] ) ) {
$mapped_data[ $related_orders[ $product['order_id'] ]['order_id'] ] ['products'] [] = $product_data;
}
}
foreach ( $coupons as $coupon ) {
if ( ! isset( $mapped_data[ $coupon['order_id'] ] ) ) {
$mapped_data[ $product['order_id'] ]['coupons'] = array();
}
$mapped_data[ $coupon['order_id'] ]['coupons'][] = array(
'id' => $coupon['coupon_id'],
'code' => wc_format_coupon_code( $coupon['coupon_code'] ),
);
}
foreach ( $orders_data as $key => $order_data ) {
$defaults = array(
'products' => array(),
'coupons' => array(),
'customer' => array(),
);
$orders_data[ $key ]['extended_info'] = isset( $mapped_data[ $order_data['order_id'] ] ) ? array_merge( $defaults, $mapped_data[ $order_data['order_id'] ] ) : $defaults;
if ( $order_data['customer_id'] && isset( $mapped_customers[ $order_data['customer_id'] ] ) ) {
$orders_data[ $key ]['extended_info']['customer'] = $mapped_customers[ $order_data['customer_id'] ];
}
}
}
/**
* Returns oreders that have a parent id
*
* @param array $orders Orders array.
* @return array
*/
protected function get_orders_with_parent_id( $orders ) {
$related_orders = array();
foreach ( $orders as $order ) {
if ( '0' !== $order['parent_id'] ) {
$related_orders[ $order['parent_id'] ] = $order;
}
}
return $related_orders;
}
/**
* Returns the same array index by a given key
*
* @param array $array Array to be looped over.
* @param string $key Key of values used for new array.
* @return array
*/
protected function map_array_by_key( $array, $key ) {
$mapped = array();
foreach ( $array as $item ) {
$mapped[ $item[ $key ] ] = $item;
}
return $mapped;
}
/**
* Get product IDs, names, and quantity from order IDs.
*
* @param array $order_ids Array of order IDs.
* @return array
*/
protected function get_products_by_order_ids( $order_ids ) {
global $wpdb;
$order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
$included_order_ids = implode( ',', $order_ids );
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$products = $wpdb->get_results(
"SELECT
order_id,
product_id,
variation_id,
post_title as product_name,
product_qty as product_quantity
FROM {$wpdb->posts}
JOIN
{$order_product_lookup_table}
ON {$wpdb->posts}.ID = (
CASE WHEN variation_id > 0
THEN variation_id
ELSE product_id
END
)
WHERE
order_id IN ({$included_order_ids})
",
ARRAY_A
);
/* phpcs:enable */
return $products;
}
/**
* Get customer data from Order data.
*
* @param array $orders Array of orders data.
* @return array
*/
protected function get_customers_by_orders( $orders ) {
global $wpdb;
$customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup';
$customer_ids = array();
foreach ( $orders as $order ) {
if ( $order['customer_id'] ) {
$customer_ids[] = intval( $order['customer_id'] );
}
}
if ( empty( $customer_ids ) ) {
return array();
}
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$customer_ids = implode( ',', $customer_ids );
$customers = $wpdb->get_results(
"SELECT * FROM {$customer_lookup_table} WHERE customer_id IN ({$customer_ids})",
ARRAY_A
);
/* phpcs:enable */
return $customers;
}
/**
* Get coupon information from order IDs.
*
* @param array $order_ids Array of order IDs.
* @return array
*/
protected function get_coupons_by_order_ids( $order_ids ) {
global $wpdb;
$order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup';
$included_order_ids = implode( ',', $order_ids );
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$coupons = $wpdb->get_results(
"SELECT order_id, coupon_id, post_title as coupon_code
FROM {$wpdb->posts}
JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->posts}.ID
WHERE
order_id IN ({$included_order_ids})
",
ARRAY_A
);
/* phpcs:enable */
return $coupons;
}
/**
* Get all statuses that have been synced.
*
* @return array Unique order statuses.
*/
public static function get_all_statuses() {
global $wpdb;
$cache_key = 'orders-all-statuses';
$statuses = Cache::get( $cache_key );
if ( false === $statuses ) {
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$table_name = self::get_db_table_name();
$statuses = $wpdb->get_col(
"SELECT DISTINCT status FROM {$table_name}"
);
/* phpcs:enable */
Cache::set( $cache_key, $statuses );
}
return $statuses;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.order_id' );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
}
}
Admin/API/Reports/Orders/Query.php 0000644 00000002347 15153704476 0012741 0 ustar 00 <?php
/**
* Class for parameter-based Orders Reports querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'interval' => 'week',
* 'products' => array(15, 18),
* 'coupons' => array(138),
* 'status_is' => array('completed'),
* 'status_is_not' => array('failed'),
* 'new_customers' => false,
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Orders\Query
*/
class Query extends ReportsQuery {
/**
* Get order data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_orders_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-orders' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_orders_select_query', $results, $args );
}
}
Admin/API/Reports/Orders/Stats/Controller.php 0000644 00000047171 15153704476 0015061 0 ustar 00 <?php
/**
* REST API Reports orders stats controller
*
* Handles requests to the /reports/orders/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* REST API Reports orders stats controller class.
*
* @internal
* @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
*/
class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/orders/stats';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['fields'] = $request['fields'];
$args['match'] = $request['match'];
$args['status_is'] = (array) $request['status_is'];
$args['status_is_not'] = (array) $request['status_is_not'];
$args['product_includes'] = (array) $request['product_includes'];
$args['product_excludes'] = (array) $request['product_excludes'];
$args['variation_includes'] = (array) $request['variation_includes'];
$args['variation_excludes'] = (array) $request['variation_excludes'];
$args['coupon_includes'] = (array) $request['coupon_includes'];
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['tax_rate_includes'] = (array) $request['tax_rate_includes'];
$args['tax_rate_excludes'] = (array) $request['tax_rate_excludes'];
$args['customer_type'] = $request['customer_type'];
$args['refunds'] = $request['refunds'];
$args['attribute_is'] = (array) $request['attribute_is'];
$args['attribute_is_not'] = (array) $request['attribute_is_not'];
$args['category_includes'] = (array) $request['categories'];
$args['segmentby'] = $request['segmentby'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
// For backwards compatibility, `customer` is aliased to `customer_type`.
if ( empty( $request['customer_type'] ) && ! empty( $request['customer'] ) ) {
$args['customer_type'] = $request['customer'];
}
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$orders_query = new Query( $query_args );
try {
$report_data = $orders_query->get_data();
} catch ( ParameterException $e ) {
return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param Array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_orders_stats', $response, $report, $request );
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$data_values = array(
'net_revenue' => array(
'description' => __( 'Net sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'orders_count' => array(
'title' => __( 'Orders', 'woocommerce' ),
'description' => __( 'Number of orders', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
),
'avg_order_value' => array(
'description' => __( 'Average order value.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'avg_items_per_order' => array(
'description' => __( 'Average items per order', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'num_items_sold' => array(
'description' => __( 'Number of items sold', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'coupons' => array(
'description' => __( 'Amount discounted by coupons.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'coupons_count' => array(
'description' => __( 'Unique coupons count.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'total_customers' => array(
'description' => __( 'Total distinct customers.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'products' => array(
'description' => __( 'Number of distinct products sold.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
// Products is not shown in intervals.
unset( $data_values['products'] );
$intervals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_orders_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
'intervals' => array(
'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'interval' => array(
'description' => __( 'Type of interval.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'date_start' => array(
'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_start_gmt' => array(
'description' => __( 'The date the report start, as GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end' => array(
'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end_gmt' => array(
'description' => __( 'The date the report end, as GMT.', 'woocommerce' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $intervals,
),
),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'net_revenue',
'orders_count',
'avg_order_value',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['interval'] = array(
'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ),
'type' => 'string',
'default' => 'week',
'enum' => array(
'hour',
'day',
'week',
'month',
'quarter',
'year',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['status_is'] = array(
'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'default' => null,
'items' => array(
'enum' => self::get_order_statuses(),
'type' => 'string',
),
);
$params['status_is_not'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'enum' => self::get_order_statuses(),
'type' => 'string',
),
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['variation_includes'] = array(
'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['variation_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['tax_rate_includes'] = array(
'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['tax_rate_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['customer'] = array(
'description' => __( 'Alias for customer_type (deprecated).', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'new',
'returning',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['customer_type'] = array(
'description' => __( 'Limit result set to orders that have the specified customer_type', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'new',
'returning',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['refunds'] = array(
'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ),
'type' => 'string',
'default' => '',
'enum' => array(
'',
'all',
'partial',
'full',
'none',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is'] = array(
'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
'coupon',
'customer_type', // new vs returning.
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}
Admin/API/Reports/Orders/Stats/DataStore.php 0000644 00000064022 15153704476 0014616 0 ustar 00 <?php
/**
* API\Reports\Orders\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* API\Reports\Orders\Stats\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_stats';
/**
* Cron event name.
*/
const CRON_EVENT = 'wc_order_stats_update';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'orders_stats';
/**
* Type for each column to cast values correctly later.
*
* @var array
*/
protected $column_types = array(
'orders_count' => 'intval',
'num_items_sold' => 'intval',
'gross_sales' => 'floatval',
'total_sales' => 'floatval',
'coupons' => 'floatval',
'coupons_count' => 'intval',
'refunds' => 'floatval',
'taxes' => 'floatval',
'shipping' => 'floatval',
'net_revenue' => 'floatval',
'avg_items_per_order' => 'floatval',
'avg_order_value' => 'floatval',
'total_customers' => 'intval',
'products' => 'intval',
'segment_id' => 'intval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'orders_stats';
/**
* Dynamically sets the date column name based on configuration
*/
public function __construct() {
$this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' );
parent::__construct();
}
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
// Avoid ambigious columns in SQL query.
$refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total ELSE 0 END ) )";
$gross_sales =
"( SUM({$table_name}.total_sales)" .
' + COALESCE( SUM(discount_amount), 0 )' . // SUM() all nulls gives null.
" - SUM({$table_name}.tax_total)" .
" - SUM({$table_name}.shipping_total)" .
" + {$refunds}" .
' ) as gross_sales';
$this->report_columns = array(
'orders_count' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) as orders_count",
'num_items_sold' => "SUM({$table_name}.num_items_sold) as num_items_sold",
'gross_sales' => $gross_sales,
'total_sales' => "SUM({$table_name}.total_sales) AS total_sales",
'coupons' => 'COALESCE( SUM(discount_amount), 0 ) AS coupons', // SUM() all nulls gives null.
'coupons_count' => 'COALESCE( coupons_count, 0 ) as coupons_count',
'refunds' => "{$refunds} AS refunds",
'taxes' => "SUM({$table_name}.tax_total) AS taxes",
'shipping' => "SUM({$table_name}.shipping_total) AS shipping",
'net_revenue' => "SUM({$table_name}.net_total) AS net_revenue",
'avg_items_per_order' => "SUM( {$table_name}.num_items_sold ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_items_per_order",
'avg_order_value' => "SUM( {$table_name}.net_total ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_order_value",
'total_customers' => "COUNT( DISTINCT( {$table_name}.customer_id ) ) as total_customers",
);
}
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'woocommerce_before_delete_order', array( __CLASS__, 'delete_order' ) );
add_action( 'delete_post', array( __CLASS__, 'delete_order' ) );
}
/**
* Updates the totals and intervals database queries with parameters used for Orders report: categories, coupons and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function orders_stats_sql_filter( $query_args ) {
// phpcs:ignore Generic.Commenting.Todo.TaskFound
// @todo Performance of all of this?
global $wpdb;
$from_clause = '';
$orders_stats_table = self::get_db_table_name();
$product_lookup = $wpdb->prefix . 'wc_order_product_lookup';
$coupon_lookup = $wpdb->prefix . 'wc_order_coupon_lookup';
$tax_rate_lookup = $wpdb->prefix . 'wc_order_tax_lookup';
$operator = $this->get_match_operator( $query_args );
$where_filters = array();
// Products filters.
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$product_lookup,
'product_id',
'IN',
$this->get_included_products( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$product_lookup,
'product_id',
'NOT IN',
$this->get_excluded_products( $query_args )
);
// Variations filters.
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$product_lookup,
'variation_id',
'IN',
$this->get_included_variations( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$product_lookup,
'variation_id',
'NOT IN',
$this->get_excluded_variations( $query_args )
);
// Coupons filters.
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$coupon_lookup,
'coupon_id',
'IN',
$this->get_included_coupons( $query_args )
);
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$coupon_lookup,
'coupon_id',
'NOT IN',
$this->get_excluded_coupons( $query_args )
);
// Tax rate filters.
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$tax_rate_lookup,
'tax_rate_id',
'IN',
implode( ',', $query_args['tax_rate_includes'] )
);
$where_filters[] = $this->get_object_where_filter(
$orders_stats_table,
'order_id',
$tax_rate_lookup,
'tax_rate_id',
'NOT IN',
implode( ',', $query_args['tax_rate_excludes'] )
);
// Product attribute filters.
$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
// Build a subquery for getting order IDs by product attribute(s).
// Done here since our use case is a little more complicated than get_object_where_filter() can handle.
$attribute_subquery = new SqlQuery();
$attribute_subquery->add_sql_clause( 'select', "{$orders_stats_table}.order_id" );
$attribute_subquery->add_sql_clause( 'from', $orders_stats_table );
// JOIN on product lookup.
$attribute_subquery->add_sql_clause( 'join', "JOIN {$product_lookup} ON {$orders_stats_table}.order_id = {$product_lookup}.order_id" );
// Add JOINs for matching attributes.
foreach ( $attribute_subqueries['join'] as $attribute_join ) {
$attribute_subquery->add_sql_clause( 'join', $attribute_join );
}
// Add WHEREs for matching attributes.
$attribute_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' );
// Generate subquery statement and add to our where filters.
$where_filters[] = "{$orders_stats_table}.order_id IN (" . $attribute_subquery->get_query_statement() . ')';
}
$where_filters[] = $this->get_customer_subquery( $query_args );
$refund_subquery = $this->get_refund_subquery( $query_args );
$from_clause .= $refund_subquery['from_clause'];
if ( $refund_subquery['where_clause'] ) {
$where_filters[] = $refund_subquery['where_clause'];
}
$where_filters = array_filter( $where_filters );
$where_subclause = implode( " $operator ", $where_filters );
// Append status filter after to avoid matching ANY on default statuses.
$order_status_filter = $this->get_status_subquery( $query_args, $operator );
if ( $order_status_filter ) {
if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) {
$operator = 'AND';
}
$where_subclause = implode( " $operator ", array_filter( array( $where_subclause, $order_status_filter ) ) );
}
// To avoid requesting the subqueries twice, the result is applied to all queries passed to the method.
if ( $where_subclause ) {
$this->total_query->add_sql_clause( 'where', "AND ( $where_subclause )" );
$this->total_query->add_sql_clause( 'join', $from_clause );
$this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" );
$this->interval_query->add_sql_clause( 'join', $from_clause );
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only applied when not using REST API, as the API has its own defaults that overwrite these for most values (except before, after, etc).
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'interval' => 'week',
'fields' => '*',
'segmentby' => '',
'match' => 'all',
'status_is' => array(),
'status_is_not' => array(),
'product_includes' => array(),
'product_excludes' => array(),
'coupon_includes' => array(),
'coupon_excludes' => array(),
'tax_rate_includes' => array(),
'tax_rate_excludes' => array(),
'customer_type' => '',
'category_includes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'totals' => (object) array(),
'intervals' => (object) array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$this->add_time_period_sql_params( $query_args, $table_name );
$this->add_intervals_sql_params( $query_args, $table_name );
$this->add_order_by_sql_params( $query_args );
$where_time = $this->get_sql_clause( 'where_time' );
$params = $this->get_limit_sql_params( $query_args );
$coupon_join = "LEFT JOIN (
SELECT
order_id,
SUM(discount_amount) AS discount_amount,
COUNT(DISTINCT coupon_id) AS coupons_count
FROM
{$wpdb->prefix}wc_order_coupon_lookup
GROUP BY
order_id
) order_coupon_lookup
ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id";
// Additional filtering for Orders report.
$this->orders_stats_sql_filter( $query_args );
$this->total_query->add_sql_clause( 'select', $selections );
$this->total_query->add_sql_clause( 'left_join', $coupon_join );
$this->total_query->add_sql_clause( 'where_time', $where_time );
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
if ( null === $totals ) {
return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
// phpcs:ignore Generic.Commenting.Todo.TaskFound
// @todo Remove these assignements when refactoring segmenter classes to use query objects.
$totals_query = array(
'from_clause' => $this->total_query->get_sql_clause( 'join' ),
'where_time_clause' => $where_time,
'where_clause' => $this->total_query->get_sql_clause( 'where' ),
);
$intervals_query = array(
'select_clause' => $this->get_sql_clause( 'select' ),
'from_clause' => $this->interval_query->get_sql_clause( 'join' ),
'where_time_clause' => $where_time,
'where_clause' => $this->interval_query->get_sql_clause( 'where' ),
'limit' => $this->get_sql_clause( 'limit' ),
);
$unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] );
$totals[0]['products'] = $unique_products;
$segmenter = new Segmenter( $query_args, $this->report_columns );
$unique_coupons = $this->get_unique_coupon_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] );
$totals[0]['coupons_count'] = $unique_coupons;
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
$totals = (object) $this->cast_numbers( $totals[0] );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
$this->interval_query->add_sql_clause( 'left_join', $coupon_join );
$this->interval_query->add_sql_clause( 'where_time', $where_time );
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
); // phpcs:ignore cache ok, DB call ok, , unprepared SQL ok.
$db_interval_count = count( $db_intervals );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
if ( null === $intervals ) {
return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
if ( isset( $intervals[0] ) ) {
$unique_coupons = $this->get_unique_coupon_count( $intervals_query['from_clause'], $intervals_query['where_time_clause'], $intervals_query['where_clause'], true );
$intervals[0]['coupons_count'] = $unique_coupons;
}
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Get unique products based on user time query
*
* @param string $from_clause From clause with date query.
* @param string $where_time_clause Where clause with date query.
* @param string $where_clause Where clause with date query.
* @return integer Unique product count.
*/
public function get_unique_product_count( $from_clause, $where_time_clause, $where_clause ) {
global $wpdb;
$table_name = self::get_db_table_name();
return $wpdb->get_var(
"SELECT
COUNT( DISTINCT {$wpdb->prefix}wc_order_product_lookup.product_id )
FROM
{$wpdb->prefix}wc_order_product_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_product_lookup.order_id = {$table_name}.order_id
{$from_clause}
WHERE
1=1
{$where_time_clause}
{$where_clause}"
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
}
/**
* Get unique coupons based on user time query
*
* @param string $from_clause From clause with date query.
* @param string $where_time_clause Where clause with date query.
* @param string $where_clause Where clause with date query.
* @return integer Unique product count.
*/
public function get_unique_coupon_count( $from_clause, $where_time_clause, $where_clause ) {
global $wpdb;
$table_name = self::get_db_table_name();
return $wpdb->get_var(
"SELECT
COUNT(DISTINCT coupon_id)
FROM
{$wpdb->prefix}wc_order_coupon_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_coupon_lookup.order_id = {$table_name}.order_id
{$from_clause}
WHERE
1=1
{$where_time_clause}
{$where_clause}"
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
}
/**
* Add order information to the lookup table when orders are created or modified.
*
* @param int $post_id Post ID.
* @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
*/
public static function sync_order( $post_id ) {
if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) {
return -1;
}
$order = wc_get_order( $post_id );
if ( ! $order ) {
return -1;
}
return self::update( $order );
}
/**
* Update the database with stats data.
*
* @param WC_Order|WC_Order_Refund $order Order or refund to update row for.
* @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
*/
public static function update( $order ) {
global $wpdb;
$table_name = self::get_db_table_name();
if ( ! $order->get_id() || ! $order->get_date_created() ) {
return -1;
}
/**
* Filters order stats data.
*
* @param array $data Data written to order stats lookup table.
* @param WC_Order $order Order object.
*
* @since 4.0.0
*/
$data = apply_filters(
'woocommerce_analytics_update_order_stats_data',
array(
'order_id' => $order->get_id(),
'parent_id' => $order->get_parent_id(),
'date_created' => $order->get_date_created()->date( 'Y-m-d H:i:s' ),
'date_paid' => $order->get_date_paid() ? $order->get_date_paid()->date( 'Y-m-d H:i:s' ) : null,
'date_completed' => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d H:i:s' ) : null,
'date_created_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ),
'num_items_sold' => self::get_num_items_sold( $order ),
'total_sales' => $order->get_total(),
'tax_total' => $order->get_total_tax(),
'shipping_total' => $order->get_shipping_total(),
'net_total' => self::get_net_total( $order ),
'status' => self::normalize_order_status( $order->get_status() ),
'customer_id' => $order->get_report_customer_id(),
'returning_customer' => $order->is_returning_customer(),
),
$order
);
$format = array(
'%d',
'%d',
'%s',
'%s',
'%s',
'%s',
'%d',
'%f',
'%f',
'%f',
'%f',
'%s',
'%d',
'%d',
);
if ( 'shop_order_refund' === $order->get_type() ) {
$parent_order = wc_get_order( $order->get_parent_id() );
if ( $parent_order ) {
$data['parent_id'] = $parent_order->get_id();
$data['status'] = self::normalize_order_status( $parent_order->get_status() );
}
/**
* Set date_completed and date_paid the same as date_created to avoid problems
* when they are being used to sort the data, as refunds don't have them filled
*/
$data['date_completed'] = $data['date_created'];
$data['date_paid'] = $data['date_created'];
}
// Update or add the information to the DB.
$result = $wpdb->replace( $table_name, $data, $format );
/**
* Fires when order's stats reports are updated.
*
* @param int $order_id Order ID.
*
* @since 4.0.0.
*/
do_action( 'woocommerce_analytics_update_order_stats', $order->get_id() );
// Check the rows affected for success. Using REPLACE can affect 2 rows if the row already exists.
return ( 1 === $result || 2 === $result );
}
/**
* Deletes the order stats when an order is deleted.
*
* @param int $post_id Post ID.
*/
public static function delete_order( $post_id ) {
global $wpdb;
$order_id = (int) $post_id;
if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) {
return;
}
// Retrieve customer details before the order is deleted.
$order = wc_get_order( $order_id );
$customer_id = absint( CustomersDataStore::get_existing_customer_id_from_order( $order ) );
// Delete the order.
$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
/**
* Fires when orders stats are deleted.
*
* @param int $order_id Order ID.
* @param int $customer_id Customer ID.
*
* @since 4.0.0
*/
do_action( 'woocommerce_analytics_delete_order_stats', $order_id, $customer_id );
ReportsCache::invalidate();
}
/**
* Calculation methods.
*/
/**
* Get number of items sold among all orders.
*
* @param array $order WC_Order object.
* @return int
*/
protected static function get_num_items_sold( $order ) {
$num_items = 0;
$line_items = $order->get_items( 'line_item' );
foreach ( $line_items as $line_item ) {
$num_items += $line_item->get_quantity();
}
return $num_items;
}
/**
* Get the net amount from an order without shipping, tax, or refunds.
*
* @param array $order WC_Order object.
* @return float
*/
protected static function get_net_total( $order ) {
$net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() );
return (float) $net_total;
}
/**
* Check to see if an order's customer has made previous orders or not
*
* @param array $order WC_Order object.
* @param int|false $customer_id Customer ID. Optional.
* @return bool
*/
public static function is_returning_customer( $order, $customer_id = null ) {
if ( is_null( $customer_id ) ) {
$customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_existing_customer_id_from_order( $order );
}
if ( ! $customer_id ) {
return false;
}
$oldest_orders = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_oldest_orders( $customer_id );
if ( empty( $oldest_orders ) ) {
return false;
}
$first_order = $oldest_orders[0];
$second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false;
$excluded_statuses = self::get_excluded_report_order_statuses();
// Order is older than previous first order.
if ( $order->get_date_created() < wc_string_to_datetime( $first_order->date_created ) &&
! in_array( $order->get_status(), $excluded_statuses, true )
) {
self::set_customer_first_order( $customer_id, $order->get_id() );
return false;
}
// The current order is the oldest known order.
$is_first_order = (int) $order->get_id() === (int) $first_order->order_id;
// Order date has changed and next oldest is now the first order.
$date_change = $second_order &&
$order->get_date_created() > wc_string_to_datetime( $first_order->date_created ) &&
wc_string_to_datetime( $second_order->date_created ) < $order->get_date_created();
// Status has changed to an excluded status and next oldest order is now the first order.
$status_change = $second_order &&
in_array( $order->get_status(), $excluded_statuses, true );
if ( $is_first_order && ( $date_change || $status_change ) ) {
self::set_customer_first_order( $customer_id, $second_order->order_id );
return true;
}
return (int) $order->get_id() !== (int) $first_order->order_id;
}
/**
* Set a customer's first order and all others to returning.
*
* @param int $customer_id Customer ID.
* @param int $order_id Order ID.
*/
protected static function set_customer_first_order( $customer_id, $order_id ) {
global $wpdb;
$orders_stats_table = self::get_db_table_name();
$wpdb->query(
$wpdb->prepare(
// phpcs:ignore Generic.Commenting.Todo.TaskFound
// TODO: use the %i placeholder to prepare the table name when available in the the minimum required WordPress version.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"UPDATE {$orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d",
$order_id,
$customer_id
)
);
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}
Admin/API/Reports/Orders/Stats/Query.php 0000644 00000003003 15153704476 0014025 0 ustar 00 <?php
/**
* Class for parameter-based Order Stats Reports querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'interval' => 'week',
* 'categories' => array(15, 18),
* 'coupons' => array(138),
* 'status_in' => array('completed'),
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Orders\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Orders report.
*
* @return array
*/
protected function get_default_query_vars() {
return array(
'fields' => array(
'net_revenue',
'avg_order_value',
'orders_count',
'avg_items_per_order',
'num_items_sold',
'coupons',
'coupons_count',
'total_customers',
),
);
}
/**
* Get revenue data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_orders_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-orders-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_orders_stats_select_query', $results, $args );
}
}
Admin/API/Reports/Orders/Stats/Segmenter.php 0000644 00000051633 15153704476 0014665 0 ustar 00 <?php
/**
* Class for adding segmenting support without cluttering the data stores.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter extends ReportsSegmenter {
/**
* Returns column => query mapping to be used for product-related product-level segmenting query
* (e.g. products sold, revenue from product X when segmenting by category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'num_items_sold' => "SUM($products_table.product_qty) as num_items_sold",
'total_sales' => "SUM($products_table.product_gross_revenue) AS total_sales",
'coupons' => 'SUM( coupon_lookup_left_join.discount_amount ) AS coupons',
'coupons_count' => 'COUNT( DISTINCT( coupon_lookup_left_join.coupon_id ) ) AS coupons_count',
'refunds' => "SUM( CASE WHEN $products_table.product_gross_revenue < 0 THEN $products_table.product_gross_revenue ELSE 0 END ) AS refunds",
'taxes' => "SUM($products_table.tax_amount) AS taxes",
'shipping' => "SUM($products_table.shipping_amount) AS shipping",
'net_revenue' => "SUM($products_table.product_net_revenue) AS net_revenue",
);
return $columns_mapping;
}
/**
* Returns column => query mapping to be used for order-related product-level segmenting query
* (e.g. avg items per order when segmented by category).
*
* @param string $unique_orders_table Name of SQL table containing the order-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_order_level( $unique_orders_table ) {
$columns_mapping = array(
'orders_count' => "COUNT($unique_orders_table.order_id) AS orders_count",
'avg_items_per_order' => "AVG($unique_orders_table.num_items_sold) AS avg_items_per_order",
'avg_order_value' => "SUM($unique_orders_table.net_total) / COUNT($unique_orders_table.order_id) AS avg_order_value",
'total_customers' => "COUNT( DISTINCT( $unique_orders_table.customer_id ) ) AS total_customers",
);
return $columns_mapping;
}
/**
* Returns column => query mapping to be used for order-level segmenting query
* (e.g. avg items per order or Net sales when segmented by coupons).
*
* @param string $order_stats_table Name of SQL table containing the order-level info.
* @param array $overrides Array of overrides for default column calculations.
*
* @return array Column => SELECT query mapping.
*/
protected function segment_selections_orders( $order_stats_table, $overrides = array() ) {
$columns_mapping = array(
'num_items_sold' => "SUM($order_stats_table.num_items_sold) as num_items_sold",
'total_sales' => "SUM($order_stats_table.total_sales) AS total_sales",
'coupons' => "SUM($order_stats_table.discount_amount) AS coupons",
'coupons_count' => 'COUNT( DISTINCT(coupon_lookup_left_join.coupon_id) ) AS coupons_count',
'refunds' => "SUM( CASE WHEN $order_stats_table.parent_id != 0 THEN $order_stats_table.total_sales END ) AS refunds",
'taxes' => "SUM($order_stats_table.tax_total) AS taxes",
'shipping' => "SUM($order_stats_table.shipping_total) AS shipping",
'net_revenue' => "SUM($order_stats_table.net_total) AS net_revenue",
'orders_count' => "COUNT($order_stats_table.order_id) AS orders_count",
'avg_items_per_order' => "AVG($order_stats_table.num_items_sold) AS avg_items_per_order",
'avg_order_value' => "SUM($order_stats_table.net_total) / COUNT($order_stats_table.order_id) AS avg_order_value",
'total_customers' => "COUNT( DISTINCT( $order_stats_table.customer_id ) ) AS total_customers",
);
if ( $overrides ) {
$columns_mapping = array_merge( $columns_mapping, $overrides );
}
return $columns_mapping;
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
// Order level numbers.
// As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc.
$segments_orders = $wpdb->get_results(
"SELECT
$unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name
{$segmenting_selections['order_level']}
FROM
(
SELECT
$table_name.order_id,
$segmenting_groupby AS $segmenting_dimension_name,
MAX( num_items_sold ) AS num_items_sold,
MAX( net_total ) as net_total,
MAX( returning_customer ) AS returning_customer,
MAX( $table_name.customer_id ) as customer_id
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$product_segmenting_table.order_id, $segmenting_groupby
) AS $unique_orders_table
GROUP BY
$unique_orders_table.$segmenting_dimension_name",
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, $segments_orders );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments.
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
// Order level numbers.
// As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc.
$segments_orders = $wpdb->get_results(
"SELECT
$unique_orders_table.time_interval AS time_interval,
$unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name
{$segmenting_selections['order_level']}
FROM
(
SELECT
MAX( $table_name.date_created ) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$table_name.order_id,
$segmenting_groupby AS $segmenting_dimension_name,
MAX( num_items_sold ) AS num_items_sold,
MAX( net_total ) as net_total,
MAX( returning_customer ) AS returning_customer,
MAX( $table_name.customer_id ) as customer_id
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $product_segmenting_table.order_id, $segmenting_groupby
) AS $unique_orders_table
GROUP BY
time_interval, $unique_orders_table.$segmenting_dimension_name
$segmenting_limit",
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, $segments_orders );
return $intervals_segments;
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
global $wpdb;
$totals_segments = $wpdb->get_results(
"SELECT
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
return $totals_segments;
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
global $wpdb;
$segmenting_limit = '';
$limit_parts = explode( ',', $intervals_query['limit'] );
if ( 2 === count( $limit_parts ) ) {
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
}
$intervals_segments = $wpdb->get_results(
"SELECT
MAX($table_name.date_created) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = 'uniq_orders';
$segmenting_from = "LEFT JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup_left_join ON ($table_name.order_id = coupon_lookup_left_join.order_id) ";
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'product' === $this->query_args['segmentby'] ) {
// @todo How to handle shipping taxes when grouped by product?
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
'order_level' => $this->prepare_selections( $order_level_columns ),
);
$this->report_columns = array_merge( $product_level_columns, $order_level_columns );
$segmenting_from .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_groupby = $product_segmenting_table . '.product_id';
$segmenting_dimension_name = 'product_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) );
}
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
'order_level' => $this->prepare_selections( $order_level_columns ),
);
$this->report_columns = array_merge( $product_level_columns, $order_level_columns );
$segmenting_from .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
'order_level' => $this->prepare_selections( $order_level_columns ),
);
$this->report_columns = array_merge( $product_level_columns, $order_level_columns );
$segmenting_from .= "
INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id
JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id
LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id
";
$segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
$segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id";
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
// As there can be 2 or more coupons applied per one order, coupon amount needs to be split.
$coupon_override = array(
'coupons' => 'SUM(coupon_lookup.discount_amount) AS coupons',
);
$coupon_level_columns = $this->segment_selections_orders( $table_name, $coupon_override );
$segmenting_selections = $this->prepare_selections( $coupon_level_columns );
$this->report_columns = $coupon_level_columns;
$segmenting_from .= "
INNER JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup ON ($table_name.order_id = coupon_lookup.order_id)
";
$segmenting_groupby = 'coupon_lookup.coupon_id';
$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
} elseif ( 'customer_type' === $this->query_args['segmentby'] ) {
$customer_level_columns = $this->segment_selections_orders( $table_name );
$segmenting_selections = $this->prepare_selections( $customer_level_columns );
$this->report_columns = $customer_level_columns;
$segmenting_groupby = "$table_name.returning_customer";
$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
return $segments;
}
}
Admin/API/Reports/ParameterException.php 0000644 00000000506 15153704476 0014170 0 ustar 00 <?php
/**
* WooCommerce Admin Input Parameter Exception Class
*
* Exception class thrown when user provides incorrect parameters.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
/**
* API\Reports\ParameterException class.
*/
class ParameterException extends \WC_Data_Exception {}
Admin/API/Reports/PerformanceIndicators/Controller.php 0000644 00000044764 15153704476 0017013 0 ustar 00 <?php
/**
* REST API Performance indicators controller
*
* Handles requests to the /reports/store-performance endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use WP_REST_Request;
use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
/**
* REST API Reports Performance indicators controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/performance-indicators';
/**
* Contains a list of endpoints by report slug.
*
* @var array
*/
protected $endpoints = array();
/**
* Contains a list of active Jetpack module slugs.
*
* @var array
*/
protected $active_jetpack_modules = null;
/**
* Contains a list of allowed stats.
*
* @var array
*/
protected $allowed_stats = array();
/**
* Contains a list of stat labels.
*
* @var array
*/
protected $labels = array();
/**
* Contains a list of endpoints by url.
*
* @var array
*/
protected $urls = array();
/**
* Contains a cache of retrieved stats data, grouped by report slug.
*
* @var array
*/
protected $stats_data = array();
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_rest_performance_indicators_data_value', array( $this, 'format_data_value' ), 10, 5 );
}
/**
* Register the routes for reports.
*/
public function register_routes() {
parent::register_routes();
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/allowed',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_allowed_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_allowed_item_schema' ),
)
);
}
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['stats'] = $request['stats'];
return $args;
}
/**
* Get analytics report data and endpoints.
*/
private function get_analytics_report_data() {
$request = new \WP_REST_Request( 'GET', '/wc-analytics/reports' );
$response = rest_do_request( $request );
if ( is_wp_error( $response ) ) {
return $response;
}
if ( 200 !== $response->get_status() ) {
return new \WP_Error( 'woocommerce_analytics_performance_indicators_result_failed', __( 'Sorry, fetching performance indicators failed.', 'woocommerce' ) );
}
$endpoints = $response->get_data();
foreach ( $endpoints as $endpoint ) {
if ( '/stats' === substr( $endpoint['slug'], -6 ) ) {
$request = new \WP_REST_Request( 'OPTIONS', $endpoint['path'] );
$response = rest_do_request( $request );
if ( is_wp_error( $response ) ) {
return $response;
}
$data = $response->get_data();
$prefix = substr( $endpoint['slug'], 0, -6 );
if ( empty( $data['schema']['properties']['totals']['properties'] ) ) {
continue;
}
foreach ( $data['schema']['properties']['totals']['properties'] as $property_key => $schema_info ) {
if ( empty( $schema_info['indicator'] ) || ! $schema_info['indicator'] ) {
continue;
}
$stat = $prefix . '/' . $property_key;
$this->allowed_stats[] = $stat;
$stat_label = empty( $schema_info['title'] ) ? $schema_info['description'] : $schema_info['title'];
$this->labels[ $stat ] = trim( $stat_label, '.' );
$this->formats[ $stat ] = isset( $schema_info['format'] ) ? $schema_info['format'] : 'number';
}
$this->endpoints[ $prefix ] = $endpoint['path'];
$this->urls[ $prefix ] = $endpoint['_links']['report'][0]['href'];
}
}
}
/**
* Get active Jetpack modules.
*
* @return array List of active Jetpack module slugs.
*/
private function get_active_jetpack_modules() {
if ( is_null( $this->active_jetpack_modules ) ) {
if ( class_exists( '\Jetpack' ) && method_exists( '\Jetpack', 'get_active_modules' ) ) {
$active_modules = \Jetpack::get_active_modules();
$this->active_jetpack_modules = is_array( $active_modules ) ? $active_modules : array();
} else {
$this->active_jetpack_modules = array();
}
}
return $this->active_jetpack_modules;
}
/**
* Set active Jetpack modules.
*
* @internal
* @param array $modules List of active Jetpack module slugs.
*/
public function set_active_jetpack_modules( $modules ) {
$this->active_jetpack_modules = $modules;
}
/**
* Get active Jetpack modules and endpoints.
*/
private function get_jetpack_modules_data() {
$active_modules = $this->get_active_jetpack_modules();
if ( empty( $active_modules ) ) {
return;
}
$items = apply_filters(
'woocommerce_rest_performance_indicators_jetpack_items',
array(
'stats/visitors' => array(
'label' => __( 'Visitors', 'woocommerce' ),
'permission' => 'view_stats',
'format' => 'number',
'module' => 'stats',
),
'stats/views' => array(
'label' => __( 'Views', 'woocommerce' ),
'permission' => 'view_stats',
'format' => 'number',
'module' => 'stats',
),
)
);
foreach ( $items as $item_key => $item ) {
if ( ! in_array( $item['module'], $active_modules, true ) ) {
return;
}
if ( $item['permission'] && ! current_user_can( $item['permission'] ) ) {
return;
}
$stat = 'jetpack/' . $item_key;
$endpoint = 'jetpack/' . $item['module'];
$this->allowed_stats[] = $stat;
$this->labels[ $stat ] = $item['label'];
$this->endpoints[ $endpoint ] = '/jetpack/v4/module/' . $item['module'] . '/data';
$this->formats[ $stat ] = $item['format'];
}
$this->urls['jetpack/stats'] = '/jetpack';
}
/**
* Get information such as allowed stats, stat labels, and endpoint data from stats reports.
*
* @return WP_Error|True
*/
private function get_indicator_data() {
// Data already retrieved.
if ( ! empty( $this->endpoints ) && ! empty( $this->labels ) && ! empty( $this->allowed_stats ) ) {
return true;
}
$this->get_analytics_report_data();
$this->get_jetpack_modules_data();
return true;
}
/**
* Returns a list of allowed performance indicators.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_allowed_items( $request ) {
$indicator_data = $this->get_indicator_data();
if ( is_wp_error( $indicator_data ) ) {
return $indicator_data;
}
$data = array();
foreach ( $this->allowed_stats as $stat ) {
$pieces = $this->get_stats_parts( $stat );
$report = $pieces[0];
$chart = $pieces[1];
$data[] = (object) array(
'stat' => $stat,
'chart' => $chart,
'label' => $this->labels[ $stat ],
);
}
usort( $data, array( $this, 'sort' ) );
$objects = array();
foreach ( $data as $item ) {
$prepared = $this->prepare_item_for_response( $item, $request );
$objects[] = $this->prepare_response_for_collection( $prepared );
}
return $this->add_pagination_headers(
$request,
$objects,
(int) count( $data ),
1,
1
);
}
/**
* Sorts the list of stats. Sorted by custom arrangement.
*
* @internal
* @see https://github.com/woocommerce/woocommerce-admin/issues/1282
* @param object $a First item.
* @param object $b Second item.
* @return order
*/
public function sort( $a, $b ) {
/**
* Custom ordering for store performance indicators.
*
* @see https://github.com/woocommerce/woocommerce-admin/issues/1282
* @param array $indicators A list of ordered indicators.
*/
$stat_order = apply_filters(
'woocommerce_rest_report_sort_performance_indicators',
array(
'revenue/total_sales',
'revenue/net_revenue',
'orders/orders_count',
'orders/avg_order_value',
'products/items_sold',
'revenue/refunds',
'coupons/orders_count',
'coupons/amount',
'taxes/total_tax',
'taxes/order_tax',
'taxes/shipping_tax',
'revenue/shipping',
'downloads/download_count',
)
);
$a = array_search( $a->stat, $stat_order, true );
$b = array_search( $b->stat, $stat_order, true );
if ( false === $a && false === $b ) {
return 0;
} elseif ( false === $a ) {
return 1;
} elseif ( false === $b ) {
return -1;
} else {
return $a - $b;
}
}
/**
* Get report stats data, avoiding duplicate requests for stats that use the same endpoint.
*
* @param string $report Report slug to request data for.
* @param array $query_args Report query args.
* @return WP_REST_Response|WP_Error Report stats data.
*/
private function get_stats_data( $report, $query_args ) {
// Return from cache if we've already requested these report stats.
if ( isset( $this->stats_data[ $report ] ) ) {
return $this->stats_data[ $report ];
}
// Request the report stats.
$request_url = $this->endpoints[ $report ];
$request = new \WP_REST_Request( 'GET', $request_url );
$request->set_param( 'before', $query_args['before'] );
$request->set_param( 'after', $query_args['after'] );
$response = rest_do_request( $request );
// Cache the response.
$this->stats_data[ $report ] = $response;
return $response;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$indicator_data = $this->get_indicator_data();
if ( is_wp_error( $indicator_data ) ) {
return $indicator_data;
}
$query_args = $this->prepare_reports_query( $request );
if ( empty( $query_args['stats'] ) ) {
return new \WP_Error( 'woocommerce_analytics_performance_indicators_empty_query', __( 'A list of stats to query must be provided.', 'woocommerce' ), 400 );
}
$stats = array();
foreach ( $query_args['stats'] as $stat ) {
$is_error = false;
$pieces = $this->get_stats_parts( $stat );
$report = $pieces[0];
$chart = $pieces[1];
if ( ! in_array( $stat, $this->allowed_stats, true ) ) {
continue;
}
$response = $this->get_stats_data( $report, $query_args );
if ( is_wp_error( $response ) ) {
return $response;
}
$data = $response->get_data();
$format = $this->formats[ $stat ];
$label = $this->labels[ $stat ];
if ( 200 !== $response->get_status() ) {
$stats[] = (object) array(
'stat' => $stat,
'chart' => $chart,
'label' => $label,
'format' => $format,
'value' => null,
);
continue;
}
$stats[] = (object) array(
'stat' => $stat,
'chart' => $chart,
'label' => $label,
'format' => $format,
'value' => apply_filters( 'woocommerce_rest_performance_indicators_data_value', $data, $stat, $report, $chart, $query_args ),
);
}
usort( $stats, array( $this, 'sort' ) );
$objects = array();
foreach ( $stats as $stat ) {
$data = $this->prepare_item_for_response( $stat, $request );
$objects[] = $this->prepare_response_for_collection( $data );
}
$response = rest_ensure_response( $objects );
$response->header( 'X-WP-Total', count( $stats ) );
$response->header( 'X-WP-TotalPages', 1 );
$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
return $response;
}
/**
* Prepare a report object for serialization.
*
* @param array $stat_data Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $stat_data, $request ) {
$response = parent::prepare_item_for_response( $stat_data, $request );
$response->add_links( $this->prepare_links( $stat_data ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_performance_indicators', $response, $stat_data, $request );
}
/**
* Prepare links for the request.
*
* @param \Automattic\WooCommerce\Admin\API\Reports\Query $object Object data.
* @return array
*/
protected function prepare_links( $object ) {
$pieces = $this->get_stats_parts( $object->stat );
$endpoint = $pieces[0];
$stat = $pieces[1];
$url = isset( $this->urls[ $endpoint ] ) ? $this->urls[ $endpoint ] : '';
$links = array(
'api' => array(
'href' => rest_url( $this->endpoints[ $endpoint ] ),
),
'report' => array(
'href' => $url,
),
);
return $links;
}
/**
* Returns the endpoint part of a stat request (prefix) and the actual stat total we want.
* To allow extensions to namespace (example: fue/emails/sent), we break on the last forward slash.
*
* @param string $full_stat A stat request string like orders/avg_order_value or fue/emails/sent.
* @return array Containing the prefix (endpoint) and suffix (stat).
*/
private function get_stats_parts( $full_stat ) {
$endpoint = substr( $full_stat, 0, strrpos( $full_stat, '/' ) );
$stat = substr( $full_stat, ( strrpos( $full_stat, '/' ) + 1 ) );
return array(
$endpoint,
$stat,
);
}
/**
* Format the data returned from the API for given stats.
*
* @param array $data Data from external endpoint.
* @param string $stat Name of the stat.
* @param string $report Name of the report.
* @param string $chart Name of the chart.
* @param array $query_args Query args.
* @return mixed
*/
public function format_data_value( $data, $stat, $report, $chart, $query_args ) {
if ( 'jetpack/stats' === $report ) {
// Get the index of the field to tally.
$index = array_search( $chart, $data['general']->visits->fields, true );
if ( ! $index ) {
return null;
}
// Loop over provided data and filter by the queried date.
// Note that this is currently limited to 30 days via the Jetpack API
// but the WordPress.com endpoint allows up to 90 days.
$total = 0;
$before = gmdate( 'Y-m-d', strtotime( isset( $query_args['before'] ) ? $query_args['before'] : TimeInterval::default_before() ) );
$after = gmdate( 'Y-m-d', strtotime( isset( $query_args['after'] ) ? $query_args['after'] : TimeInterval::default_after() ) );
foreach ( $data['general']->visits->data as $datum ) {
if ( $datum[0] >= $after && $datum[0] <= $before ) {
$total += $datum[ $index ];
}
}
return $total;
}
if ( isset( $data['totals'] ) && isset( $data['totals'][ $chart ] ) ) {
return $data['totals'][ $chart ];
}
return null;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$indicator_data = $this->get_indicator_data();
if ( is_wp_error( $indicator_data ) ) {
$allowed_stats = array();
} else {
$allowed_stats = $this->allowed_stats;
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_performance_indicator',
'type' => 'object',
'properties' => array(
'stat' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => $allowed_stats,
),
'chart' => array(
'description' => __( 'The specific chart this stat referrers to.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'label' => array(
'description' => __( 'Human readable label for the stat.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'format' => array(
'description' => __( 'Format of the stat.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'number', 'currency' ),
),
'value' => array(
'description' => __( 'Value of the stat. Returns null if the stat does not exist or cannot be loaded.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get schema for the list of allowed performance indicators.
*
* @return array $schema
*/
public function get_public_allowed_item_schema() {
$schema = $this->get_public_item_schema();
unset( $schema['properties']['value'] );
unset( $schema['properties']['format'] );
return $schema;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$indicator_data = $this->get_indicator_data();
if ( is_wp_error( $indicator_data ) ) {
$allowed_stats = __( 'There was an issue loading the report endpoints', 'woocommerce' );
} else {
$allowed_stats = implode( ', ', $this->allowed_stats );
}
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['stats'] = array(
'description' => sprintf(
/* translators: Allowed values is a list of stat endpoints. */
__( 'Limit response to specific report stats. Allowed values: %s.', 'woocommerce' ),
$allowed_stats
),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
'enum' => $this->allowed_stats,
),
'default' => $this->allowed_stats,
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}
Admin/API/Reports/Products/Controller.php 0000644 00000026673 15153704476 0014334 0 ustar 00 <?php
/**
* REST API Reports products controller
*
* Handles requests to the /reports/products endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports products controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController implements ExportableInterface {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/products';
/**
* Mapping between external parameter name and name used in query class.
*
* @var array
*/
protected $param_mapping = array(
'categories' => 'category_includes',
'products' => 'product_includes',
'variations' => 'variation_includes',
);
/**
* Get items.
*
* @param WP_REST_Request $request Request data.
*
* @return array|WP_Error
*/
public function get_items( $request ) {
$args = array();
$registered = array_keys( $this->get_collection_params() );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
if ( isset( $this->param_mapping[ $param_name ] ) ) {
$args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
} else {
$args[ $param_name ] = $request[ $param_name ];
}
}
}
$reports = new Query( $args );
$products_data = $reports->get_data();
$data = array();
foreach ( $products_data->data as $product_data ) {
$item = $this->prepare_item_for_response( $product_data, $request );
if ( isset( $item->data['extended_info']['name'] ) ) {
$item->data['extended_info']['name'] = wp_strip_all_tags( $item->data['extended_info']['name'] );
}
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $products_data->total,
(int) $products_data->page_no,
(int) $products_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param Array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_products', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param Array $object Object data.
* @return array Links for the given post.
*/
protected function prepare_links( $object ) {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ),
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_products',
'type' => 'object',
'properties' => array(
'product_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product ID.', 'woocommerce' ),
),
'items_sold' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Number of items sold.', 'woocommerce' ),
),
'net_revenue' => array(
'type' => 'number',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ),
),
'orders_count' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Number of orders product appeared in.', 'woocommerce' ),
),
'extended_info' => array(
'name' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product name.', 'woocommerce' ),
),
'price' => array(
'type' => 'number',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product price.', 'woocommerce' ),
),
'image' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product image.', 'woocommerce' ),
),
'permalink' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product link.', 'woocommerce' ),
),
'category_ids' => array(
'type' => 'array',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product category IDs.', 'woocommerce' ),
),
'stock_status' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory status.', 'woocommerce' ),
),
'stock_quantity' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory quantity.', 'woocommerce' ),
),
'low_stock_amount' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ),
),
'variations' => array(
'type' => 'array',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product variations IDs.', 'woocommerce' ),
),
'sku' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product SKU.', 'woocommerce' ),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array(
'date',
'net_revenue',
'orders_count',
'items_sold',
'product_name',
'variations',
'sku',
);
$params['categories'] = array(
'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['products'] = array(
'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each product to the report.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get stock status column export value.
*
* @param array $status Stock status from report row.
* @return string
*/
protected function get_stock_status( $status ) {
$statuses = wc_get_product_stock_status_options();
return isset( $statuses[ $status ] ) ? $statuses[ $status ] : '';
}
/**
* Get categories column export value.
*
* @param array $category_ids Category IDs from report row.
* @return string
*/
protected function get_categories( $category_ids ) {
$category_names = get_terms(
array(
'taxonomy' => 'product_cat',
'include' => $category_ids,
'fields' => 'names',
)
);
return implode( ', ', $category_names );
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'product_name' => __( 'Product title', 'woocommerce' ),
'sku' => __( 'SKU', 'woocommerce' ),
'items_sold' => __( 'Items sold', 'woocommerce' ),
'net_revenue' => __( 'N. Revenue', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
'product_cat' => __( 'Category', 'woocommerce' ),
'variations' => __( 'Variations', 'woocommerce' ),
);
if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
$export_columns['stock_status'] = __( 'Status', 'woocommerce' );
$export_columns['stock'] = __( 'Stock', 'woocommerce' );
}
/**
* Filter to add or remove column names from the products report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_products_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$export_item = array(
'product_name' => $item['extended_info']['name'],
'sku' => $item['extended_info']['sku'],
'items_sold' => $item['items_sold'],
'net_revenue' => $item['net_revenue'],
'orders_count' => $item['orders_count'],
'product_cat' => $this->get_categories( $item['extended_info']['category_ids'] ),
'variations' => isset( $item['extended_info']['variations'] ) ? count( $item['extended_info']['variations'] ) : 0,
);
if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
if ( $item['extended_info']['manage_stock'] ) {
$export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] );
$export_item['stock'] = $item['extended_info']['stock_quantity'];
} else {
$export_item['stock_status'] = __( 'N/A', 'woocommerce' );
$export_item['stock'] = __( 'N/A', 'woocommerce' );
}
}
/**
* Filter to prepare extra columns in the export item for the products
* report.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_products_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Products/DataStore.php 0000644 00000042526 15153704476 0014072 0 ustar 00 <?php
/**
* API\Reports\Products\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
/**
* API\Reports\Products\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_product_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'products';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
// Extended attributes.
'name' => 'strval',
'price' => 'floatval',
'image' => 'strval',
'permalink' => 'strval',
'stock_status' => 'strval',
'stock_quantity' => 'intval',
'low_stock_amount' => 'intval',
'category_ids' => 'array_values',
'variations' => 'array_values',
'sku' => 'strval',
);
/**
* Extended product attributes to include in the data.
*
* @var array
*/
protected $extended_attributes = array(
'name',
'price',
'image',
'permalink',
'stock_status',
'stock_quantity',
'manage_stock',
'low_stock_amount',
'category_ids',
'variations',
'sku',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'products';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'product_id' => 'product_id',
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
);
}
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 10 );
}
/**
* Fills FROM clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @param string $arg_name Target of the JOIN sql param.
* @param string $id_cell ID cell identifier, like `table_name.id_column_name`.
*/
protected function add_from_sql_params( $query_args, $arg_name, $id_cell ) {
global $wpdb;
$type = 'join';
// Order by product name requires extra JOIN.
switch ( $query_args['orderby'] ) {
case 'product_name':
$join = " JOIN {$wpdb->posts} AS _products ON {$id_cell} = _products.ID";
break;
case 'sku':
$join = " LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'";
break;
case 'variations':
$type = 'left_join';
$join = "LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->posts} WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent";
break;
default:
$join = '';
break;
}
if ( $join ) {
if ( 'inner' === $arg_name ) {
$this->subquery->add_sql_clause( $type, $join );
} else {
$this->add_sql_clause( $type, $join );
}
}
}
/**
* Updates the database query with parameters used for Products report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_product_lookup_table = self::get_db_table_name();
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$this->add_from_sql_params( $query_args, 'outer', 'default_results.product_id' );
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
} else {
$this->add_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" );
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" );
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
}
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return self::get_db_table_name() . '.date_created';
}
if ( 'product_name' === $order_by ) {
return 'post_title';
}
if ( 'sku' === $order_by ) {
return 'meta_value';
}
return $order_by;
}
/**
* Enriches the product data with attributes specified by the extended_attributes.
*
* @param array $products_data Product data.
* @param array $query_args Query parameters.
*/
protected function include_extended_info( &$products_data, $query_args ) {
global $wpdb;
$product_names = array();
foreach ( $products_data as $key => $product_data ) {
$extended_info = new \ArrayObject();
if ( $query_args['extended_info'] ) {
$product_id = $product_data['product_id'];
$product = wc_get_product( $product_id );
// Product was deleted.
if ( ! $product ) {
if ( ! isset( $product_names[ $product_id ] ) ) {
$product_names[ $product_id ] = $wpdb->get_var(
$wpdb->prepare(
"SELECT i.order_item_name
FROM {$wpdb->prefix}woocommerce_order_items i, {$wpdb->prefix}woocommerce_order_itemmeta m
WHERE i.order_item_id = m.order_item_id
AND m.meta_key = '_product_id'
AND m.meta_value = %s
ORDER BY i.order_item_id DESC
LIMIT 1",
$product_id
)
);
}
/* translators: %s is product name */
$products_data[ $key ]['extended_info']['name'] = $product_names[ $product_id ] ? sprintf( __( '%s (Deleted)', 'woocommerce' ), $product_names[ $product_id ] ) : __( '(Deleted)', 'woocommerce' );
continue;
}
$extended_attributes = apply_filters( 'woocommerce_rest_reports_products_extended_attributes', $this->extended_attributes, $product_data );
foreach ( $extended_attributes as $extended_attribute ) {
if ( 'variations' === $extended_attribute ) {
if ( ! $product->is_type( 'variable' ) ) {
continue;
}
$function = 'get_children';
} else {
$function = 'get_' . $extended_attribute;
}
if ( is_callable( array( $product, $function ) ) ) {
$value = $product->{$function}();
$extended_info[ $extended_attribute ] = $value;
}
}
// If there is no set low_stock_amount, use the one in user settings.
if ( '' === $extended_info['low_stock_amount'] ) {
$extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
}
$extended_info = $this->cast_numbers( $extended_info );
}
$products_data[ $key ]['extended_info'] = $extended_info;
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'product_includes' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$included_products = $this->get_included_products_array( $query_args );
$params = $this->get_limit_params( $query_args );
$this->add_sql_query_params( $query_args );
if ( count( $included_products ) > 0 ) {
$filtered_products = array_diff( $included_products, array( '-1' ) );
$total_results = count( $filtered_products );
$total_pages = (int) ceil( $total_results / $params['per_page'] );
if ( 'date' === $query_args['orderby'] ) {
$selections .= ", {$table_name}.date_created";
}
$fields = $this->get_fields( $query_args );
$join_selections = $this->format_join_selections( $fields, array( 'product_id' ) );
$ids_table = $this->get_ids_table( $included_products, 'product_id' );
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
$this->add_sql_clause( 'select', $join_selections );
$this->add_sql_clause( 'from', '(' );
$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
$this->add_sql_clause( 'from', ") AS {$table_name}" );
$this->add_sql_clause(
'right_join',
"RIGHT JOIN ( {$ids_table} ) AS default_results
ON default_results.product_id = {$table_name}.product_id"
);
$this->add_sql_clause( 'where', 'AND default_results.product_id != -1' );
$products_query = $this->get_query_statement();
} else {
$count_query = "SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) AS tt";
$db_records_count = (int) $wpdb->get_var(
$count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$total_results = $db_records_count;
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) {
return $data;
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$products_query = $this->subquery->get_query_statement();
}
$product_data = $wpdb->get_results(
$products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $product_data ) {
return $data;
}
$product_data = array_map( array( $this, 'cast_numbers' ), $product_data );
$data = (object) array(
'data' => $product_data,
'total' => $total_results,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
$this->include_extended_info( $data->data, $query_args );
return $data;
}
/**
* Create or update an entry in the wc_admin_order_product_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
*/
public static function sync_order_products( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return -1;
}
$table_name = self::get_db_table_name();
$existing_items = $wpdb->get_col(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT order_item_id FROM {$table_name} WHERE order_id = %d",
$order_id
)
);
$existing_items = array_flip( $existing_items );
$order_items = $order->get_items();
$num_updated = 0;
$decimals = wc_get_price_decimals();
$round_tax = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );
foreach ( $order_items as $order_item ) {
$order_item_id = $order_item->get_id();
unset( $existing_items[ $order_item_id ] );
$product_qty = $order_item->get_quantity( 'edit' );
$shipping_amount = $order->get_item_shipping_amount( $order_item );
$shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item );
$coupon_amount = $order->get_item_coupon_amount( $order_item );
// Skip line items without changes to product quantity.
if ( ! $product_qty ) {
$num_updated++;
continue;
}
// Tax amount.
$tax_amount = 0;
$order_taxes = $order->get_taxes();
$tax_data = $order_item->get_taxes();
foreach ( $order_taxes as $tax_item ) {
$tax_item_id = $tax_item->get_rate_id();
$tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
}
$net_revenue = round( $order_item->get_total( 'edit' ), $decimals );
if ( $round_tax ) {
$tax_amount = round( $tax_amount, $decimals );
}
$result = $wpdb->replace(
self::get_db_table_name(),
array(
'order_item_id' => $order_item_id,
'order_id' => $order->get_id(),
'product_id' => wc_get_order_item_meta( $order_item_id, '_product_id' ),
'variation_id' => wc_get_order_item_meta( $order_item_id, '_variation_id' ),
'customer_id' => $order->get_report_customer_id(),
'product_qty' => $product_qty,
'product_net_revenue' => $net_revenue,
'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
'coupon_amount' => $coupon_amount,
'tax_amount' => $tax_amount,
'shipping_amount' => $shipping_amount,
'shipping_tax_amount' => $shipping_tax_amount,
// @todo Can this be incorrect if modified by filters?
'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
),
array(
'%d', // order_item_id.
'%d', // order_id.
'%d', // product_id.
'%d', // variation_id.
'%d', // customer_id.
'%d', // product_qty.
'%f', // product_net_revenue.
'%s', // date_created.
'%f', // coupon_amount.
'%f', // tax_amount.
'%f', // shipping_amount.
'%f', // shipping_tax_amount.
'%f', // product_gross_revenue.
)
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
/**
* Fires when product's reports are updated.
*
* @param int $order_item_id Order Item ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_update_product', $order_item_id, $order->get_id() );
// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
}
if ( ! empty( $existing_items ) ) {
$existing_items = array_flip( $existing_items );
$format = array_fill( 0, count( $existing_items ), '%d' );
$format = implode( ',', $format );
array_unshift( $existing_items, $order_id );
$wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"DELETE FROM {$table_name} WHERE order_id = %d AND order_item_id in ({$format})",
$existing_items
)
);
}
return ( count( $order_items ) === $num_updated );
}
/**
* Clean products data when an order is deleted.
*
* @param int $order_id Order ID.
*/
public static function sync_on_order_delete( $order_id ) {
global $wpdb;
$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
/**
* Fires when product's reports are removed from database.
*
* @param int $product_id Product ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_delete_product', 0, $order_id );
ReportsCache::invalidate();
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'select', 'product_id' );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
$this->subquery->add_sql_clause( 'group_by', 'product_id' );
}
}
Admin/API/Reports/Products/Query.php 0000644 00000002352 15153704476 0013302 0 ustar 00 <?php
/**
* Class for parameter-based Products Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'categories' => array(15, 18),
* 'products' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Products\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_products_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-products' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_products_select_query', $results, $args );
}
}
Admin/API/Reports/Products/Stats/Controller.php 0000644 00000017310 15153704476 0015416 0 ustar 00 <?php
/**
* REST API Reports products stats controller
*
* Handles requests to the /reports/products/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports products stats controller class.
*
* @internal
* @extends GenericStatsController
*/
class Controller extends GenericStatsController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/products/stats';
/**
* Mapping between external parameter name and name used in query class.
*
* @var array
*/
protected $param_mapping = array(
'categories' => 'category_includes',
'products' => 'product_includes',
'variations' => 'variation_includes',
);
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_analytics_products_stats_select_query', array( $this, 'set_default_report_data' ) );
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = array(
'fields' => array(
'items_sold',
'net_revenue',
'orders_count',
'products_count',
'variations_count',
),
);
$registered = array_keys( $this->get_collection_params() );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
if ( isset( $this->param_mapping[ $param_name ] ) ) {
$query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
} else {
$query_args[ $param_name ] = $request[ $param_name ];
}
}
}
$query = new Query( $query_args );
try {
$report_data = $query->get_data();
} catch ( ParameterException $e ) {
return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_products_stats', $response, $report, $request );
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
protected function get_item_properties_schema() {
return array(
'items_sold' => array(
'title' => __( 'Products sold', 'woocommerce' ),
'description' => __( 'Number of product items sold.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
),
'net_revenue' => array(
'description' => __( 'Net sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$schema['title'] = 'report_products_stats';
$segment_label = array(
'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
);
$schema['properties']['totals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label;
$schema['properties']['intervals']['items']['properties']['subtotals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label;
return $this->add_additional_fields_schema( $schema );
}
/**
* Set the default results to 0 if API returns an empty array
*
* @internal
* @param Mixed $results Report data.
* @return object
*/
public function set_default_report_data( $results ) {
if ( empty( $results ) ) {
$results = new \stdClass();
$results->total = 0;
$results->totals = new \stdClass();
$results->totals->items_sold = 0;
$results->totals->net_revenue = 0;
$results->totals->orders_count = 0;
$results->intervals = array();
$results->pages = 1;
$results->page_no = 1;
}
return $results;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array(
'date',
'net_revenue',
'coupons',
'refunds',
'shipping',
'taxes',
'net_revenue',
'orders_count',
'items_sold',
);
$params['categories'] = array(
'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['products'] = array(
'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['variations'] = array(
'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
}
Admin/API/Reports/Products/Stats/DataStore.php 0000644 00000023373 15153704476 0015167 0 ustar 00 <?php
/**
* API\Reports\Products\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Products\Stats\DataStore.
*/
class DataStore extends ProductsDataStore implements DataStoreInterface {
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'products_count' => 'intval',
'variations_count' => 'intval',
);
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'products_stats';
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'products_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
'products_count' => 'COUNT(DISTINCT product_id) as products_count',
'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count',
);
}
/**
* Updates the database query with parameters used for Products Stats report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function update_sql_query_params( $query_args ) {
global $wpdb;
$products_where_clause = '';
$products_from_clause = '';
$order_product_lookup_table = self::get_db_table_name();
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
$products_where_clause .= " AND ( {$order_status_filter} )";
}
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->total_query->add_sql_clause( 'where', $products_where_clause );
$this->total_query->add_sql_clause( 'join', $products_from_clause );
$this->add_intervals_sql_params( $query_args, $order_product_lookup_table );
$this->interval_query->add_sql_clause( 'where', $products_where_clause );
$this->interval_query->add_sql_clause( 'join', $products_from_clause );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @since 3.5.0
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'interval' => 'week',
'product_includes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$selections = $this->selected_columns( $query_args );
$params = $this->get_limit_params( $query_args );
$this->update_sql_query_params( $query_args );
$this->get_limit_sql_params( $query_args );
$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$db_interval_count = count( $db_intervals );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return array();
}
$intervals = array();
$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
$this->total_query->add_sql_clause( 'select', $selections );
$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// @todo remove these assignements when refactoring segmenter classes to use query objects.
$totals_query = array(
'from_clause' => $this->total_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->total_query->get_sql_clause( 'where' ),
);
$intervals_query = array(
'select_clause' => $this->get_sql_clause( 'select' ),
'from_clause' => $this->interval_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->interval_query->get_sql_clause( 'where' ),
'order_by' => $this->get_sql_clause( 'order_by' ),
'limit' => $this->get_sql_clause( 'limit' ),
);
$segmenter = new Segmenter( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
if ( null === $totals ) {
return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $intervals ) {
return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
$totals = (object) $this->cast_numbers( $totals[0] );
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}
Admin/API/Reports/Products/Stats/Query.php 0000644 00000002425 15153704476 0014401 0 ustar 00 <?php
/**
* Class for parameter-based Products Stats Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'categories' => array(15, 18),
* 'product_ids' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Products\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_products_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-products-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_products_stats_select_query', $results, $args );
}
}
Admin/API/Reports/Products/Stats/Segmenter.php 0000644 00000024312 15153704476 0015224 0 ustar 00 <?php
/**
* Class for adding segmenting support without cluttering the data stores.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter extends ReportsSegmenter {
/**
* Returns column => query mapping to be used for product-related product-level segmenting query
* (e.g. products sold, revenue from product X when segmenting by category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'items_sold' => "SUM($products_table.product_qty) as items_sold",
'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue",
'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
'products_count' => "COUNT( DISTINCT $products_table.product_id ) AS products_count",
'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count",
);
return $columns_mapping;
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// LIMIT offset, rowcount needs to be updated to a multiple of the number of segments.
preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts );
$segment_count = count( $this->get_all_segments() );
$orig_offset = intval( $limit_parts[1] );
$orig_rowcount = intval( $limit_parts[2] );
$segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count );
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = 'uniq_orders';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'product' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
);
$this->report_columns = $product_level_columns;
$segmenting_from = '';
$segmenting_groupby = $product_segmenting_table . '.product_id';
$segmenting_dimension_name = 'product_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) );
}
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
);
$this->report_columns = $product_level_columns;
$segmenting_from = '';
$segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
);
$this->report_columns = $product_level_columns;
$segmenting_from = "
LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id
JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id
LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id
";
$segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
$segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id";
$segmenting_dimension_name = 'category_id';
// Restrict our search space for category comparisons.
if ( isset( $this->query_args['category_includes'] ) ) {
$category_ids = implode( ',', $this->get_all_segments() );
$segmenting_where .= " AND {$wpdb->wc_category_lookup}.category_id IN ( $category_ids )";
}
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
}
return $segments;
}
}
Admin/API/Reports/Query.php 0000644 00000001121 15153704476 0011470 0 ustar 00 <?php
/**
* Class for parameter-based Reports querying
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
/**
* Admin\API\Reports\Query
*/
abstract class Query extends \WC_Object_Query {
/**
* Get report data matching the current query vars.
*
* @return array|object of WC_Product objects
*/
public function get_data() {
/* translators: %s: Method name */
return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) );
}
}
Admin/API/Reports/Revenue/Query.php 0000644 00000003077 15153704476 0013115 0 ustar 00 <?php
/**
* Class for parameter-based Revenue Reports querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'interval' => 'week',
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Revenue\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Revenue;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Revenue\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Revenue report.
*
* @return array
*/
protected function get_default_query_vars() {
return array(
'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default.
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => '',
'after' => '',
'interval' => 'week',
'fields' => array(
'orders_count',
'num_items_sold',
'total_sales',
'coupons',
'coupons_count',
'refunds',
'taxes',
'shipping',
'net_revenue',
'gross_sales',
),
);
}
/**
* Get revenue data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_revenue_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-revenue-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_revenue_select_query', $results, $args );
}
}
Admin/API/Reports/Revenue/Stats/Controller.php 0000644 00000022444 15153704476 0015230 0 ustar 00 <?php
/**
* REST API Reports revenue stats controller
*
* Handles requests to the /reports/revenue/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
use Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports revenue stats controller class.
*
* @internal
* @extends GenericStatsController
*/
class Controller extends GenericStatsController implements ExportableInterface {
/**
* Exportable traits.
*/
use ExportableTraits;
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/revenue/stats';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['segmentby'] = $request['segmentby'];
$args['fields'] = $request['fields'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$reports_revenue = new RevenueQuery( $query_args );
try {
$report_data = $reports_revenue->get_data();
} catch ( ParameterException $e ) {
return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Get report items for export.
*
* Returns only the interval data.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response
*/
public function get_export_items( $request ) {
$response = $this->get_items( $request );
$data = $response->get_data();
$intervals = $data['intervals'];
$response->set_data( $intervals );
return $response;
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_revenue_stats', $response, $report, $request );
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
protected function get_item_properties_schema() {
return array(
'total_sales' => array(
'description' => __( 'Total sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'net_revenue' => array(
'description' => __( 'Net sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'coupons' => array(
'description' => __( 'Amount discounted by coupons.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'coupons_count' => array(
'description' => __( 'Unique coupons count.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'shipping' => array(
'title' => __( 'Shipping', 'woocommerce' ),
'description' => __( 'Total of shipping.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'taxes' => array(
'description' => __( 'Total of taxes.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'refunds' => array(
'title' => __( 'Returns', 'woocommerce' ),
'description' => __( 'Total of returns.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'num_items_sold' => array(
'description' => __( 'Items sold.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'gross_sales' => array(
'description' => __( 'Gross sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$schema['title'] = 'report_revenue_stats';
// Products is not shown in intervals, only in totals.
$schema['properties']['totals']['properties']['products'] = array(
'description' => __( 'Products sold.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array(
'date',
'total_sales',
'coupons',
'refunds',
'shipping',
'taxes',
'net_revenue',
'orders_count',
'items_sold',
'gross_sales',
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
'coupon',
'customer_type', // new vs returning.
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
return array(
'date' => __( 'Date', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
'gross_sales' => __( 'Gross sales', 'woocommerce' ),
'refunds' => __( 'Returns', 'woocommerce' ),
'coupons' => __( 'Coupons', 'woocommerce' ),
'net_revenue' => __( 'Net sales', 'woocommerce' ),
'taxes' => __( 'Taxes', 'woocommerce' ),
'shipping' => __( 'Shipping', 'woocommerce' ),
'total_sales' => __( 'Total sales', 'woocommerce' ),
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$subtotals = (array) $item['subtotals'];
return array(
'date' => $item['date_start'],
'orders_count' => $subtotals['orders_count'],
'gross_sales' => self::csv_number_format( $subtotals['gross_sales'] ),
'refunds' => self::csv_number_format( $subtotals['refunds'] ),
'coupons' => self::csv_number_format( $subtotals['coupons'] ),
'net_revenue' => self::csv_number_format( $subtotals['net_revenue'] ),
'taxes' => self::csv_number_format( $subtotals['taxes'] ),
'shipping' => self::csv_number_format( $subtotals['shipping'] ),
'total_sales' => self::csv_number_format( $subtotals['total_sales'] ),
);
}
}
Admin/API/Reports/Segmenter.php 0000644 00000062474 15153704476 0012336 0 ustar 00 <?php
/**
* Class for adding segmenting support without cluttering the data stores.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxesStatsDataStore;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter {
/**
* Array of all segment ids.
*
* @var array|bool
*/
protected $all_segment_ids = false;
/**
* Array of all segment labels.
*
* @var array
*/
protected $segment_labels = array();
/**
* Query arguments supplied by the user for data store.
*
* @var array
*/
protected $query_args = '';
/**
* SQL definition for each column.
*
* @var array
*/
protected $report_columns = array();
/**
* Constructor.
*
* @param array $query_args Query arguments supplied by the user for data store.
* @param array $report_columns Report columns lookup from data store.
*/
public function __construct( $query_args, $report_columns ) {
$this->query_args = $query_args;
$this->report_columns = $report_columns;
}
/**
* Filters definitions for SELECT clauses based on query_args and joins them into one string usable in SELECT clause.
*
* @param array $columns_mapping Column name -> SQL statememt mapping.
*
* @return string to be used in SELECT clause statements.
*/
protected function prepare_selections( $columns_mapping ) {
if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) {
$keep = array();
foreach ( $this->query_args['fields'] as $field ) {
if ( isset( $columns_mapping[ $field ] ) ) {
$keep[ $field ] = $columns_mapping[ $field ];
}
}
$selections = implode( ', ', $keep );
} else {
$selections = implode( ', ', $columns_mapping );
}
if ( $selections ) {
$selections = ',' . $selections;
}
return $selections;
}
/**
* Update row-level db result for segments in 'totals' section to the format used for output.
*
* @param array $segments_db_result Results from the SQL db query for segmenting.
* @param string $segment_dimension Name of column used for grouping the result.
*
* @return array Reformatted array.
*/
protected function reformat_totals_segments( $segments_db_result, $segment_dimension ) {
$segment_result = array();
if ( strpos( $segment_dimension, '.' ) ) {
$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
}
$segment_labels = $this->get_segment_labels();
foreach ( $segments_db_result as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
if ( ! isset( $segment_labels[ $segment_id ] ) ) {
continue;
}
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'segment_label' => $segment_labels[ $segment_id ],
'subtotals' => $segment_data,
);
$segment_result[ $segment_id ] = $segment_datum;
}
return $segment_result;
}
/**
* Merges segmented results for totals response part.
*
* E.g. $r1 = array(
* 0 => array(
* 'product_id' => 3,
* 'net_amount' => 15,
* ),
* );
* $r2 = array(
* 0 => array(
* 'product_id' => 3,
* 'avg_order_value' => 25,
* ),
* );
*
* $merged = array(
* 3 => array(
* 'segment_id' => 3,
* 'subtotals' => array(
* 'net_amount' => 15,
* 'avg_order_value' => 25,
* )
* ),
* );
*
* @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets.
* @param array $result1 Array 1 of segmented figures.
* @param array $result2 Array 2 of segmented figures.
*
* @return array
*/
protected function merge_segment_totals_results( $segment_dimension, $result1, $result2 ) {
$result_segments = array();
$segment_labels = $this->get_segment_labels();
foreach ( $result1 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
if ( ! isset( $segment_labels[ $segment_id ] ) ) {
continue;
}
unset( $segment_data[ $segment_dimension ] );
$result_segments[ $segment_id ] = array(
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
}
foreach ( $result2 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
if ( ! isset( $segment_labels[ $segment_id ] ) ) {
continue;
}
unset( $segment_data[ $segment_dimension ] );
if ( ! isset( $result_segments[ $segment_id ] ) ) {
$result_segments[ $segment_id ] = array(
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => array(),
);
}
$result_segments[ $segment_id ]['subtotals'] = array_merge( $result_segments[ $segment_id ]['subtotals'], $segment_data );
}
return $result_segments;
}
/**
* Merges segmented results for intervals response part.
*
* E.g. $r1 = array(
* 0 => array(
* 'product_id' => 3,
* 'time_interval' => '2018-12'
* 'net_amount' => 15,
* ),
* );
* $r2 = array(
* 0 => array(
* 'product_id' => 3,
* 'time_interval' => '2018-12'
* 'avg_order_value' => 25,
* ),
* );
*
* $merged = array(
* '2018-12' => array(
* 'segments' => array(
* 3 => array(
* 'segment_id' => 3,
* 'subtotals' => array(
* 'net_amount' => 15,
* 'avg_order_value' => 25,
* ),
* ),
* ),
* ),
* );
*
* @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets.
* @param array $result1 Array 1 of segmented figures.
* @param array $result2 Array 2 of segmented figures.
*
* @return array
*/
protected function merge_segment_intervals_results( $segment_dimension, $result1, $result2 ) {
$result_segments = array();
$segment_labels = $this->get_segment_labels();
foreach ( $result1 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
if ( ! isset( $segment_labels[ $segment_id ] ) ) {
continue;
}
$time_interval = $segment_data['time_interval'];
if ( ! isset( $result_segments[ $time_interval ] ) ) {
$result_segments[ $time_interval ] = array();
$result_segments[ $time_interval ]['segments'] = array();
}
unset( $segment_data['time_interval'] );
unset( $segment_data['datetime_anchor'] );
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$result_segments[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
}
foreach ( $result2 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
if ( ! isset( $segment_labels[ $segment_id ] ) ) {
continue;
}
$time_interval = $segment_data['time_interval'];
if ( ! isset( $result_segments[ $time_interval ] ) ) {
$result_segments[ $time_interval ] = array();
$result_segments[ $time_interval ]['segments'] = array();
}
unset( $segment_data['time_interval'] );
unset( $segment_data['datetime_anchor'] );
unset( $segment_data[ $segment_dimension ] );
if ( ! isset( $result_segments[ $time_interval ]['segments'][ $segment_id ] ) ) {
$result_segments[ $time_interval ]['segments'][ $segment_id ] = array(
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => array(),
);
}
$result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'] = array_merge( $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'], $segment_data );
}
return $result_segments;
}
/**
* Update row-level db result for segments in 'intervals' section to the format used for output.
*
* @param array $segments_db_result Results from the SQL db query for segmenting.
* @param string $segment_dimension Name of column used for grouping the result.
*
* @return array Reformatted array.
*/
protected function reformat_intervals_segments( $segments_db_result, $segment_dimension ) {
$aggregated_segment_result = array();
if ( strpos( $segment_dimension, '.' ) ) {
$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
}
$segment_labels = $this->get_segment_labels();
foreach ( $segments_db_result as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
if ( ! isset( $segment_labels[ $segment_id ] ) ) {
continue;
}
$time_interval = $segment_data['time_interval'];
if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) {
$aggregated_segment_result[ $time_interval ] = array();
$aggregated_segment_result[ $time_interval ]['segments'] = array();
}
unset( $segment_data['time_interval'] );
unset( $segment_data['datetime_anchor'] );
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$aggregated_segment_result[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
}
return $aggregated_segment_result;
}
/**
* Fetches all segment ids from db and stores it for later use.
*
* @return void
*/
protected function set_all_segments() {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
$this->all_segment_ids = array();
return;
}
$segments = array();
$segment_labels = array();
if ( 'product' === $this->query_args['segmentby'] ) {
$args = array(
'return' => 'objects',
'limit' => -1,
);
if ( isset( $this->query_args['product_includes'] ) ) {
$args['include'] = $this->query_args['product_includes'];
}
if ( isset( $this->query_args['category_includes'] ) ) {
$categories = $this->query_args['category_includes'];
$args['category'] = array();
foreach ( $categories as $category_id ) {
$terms = get_term_by( 'id', $category_id, 'product_cat' );
$args['category'][] = $terms->slug;
}
}
$segment_objects = wc_get_products( $args );
foreach ( $segment_objects as $segment ) {
$id = $segment->get_id();
$segments[] = $id;
$segment_labels[ $id ] = $segment->get_name();
}
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
$args = array(
'return' => 'objects',
'limit' => -1,
'type' => 'variation',
);
if (
isset( $this->query_args['product_includes'] ) &&
count( $this->query_args['product_includes'] ) === 1
) {
$args['parent'] = $this->query_args['product_includes'][0];
}
if ( isset( $this->query_args['variation_includes'] ) ) {
$args['include'] = $this->query_args['variation_includes'];
}
$segment_objects = wc_get_products( $args );
foreach ( $segment_objects as $segment ) {
$id = $segment->get_id();
$segments[] = $id;
$product_name = $segment->get_name();
$separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $segment );
$attributes = wc_get_formatted_variation( $segment, true, false );
$segment_labels[ $id ] = $product_name . $separator . $attributes;
}
// If no variations were specified, add a segment for the parent product (variation = 0).
// This is to catch simple products with prior sales converted into variable products.
// See: https://github.com/woocommerce/woocommerce-admin/issues/2719.
if ( isset( $args['parent'] ) && empty( $args['include'] ) ) {
$parent_object = wc_get_product( $args['parent'] );
$segments[] = 0;
$segment_labels[0] = $parent_object->get_name();
}
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$args = array(
'taxonomy' => 'product_cat',
);
if ( isset( $this->query_args['category_includes'] ) ) {
$args['include'] = $this->query_args['category_includes'];
}
// @todo: Look into `wc_get_products` or data store methods and not directly touching the database or post types.
$categories = get_categories( $args );
$segments = wp_list_pluck( $categories, 'cat_ID' );
$segment_labels = wp_list_pluck( $categories, 'name', 'cat_ID' );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
$args = array();
if ( isset( $this->query_args['coupons'] ) ) {
$args['include'] = $this->query_args['coupons'];
}
$coupons_store = new CouponsDataStore();
$coupons = $coupons_store->get_coupons( $args );
$segments = wp_list_pluck( $coupons, 'ID' );
$segment_labels = wp_list_pluck( $coupons, 'post_title', 'ID' );
$segment_labels = array_map( 'wc_format_coupon_code', $segment_labels );
} elseif ( 'customer_type' === $this->query_args['segmentby'] ) {
// 0 -- new customer
// 1 -- returning customer
$segments = array( 0, 1 );
} elseif ( 'tax_rate_id' === $this->query_args['segmentby'] ) {
$args = array();
if ( isset( $this->query_args['taxes'] ) ) {
$args['include'] = $this->query_args['taxes'];
}
$taxes = TaxesStatsDataStore::get_taxes( $args );
foreach ( $taxes as $tax ) {
$id = $tax['tax_rate_id'];
$segments[] = $id;
$segment_labels[ $id ] = \WC_Tax::get_rate_code( (object) $tax );
}
} else {
// Catch all default.
$segments = array();
}
$this->all_segment_ids = $segments;
$this->segment_labels = $segment_labels;
}
/**
* Return all segment ids for given segmentby query parameter.
*
* @return array
*/
protected function get_all_segments() {
if ( ! is_array( $this->all_segment_ids ) ) {
$this->set_all_segments();
}
return $this->all_segment_ids;
}
/**
* Return all segment labels for given segmentby query parameter.
*
* @return array
*/
protected function get_segment_labels() {
if ( ! is_array( $this->all_segment_ids ) ) {
$this->set_all_segments();
}
return $this->segment_labels;
}
/**
* Compares two report data objects by pre-defined object property and ASC/DESC ordering.
*
* @param stdClass $a Object a.
* @param stdClass $b Object b.
* @return string
*/
private function segment_cmp( $a, $b ) {
if ( $a['segment_id'] === $b['segment_id'] ) {
return 0;
} elseif ( $a['segment_id'] > $b['segment_id'] ) {
return 1;
} elseif ( $a['segment_id'] < $b['segment_id'] ) {
return - 1;
}
}
/**
* Adds zeroes for segments not present in the data selection.
*
* @param array $segments Array of segments from the database for given data points.
*
* @return array
*/
protected function fill_in_missing_segments( $segments ) {
$segment_subtotals = array();
if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) {
foreach ( $this->query_args['fields'] as $field ) {
if ( isset( $this->report_columns[ $field ] ) ) {
$segment_subtotals[ $field ] = 0;
}
}
} else {
foreach ( $this->report_columns as $field => $sql_clause ) {
$segment_subtotals[ $field ] = 0;
}
}
if ( ! is_array( $segments ) ) {
$segments = array();
}
$all_segment_ids = $this->get_all_segments();
$segment_labels = $this->get_segment_labels();
foreach ( $all_segment_ids as $segment_id ) {
if ( ! isset( $segments[ $segment_id ] ) ) {
$segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'segment_label' => $segment_labels[ $segment_id ],
'subtotals' => $segment_subtotals,
);
}
}
// Using array_values to remove custom keys, so that it gets later converted to JSON as an array.
$segments_no_keys = array_values( $segments );
usort( $segments_no_keys, array( $this, 'segment_cmp' ) );
return $segments_no_keys;
}
/**
* Adds missing segments to intervals, modifies $data.
*
* @param stdClass $data Response data.
*/
protected function fill_in_missing_interval_segments( &$data ) {
foreach ( $data->intervals as $order_id => $interval_data ) {
$data->intervals[ $order_id ]['segments'] = $this->fill_in_missing_segments( $data->intervals[ $order_id ]['segments'] );
}
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
return array();
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
return array();
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
return array();
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
return array();
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
*/
protected function get_segments( $type, $query_params, $table_name ) {
return array();
}
/**
* Calculate segments for segmenting property bound to product (e.g. category, product_id, variation_id).
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $query_params Array of SQL clauses for intervals/totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ) {
if ( 'totals' === $type ) {
return $this->get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'intervals' === $type ) {
return $this->get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
}
}
/**
* Calculate segments for segmenting property bound to order (e.g. coupon or customer type).
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $query_params Array of SQL clauses for intervals/totals query.
*
* @return array
*/
protected function get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ) {
if ( 'totals' === $type ) {
return $this->get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
} elseif ( 'intervals' === $type ) {
return $this->get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
}
/**
* Assign segments to time intervals by updating original $intervals array.
*
* @param array $intervals Result array from intervals SQL query.
* @param array $intervals_segments Result array from interval segments SQL query.
*/
protected function assign_segments_to_intervals( &$intervals, $intervals_segments ) {
$old_keys = array_keys( $intervals );
foreach ( $intervals as $interval ) {
$intervals[ $interval['time_interval'] ] = $interval;
$intervals[ $interval['time_interval'] ]['segments'] = array();
}
foreach ( $old_keys as $key ) {
unset( $intervals[ $key ] );
}
foreach ( $intervals_segments as $time_interval => $segment ) {
if ( isset( $intervals[ $time_interval ] ) ) {
$intervals[ $time_interval ]['segments'] = $segment['segments'];
}
}
// To remove time interval keys (so that REST response is formatted correctly).
$intervals = array_values( $intervals );
}
/**
* Returns an array of segments for totals part of REST response.
*
* @param array $query_params Totals SQL query parameters.
* @param string $table_name Name of the SQL table that is the main order stats table.
*
* @return array
*/
public function get_totals_segments( $query_params, $table_name ) {
$segments = $this->get_segments( 'totals', $query_params, $table_name );
$segments = $this->fill_in_missing_segments( $segments );
return $segments;
}
/**
* Adds an array of segments to data->intervals object.
*
* @param stdClass $data Data object representing the REST response.
* @param array $intervals_query Intervals SQL query parameters.
* @param string $table_name Name of the SQL table that is the main order stats table.
*/
public function add_intervals_segments( &$data, $intervals_query, $table_name ) {
$intervals_segments = $this->get_segments( 'intervals', $intervals_query, $table_name );
$this->assign_segments_to_intervals( $data->intervals, $intervals_segments );
$this->fill_in_missing_interval_segments( $data );
}
}
Admin/API/Reports/SqlQuery.php 0000644 00000012236 15153704476 0012161 0 ustar 00 <?php
/**
* Admin\API\Reports\SqlQuery class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Admin\API\Reports\SqlQuery: Common parent for manipulating SQL query clauses.
*/
class SqlQuery {
/**
* List of SQL clauses.
*
* @var array
*/
private $sql_clauses = array(
'select' => array(),
'from' => array(),
'left_join' => array(),
'join' => array(),
'right_join' => array(),
'where' => array(),
'where_time' => array(),
'group_by' => array(),
'having' => array(),
'limit' => array(),
'order_by' => array(),
'union' => array(),
);
/**
* SQL clause merge filters.
*
* @var array
*/
private $sql_filters = array(
'where' => array(
'where',
'where_time',
),
'join' => array(
'right_join',
'join',
'left_join',
),
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context;
/**
* Constructor.
*
* @param string $context Optional context passed to filters. Default empty string.
*/
public function __construct( $context = '' ) {
$this->context = $context;
}
/**
* Add a SQL clause to be included when get_data is called.
*
* @param string $type Clause type.
* @param string $clause SQL clause.
*/
public function add_sql_clause( $type, $clause ) {
if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) {
$this->sql_clauses[ $type ][] = $clause;
}
}
/**
* Get SQL clause by type.
*
* @param string $type Clause type.
* @param string $handling Whether to filter the return value (filtered|unfiltered). Default unfiltered.
*
* @return string SQL clause.
*/
protected function get_sql_clause( $type, $handling = 'unfiltered' ) {
if ( ! isset( $this->sql_clauses[ $type ] ) ) {
return '';
}
/**
* Default to bypassing filters for clause retrieval internal to data stores.
* The filters are applied when the full SQL statement is retrieved.
*/
if ( 'unfiltered' === $handling ) {
return implode( ' ', $this->sql_clauses[ $type ] );
}
if ( isset( $this->sql_filters[ $type ] ) ) {
$clauses = array();
foreach ( $this->sql_filters[ $type ] as $subset ) {
$clauses = array_merge( $clauses, $this->sql_clauses[ $subset ] );
}
} else {
$clauses = $this->sql_clauses[ $type ];
}
/**
* Filter SQL clauses by type and context.
*
* @param array $clauses The original arguments for the request.
* @param string $context The data store context.
*/
$clauses = apply_filters( "woocommerce_analytics_clauses_{$type}", $clauses, $this->context );
/**
* Filter SQL clauses by type and context.
*
* @param array $clauses The original arguments for the request.
*/
$clauses = apply_filters( "woocommerce_analytics_clauses_{$type}_{$this->context}", $clauses );
return implode( ' ', $clauses );
}
/**
* Clear SQL clauses by type.
*
* @param string|array $types Clause type.
*/
protected function clear_sql_clause( $types ) {
foreach ( (array) $types as $type ) {
if ( isset( $this->sql_clauses[ $type ] ) ) {
$this->sql_clauses[ $type ] = array();
}
}
}
/**
* Replace strings within SQL clauses by type.
*
* @param string $type Clause type.
* @param string $search String to search for.
* @param string $replace Replacement string.
*/
protected function str_replace_clause( $type, $search, $replace ) {
if ( isset( $this->sql_clauses[ $type ] ) ) {
foreach ( $this->sql_clauses[ $type ] as $key => $sql ) {
$this->sql_clauses[ $type ][ $key ] = str_replace( $search, $replace, $sql );
}
}
}
/**
* Get the full SQL statement.
*
* @return string
*/
public function get_query_statement() {
$join = $this->get_sql_clause( 'join', 'filtered' );
$where = $this->get_sql_clause( 'where', 'filtered' );
$group_by = $this->get_sql_clause( 'group_by', 'filtered' );
$having = $this->get_sql_clause( 'having', 'filtered' );
$order_by = $this->get_sql_clause( 'order_by', 'filtered' );
$union = $this->get_sql_clause( 'union', 'filtered' );
$statement = '';
$statement .= "
SELECT
{$this->get_sql_clause( 'select', 'filtered' )}
FROM
{$this->get_sql_clause( 'from', 'filtered' )}
{$join}
WHERE
1=1
{$where}
";
if ( ! empty( $group_by ) ) {
$statement .= "
GROUP BY
{$group_by}
";
if ( ! empty( $having ) ) {
$statement .= "
HAVING
1=1
{$having}
";
}
}
if ( ! empty( $union ) ) {
$statement .= "
UNION
{$union}
";
}
if ( ! empty( $order_by ) ) {
$statement .= "
ORDER BY
{$order_by}
";
}
return $statement . $this->get_sql_clause( 'limit', 'filtered' );
}
/**
* Reinitialize the clause array.
*/
public function clear_all_clauses() {
$this->sql_clauses = array(
'select' => array(),
'from' => array(),
'left_join' => array(),
'join' => array(),
'right_join' => array(),
'where' => array(),
'where_time' => array(),
'group_by' => array(),
'having' => array(),
'limit' => array(),
'order_by' => array(),
'union' => array(),
);
}
}
Admin/API/Reports/Stock/Controller.php 0000644 00000040357 15153704476 0013607 0 ustar 00 <?php
/**
* REST API Reports stock controller
*
* Handles requests to the /reports/stock endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Stock;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports stock controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController implements ExportableInterface {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/stock';
/**
* Registered stock status options.
*
* @var array
*/
protected $status_options;
/**
* Constructor.
*/
public function __construct() {
$this->status_options = wc_get_product_stock_status_options();
}
/**
* Maps query arguments from the REST request.
*
* @param WP_REST_Request $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['offset'] = $request['offset'];
$args['order'] = $request['order'];
$args['orderby'] = $request['orderby'];
$args['paged'] = $request['page'];
$args['post__in'] = $request['include'];
$args['post__not_in'] = $request['exclude'];
$args['posts_per_page'] = $request['per_page'];
$args['post_parent__in'] = $request['parent'];
$args['post_parent__not_in'] = $request['parent_exclude'];
if ( 'date' === $args['orderby'] ) {
$args['orderby'] = 'date ID';
} elseif ( 'include' === $args['orderby'] ) {
$args['orderby'] = 'post__in';
} elseif ( 'id' === $args['orderby'] ) {
$args['orderby'] = 'ID'; // ID must be capitalized.
}
$args['post_type'] = array( 'product', 'product_variation' );
if ( 'lowstock' === $request['type'] ) {
$args['low_in_stock'] = true;
} elseif ( in_array( $request['type'], array_keys( $this->status_options ), true ) ) {
$args['stock_status'] = $request['type'];
}
$args['ignore_sticky_posts'] = true;
return $args;
}
/**
* Query products.
*
* @param array $query_args Query args.
* @return array
*/
protected function get_products( $query_args ) {
$query = new \WP_Query();
$result = $query->query( $query_args );
$total_posts = $query->found_posts;
if ( $total_posts < 1 ) {
// Out-of-bounds, run the query again without LIMIT for total count.
unset( $query_args['paged'] );
$count_query = new \WP_Query();
$count_query->query( $query_args );
$total_posts = $count_query->found_posts;
}
return array(
'objects' => array_map( 'wc_get_product', $result ),
'total' => (int) $total_posts,
'pages' => (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ),
);
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 );
add_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10, 2 );
$query_args = $this->prepare_reports_query( $request );
$query_results = $this->get_products( $query_args );
remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 );
remove_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10 );
$objects = array();
foreach ( $query_results['objects'] as $object ) {
$data = $this->prepare_item_for_response( $object, $request );
$objects[] = $this->prepare_response_for_collection( $data );
}
return $this->add_pagination_headers(
$request,
$objects,
(int) $query_results['total'],
(int) $query_args['paged'],
(int) $query_results['pages']
);
}
/**
* Add in conditional search filters for products.
*
* @internal
* @param string $where Where clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_filter( $where, $wp_query ) {
global $wpdb;
$stock_status = $wp_query->get( 'stock_status' );
if ( $stock_status ) {
$where .= $wpdb->prepare(
' AND wc_product_meta_lookup.stock_status = %s ',
$stock_status
);
}
if ( $wp_query->get( 'low_in_stock' ) ) {
// We want products with stock < low stock amount, but greater than no stock amount.
$no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) );
$low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
$where .= "
AND wc_product_meta_lookup.stock_quantity IS NOT NULL
AND wc_product_meta_lookup.stock_status = 'instock'
AND (
(
low_stock_amount_meta.meta_value > ''
AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED)
AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount}
)
OR (
(
low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= ''
)
AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount}
AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount}
)
)";
}
return $where;
}
/**
* Join posts meta tables when product search or low stock query is present.
*
* @internal
* @param string $join Join clause used to search posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_join( $join, $wp_query ) {
global $wpdb;
$stock_status = $wp_query->get( 'stock_status' );
if ( $stock_status ) {
$join = self::append_product_sorting_table_join( $join );
}
if ( $wp_query->get( 'low_in_stock' ) ) {
$join = self::append_product_sorting_table_join( $join );
$join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' ";
}
return $join;
}
/**
* Join wc_product_meta_lookup to posts if not already joined.
*
* @internal
* @param string $sql SQL join.
* @return string
*/
protected static function append_product_sorting_table_join( $sql ) {
global $wpdb;
if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
}
return $sql;
}
/**
* Group by post ID to prevent duplicates.
*
* @internal
* @param string $groupby Group by clause used to organize posts.
* @param object $wp_query WP_Query object.
* @return string
*/
public static function add_wp_query_group_by( $groupby, $wp_query ) {
global $wpdb;
if ( empty( $groupby ) ) {
$groupby = $wpdb->posts . '.ID';
}
return $groupby;
}
/**
* Custom orderby clauses using the lookup tables.
*
* @internal
* @param array $args Query args.
* @param object $wp_query WP_Query object.
* @return array
*/
public static function add_wp_query_orderby( $args, $wp_query ) {
global $wpdb;
$orderby = $wp_query->get( 'orderby' );
$order = esc_sql( $wp_query->get( 'order' ) ? $wp_query->get( 'order' ) : 'desc' );
switch ( $orderby ) {
case 'stock_quantity':
$args['join'] = self::append_product_sorting_table_join( $args['join'] );
$args['orderby'] = " wc_product_meta_lookup.stock_quantity {$order}, wc_product_meta_lookup.product_id {$order} ";
break;
case 'stock_status':
$args['join'] = self::append_product_sorting_table_join( $args['join'] );
$args['orderby'] = " wc_product_meta_lookup.stock_status {$order}, wc_product_meta_lookup.stock_quantity {$order} ";
break;
case 'sku':
$args['join'] = self::append_product_sorting_table_join( $args['join'] );
$args['orderby'] = " wc_product_meta_lookup.sku {$order}, wc_product_meta_lookup.product_id {$order} ";
break;
}
return $args;
}
/**
* Prepare a report object for serialization.
*
* @param WC_Product $product Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $product, $request ) {
$data = array(
'id' => $product->get_id(),
'parent_id' => $product->get_parent_id(),
'name' => wp_strip_all_tags( $product->get_name() ),
'sku' => $product->get_sku(),
'stock_status' => $product->get_stock_status(),
'stock_quantity' => (float) $product->get_stock_quantity(),
'manage_stock' => $product->get_manage_stock(),
'low_stock_amount' => $product->get_low_stock_amount(),
);
if ( '' === $data['low_stock_amount'] ) {
$data['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
}
$response = parent::prepare_item_for_response( $data, $request );
$response->add_links( $this->prepare_links( $product ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param WC_Product $product The original product object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_stock', $response, $product, $request );
}
/**
* Prepare links for the request.
*
* @param WC_Product $product Object data.
* @return array
*/
protected function prepare_links( $product ) {
if ( $product->is_type( 'variation' ) ) {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/products/%d/variations/%d', $this->namespace, $product->get_parent_id(), $product->get_id() ) ),
),
'parent' => array(
'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ),
),
);
} elseif ( $product->get_parent_id() ) {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ),
),
'parent' => array(
'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ),
),
);
} else {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ),
),
);
}
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_stock',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'parent_id' => array(
'description' => __( 'Product parent ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Product name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'sku' => array(
'description' => __( 'Unique identifier.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'stock_status' => array(
'description' => __( 'Stock status.', 'woocommerce' ),
'type' => 'string',
'enum' => array_keys( wc_get_product_stock_status_options() ),
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'stock_quantity' => array(
'description' => __( 'Stock quantity.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'manage_stock' => array(
'description' => __( 'Manage stock.', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
unset( $params['after'], $params['before'], $params['force_cache_refresh'] );
$params['exclude'] = array(
'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['include'] = array(
'description' => __( 'Limit result set to specific ids.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['offset'] = array(
'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order']['default'] = 'asc';
$params['orderby']['default'] = 'stock_status';
$params['orderby']['enum'] = array(
'stock_status',
'stock_quantity',
'date',
'id',
'include',
'title',
'sku',
);
$params['parent'] = array(
'description' => __( 'Limit result set to those of particular parent IDs.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'sanitize_callback' => 'wp_parse_id_list',
'default' => array(),
);
$params['parent_exclude'] = array(
'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'sanitize_callback' => 'wp_parse_id_list',
'default' => array(),
);
$params['type'] = array(
'description' => __( 'Limit result set to items assigned a stock report type.', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array_merge( array( 'all', 'lowstock' ), array_keys( wc_get_product_stock_status_options() ) ),
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'title' => __( 'Product / Variation', 'woocommerce' ),
'sku' => __( 'SKU', 'woocommerce' ),
'stock_status' => __( 'Status', 'woocommerce' ),
'stock_quantity' => __( 'Stock', 'woocommerce' ),
);
/**
* Filter to add or remove column names from the stock report for
* export.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_stock_export_columns',
$export_columns
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$status = $item['stock_status'];
if ( array_key_exists( $item['stock_status'], $this->status_options ) ) {
$status = $this->status_options[ $item['stock_status'] ];
}
$export_item = array(
'title' => $item['name'],
'sku' => $item['sku'],
'stock_status' => $status,
'stock_quantity' => $item['stock_quantity'],
);
/**
* Filter to prepare extra columns in the export item for the stock
* report.
*
* @since 1.6.0
*/
return apply_filters(
'woocommerce_report_stock_prepare_export_item',
$export_item,
$item
);
}
}
Admin/API/Reports/Stock/Stats/Controller.php 0000644 00000007137 15153704476 0014704 0 ustar 00 <?php
/**
* REST API Reports stock stats controller
*
* Handles requests to the /reports/stock/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats;
defined( 'ABSPATH' ) || exit;
/**
* REST API Reports stock stats controller class.
*
* @internal
* @extends WC_REST_Reports_Controller
*/
class Controller extends \WC_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/stock/stats';
/**
* Get Stock Status Totals.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$stock_query = new Query();
$report_data = $stock_query->get_data();
$out_data = array(
'totals' => $report_data,
);
return rest_ensure_response( $out_data );
}
/**
* Prepare a report object for serialization.
*
* @param WC_Product $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @since 6.5.0
*
* @param WP_REST_Response $response The response object.
* @param WC_Product $report The original object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_stock_stats', $response, $report, $request );
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$totals = array(
'products' => array(
'description' => __( 'Number of products.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'lowstock' => array(
'description' => __( 'Number of low stock products.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
$status_options = wc_get_product_stock_status_options();
foreach ( $status_options as $status => $label ) {
$totals[ $status ] = array(
/* translators: Stock status. Example: "Number of low stock products */
'description' => sprintf( __( 'Number of %s products.', 'woocommerce' ), $label ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
);
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_customers_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
return $params;
}
}
Admin/API/Reports/Stock/Stats/DataStore.php 0000644 00000010565 15153704476 0014446 0 ustar 00 <?php
/**
* API\Reports\Stock\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
/**
* API\Reports\Stock\Stats\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Get stock counts for the whole store.
*
* @param array $query Not used for the stock stats data store, but needed for the interface.
* @return array Array of counts.
*/
public function get_data( $query ) {
$report_data = array();
$cache_expire = DAY_IN_SECONDS * 30;
$low_stock_transient_name = 'wc_admin_stock_count_lowstock';
$low_stock_count = get_transient( $low_stock_transient_name );
if ( false === $low_stock_count ) {
$low_stock_count = $this->get_low_stock_count();
set_transient( $low_stock_transient_name, $low_stock_count, $cache_expire );
} else {
$low_stock_count = intval( $low_stock_count );
}
$report_data['lowstock'] = $low_stock_count;
$status_options = wc_get_product_stock_status_options();
foreach ( $status_options as $status => $label ) {
$transient_name = 'wc_admin_stock_count_' . $status;
$count = get_transient( $transient_name );
if ( false === $count ) {
$count = $this->get_count( $status );
set_transient( $transient_name, $count, $cache_expire );
} else {
$count = intval( $count );
}
$report_data[ $status ] = $count;
}
$product_count_transient_name = 'wc_admin_product_count';
$product_count = get_transient( $product_count_transient_name );
if ( false === $product_count ) {
$product_count = $this->get_product_count();
set_transient( $product_count_transient_name, $product_count, $cache_expire );
} else {
$product_count = intval( $product_count );
}
$report_data['products'] = $product_count;
return $report_data;
}
/**
* Get low stock count (products with stock < low stock amount, but greater than no stock amount).
*
* @return int Low stock count.
*/
private function get_low_stock_count() {
global $wpdb;
$no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) );
$low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
return (int) $wpdb->get_var(
$wpdb->prepare(
"
SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts
LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id
LEFT JOIN {$wpdb->postmeta} low_stock_amount_meta ON posts.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount'
WHERE posts.post_type IN ( 'product', 'product_variation' )
AND wc_product_meta_lookup.stock_quantity IS NOT NULL
AND wc_product_meta_lookup.stock_status = 'instock'
AND (
(
low_stock_amount_meta.meta_value > ''
AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED)
AND wc_product_meta_lookup.stock_quantity > %d
)
OR (
(
low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= ''
)
AND wc_product_meta_lookup.stock_quantity <= %d
AND wc_product_meta_lookup.stock_quantity > %d
)
)
",
$no_stock_amount,
$low_stock_amount,
$no_stock_amount
)
);
}
/**
* Get count for the passed in stock status.
*
* @param string $status Status slug.
* @return int Count.
*/
private function get_count( $status ) {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"
SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts
LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id
WHERE posts.post_type IN ( 'product', 'product_variation' )
AND wc_product_meta_lookup.stock_status = %s
",
$status
)
);
}
/**
* Get product count for the store.
*
* @return int Product count.
*/
private function get_product_count() {
$query_args = array();
$query_args['post_type'] = array( 'product', 'product_variation' );
$query = new \WP_Query();
$query->query( $query_args );
return intval( $query->found_posts );
}
}
Admin/API/Reports/Stock/Stats/Query.php 0000644 00000001316 15153704477 0013660 0 ustar 00 <?php
/**
* Class for stock stats report querying
*
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Query();
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Stock\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$data_store = \WC_Data_Store::load( 'report-stock-stats' );
$results = $data_store->get_data();
return apply_filters( 'woocommerce_analytics_stock_stats_query', $results );
}
}
Admin/API/Reports/Taxes/Controller.php 0000644 00000016544 15153704477 0013612 0 ustar 00 <?php
/**
* REST API Reports taxes controller
*
* Handles requests to the /reports/taxes endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports taxes controller class.
*
* @internal
* @extends GenericController
*/
class Controller extends GenericController implements ExportableInterface {
/**
* Exportable traits.
*/
use ExportableTraits;
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/taxes';
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['taxes'] = $request['taxes'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$taxes_query = new Query( $query_args );
$report_data = $taxes_query->get_data();
$data = array();
foreach ( $report_data->data as $tax_data ) {
$item = $this->prepare_item_for_response( (object) $tax_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param stdClass $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_taxes', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param WC_Reports_Query $object Object data.
* @return array
*/
protected function prepare_links( $object ) {
$links = array(
'tax' => array(
'href' => rest_url( sprintf( '/%s/taxes/%d', $this->namespace, $object->tax_rate_id ) ),
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_taxes',
'type' => 'object',
'properties' => array(
'tax_rate_id' => array(
'description' => __( 'Tax rate ID.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Tax rate name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'tax_rate' => array(
'description' => __( 'Tax rate.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'country' => array(
'description' => __( 'Country / Region.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'state' => array(
'description' => __( 'State.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'priority' => array(
'description' => __( 'Priority.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'total_tax' => array(
'description' => __( 'Total tax.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'order_tax' => array(
'description' => __( 'Order tax.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'shipping_tax' => array(
'description' => __( 'Shipping tax.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['default'] = 'tax_rate_id';
$params['orderby']['enum'] = array(
'name',
'tax_rate_id',
'tax_code',
'rate',
'order_tax',
'total_tax',
'shipping_tax',
'orders_count',
);
$params['taxes'] = array(
'description' => __( 'Limit result set to items assigned one or more tax rates.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
return array(
'tax_code' => __( 'Tax code', 'woocommerce' ),
'rate' => __( 'Rate', 'woocommerce' ),
'total_tax' => __( 'Total tax', 'woocommerce' ),
'order_tax' => __( 'Order tax', 'woocommerce' ),
'shipping_tax' => __( 'Shipping tax', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
);
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
return array(
'tax_code' => \WC_Tax::get_rate_code( $item['tax_rate_id'] ),
'rate' => $item['tax_rate'],
'total_tax' => self::csv_number_format( $item['total_tax'] ),
'order_tax' => self::csv_number_format( $item['order_tax'] ),
'shipping_tax' => self::csv_number_format( $item['shipping_tax'] ),
'orders_count' => $item['orders_count'],
);
}
}
Admin/API/Reports/Taxes/DataStore.php 0000644 00000025746 15153704477 0013361 0 ustar 00 <?php
/**
* API\Reports\Taxes\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
/**
* API\Reports\Taxes\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_tax_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'taxes';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'tax_rate_id' => 'intval',
'name' => 'strval',
'tax_rate' => 'floatval',
'country' => 'strval',
'state' => 'strval',
'priority' => 'intval',
'total_tax' => 'floatval',
'order_tax' => 'floatval',
'shipping_tax' => 'floatval',
'orders_count' => 'intval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'taxes';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'tax_rate_id' => "{$table_name}.tax_rate_id",
'name' => 'tax_rate_name as name',
'tax_rate' => 'tax_rate',
'country' => 'tax_rate_country as country',
'state' => 'tax_rate_state as state',
'priority' => 'tax_rate_priority as priority',
'total_tax' => 'SUM(total_tax) as total_tax',
'order_tax' => 'SUM(order_tax) as order_tax',
'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN total_tax >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
);
}
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15 );
}
/**
* Fills FROM clause of SQL request based on user supplied parameters.
*
* @param array $query_args Query arguments supplied by the user.
* @param string $order_status_filter Order status subquery.
*/
protected function add_from_sql_params( $query_args, $order_status_filter ) {
global $wpdb;
$table_name = self::get_db_table_name();
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
}
if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
$this->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_tax_rates ON default_results.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id" );
} else {
$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_tax_rates ON {$table_name}.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id" );
}
}
/**
* Updates the database query with parameters used for Taxes report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_tax_lookup_table = self::get_db_table_name();
$this->add_time_period_sql_params( $query_args, $order_tax_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
$order_status_filter = $this->get_status_subquery( $query_args );
$this->add_from_sql_params( $query_args, $order_status_filter );
if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
$allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' );
$this->subquery->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" );
}
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'tax_rate_id',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'taxes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$this->add_sql_query_params( $query_args );
$params = $this->get_limit_params( $query_args );
if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
$total_results = count( $query_args['taxes'] );
$total_pages = (int) ceil( $total_results / $params['per_page'] );
$inner_selections = array( 'tax_rate_id', 'total_tax', 'order_tax', 'shipping_tax', 'orders_count' );
$outer_selections = array( 'name', 'tax_rate', 'country', 'state', 'priority' );
$selections = $this->selected_columns( array( 'fields' => $inner_selections ) );
$fields = $this->get_fields( $query_args );
$join_selections = $this->format_join_selections( $fields, array( 'tax_rate_id' ), $outer_selections );
$ids_table = $this->get_ids_table( $query_args['taxes'], 'tax_rate_id' );
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $this->selected_columns( array( 'fields' => $inner_selections ) ) );
$this->add_sql_clause( 'select', $join_selections );
$this->add_sql_clause( 'from', '(' );
$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
$this->add_sql_clause( 'from', ") AS {$table_name}" );
$this->add_sql_clause(
'right_join',
"RIGHT JOIN ( {$ids_table} ) AS default_results
ON default_results.tax_rate_id = {$table_name}.tax_rate_id"
);
$taxes_query = $this->get_query_statement();
} else {
$db_records_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) AS tt"
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$total_results = $db_records_count;
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$taxes_query = $this->subquery->get_query_statement();
}
$tax_data = $wpdb->get_results(
$taxes_query,
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $tax_data ) {
return $data;
}
$tax_data = array_map( array( $this, 'cast_numbers' ), $tax_data );
$data = (object) array(
'data' => $tax_data,
'total' => $total_results,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
global $wpdb;
if ( 'tax_code' === $order_by ) {
return 'CONCAT_WS( "-", NULLIF(tax_rate_country, ""), NULLIF(tax_rate_state, ""), NULLIF(tax_rate_name, ""), NULLIF(tax_rate_priority, "") )';
} elseif ( 'rate' === $order_by ) {
return "CAST({$wpdb->prefix}woocommerce_tax_rates.tax_rate as DECIMAL(7,4))";
}
return $order_by;
}
/**
* Create or update an entry in the wc_order_tax_lookup table for an order.
*
* @param int $order_id Order ID.
* @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
*/
public static function sync_order_taxes( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return -1;
}
$tax_items = $order->get_items( 'tax' );
$num_updated = 0;
foreach ( $tax_items as $tax_item ) {
$result = $wpdb->replace(
self::get_db_table_name(),
array(
'order_id' => $order->get_id(),
'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
'tax_rate_id' => $tax_item->get_rate_id(),
'shipping_tax' => $tax_item->get_shipping_tax_total(),
'order_tax' => $tax_item->get_tax_total(),
'total_tax' => (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total(),
),
array(
'%d',
'%s',
'%d',
'%f',
'%f',
'%f',
)
);
/**
* Fires when tax's reports are updated.
*
* @param int $tax_rate_id Tax Rate ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_update_tax', $tax_item->get_rate_id(), $order->get_id() );
// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
}
return ( count( $tax_items ) === $num_updated );
}
/**
* Clean taxes data when an order is deleted.
*
* @param int $order_id Order ID.
*/
public static function sync_on_order_delete( $order_id ) {
global $wpdb;
$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
/**
* Fires when tax's reports are removed from database.
*
* @param int $tax_rate_id Tax Rate ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_delete_tax', 0, $order_id );
ReportsCache::invalidate();
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.tax_rate_id' );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
$this->subquery->add_sql_clause( 'group_by', self::get_db_table_name() . '.tax_rate_id' );
}
}
Admin/API/Reports/Taxes/Query.php 0000644 00000002245 15153704477 0012565 0 ustar 00 <?php
/**
* Class for parameter-based Taxes Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'taxes' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Taxes\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Taxes report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_taxes_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-taxes' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_taxes_select_query', $results, $args );
}
}
Admin/API/Reports/Taxes/Stats/Controller.php 0000644 00000015501 15153704477 0014700 0 ustar 00 <?php
/**
* REST API Reports taxes stats controller
*
* Handles requests to the /reports/taxes/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports taxes stats controller class.
*
* @internal
* @extends GenericStatsController
*/
class Controller extends GenericStatsController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/taxes/stats';
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_analytics_taxes_stats_select_query', array( $this, 'set_default_report_data' ) );
}
/**
* Set the default results to 0 if API returns an empty array
*
* @internal
* @param Mixed $results Report data.
* @return object
*/
public function set_default_report_data( $results ) {
if ( empty( $results ) ) {
$results = new \stdClass();
$results->total = 0;
$results->totals = new \stdClass();
$results->totals->tax_codes = 0;
$results->totals->total_tax = 0;
$results->totals->order_tax = 0;
$results->totals->shipping_tax = 0;
$results->totals->orders = 0;
$results->intervals = array();
$results->pages = 1;
$results->page_no = 1;
}
return $results;
}
/**
* Maps query arguments from the REST request.
*
* @param array $request Request array.
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['taxes'] = (array) $request['taxes'];
$args['segmentby'] = $request['segmentby'];
$args['fields'] = $request['fields'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
return $args;
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$taxes_query = new Query( $query_args );
$report_data = $taxes_query->get_data();
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( (object) $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param stdClass $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = get_object_vars( $report );
$response = parent::prepare_item_for_response( $data, $request );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_taxes_stats', $response, $report, $request );
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
protected function get_item_properties_schema() {
return array(
'total_tax' => array(
'description' => __( 'Total tax.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'order_tax' => array(
'description' => __( 'Order tax.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'shipping_tax' => array(
'description' => __( 'Shipping tax.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
'format' => 'currency',
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'tax_codes' => array(
'description' => __( 'Amount of tax codes.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$schema['title'] = 'report_taxes_stats';
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['orderby']['enum'] = array(
'date',
'items_sold',
'total_sales',
'orders_count',
'products_count',
);
$params['taxes'] = array(
'description' => __( 'Limit result set to all items that have the specified term assigned in the taxes taxonomy.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'tax_rate_id',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
}
Admin/API/Reports/Taxes/Stats/DataStore.php 0000644 00000023636 15153704477 0014453 0 ustar 00 <?php
/**
* API\Reports\Taxes\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Taxes\Stats\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_tax_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'taxes_stats';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'tax_codes' => 'intval',
'total_tax' => 'floatval',
'order_tax' => 'floatval',
'shipping_tax' => 'floatval',
'orders_count' => 'intval',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'taxes_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'tax_codes' => 'COUNT(DISTINCT tax_rate_id) as tax_codes',
'total_tax' => 'SUM(total_tax) AS total_tax',
'order_tax' => 'SUM(order_tax) as order_tax',
'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
);
}
/**
* Updates the database query with parameters used for Taxes Stats report
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function update_sql_query_params( $query_args ) {
global $wpdb;
$taxes_where_clause = '';
$order_tax_lookup_table = self::get_db_table_name();
if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
$tax_id_placeholders = implode( ',', array_fill( 0, count( $query_args['taxes'] ), '%d' ) );
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$taxes_where_clause .= $wpdb->prepare( " AND {$order_tax_lookup_table}.tax_rate_id IN ({$tax_id_placeholders})", $query_args['taxes'] );
/* phpcs:enable */
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$taxes_where_clause .= " AND ( {$order_status_filter} )";
}
$this->add_time_period_sql_params( $query_args, $order_tax_lookup_table );
$this->total_query->add_sql_clause( 'where', $taxes_where_clause );
$this->add_intervals_sql_params( $query_args, $order_tax_lookup_table );
$this->interval_query->add_sql_clause( 'where', $taxes_where_clause );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
}
/**
* Get taxes associated with a store.
*
* @param array $args Array of args to filter the query by. Supports `include`.
* @return array An array of all taxes.
*/
public static function get_taxes( $args ) {
global $wpdb;
$query = "
SELECT
tax_rate_id,
tax_rate_country,
tax_rate_state,
tax_rate_name,
tax_rate_priority
FROM {$wpdb->prefix}woocommerce_tax_rates
";
if ( ! empty( $args['include'] ) ) {
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$tax_placeholders = implode( ',', array_fill( 0, count( $args['include'] ), '%d' ) );
$query .= $wpdb->prepare( " WHERE tax_rate_id IN ({$tax_placeholders})", $args['include'] );
/* phpcs:enable */
}
return $wpdb->get_results( $query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok.
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'tax_rate_id',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'taxes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'totals' => (object) array(),
'intervals' => (object) array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$params = $this->get_limit_params( $query_args );
$order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
$this->update_sql_query_params( $query_args );
$this->interval_query->add_sql_clause( 'join', $order_stats_join );
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$db_interval_count = count( $db_intervals );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->total_query->add_sql_clause( 'select', $selections );
$this->total_query->add_sql_clause( 'join', $order_stats_join );
$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $totals ) {
return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
// @todo remove these assignements when refactoring segmenter classes to use query objects.
$totals_query = array(
'from_clause' => $this->total_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->total_query->get_sql_clause( 'where' ),
);
$intervals_query = array(
'select_clause' => $this->get_sql_clause( 'select' ),
'from_clause' => $this->interval_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->interval_query->get_sql_clause( 'where' ),
);
$segmenter = new Segmenter( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $intervals ) {
return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching tax data failed.', 'woocommerce' ) );
}
$totals = (object) $this->cast_numbers( $totals[0] );
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}
Admin/API/Reports/Taxes/Stats/Query.php 0000644 00000002376 15153704477 0013670 0 ustar 00 <?php
/**
* Class for parameter-based Taxes Stats Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'categories' => array(15, 18),
* 'product_ids' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Taxes\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Taxes report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get tax stats data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_taxes_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-taxes-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_taxes_stats_select_query', $results, $args );
}
}
Admin/API/Reports/Taxes/Stats/Segmenter.php 0000644 00000013031 15153704477 0014502 0 ustar 00 <?php
/**
* Class for adding segmenting support without cluttering the data stores.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter extends ReportsSegmenter {
/**
* Returns column => query mapping to be used for order-related order-level segmenting query (e.g. tax_rate_id).
*
* @param string $lookup_table Name of SQL table containing the order-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_order_level( $lookup_table ) {
$columns_mapping = array(
'tax_codes' => "COUNT(DISTINCT $lookup_table.tax_rate_id) as tax_codes",
'total_tax' => "SUM($lookup_table.total_tax) AS total_tax",
'order_tax' => "SUM($lookup_table.order_tax) as order_tax",
'shipping_tax' => "SUM($lookup_table.shipping_tax) as shipping_tax",
'orders_count' => "COUNT(DISTINCT $lookup_table.order_id) as orders_count",
);
return $columns_mapping;
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
global $wpdb;
$totals_segments = $wpdb->get_results(
"SELECT
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
return $totals_segments;
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
global $wpdb;
$segmenting_limit = '';
$limit_parts = explode( ',', $intervals_query['limit'] );
if ( 2 === count( $limit_parts ) ) {
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
}
$intervals_segments = $wpdb->get_results(
"SELECT
MAX($table_name.date_created) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$segmenting_where = '';
$segmenting_from = '';
$segments = array();
if ( 'tax_rate_id' === $this->query_args['segmentby'] ) {
$tax_rate_level_columns = $this->get_segment_selections_order_level( $table_name );
$segmenting_select = $this->prepare_selections( $tax_rate_level_columns );
$this->report_columns = $tax_rate_level_columns;
$segmenting_groupby = $table_name . '.tax_rate_id';
$segments = $this->get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
return $segments;
}
}
Admin/API/Reports/TimeInterval.php 0000644 00000056655 15153704477 0013015 0 ustar 00 <?php
/**
* Class for time interval and numeric range handling for reports.
*/
namespace Automattic\WooCommerce\Admin\API\Reports;
defined( 'ABSPATH' ) || exit;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class TimeInterval {
/**
* Format string for ISO DateTime formatter.
*
* @var string
*/
public static $iso_datetime_format = 'Y-m-d\TH:i:s';
/**
* Format string for use in SQL queries.
*
* @var string
*/
public static $sql_datetime_format = 'Y-m-d H:i:s';
/**
* Converts local datetime to GMT/UTC time.
*
* @param string $datetime_string String representation of local datetime.
* @return DateTime
*/
public static function convert_local_datetime_to_gmt( $datetime_string ) {
$datetime = new \DateTime( $datetime_string, new \DateTimeZone( wc_timezone_string() ) );
$datetime->setTimezone( new \DateTimeZone( 'GMT' ) );
return $datetime;
}
/**
* Returns default 'before' parameter for the reports.
*
* @return DateTime
*/
public static function default_before() {
$datetime = new \WC_DateTime();
// Set local timezone or offset.
if ( get_option( 'timezone_string' ) ) {
$datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) );
} else {
$datetime->set_utc_offset( wc_timezone_offset() );
}
return $datetime;
}
/**
* Returns default 'after' parameter for the reports.
*
* @return DateTime
*/
public static function default_after() {
$now = time();
$week_back = $now - WEEK_IN_SECONDS;
$datetime = new \WC_DateTime();
$datetime->setTimestamp( $week_back );
// Set local timezone or offset.
if ( get_option( 'timezone_string' ) ) {
$datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) );
} else {
$datetime->set_utc_offset( wc_timezone_offset() );
}
return $datetime;
}
/**
* Returns date format to be used as grouping clause in SQL.
*
* @param string $time_interval Time interval.
* @param string $table_name Name of the db table relevant for the date constraint.
* @param string $date_column_name Name of the date table column.
* @return mixed
*/
public static function db_datetime_format( $time_interval, $table_name, $date_column_name = 'date_created' ) {
$first_day_of_week = absint( get_option( 'start_of_week' ) );
if ( 1 === $first_day_of_week ) {
// Week begins on Monday, ISO 8601.
$week_format = "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%x-%v')";
} else {
// Week begins on day other than specified by ISO 8601, needs to be in sync with function simple_week_number.
$week_format = "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', LPAD( FLOOR( ( DAYOFYEAR({$table_name}.`{$date_column_name}`) + ( ( DATE_FORMAT(MAKEDATE(YEAR({$table_name}.`{$date_column_name}`),1), '%w') - $first_day_of_week + 7 ) % 7 ) - 1 ) / 7 ) + 1 , 2, '0'))";
}
// Whenever this is changed, double check method time_interval_id to make sure they are in sync.
$mysql_date_format_mapping = array(
'hour' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d %H')",
'day' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d')",
'week' => $week_format,
'month' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m')",
'quarter' => "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', QUARTER({$table_name}.`{$date_column_name}`))",
'year' => "YEAR({$table_name}.`{$date_column_name}`)",
);
return $mysql_date_format_mapping[ $time_interval ];
}
/**
* Returns quarter for the DateTime.
*
* @param DateTime $datetime Local date & time.
* @return int|null
*/
public static function quarter( $datetime ) {
switch ( (int) $datetime->format( 'm' ) ) {
case 1:
case 2:
case 3:
return 1;
case 4:
case 5:
case 6:
return 2;
case 7:
case 8:
case 9:
return 3;
case 10:
case 11:
case 12:
return 4;
}
return null;
}
/**
* Returns simple week number for the DateTime, for week starting on $first_day_of_week.
*
* The first week of the year is considered to be the week containing January 1.
* The second week starts on the next $first_day_of_week.
*
* @param DateTime $datetime Local date for which the week number is to be calculated.
* @param int $first_day_of_week 0 for Sunday to 6 for Saturday.
* @return int
*/
public static function simple_week_number( $datetime, $first_day_of_week ) {
$beg_of_year_day = new \DateTime( "{$datetime->format('Y')}-01-01" );
$adj_day_beg_of_year = ( (int) $beg_of_year_day->format( 'w' ) - $first_day_of_week + 7 ) % 7;
$days_since_start_of_year = (int) $datetime->format( 'z' ) + 1;
return (int) floor( ( ( $days_since_start_of_year + $adj_day_beg_of_year - 1 ) / 7 ) ) + 1;
}
/**
* Returns ISO 8601 week number for the DateTime, if week starts on Monday,
* otherwise returns simple week number.
*
* @see TimeInterval::simple_week_number()
*
* @param DateTime $datetime Local date for which the week number is to be calculated.
* @param int $first_day_of_week 0 for Sunday to 6 for Saturday.
* @return int
*/
public static function week_number( $datetime, $first_day_of_week ) {
if ( 1 === $first_day_of_week ) {
$week_number = (int) $datetime->format( 'W' );
} else {
$week_number = self::simple_week_number( $datetime, $first_day_of_week );
}
return $week_number;
}
/**
* Returns time interval id for the DateTime.
*
* @param string $time_interval Time interval type (week, day, etc).
* @param DateTime $datetime Date & time.
* @return string
*/
public static function time_interval_id( $time_interval, $datetime ) {
// Whenever this is changed, double check method db_datetime_format to make sure they are in sync.
$php_time_format_for = array(
'hour' => 'Y-m-d H',
'day' => 'Y-m-d',
'week' => 'o-W',
'month' => 'Y-m',
'quarter' => 'Y-' . self::quarter( $datetime ),
'year' => 'Y',
);
// If the week does not begin on Monday.
$first_day_of_week = absint( get_option( 'start_of_week' ) );
if ( 'week' === $time_interval && 1 !== $first_day_of_week ) {
$week_no = self::simple_week_number( $datetime, $first_day_of_week );
$week_no = str_pad( $week_no, 2, '0', STR_PAD_LEFT );
$year_no = $datetime->format( 'Y' );
return "$year_no-$week_no";
}
return $datetime->format( $php_time_format_for[ $time_interval ] );
}
/**
* Calculates number of time intervals between two dates, closed interval on both sides.
*
* @param DateTime $start_datetime Start date & time.
* @param DateTime $end_datetime End date & time.
* @param string $interval Time interval increment, e.g. hour, day, week.
*
* @return int
*/
public static function intervals_between( $start_datetime, $end_datetime, $interval ) {
switch ( $interval ) {
case 'hour':
$end_timestamp = (int) $end_datetime->format( 'U' );
$start_timestamp = (int) $start_datetime->format( 'U' );
$addendum = 0;
// modulo HOUR_IN_SECONDS would normally work, but there are non-full hour timezones, e.g. Nepal.
$start_min_sec = (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' );
$end_min_sec = (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' );
if ( $end_min_sec < $start_min_sec ) {
$addendum = 1;
}
$diff_timestamp = $end_timestamp - $start_timestamp;
return (int) floor( ( (int) $diff_timestamp ) / HOUR_IN_SECONDS ) + 1 + $addendum;
case 'day':
$days = $start_datetime->diff( $end_datetime )->format( '%r%a' );
$end_hour_min_sec = (int) $end_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' );
$start_hour_min_sec = (int) $start_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' );
if ( $end_hour_min_sec < $start_hour_min_sec ) {
$days++;
}
return $days + 1;
case 'week':
// @todo Optimize? approximately day count / 7, but year end is tricky, a week can have fewer days.
$week_count = 0;
do {
$start_datetime = self::next_week_start( $start_datetime );
$week_count++;
} while ( $start_datetime <= $end_datetime );
return $week_count;
case 'month':
// Year diff in months: (end_year - start_year - 1) * 12.
$year_diff_in_months = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 12;
// All the months in end_date year plus months from X to 12 in the start_date year.
$month_diff = (int) $end_datetime->format( 'n' ) + ( 12 - (int) $start_datetime->format( 'n' ) );
// Add months for number of years between end_date and start_date.
$month_diff += $year_diff_in_months + 1;
return $month_diff;
case 'quarter':
// Year diff in quarters: (end_year - start_year - 1) * 4.
$year_diff_in_quarters = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 4;
// All the quarters in end_date year plus quarters from X to 4 in the start_date year.
$quarter_diff = self::quarter( $end_datetime ) + ( 4 - self::quarter( $start_datetime ) );
// Add quarters for number of years between end_date and start_date.
$quarter_diff += $year_diff_in_quarters + 1;
return $quarter_diff;
case 'year':
$year_diff = (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' );
return $year_diff + 1;
}
return 0;
}
/**
* Returns a new DateTime object representing the next hour start/previous hour end if reversed.
*
* @param DateTime $datetime Date and time.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function next_hour_start( $datetime, $reversed = false ) {
$hour_increment = $reversed ? 0 : 1;
$timestamp = (int) $datetime->format( 'U' );
$seconds_into_hour = (int) $datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $datetime->format( 's' );
$hours_offset_timestamp = $timestamp + ( $hour_increment * HOUR_IN_SECONDS - $seconds_into_hour );
if ( $reversed ) {
$hours_offset_timestamp --;
}
$hours_offset_time = new \DateTime();
$hours_offset_time->setTimestamp( $hours_offset_timestamp );
$hours_offset_time->setTimezone( new \DateTimeZone( wc_timezone_string() ) );
return $hours_offset_time;
}
/**
* Returns a new DateTime object representing the next day start, or previous day end if reversed.
*
* @param DateTime $datetime Date and time.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function next_day_start( $datetime, $reversed = false ) {
$oneday = new \DateInterval( 'P1D' );
$new_datetime = clone $datetime;
if ( $reversed ) {
$new_datetime->sub( $oneday );
$new_datetime->setTime( 23, 59, 59 );
} else {
$new_datetime->add( $oneday );
$new_datetime->setTime( 0, 0, 0 );
}
return $new_datetime;
}
/**
* Returns DateTime object representing the next week start, or previous week end if reversed.
*
* The next week start is the first day of the next week at 00:00:00.
* The previous week end is the last day of the previous week at 23:59:59.
* The start day is determined by the "start_of_week" wp_option.
*
* @param DateTime $datetime Date and time.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function next_week_start( $datetime, $reversed = false ) {
$seven_days = new \DateInterval( 'P7D' );
// Default timezone set in wp-settings.php.
$default_timezone = date_default_timezone_get();
// Timezone that the WP site uses in Settings > General.
$original_timezone = $datetime->getTimezone();
// @codingStandardsIgnoreStart
date_default_timezone_set( 'UTC' );
$start_end_timestamp = get_weekstartend( $datetime->format( 'Y-m-d' ) );
date_default_timezone_set( $default_timezone );
// @codingStandardsIgnoreEnd
if ( $reversed ) {
$result = \DateTime::createFromFormat( 'U', $start_end_timestamp['end'] )->sub( $seven_days );
} else {
$result = \DateTime::createFromFormat( 'U', $start_end_timestamp['start'] )->add( $seven_days );
}
return \DateTime::createFromFormat( 'Y-m-d H:i:s', $result->format( 'Y-m-d H:i:s' ), $original_timezone );
}
/**
* Returns a new DateTime object representing the next month start, or previous month end if reversed.
*
* @param DateTime $datetime Date and time.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function next_month_start( $datetime, $reversed = false ) {
$month_increment = 1;
$year = $datetime->format( 'Y' );
$month = (int) $datetime->format( 'm' );
if ( $reversed ) {
$beg_of_month_datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
$timestamp = (int) $beg_of_month_datetime->format( 'U' );
$end_of_prev_month_timestamp = $timestamp - 1;
$datetime->setTimestamp( $end_of_prev_month_timestamp );
} else {
$month += $month_increment;
if ( $month > 12 ) {
$month = 1;
$year ++;
}
$day = '01';
$datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
}
return $datetime;
}
/**
* Returns a new DateTime object representing the next quarter start, or previous quarter end if reversed.
*
* @param DateTime $datetime Date and time.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function next_quarter_start( $datetime, $reversed = false ) {
$year = $datetime->format( 'Y' );
$month = (int) $datetime->format( 'n' );
switch ( $month ) {
case 1:
case 2:
case 3:
if ( $reversed ) {
$month = 1;
} else {
$month = 4;
}
break;
case 4:
case 5:
case 6:
if ( $reversed ) {
$month = 4;
} else {
$month = 7;
}
break;
case 7:
case 8:
case 9:
if ( $reversed ) {
$month = 7;
} else {
$month = 10;
}
break;
case 10:
case 11:
case 12:
if ( $reversed ) {
$month = 10;
} else {
$month = 1;
$year ++;
}
break;
}
$datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
if ( $reversed ) {
$timestamp = (int) $datetime->format( 'U' );
$end_of_prev_month_timestamp = $timestamp - 1;
$datetime->setTimestamp( $end_of_prev_month_timestamp );
}
return $datetime;
}
/**
* Return a new DateTime object representing the next year start, or previous year end if reversed.
*
* @param DateTime $datetime Date and time.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function next_year_start( $datetime, $reversed = false ) {
$year_increment = 1;
$year = (int) $datetime->format( 'Y' );
$month = '01';
$day = '01';
if ( $reversed ) {
$datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
$timestamp = (int) $datetime->format( 'U' );
$end_of_prev_year_timestamp = $timestamp - 1;
$datetime->setTimestamp( $end_of_prev_year_timestamp );
} else {
$year += $year_increment;
$datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
}
return $datetime;
}
/**
* Returns beginning of next time interval for provided DateTime.
*
* E.g. for current DateTime, beginning of next day, week, quarter, etc.
*
* @param DateTime $datetime Date and time.
* @param string $time_interval Time interval, e.g. week, day, hour.
* @param bool $reversed Going backwards in time instead of forward.
* @return DateTime
*/
public static function iterate( $datetime, $time_interval, $reversed = false ) {
return call_user_func( array( __CLASS__, "next_{$time_interval}_start" ), $datetime, $reversed );
}
/**
* Returns expected number of items on the page in case of date ordering.
*
* @param int $expected_interval_count Expected number of intervals in total.
* @param int $items_per_page Number of items per page.
* @param int $page_no Page number.
*
* @return float|int
*/
public static function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) {
$total_pages = (int) ceil( $expected_interval_count / $items_per_page );
if ( $page_no < $total_pages ) {
return $items_per_page;
} elseif ( $page_no === $total_pages ) {
return $expected_interval_count - ( $page_no - 1 ) * $items_per_page;
} else {
return 0;
}
}
/**
* Returns true if there are any intervals that need to be filled in the response.
*
* @param int $expected_interval_count Expected number of intervals in total.
* @param int $db_records Total number of records for given period in the database.
* @param int $items_per_page Number of items per page.
* @param int $page_no Page number.
* @param string $order asc or desc.
* @param string $order_by Column by which the result will be sorted.
* @param int $intervals_count Number of records for given (possibly shortened) time interval.
*
* @return bool
*/
public static function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) {
if ( $expected_interval_count <= $db_records ) {
return false;
}
if ( 'date' === $order_by ) {
$expected_intervals_on_page = self::expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no );
return $intervals_count < $expected_intervals_on_page;
}
if ( 'desc' === $order ) {
return $page_no > floor( $db_records / $items_per_page );
}
if ( 'asc' === $order ) {
return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page );
}
// Invalid ordering.
return false;
}
/**
* Normalize "*_between" parameters to "*_min" and "*_max" for numeric values
* and "*_after" and "*_before" for date values.
*
* @param array $request Query params from REST API request.
* @param string|array $param_names One or more param names to handle. Should not include "_between" suffix.
* @param bool $is_date Boolean if the param is date is related.
* @return array Normalized query values.
*/
public static function normalize_between_params( $request, $param_names, $is_date ) {
if ( ! is_array( $param_names ) ) {
$param_names = array( $param_names );
}
$normalized = array();
foreach ( $param_names as $param_name ) {
if ( ! is_array( $request[ $param_name . '_between' ] ) ) {
continue;
}
$range = $request[ $param_name . '_between' ];
if ( 2 !== count( $range ) ) {
continue;
}
$min = $is_date ? '_after' : '_min';
$max = $is_date ? '_before' : '_max';
if ( $range[0] < $range[1] ) {
$normalized[ $param_name . $min ] = $range[0];
$normalized[ $param_name . $max ] = $range[1];
} else {
$normalized[ $param_name . $min ] = $range[1];
$normalized[ $param_name . $max ] = $range[0];
}
}
return $normalized;
}
/**
* Validate a "*_between" range argument (an array with 2 numeric items).
*
* @param mixed $value Parameter value.
* @param WP_REST_Request $request REST Request.
* @param string $param Parameter name.
* @return WP_Error|boolean
*/
public static function rest_validate_between_numeric_arg( $value, $request, $param ) {
if ( ! wp_is_numeric_array( $value ) ) {
return new \WP_Error(
'rest_invalid_param',
/* translators: 1: parameter name */
sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param )
);
}
if (
2 !== count( $value ) ||
! is_numeric( $value[0] ) ||
! is_numeric( $value[1] )
) {
return new \WP_Error(
'rest_invalid_param',
/* translators: %s: parameter name */
sprintf( __( '%s must contain 2 numbers.', 'woocommerce' ), $param )
);
}
return true;
}
/**
* Validate a "*_between" range argument (an array with 2 date items).
*
* @param mixed $value Parameter value.
* @param WP_REST_Request $request REST Request.
* @param string $param Parameter name.
* @return WP_Error|boolean
*/
public static function rest_validate_between_date_arg( $value, $request, $param ) {
if ( ! wp_is_numeric_array( $value ) ) {
return new \WP_Error(
'rest_invalid_param',
/* translators: 1: parameter name */
sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param )
);
}
if (
2 !== count( $value ) ||
! rest_parse_date( $value[0] ) ||
! rest_parse_date( $value[1] )
) {
return new \WP_Error(
'rest_invalid_param',
/* translators: %s: parameter name */
sprintf( __( '%s must contain 2 valid dates.', 'woocommerce' ), $param )
);
}
return true;
}
/**
* Get dates from a timeframe string.
*
* @param int $timeframe Timeframe to use. One of: last_week|last_month|last_quarter|last_6_months|last_year.
* @param DateTime|null $current_date DateTime of current date to compare.
* @return array
*/
public static function get_timeframe_dates( $timeframe, $current_date = null ) {
if ( ! $current_date ) {
$current_date = new \DateTime();
}
$current_year = $current_date->format( 'Y' );
$current_month = $current_date->format( 'm' );
if ( 'last_week' === $timeframe ) {
return array(
'start' => $current_date->modify( 'last week monday' )->format( 'Y-m-d 00:00:00' ),
'end' => $current_date->modify( 'this sunday' )->format( 'Y-m-d 23:59:59' ),
);
}
if ( 'last_month' === $timeframe ) {
return array(
'start' => $current_date->modify( 'first day of previous month' )->format( 'Y-m-d 00:00:00' ),
'end' => $current_date->modify( 'last day of this month' )->format( 'Y-m-d 23:59:59' ),
);
}
if ( 'last_quarter' === $timeframe ) {
switch ( $current_month ) {
case $current_month >= 1 && $current_month <= 3:
return array(
'start' => ( $current_year - 1 ) . '-10-01 00:00:00',
'end' => ( $current_year - 1 ) . '-12-31 23:59:59',
);
case $current_month >= 4 && $current_month <= 6:
return array(
'start' => $current_year . '-01-01 00:00:00',
'end' => $current_year . '-03-31 23:59:59',
);
case $current_month >= 7 && $current_month <= 9:
return array(
'start' => $current_year . '-04-01 00:00:00',
'end' => $current_year . '-06-30 23:59:59',
);
case $current_month >= 10 && $current_month <= 12:
return array(
'start' => $current_year . '-07-01 00:00:00',
'end' => $current_year . '-09-31 23:59:59',
);
}
}
if ( 'last_6_months' === $timeframe ) {
if ( $current_month >= 1 && $current_month <= 6 ) {
return array(
'start' => ( $current_year - 1 ) . '-07-01 00:00:00',
'end' => ( $current_year - 1 ) . '-12-31 23:59:59',
);
}
return array(
'start' => $current_year . '-01-01 00:00:00',
'end' => $current_year . '-06-30 23:59:59',
);
}
if ( 'last_year' === $timeframe ) {
return array(
'start' => ( $current_year - 1 ) . '-01-01 00:00:00',
'end' => ( $current_year - 1 ) . '-12-31 23:59:59',
);
}
return false;
}
}
Admin/API/Reports/Variations/Controller.php 0000644 00000035153 15153704477 0014642 0 ustar 00 <?php
/**
* REST API Reports products controller
*
* Handles requests to the /reports/products endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
/**
* REST API Reports products controller class.
*
* @internal
* @extends ReportsController
*/
class Controller extends ReportsController implements ExportableInterface {
/**
* Exportable traits.
*/
use ExportableTraits;
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/variations';
/**
* Mapping between external parameter name and name used in query class.
*
* @var array
*/
protected $param_mapping = array(
'variations' => 'variation_includes',
);
/**
* Get items.
*
* @param WP_REST_Request $request Request data.
*
* @return array|WP_Error
*/
public function get_items( $request ) {
$args = array();
/**
* Experimental: Filter the list of parameters provided when querying data from the data store.
*
* @ignore
*
* @param array $collection_params List of parameters.
*/
$collection_params = apply_filters(
'experimental_woocommerce_analytics_variations_collection_params',
$this->get_collection_params()
);
$registered = array_keys( $collection_params );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
if ( isset( $this->param_mapping[ $param_name ] ) ) {
$args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
} else {
$args[ $param_name ] = $request[ $param_name ];
}
}
}
$reports = new Query( $args );
$products_data = $reports->get_data();
$data = array();
foreach ( $products_data->data as $product_data ) {
$item = $this->prepare_item_for_response( $product_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$data,
(int) $products_data->total,
(int) $products_data->page_no,
(int) $products_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $report ) );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_variations', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param array $object Object data.
* @return array Links for the given post.
*/
protected function prepare_links( $object ) {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ),
),
'variation' => array(
'href' => rest_url( sprintf( '/%s/%s/%d/%s/%d', $this->namespace, 'products', $object['product_id'], 'variation', $object['variation_id'] ) ),
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_varitations',
'type' => 'object',
'properties' => array(
'product_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product ID.', 'woocommerce' ),
),
'variation_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product ID.', 'woocommerce' ),
),
'items_sold' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Number of items sold.', 'woocommerce' ),
),
'net_revenue' => array(
'type' => 'number',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ),
),
'orders_count' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Number of orders product appeared in.', 'woocommerce' ),
),
'extended_info' => array(
'name' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product name.', 'woocommerce' ),
),
'price' => array(
'type' => 'number',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product price.', 'woocommerce' ),
),
'image' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product image.', 'woocommerce' ),
),
'permalink' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product link.', 'woocommerce' ),
),
'attributes' => array(
'type' => 'array',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product attributes.', 'woocommerce' ),
),
'stock_status' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory status.', 'woocommerce' ),
),
'stock_quantity' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory quantity.', 'woocommerce' ),
),
'low_stock_amount' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ),
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'net_revenue',
'orders_count',
'items_sold',
'sku',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['variations'] = array(
'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['extended_info'] = array(
'description' => __( 'Add additional piece of info about each variation to the report.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is'] = array(
'description' => __( 'Limit result set to variations that include the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to variations that don\'t include the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['category_includes'] = array(
'description' => __( 'Limit result set to variations in the specified categories.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['category_excludes'] = array(
'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['force_cache_refresh'] = array(
'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wp_validate_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get stock status column export value.
*
* @param array $status Stock status from report row.
* @return string
*/
protected function get_stock_status( $status ) {
$statuses = wc_get_product_stock_status_options();
return isset( $statuses[ $status ] ) ? $statuses[ $status ] : '';
}
/**
* Get the column names for export.
*
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
$export_columns = array(
'product_name' => __( 'Product / Variation title', 'woocommerce' ),
'sku' => __( 'SKU', 'woocommerce' ),
'items_sold' => __( 'Items sold', 'woocommerce' ),
'net_revenue' => __( 'N. Revenue', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
);
if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
$export_columns['stock_status'] = __( 'Status', 'woocommerce' );
$export_columns['stock'] = __( 'Stock', 'woocommerce' );
}
return $export_columns;
}
/**
* Get the column values for export.
*
* @param array $item Single report item/row.
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
$export_item = array(
'product_name' => $item['extended_info']['name'],
'sku' => $item['extended_info']['sku'],
'items_sold' => $item['items_sold'],
'net_revenue' => self::csv_number_format( $item['net_revenue'] ),
'orders_count' => $item['orders_count'],
);
if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
$export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] );
$export_item['stock'] = $item['extended_info']['stock_quantity'];
}
return $export_item;
}
}
Admin/API/Reports/Variations/DataStore.php 0000644 00000042711 15153704477 0014403 0 ustar 00 <?php
/**
* API\Reports\Variations\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Variations\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @var string
*/
protected static $table_name = 'wc_order_product_lookup';
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'variations';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'variation_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'name' => 'strval',
'price' => 'floatval',
'image' => 'strval',
'permalink' => 'strval',
'sku' => 'strval',
);
/**
* Extended product attributes to include in the data.
*
* @var array
*/
protected $extended_attributes = array(
'name',
'price',
'image',
'permalink',
'stock_status',
'stock_quantity',
'low_stock_amount',
'sku',
);
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'variations';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'product_id' => 'product_id',
'variation_id' => 'variation_id',
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
);
}
/**
* Fills FROM clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @param string $arg_name Target of the JOIN sql param.
*/
protected function add_from_sql_params( $query_args, $arg_name ) {
global $wpdb;
if ( 'sku' !== $query_args['orderby'] ) {
return;
}
$table_name = self::get_db_table_name();
$join = "LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'";
if ( 'inner' === $arg_name ) {
$this->subquery->add_sql_clause( 'join', $join );
} else {
$this->add_sql_clause( 'join', $join );
}
}
/**
* Generate a subquery for order_item_id based on the attribute filters.
*
* @param array $query_args Query arguments supplied by the user.
* @return string
*/
protected function get_order_item_by_attribute_subquery( $query_args ) {
$order_product_lookup_table = self::get_db_table_name();
$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
// Perform a subquery for DISTINCT order items that match our attribute filters.
$attr_subquery = new SqlQuery( $this->context . '_attribute_subquery' );
$attr_subquery->add_sql_clause( 'select', "DISTINCT {$order_product_lookup_table}.order_item_id" );
$attr_subquery->add_sql_clause( 'from', $order_product_lookup_table );
if ( $this->should_exclude_simple_products( $query_args ) ) {
$attr_subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" );
}
foreach ( $attribute_subqueries['join'] as $attribute_join ) {
$attr_subquery->add_sql_clause( 'join', $attribute_join );
}
$operator = $this->get_match_operator( $query_args );
$attr_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' );
return "AND {$order_product_lookup_table}.order_item_id IN ({$attr_subquery->get_query_statement()})";
}
return false;
}
/**
* Updates the database query with parameters used for Products report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_product_lookup_table = self::get_db_table_name();
$order_stats_lookup_table = $wpdb->prefix . 'wc_order_stats';
$order_item_meta_table = $wpdb->prefix . 'woocommerce_order_itemmeta';
$where_subquery = array();
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations > 0 ) {
$this->add_from_sql_params( $query_args, 'outer' );
} else {
$this->add_from_sql_params( $query_args, 'inner' );
}
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
}
$excluded_products = $this->get_excluded_products( $query_args );
if ( $excluded_products ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})" );
}
if ( $included_variations ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" );
} elseif ( ! $included_products ) {
if ( $this->should_exclude_simple_products( $query_args ) ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" );
}
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$order_stats_lookup_table} ON {$order_product_lookup_table}.order_id = {$order_stats_lookup_table}.order_id" );
$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
}
$attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args );
if ( $attribute_order_items_subquery ) {
// JOIN on product lookup if we haven't already.
if ( ! $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );
}
// Add subquery for matching attributes to WHERE.
$this->subquery->add_sql_clause( 'where', $attribute_order_items_subquery );
}
if ( 0 < count( $where_subquery ) ) {
$operator = $this->get_match_operator( $query_args );
$this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' );
}
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
*
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return self::get_db_table_name() . '.date_created';
}
if ( 'sku' === $order_by ) {
return 'meta_value';
}
return $order_by;
}
/**
* Enriches the product data with attributes specified by the extended_attributes.
*
* @param array $products_data Product data.
* @param array $query_args Query parameters.
*/
protected function include_extended_info( &$products_data, $query_args ) {
foreach ( $products_data as $key => $product_data ) {
$extended_info = new \ArrayObject();
if ( $query_args['extended_info'] ) {
$extended_attributes = apply_filters( 'woocommerce_rest_reports_variations_extended_attributes', $this->extended_attributes, $product_data );
$parent_product = wc_get_product( $product_data['product_id'] );
$attributes = array();
// Base extended info off the parent variable product if the variation ID is 0.
// This is caused by simple products with prior sales being converted into variable products.
// See: https://github.com/woocommerce/woocommerce-admin/issues/2719.
$variation_id = (int) $product_data['variation_id'];
$variation_product = ( 0 === $variation_id ) ? $parent_product : wc_get_product( $variation_id );
// Fall back to the parent product if the variation can't be found.
$extended_attributes_product = is_a( $variation_product, 'WC_Product' ) ? $variation_product : $parent_product;
// If both product and variation is not found, set deleted to true.
if ( ! $extended_attributes_product ) {
$extended_info['deleted'] = true;
}
foreach ( $extended_attributes as $extended_attribute ) {
$function = 'get_' . $extended_attribute;
if ( is_callable( array( $extended_attributes_product, $function ) ) ) {
$value = $extended_attributes_product->{$function}();
$extended_info[ $extended_attribute ] = $value;
}
}
// If this is a variation, add its attributes.
// NOTE: We don't fall back to the parent product here because it will include all possible attribute options.
if (
0 < $variation_id &&
is_callable( array( $variation_product, 'get_variation_attributes' ) )
) {
$variation_attributes = $variation_product->get_variation_attributes();
foreach ( $variation_attributes as $attribute_name => $attribute ) {
$name = str_replace( 'attribute_', '', $attribute_name );
$option_term = get_term_by( 'slug', $attribute, $name );
$attributes[] = array(
'id' => wc_attribute_taxonomy_id_by_name( $name ),
'name' => str_replace( 'pa_', '', $name ),
'option' => $option_term && ! is_wp_error( $option_term ) ? $option_term->name : $attribute,
);
}
}
$extended_info['attributes'] = $attributes;
// If there is no set low_stock_amount, use the one in user settings.
if ( '' === $extended_info['low_stock_amount'] ) {
$extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
}
$extended_info = $this->cast_numbers( $extended_info );
}
$products_data[ $key ]['extended_info'] = $extended_info;
}
}
/**
* Returns if simple products should be excluded from the report.
*
* @internal
*
* @param array $query_args Query parameters.
*
* @return boolean
*/
protected function should_exclude_simple_products( array $query_args ) {
return apply_filters( 'experimental_woocommerce_analytics_variations_should_exclude_simple_products', true, $query_args );
}
/**
* Fill missing extended_info.name for the deleted products.
*
* @param array $products Product data.
*/
protected function fill_deleted_product_name( array &$products ) {
global $wpdb;
$product_variation_ids = [];
// Find products with missing extended_info.name.
foreach ( $products as $key => $product ) {
if ( ! isset( $product['extended_info']['name'] ) ) {
$product_variation_ids[ $key ] = [
'product_id' => $product['product_id'],
'variation_id' => $product['variation_id'],
];
}
}
if ( ! count( $product_variation_ids ) ) {
return;
}
$where_clauses = implode(
' or ',
array_map(
function( $ids ) {
return "(
product_lookup.product_id = {$ids['product_id']}
and
product_lookup.variation_id = {$ids['variation_id']}
)";
},
$product_variation_ids
)
);
$query = "
select
product_lookup.product_id,
product_lookup.variation_id,
order_items.order_item_name
from
{$wpdb->prefix}wc_order_product_lookup as product_lookup
left join {$wpdb->prefix}woocommerce_order_items as order_items
on product_lookup.order_item_id = order_items.order_item_id
where
{$where_clauses}
group by
product_lookup.product_id,
product_lookup.variation_id,
order_items.order_item_name
";
// phpcs:ignore
$results = $wpdb->get_results( $query );
$index = [];
foreach ( $results as $result ) {
$index[ $result->product_id . '_' . $result->variation_id ] = $result->order_item_name;
}
foreach ( $product_variation_ids as $product_key => $ids ) {
$product = $products[ $product_key ];
$index_key = $product['product_id'] . '_' . $product['variation_id'];
if ( isset( $index[ $index_key ] ) ) {
$products[ $product_key ]['extended_info']['name'] = $index[ $index_key ];
}
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
*
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'product_includes' => array(),
'variation_includes' => array(),
'extended_info' => false,
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$included_variations =
( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) )
? $query_args['variation_includes']
: array();
$params = $this->get_limit_params( $query_args );
$this->add_sql_query_params( $query_args );
if ( count( $included_variations ) > 0 ) {
$total_results = count( $included_variations );
$total_pages = (int) ceil( $total_results / $params['per_page'] );
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
if ( 'date' === $query_args['orderby'] ) {
$this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" );
}
$fields = $this->get_fields( $query_args );
$join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) );
$ids_table = $this->get_ids_table( $included_variations, 'variation_id' );
$this->add_sql_clause( 'select', $join_selections );
$this->add_sql_clause( 'from', '(' );
$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
$this->add_sql_clause( 'from', ") AS {$table_name}" );
$this->add_sql_clause(
'right_join',
"RIGHT JOIN ( {$ids_table} ) AS default_results
ON default_results.variation_id = {$table_name}.variation_id"
);
$variations_query = $this->get_query_statement();
} else {
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
/**
* Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses.
*
* @since 7.4.0
* @param array $query_args Query parameters.
* @param SqlQuery $subquery Variations query class.
*/
apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery );
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$db_records_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) AS tt"
);
/* phpcs:enable */
$total_results = $db_records_count;
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$variations_query = $this->subquery->get_query_statement();
}
/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
$product_data = $wpdb->get_results(
$variations_query,
ARRAY_A
);
/* phpcs:enable */
if ( null === $product_data ) {
return $data;
}
$this->include_extended_info( $product_data, $query_args );
if ( $query_args['extended_info'] ) {
$this->fill_deleted_product_name( $product_data );
}
$product_data = array_map( array( $this, 'cast_numbers' ), $product_data );
$data = (object) array(
'data' => $product_data,
'total' => $total_results,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'select', 'product_id' );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
$this->subquery->add_sql_clause( 'group_by', 'product_id, variation_id' );
}
}
Admin/API/Reports/Variations/Query.php 0000644 00000002366 15153704477 0013624 0 ustar 00 <?php
/**
* Class for parameter-based Products Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'categories' => array(15, 18),
* 'products' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Variations\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_variations_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-variations' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_variations_select_query', $results, $args );
}
}
Admin/API/Reports/Variations/Stats/Controller.php 0000644 00000023765 15153704477 0015746 0 ustar 00 <?php
/**
* REST API Reports variations stats controller
*
* Handles requests to the /reports/variations/stats endpoint.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
use WP_REST_Request;
use WP_REST_Response;
/**
* REST API Reports variations stats controller class.
*
* @internal
* @extends GenericStatsController
*/
class Controller extends GenericStatsController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/variations/stats';
/**
* Mapping between external parameter name and name used in query class.
*
* @var array
*/
protected $param_mapping = array(
'variations' => 'variation_includes',
);
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_analytics_variations_stats_select_query', array( $this, 'set_default_report_data' ) );
}
/**
* Get all reports.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$query_args = array(
'fields' => array(
'items_sold',
'net_revenue',
'orders_count',
'variations_count',
),
);
/**
* Experimental: Filter the list of parameters provided when querying data from the data store.
*
* @ignore
*
* @param array $collection_params List of parameters.
*/
$collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_stats_collection_params', $this->get_collection_params() );
$registered = array_keys( $collection_params );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
if ( isset( $this->param_mapping[ $param_name ] ) ) {
$query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
} else {
$query_args[ $param_name ] = $request[ $param_name ];
}
}
}
$query = new Query( $query_args );
try {
$report_data = $query->get_data();
} catch ( ParameterException $e ) {
return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
'intervals' => array(),
);
foreach ( $report_data->intervals as $interval_data ) {
$item = $this->prepare_item_for_response( $interval_data, $request );
$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
}
return $this->add_pagination_headers(
$request,
$out_data,
(int) $report_data->total,
(int) $report_data->page_no,
(int) $report_data->pages
);
}
/**
* Prepare a report object for serialization.
*
* @param array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$response = parent::prepare_item_for_response( $report, $request );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_variations_stats', $response, $report, $request );
}
/**
* Get the Report's item properties schema.
* Will be used by `get_item_schema` as `totals` and `subtotals`.
*
* @return array
*/
protected function get_item_properties_schema() {
return array(
'items_sold' => array(
'title' => __( 'Variations Sold', 'woocommerce' ),
'description' => __( 'Number of variation items sold.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'indicator' => true,
),
'net_revenue' => array(
'description' => __( 'Net sales.', 'woocommerce' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'orders_count' => array(
'description' => __( 'Number of orders.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
$schema['title'] = 'report_variations_stats';
$segment_label = array(
'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
);
$schema['properties']['totals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label;
$schema['properties']['intervals']['items']['properties']['subtotals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label;
return $this->add_additional_fields_schema( $schema );
}
/**
* Set the default results to 0 if API returns an empty array
*
* @param Mixed $results Report data.
* @return object
*/
public function set_default_report_data( $results ) {
if ( empty( $results ) ) {
$results = new \stdClass();
$results->total = 0;
$results->totals = new \stdClass();
$results->totals->items_sold = 0;
$results->totals->net_revenue = 0;
$results->totals->orders_count = 0;
$results->intervals = array();
$results->pages = 1;
$results->page_no = 1;
}
return $results;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby']['enum'] = array(
'date',
'net_revenue',
'coupons',
'refunds',
'shipping',
'taxes',
'net_revenue',
'orders_count',
'items_sold',
);
$params['category_includes'] = array(
'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['category_excludes'] = array(
'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['variations'] = array(
'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['fields'] = array(
'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['attribute_is'] = array(
'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
$params['attribute_is_not'] = array(
'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'array',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}
Admin/API/Reports/Variations/Stats/DataStore.php 0000644 00000026356 15153704477 0015510 0 ustar 00 <?php
/**
* API\Reports\Products\Stats\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore as VariationsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
/**
* API\Reports\Variations\Stats\DataStore.
*/
class DataStore extends VariationsDataStore implements DataStoreInterface {
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'variations_count' => 'intval',
);
/**
* Cache identifier.
*
* @var string
*/
protected $cache_key = 'variations_stats';
/**
* Data store context used to pass to filters.
*
* @var string
*/
protected $context = 'variations_stats';
/**
* Assign report columns once full table name has been assigned.
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count',
);
}
/**
* Updates the database query with parameters used for Products Stats report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function update_sql_query_params( $query_args ) {
global $wpdb;
$products_where_clause = '';
$products_from_clause = '';
$where_subquery = array();
$order_product_lookup_table = self::get_db_table_name();
$order_item_meta_table = $wpdb->prefix . 'woocommerce_order_itemmeta';
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
}
$excluded_products = $this->get_excluded_products( $query_args );
if ( $excluded_products ) {
$products_where_clause .= "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})";
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
} elseif ( $this->should_exclude_simple_products( $query_args ) ) {
$products_where_clause .= " AND {$order_product_lookup_table}.variation_id != 0";
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
$products_where_clause .= " AND ( {$order_status_filter} )";
}
$attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args );
if ( $attribute_order_items_subquery ) {
// JOIN on product lookup if we haven't already.
if ( ! $order_status_filter ) {
$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
}
// Add subquery for matching attributes to WHERE.
$products_where_clause .= $attribute_order_items_subquery;
}
if ( 0 < count( $where_subquery ) ) {
$operator = $this->get_match_operator( $query_args );
$products_where_clause .= 'AND (' . implode( " {$operator} ", $where_subquery ) . ')';
}
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->total_query->add_sql_clause( 'where', $products_where_clause );
$this->total_query->add_sql_clause( 'join', $products_from_clause );
$this->add_intervals_sql_params( $query_args, $order_product_lookup_table );
$this->interval_query->add_sql_clause( 'where', $products_where_clause );
$this->interval_query->add_sql_clause( 'join', $products_from_clause );
$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
}
/**
* Returns if simple products should be excluded from the report.
*
* @internal
*
* @param array $query_args Query parameters.
*
* @return boolean
*/
protected function should_exclude_simple_products( array $query_args ) {
return apply_filters( 'experimental_woocommerce_analytics_variations_stats_should_exclude_simple_products', true, $query_args );
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @since 3.5.0
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date',
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
'fields' => '*',
'category_includes' => array(),
'interval' => 'week',
'product_includes' => array(),
'variation_includes' => array(),
);
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
/*
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
*/
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
if ( false === $data ) {
$this->initialize_queries();
$selections = $this->selected_columns( $query_args );
$params = $this->get_limit_params( $query_args );
$this->update_sql_query_params( $query_args );
$this->get_limit_sql_params( $query_args );
$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
$db_intervals = $wpdb->get_col(
$this->interval_query->get_query_statement()
);
/* phpcs:enable */
$db_interval_count = count( $db_intervals );
$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return array();
}
$intervals = array();
$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
$this->total_query->add_sql_clause( 'select', $selections );
$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
$totals = $wpdb->get_results(
$this->total_query->get_query_statement(),
ARRAY_A
);
/* phpcs:enable */
// @todo remove these assignements when refactoring segmenter classes to use query objects.
$totals_query = array(
'from_clause' => $this->total_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->total_query->get_sql_clause( 'where' ),
);
$intervals_query = array(
'select_clause' => $this->get_sql_clause( 'select' ),
'from_clause' => $this->interval_query->get_sql_clause( 'join' ),
'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ),
'where_clause' => $this->interval_query->get_sql_clause( 'where' ),
'order_by' => $this->get_sql_clause( 'order_by' ),
'limit' => $this->get_sql_clause( 'limit' ),
);
$segmenter = new Segmenter( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
if ( null === $totals ) {
return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
if ( '' !== $selections ) {
$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
}
/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
$intervals = $wpdb->get_results(
$this->interval_query->get_query_statement(),
ARRAY_A
);
/* phpcs:enable */
if ( null === $intervals ) {
return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
}
$totals = (object) $this->cast_numbers( $totals[0] );
$data = (object) array(
'totals' => $totals,
'intervals' => $intervals,
'total' => $expected_interval_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
$this->set_cached_data( $cache_key, $data );
}
return $data;
}
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
unset( $this->subquery );
$this->total_query = new SqlQuery( $this->context . '_total' );
$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query = new SqlQuery( $this->context . '_interval' );
$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
}
}
Admin/API/Reports/Variations/Stats/Query.php 0000644 00000002446 15153704477 0014721 0 ustar 00 <?php
/**
* Class for parameter-based Variations Stats Report querying
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'categories' => array(15, 18),
* 'product_ids' => array(1,2,3)
* );
* $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Query( $args );
* $mydata = $report->get_data();
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;
/**
* API\Reports\Variations\Stats\Query
*/
class Query extends ReportsQuery {
/**
* Valid fields for Products report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get variations data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_analytics_variations_stats_query_args', $this->get_query_vars() );
$data_store = \WC_Data_Store::load( 'report-variations-stats' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_analytics_variations_stats_select_query', $results, $args );
}
}
Admin/API/Reports/Variations/Stats/Segmenter.php 0000644 00000017667 15153704477 0015560 0 ustar 00 <?php
/**
* Class for adding segmenting support without cluttering the data stores.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class Segmenter extends ReportsSegmenter {
/**
* Returns column => query mapping to be used for product-related product-level segmenting query
* (e.g. products sold, revenue from product X when segmenting by category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return array Column => SELECT query mapping.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'items_sold' => "SUM($products_table.product_qty) as items_sold",
'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue",
'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count",
);
return $columns_mapping;
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
);
/* phpcs:enable */
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// LIMIT offset, rowcount needs to be updated to a multiple of the number of segments.
preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts );
$segment_count = count( $this->get_all_segments() );
$orig_offset = intval( $limit_parts[1] );
$orig_rowcount = intval( $limit_parts[2] );
$segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count );
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = 'uniq_orders';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'variation' === $this->query_args['segmentby'] ) {
$product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table );
$segmenting_selections = array(
'product_level' => $this->prepare_selections( $product_level_columns ),
);
$this->report_columns = $product_level_columns;
$segmenting_from = '';
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
// Restrict our search space for variation comparisons.
if ( isset( $this->query_args['variation_includes'] ) ) {
$variation_ids = implode( ',', $this->get_all_segments() );
$segmenting_where = " AND $product_segmenting_table.variation_id IN ( $variation_ids )";
}
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
}
return $segments;
}
}
Admin/API/SettingOptions.php 0000644 00000001556 15153704477 0011733 0 ustar 00 <?php
/**
* REST API Setting Options Controller
*
* Handles requests to /settings/{option}
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
/**
* Setting Options controller.
*
* @internal
* @extends WC_REST_Setting_Options_Controller
*/
class SettingOptions extends \WC_REST_Setting_Options_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Invalidates API cache when updating settings options.
*
* @param WP_REST_Request $request Full details about the request.
* @return array Of WP_Error or WP_REST_Response.
*/
public function batch_items( $request ) {
// Invalidate the API cache.
ReportsCache::invalidate();
// Process the request.
return parent::batch_items( $request );
}
}
Admin/API/ShippingPartnerSuggestions.php 0000644 00000013364 15153704477 0014312 0 ustar 00 <?php
/**
* Handles requests for shipping partner suggestions.
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\DefaultShippingPartners;
use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\ShippingPartnerSuggestions as Suggestions;
defined( 'ABSPATH' ) || exit;
/**
* ShippingPartnerSuggestions Controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class ShippingPartnerSuggestions extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'shipping-partner-suggestions';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_suggestions' ),
'permission_callback' => array( $this, 'get_permission_check' ),
'args' => array(
'force_default_suggestions' => array(
'type' => 'boolean',
'description' => __( 'Return the default shipping partner suggestions when woocommerce_show_marketplace_suggestions option is set to no', 'woocommerce' ),
),
),
),
'schema' => array( $this, 'get_suggestions_schema' ),
)
);
}
/**
* Check if a given request has access to manage plugins.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function get_permission_check( $request ) {
if ( ! current_user_can( 'install_plugins' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Check if suggestions should be shown in the settings screen.
*
* @return bool
*/
private function should_display() {
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return false;
}
/**
* The return value can be controlled via woocommerce_allow_shipping_partner_suggestions filter.
*
* @since 7.4.1
*/
return apply_filters( 'woocommerce_allow_shipping_partner_suggestions', true );
}
/**
* Return suggested shipping partners.
*
* @param WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
*/
public function get_suggestions( $request ) {
$should_display = $this->should_display();
$force_default = $request->get_param( 'force_default_suggestions' );
if ( $should_display ) {
return Suggestions::get_suggestions();
} elseif ( false === $should_display && true === $force_default ) {
return rest_ensure_response( Suggestions::get_suggestions( DefaultShippingPartners::get_all() ) );
}
return rest_ensure_response( Suggestions::get_suggestions( DefaultShippingPartners::get_all() ) );
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public static function get_suggestions_schema() {
$feature_def = array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'icon' => array(
'type' => 'string',
),
'title' => array(
'type' => 'string',
),
'description' => array(
'type' => 'string',
),
),
),
);
$layout_def = array(
'type' => 'object',
'properties' => array(
'image' => array(
'type' => 'string',
'description' => '',
),
'features' => $feature_def,
),
);
$item_schema = array(
'type' => 'object',
'required' => array( 'name', 'is_visible', 'available_layouts' ),
// require layout_row or layout_column. One of them must exist.
'anyOf' => array(
array(
'required' => 'layout_row',
),
array(
'required' => 'layout_column',
),
),
'properties' => array(
'name' => array(
'description' => __( 'Plugin name.', 'woocommerce' ),
'type' => 'string',
'required' => true,
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'slug' => array(
'description' => __( 'Plugin slug used in https://wordpress.org/plugins/{slug}.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'layout_row' => $layout_def,
'layout_column' => $layout_def,
'description' => array(
'description' => __( 'Description', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'learn_more_link' => array(
'description' => __( 'Learn more link .', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'is_visible' => array(
'description' => __( 'Suggestion visibility.', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'available_layouts' => array(
'description' => __( 'Available layouts -- single, dual, or both', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => array( 'row', 'column' ),
),
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'shipping-partner-suggestions',
'type' => 'array',
'items' => array( $item_schema ),
);
return $schema;
}
}
Admin/API/Taxes.php 0000644 00000011634 15153704477 0010024 0 ustar 00 <?php
/**
* REST API Taxes Controller
*
* Handles requests to /taxes/*
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
/**
* Taxes controller.
*
* @internal
* @extends WC_REST_Taxes_Controller
*/
class Taxes extends \WC_REST_Taxes_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-analytics';
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['search'] = array(
'description' => __( 'Search by similar tax code.', 'woocommerce' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
$params['include'] = array(
'description' => __( 'Limit result set to items that have the specified rate ID(s) assigned.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get all taxes and allow filtering by tax code.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
global $wpdb;
$prepared_args = array();
$prepared_args['order'] = $request['order'];
$prepared_args['number'] = $request['per_page'];
if ( ! empty( $request['offset'] ) ) {
$prepared_args['offset'] = $request['offset'];
} else {
$prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number'];
}
$orderby_possibles = array(
'id' => 'tax_rate_id',
'order' => 'tax_rate_order',
);
$prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ];
$prepared_args['class'] = $request['class'];
$prepared_args['search'] = $request['search'];
$prepared_args['include'] = $request['include'];
/**
* Filter arguments, before passing to $wpdb->get_results(), when querying taxes via the REST API.
*
* @param array $prepared_args Array of arguments for $wpdb->get_results().
* @param WP_REST_Request $request The current request.
*/
$prepared_args = apply_filters( 'woocommerce_rest_tax_query', $prepared_args, $request );
$query = "
SELECT *
FROM {$wpdb->prefix}woocommerce_tax_rates
WHERE 1 = 1
";
// Filter by tax class.
if ( ! empty( $prepared_args['class'] ) ) {
$class = 'standard' !== $prepared_args['class'] ? sanitize_title( $prepared_args['class'] ) : '';
$query .= " AND tax_rate_class = '$class'";
}
// Filter by tax code.
$tax_code_search = $prepared_args['search'];
if ( $tax_code_search ) {
$code_like = '%' . $wpdb->esc_like( $tax_code_search ) . '%';
$query .= $wpdb->prepare( ' AND CONCAT_WS( "-", NULLIF(tax_rate_country, ""), NULLIF(tax_rate_state, ""), NULLIF(tax_rate_name, ""), NULLIF(tax_rate_priority, "") ) LIKE %s', $code_like );
}
// Filter by included tax rate IDs.
$included_taxes = array_map( 'absint', $prepared_args['include'] );
if ( ! empty( $included_taxes ) ) {
$included_taxes = implode( ',', $prepared_args['include'] );
$query .= " AND tax_rate_id IN ({$included_taxes})";
}
// Order tax rates.
$order_by = sprintf( ' ORDER BY %s', sanitize_key( $prepared_args['orderby'] ) );
// Pagination.
$pagination = sprintf( ' LIMIT %d, %d', $prepared_args['offset'], $prepared_args['number'] );
// Query taxes.
$results = $wpdb->get_results( $query . $order_by . $pagination ); // @codingStandardsIgnoreLine.
$taxes = array();
foreach ( $results as $tax ) {
$data = $this->prepare_item_for_response( $tax, $request );
$taxes[] = $this->prepare_response_for_collection( $data );
}
$response = rest_ensure_response( $taxes );
// Store pagination values for headers then unset for count query.
$per_page = (int) $prepared_args['number'];
$page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 );
// Query only for ids.
$wpdb->get_results( str_replace( 'SELECT *', 'SELECT tax_rate_id', $query ) ); // @codingStandardsIgnoreLine.
// Calculate totals.
$total_taxes = (int) $wpdb->num_rows;
$response->header( 'X-WP-Total', (int) $total_taxes );
$max_pages = ceil( $total_taxes / $per_page );
$response->header( 'X-WP-TotalPages', (int) $max_pages );
$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
if ( $page > 1 ) {
$prev_page = $page - 1;
if ( $prev_page > $max_pages ) {
$prev_page = $max_pages;
}
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $max_pages > $page ) {
$next_page = $page + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
}
Admin/API/Templates/digital_product.csv 0000644 00000000067 15153704477 0014055 0 ustar 00 Type,Name,Published
"simple, downloadable, virtual",,-1 Admin/API/Templates/external_product.csv 0000644 00000000040 15153704477 0014251 0 ustar 00 Type,Name,Published
external,,-1 Admin/API/Templates/grouped_product.csv 0000644 00000000040 15153704477 0014074 0 ustar 00 Type,Name,Published
grouped,,-1
Admin/API/Templates/physical_product.csv 0000644 00000000036 15153704477 0014250 0 ustar 00 Type,Name,Published
simple,,-1 Admin/API/Templates/variable_product.csv 0000644 00000000040 15153704477 0014214 0 ustar 00 Type,Name,Published
variable,,-1 Admin/API/Themes.php 0000644 00000014141 15153704477 0010161 0 ustar 00 <?php
/**
* REST API Themes Controller
*
* Handles requests to /themes
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Overrides\ThemeUpgrader;
use Automattic\WooCommerce\Admin\Overrides\ThemeUpgraderSkin;
/**
* Themes controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Themes extends \WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'themes';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'upload_theme' ),
'permission_callback' => array( $this, 'upload_theme_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check whether a given request has permission to edit upload plugins/themes.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function upload_theme_permissions_check( $request ) {
if ( ! current_user_can( 'upload_themes' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you are not allowed to install themes on this site.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Upload and install a theme.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function upload_theme( $request ) {
if (
! isset( $_FILES['pluginzip'] ) || ! isset( $_FILES['pluginzip']['tmp_name'] ) || ! is_uploaded_file( $_FILES['pluginzip']['tmp_name'] ) || ! is_file( $_FILES['pluginzip']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
return new \WP_Error( 'woocommerce_rest_invalid_file', __( 'Specified file failed upload test.', 'woocommerce' ) );
}
include_once ABSPATH . 'wp-admin/includes/file.php';
include_once ABSPATH . '/wp-admin/includes/admin.php';
include_once ABSPATH . '/wp-admin/includes/theme-install.php';
include_once ABSPATH . '/wp-admin/includes/theme.php';
include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php';
include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php';
$_GET['package'] = true;
$file_upload = new \File_Upload_Upgrader( 'pluginzip', 'package' );
$upgrader = new ThemeUpgrader( new ThemeUpgraderSkin() );
$install = $upgrader->install( $file_upload->package );
if ( $install || is_wp_error( $install ) ) {
$file_upload->cleanup();
}
if ( ! is_wp_error( $install ) && isset( $install['destination_name'] ) ) {
$theme = $install['destination_name'];
$result = array(
'status' => 'success',
'message' => $upgrader->strings['process_success'],
'theme' => $theme,
);
/**
* Fires when a theme is successfully installed.
*
* @param string $theme The theme name.
*/
do_action( 'woocommerce_theme_installed', $theme );
} else {
if ( is_wp_error( $install ) && $install->get_error_code() ) {
$error_message = isset( $upgrader->strings[ $install->get_error_code() ] ) ? $upgrader->strings[ $install->get_error_code() ] : $install->get_error_data();
} else {
$error_message = $upgrader->strings['process_failed'];
}
$result = array(
'status' => 'error',
'message' => $error_message,
);
}
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Prepare the data object for response.
*
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
/**
* Filter the list returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_themes', $response, $item, $request );
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'upload_theme',
'type' => 'object',
'properties' => array(
'status' => array(
'description' => __( 'Theme installation status.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'message' => array(
'description' => __( 'Theme installation message.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'theme' => array(
'description' => __( 'Uploaded theme.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['pluginzip'] = array(
'description' => __( 'A zip file of the theme to be uploaded.', 'woocommerce' ),
'type' => 'file',
'validate_callback' => 'rest_validate_request_arg',
);
return apply_filters( 'woocommerce_rest_themes_collection_params', $params );
}
}
Admin/BlockTemplates/BlockContainerInterface.php 0000644 00000000272 15153704477 0015752 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\BlockTemplates;
/**
* Interface for block containers.
*/
interface BlockContainerInterface extends BlockInterface, ContainerInterface {}
Admin/BlockTemplates/BlockInterface.php 0000644 00000003523 15153704477 0014111 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\BlockTemplates;
/**
* Interface for block configuration used to specify blocks in BlockTemplate.
*/
interface BlockInterface {
/**
* Key for the block name in the block configuration.
*/
public const NAME_KEY = 'blockName';
/**
* Key for the block ID in the block configuration.
*/
public const ID_KEY = 'id';
/**
* Key for the internal order in the block configuration.
*/
public const ORDER_KEY = 'order';
/**
* Key for the block attributes in the block configuration.
*/
public const ATTRIBUTES_KEY = 'attributes';
/**
* Get the block name.
*/
public function get_name(): string;
/**
* Get the block ID.
*/
public function get_id(): string;
/**
* Get the block order.
*/
public function get_order(): int;
/**
* Set the block order.
*
* @param int $order The block order.
*/
public function set_order( int $order );
/**
* Get the block attributes.
*/
public function get_attributes(): array;
/**
* Set the block attributes.
*
* @param array $attributes The block attributes.
*/
public function set_attributes( array $attributes );
/**
* Get the parent container that the block belongs to.
*/
public function &get_parent(): ContainerInterface;
/**
* Get the root template that the block belongs to.
*/
public function &get_root_template(): BlockTemplateInterface;
/**
* Remove the block from its parent.
*/
public function remove();
/**
* Check if the block is detached from its parent or root template.
*
* @return bool True if the block is detached from its parent or root template.
*/
public function is_detached(): bool;
/**
* Get the block configuration as a formatted template.
*
* @return array The block configuration as a formatted template.
*/
public function get_formatted_template(): array;
}
Admin/BlockTemplates/BlockTemplateInterface.php 0000644 00000001261 15153704477 0015602 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\BlockTemplates;
/**
* Interface for block-based template.
*/
interface BlockTemplateInterface extends ContainerInterface {
/**
* Get the template ID.
*/
public function get_id(): string;
/**
* Get the template title.
*/
public function get_title(): string;
/**
* Get the template description.
*/
public function get_description(): string;
/**
* Get the template area.
*/
public function get_area(): string;
/**
* Generate a block ID based on a base.
*
* @param string $id_base The base to use when generating an ID.
* @return string
*/
public function generate_block_id( string $id_base ): string;
}
Admin/BlockTemplates/ContainerInterface.php 0000644 00000001536 15153704477 0015003 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\BlockTemplates;
/**
* Interface for block containers.
*/
interface ContainerInterface {
/**
* Get the root template that the block belongs to.
*/
public function &get_root_template(): BlockTemplateInterface;
/**
* Get the block configuration as a formatted template.
*/
public function get_formatted_template(): array;
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface;
/**
* Removes a block from the container.
*
* @param string $block_id The block ID.
*
* @throws \UnexpectedValueException If the block container is not an ancestor of the block.
*/
public function remove_block( string $block_id );
/**
* Removes all blocks from the container.
*/
public function remove_blocks();
}
Admin/Composer/Package.php 0000644 00000004577 15153704477 0011461 0 ustar 00 <?php
/**
* Returns information about the package and handles init.
*/
/**
* This namespace isn't compatible with the PSR-4
* which ensures that the copy in the standalone plugin will not be autoloaded.
*/
namespace Automattic\WooCommerce\Admin\Composer;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NotesUnavailableException;
use Automattic\WooCommerce\Internal\Admin\FeaturePlugin;
/**
* Main package class.
*/
class Package {
/**
* Version.
*
* @var string
*/
const VERSION = '3.3.0';
/**
* Package active.
*
* @var bool
*/
private static $package_active = false;
/**
* Active version
*
* @var bool
*/
private static $active_version = null;
/**
* Init the package.
*
* Only initialize for WP 5.3 or greater.
*/
public static function init() {
// Avoid double initialization when the feature plugin is in use.
if (defined( 'WC_ADMIN_VERSION_NUMBER' ) ) {
self::$active_version = WC_ADMIN_VERSION_NUMBER;
return;
}
$feature_plugin_instance = FeaturePlugin::instance();
// Indicate to the feature plugin that the core package exists.
if ( ! defined( 'WC_ADMIN_PACKAGE_EXISTS' ) ) {
define( 'WC_ADMIN_PACKAGE_EXISTS', true );
}
self::$package_active = true;
self::$active_version = self::VERSION;
$feature_plugin_instance->init();
// Unhook the custom Action Scheduler data store class in active older versions of WC Admin.
remove_filter( 'action_scheduler_store_class', array( $feature_plugin_instance, 'replace_actionscheduler_store_class' ) );
}
/**
* Return the version of the package.
*
* @return string
*/
public static function get_version() {
return self::VERSION;
}
/**
* Return the active version of WC Admin.
*
* @return string
*/
public static function get_active_version() {
return self::$active_version;
}
/**
* Return whether the package is active.
*
* @return bool
*/
public static function is_package_active() {
return self::$package_active;
}
/**
* Return the path to the package.
*
* @return string
*/
public static function get_path() {
return dirname( __DIR__ );
}
/**
* Checks if notes have been initialized.
*/
private static function is_notes_initialized() {
try {
Notes::load_data_store();
} catch ( NotesUnavailableException $e ) {
return false;
}
return true;
}
}
Admin/DataSourcePoller.php 0000644 00000014001 15153704477 0011526 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin;
/**
* Specs data source poller class.
* This handles polling specs from JSON endpoints, and
* stores the specs in to the database as an option.
*/
abstract class DataSourcePoller {
/**
* Get class instance.
*/
abstract public static function get_instance();
/**
* Name of data sources filter.
*/
const FILTER_NAME = 'data_source_poller_data_sources';
/**
* Name of data source specs filter.
*/
const FILTER_NAME_SPECS = 'data_source_poller_specs';
/**
* Id of DataSourcePoller.
*
* @var string
*/
protected $id = array();
/**
* Default data sources array.
*
* @var array
*/
protected $data_sources = array();
/**
* Default args.
*
* @var array
*/
protected $args = array();
/**
* The logger instance.
*
* @var WC_Logger|null
*/
protected static $logger = null;
/**
* Constructor.
*
* @param string $id id of DataSourcePoller.
* @param array $data_sources urls for data sources.
* @param array $args Options for DataSourcePoller.
*/
public function __construct( $id, $data_sources = array(), $args = array() ) {
$this->data_sources = $data_sources;
$this->id = $id;
$arg_defaults = array(
'spec_key' => 'id',
'transient_name' => 'woocommerce_admin_' . $id . '_specs',
'transient_expiry' => 7 * DAY_IN_SECONDS,
);
$this->args = wp_parse_args( $args, $arg_defaults );
}
/**
* Get the logger instance.
*
* @return WC_Logger
*/
protected static function get_logger() {
if ( is_null( self::$logger ) ) {
self::$logger = wc_get_logger();
}
return self::$logger;
}
/**
* Returns the key identifier of spec, this can easily be overwritten. Defaults to id.
*
* @param mixed $spec a JSON parsed spec coming from the JSON feed.
* @return string|boolean
*/
protected function get_spec_key( $spec ) {
$key = $this->args['spec_key'];
if ( isset( $spec->$key ) ) {
return $spec->$key;
}
return false;
}
/**
* Reads the data sources for specs and persists those specs.
*
* @return array list of specs.
*/
public function get_specs_from_data_sources() {
$locale = get_user_locale();
$specs_group = get_transient( $this->args['transient_name'] ) ?? array();
$specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : array();
if ( ! is_array( $specs ) || empty( $specs ) ) {
$this->read_specs_from_data_sources();
$specs_group = get_transient( $this->args['transient_name'] );
$specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : array();
}
$specs = apply_filters( self::FILTER_NAME_SPECS, $specs, $this->id );
return $specs !== false ? $specs : array();
}
/**
* Reads the data sources for specs and persists those specs.
*
* @return bool Whether any specs were read.
*/
public function read_specs_from_data_sources() {
$specs = array();
$data_sources = apply_filters( self::FILTER_NAME, $this->data_sources, $this->id );
// Note that this merges the specs from the data sources based on the
// id - last one wins.
foreach ( $data_sources as $url ) {
$specs_from_data_source = self::read_data_source( $url );
$this->merge_specs( $specs_from_data_source, $specs, $url );
}
$specs_group = get_transient( $this->args['transient_name'] ) ?? array();
$locale = get_user_locale();
$specs_group[ $locale ] = $specs;
// Persist the specs as a transient.
set_transient(
$this->args['transient_name'],
$specs_group,
$this->args['transient_expiry']
);
return count( $specs ) !== 0;
}
/**
* Delete the specs transient.
*
* @return bool success of failure of transient deletion.
*/
public function delete_specs_transient() {
return delete_transient( $this->args['transient_name'] );
}
/**
* Read a single data source and return the read specs
*
* @param string $url The URL to read the specs from.
*
* @return array The specs that have been read from the data source.
*/
protected static function read_data_source( $url ) {
$logger_context = array( 'source' => $url );
$logger = self::get_logger();
$response = wp_remote_get(
add_query_arg(
'locale',
get_user_locale(),
$url
),
array(
'user-agent' => 'WooCommerce/' . WC_VERSION . '; ' . home_url( '/' ),
)
);
if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) {
$logger->error(
'Error getting data feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $response, true ), $logger_context );
return [];
}
$body = $response['body'];
$specs = json_decode( $body );
if ( $specs === null ) {
$logger->error(
'Empty response in data feed',
$logger_context
);
return [];
}
if ( ! is_array( $specs ) ) {
$logger->error(
'Data feed is not an array',
$logger_context
);
return [];
}
return $specs;
}
/**
* Merge the specs.
*
* @param Array $specs_to_merge_in The specs to merge in to $specs.
* @param Array $specs The list of specs being merged into.
* @param string $url The url of the feed being merged in (for error reporting).
*/
protected function merge_specs( $specs_to_merge_in, &$specs, $url ) {
foreach ( $specs_to_merge_in as $spec ) {
if ( ! $this->validate_spec( $spec, $url ) ) {
continue;
}
$id = $this->get_spec_key( $spec );
$specs[ $id ] = $spec;
}
}
/**
* Validate the spec.
*
* @param object $spec The spec to validate.
* @param string $url The url of the feed that provided the spec.
*
* @return bool The result of the validation.
*/
protected function validate_spec( $spec, $url ) {
$logger = self::get_logger();
$logger_context = array( 'source' => $url );
if ( ! $this->get_spec_key( $spec ) ) {
$logger->error(
'Spec is invalid because the id is missing in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
return true;
}
}
Admin/DateTimeProvider/CurrentDateTimeProvider.php 0000644 00000000763 15153704477 0016311 0 ustar 00 <?php
/**
* A provider for getting the current DateTime.
*/
namespace Automattic\WooCommerce\Admin\DateTimeProvider;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\DateTimeProvider\DateTimeProviderInterface;
/**
* Current DateTime Provider.
*
* Uses the current DateTime.
*/
class CurrentDateTimeProvider implements DateTimeProviderInterface {
/**
* Returns the current DateTime.
*
* @return DateTime
*/
public function get_now() {
return new \DateTime();
}
}
Admin/DateTimeProvider/DateTimeProviderInterface.php 0000644 00000000602 15153704477 0016557 0 ustar 00 <?php
/**
* Interface for a provider for getting the current DateTime,
* designed to be mockable for unit tests.
*/
namespace Automattic\WooCommerce\Admin\DateTimeProvider;
defined( 'ABSPATH' ) || exit;
/**
* DateTime Provider Interface.
*/
interface DateTimeProviderInterface {
/**
* Returns the current DateTime.
*
* @return DateTime
*/
public function get_now();
}
Admin/DeprecatedClassFacade.php 0000644 00000005105 15153704477 0012435 0 ustar 00 <?php
/**
* A facade to allow deprecating an entire class. Calling instance or static
* functions on the facade triggers a deprecation notice before calling the
* underlying function.
*
* Use it by extending DeprecatedClassFacade in your facade class, setting the
* static $facade_over_classname string to the name of the class to build
* a facade over, and setting the static $deprecated_in_version to the version
* that the class was deprecated in. Eg.:
*
* class DeprecatedGoose extends DeprecatedClassFacade {
* static $facade_over_classname = 'Goose';
* static $deprecated_in_version = '1.7.0';
* }
*/
namespace Automattic\WooCommerce\Admin;
defined( 'ABSPATH' ) || exit;
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
/**
* A facade to allow deprecating an entire class.
*/
class DeprecatedClassFacade {
/**
* The instance that this facade covers over.
*
* @var object
*/
protected $instance;
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname;
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '';
/**
* Constructor.
*/
public function __construct() {
$this->instance = new static::$facade_over_classname();
}
/**
* Log a deprecation to the error log.
*
* @param string $function The name of the deprecated function being called.
*/
private static function log_deprecation( $function ) {
error_log( // phpcs:ignore
sprintf(
'%1$s is deprecated since version %2$s! Use %3$s instead.',
static::class . '::' . $function,
static::$deprecated_in_version,
static::$facade_over_classname . '::' . $function
)
);
}
/**
* Executes when calling any function on an instance of this class.
*
* @param string $name The name of the function being called.
* @param array $arguments An array of the arguments to the function call.
*/
public function __call( $name, $arguments ) {
self::log_deprecation( $name );
return call_user_func_array(
array(
$this->instance,
$name,
),
$arguments
);
}
/**
* Executes when calling any static function on this class.
*
* @param string $name The name of the function being called.
* @param array $arguments An array of the arguments to the function call.
*/
public static function __callStatic( $name, $arguments ) {
self::log_deprecation( $name );
return call_user_func_array(
array(
static::$facade_over_classname,
$name,
),
$arguments
);
}
}
Admin/FeaturePlugin.php 0000644 00000001673 15153704477 0011103 0 ustar 00 <?php
/**
* WooCommerce Admin: Feature plugin main class.
*/
namespace Automattic\WooCommerce\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Feature plugin main class.
*
* @deprecated since 6.4.0
*/
class FeaturePlugin extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\FeaturePlugin';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '6.4.0';
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
return new static();
}
/**
* Init the feature plugin, only if we can detect both Gutenberg and WooCommerce.
*
* @deprecated 6.4.0
*/
public function init() {}
}
Admin/Features/AsyncProductEditorCategoryField/Init.php 0000644 00000004641 15153704477 0017237 0 ustar 00 <?php
/**
* WooCommerce Async Product Editor Category Field.
*/
namespace Automattic\WooCommerce\Admin\Features\AsyncProductEditorCategoryField;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Admin\PageController;
/**
* Loads assets related to the async category field for the product editor.
*/
class Init {
const FEATURE_ID = 'async-product-editor-category-field';
/**
* Constructor
*/
public function __construct() {
if ( Features::is_enabled( self::FEATURE_ID ) ) {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_filter( 'woocommerce_taxonomy_args_product_cat', array( $this, 'add_metabox_args' ) );
}
}
/**
* Adds meta_box_cb callback arguments for custom metabox.
*
* @param array $args Category taxonomy args.
* @return array $args category taxonomy args.
*/
public function add_metabox_args( $args ) {
if ( ! isset( $args['meta_box_cb'] ) ) {
$args['meta_box_cb'] = 'WC_Meta_Box_Product_Categories::output';
$args['meta_box_sanitize_cb'] = 'taxonomy_meta_box_sanitize_cb_checkboxes';
}
return $args;
}
/**
* Enqueue scripts needed for the product form block editor.
*/
public function enqueue_scripts() {
if ( ! PageController::is_embed_page() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'product-category-metabox', true );
wp_localize_script(
'wc-admin-product-category-metabox',
'wc_product_category_metabox_params',
array(
'search_categories_nonce' => wp_create_nonce( 'search-categories' ),
'search_taxonomy_terms_nonce' => wp_create_nonce( 'search-taxonomy-terms' ),
)
);
wp_enqueue_script( 'product-category-metabox' );
}
/**
* Enqueue styles needed for the rich text editor.
*/
public function enqueue_styles() {
if ( ! PageController::is_embed_page() ) {
return;
}
$version = Constants::get_constant( 'WC_VERSION' );
wp_register_style(
'woocommerce_admin_product_category_metabox_styles',
WCAdminAssets::get_url( 'product-category-metabox/style', 'css' ),
array(),
$version
);
wp_style_add_data( 'woocommerce_admin_product_category_metabox_styles', 'rtl', 'replace' );
wp_enqueue_style( 'woocommerce_admin_product_category_metabox_styles' );
}
}
Admin/Features/Features.php 0000644 00000027274 15153704477 0011672 0 ustar 00 <?php
/**
* Features loader for features developed in WooCommerce Admin.
*/
namespace Automattic\WooCommerce\Admin\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Features Class.
*/
class Features {
/**
* Class instance.
*
* @var Loader instance
*/
protected static $instance = null;
/**
* Optional features
*
* @var array
*/
protected static $optional_features = array(
'navigation' => array( 'default' => 'no' ),
'settings' => array( 'default' => 'no' ),
'analytics' => array( 'default' => 'yes' ),
'remote-inbox-notifications' => array( 'default' => 'yes' ),
);
/**
* Beta features
*
* @var array
*/
protected static $beta_features = array(
'navigation',
'new-product-management-experience',
'settings',
);
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
public function __construct() {
$this->register_internal_class_aliases();
// Load feature before WooCommerce update hooks.
add_action( 'init', array( __CLASS__, 'load_features' ), 4 );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_load_beta_features_modal' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_scripts' ), 15 );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
add_filter( 'update_option_woocommerce_allow_tracking', array( __CLASS__, 'maybe_disable_features' ), 10, 2 );
}
/**
* Gets a build configured array of enabled WooCommerce Admin features/sections, but does not respect optionally disabled features.
*
* @return array Enabled Woocommerce Admin features/sections.
*/
public static function get_features() {
return apply_filters( 'woocommerce_admin_features', array() );
}
/**
* Gets the optional feature options as an associative array that can be toggled on or off.
*
* @return array
*/
public static function get_optional_feature_options() {
$features = [];
foreach ( array_keys( self::$optional_features ) as $optional_feature_key ) {
$feature_class = self::get_feature_class( $optional_feature_key );
if ( $feature_class ) {
$features[ $optional_feature_key ] = $feature_class::TOGGLE_OPTION_NAME;
}
}
return $features;
}
/**
* Returns if a specific wc-admin feature exists in the current environment.
*
* @param string $feature Feature slug.
* @return bool Returns true if the feature exists.
*/
public static function exists( $feature ) {
$features = self::get_features();
return in_array( $feature, $features, true );
}
/**
* Get the feature class as a string.
*
* @param string $feature Feature name.
* @return string|null
*/
public static function get_feature_class( $feature ) {
$feature = str_replace( '-', '', ucwords( strtolower( $feature ), '-' ) );
$feature_class = 'Automattic\\WooCommerce\\Admin\\Features\\' . $feature;
if ( class_exists( $feature_class ) ) {
return $feature_class;
}
// Handle features contained in subdirectory.
if ( class_exists( $feature_class . '\\Init' ) ) {
return $feature_class . '\\Init';
}
return null;
}
/**
* Class loader for enabled WooCommerce Admin features/sections.
*/
public static function load_features() {
$features = self::get_features();
foreach ( $features as $feature ) {
$feature_class = self::get_feature_class( $feature );
if ( $feature_class ) {
new $feature_class();
}
}
}
/**
* Gets a build configured array of enabled WooCommerce Admin respecting optionally disabled features.
*
* @return array Enabled Woocommerce Admin features/sections.
*/
public static function get_available_features() {
$features = self::get_features();
$optional_feature_keys = array_keys( self::$optional_features );
$optional_features_unavailable = [];
/**
* Filter allowing WooCommerce Admin optional features to be disabled.
*
* @param bool $disabled False.
*/
if ( apply_filters( 'woocommerce_admin_disabled', false ) ) {
return array_values( array_diff( $features, $optional_feature_keys ) );
}
foreach ( $optional_feature_keys as $optional_feature_key ) {
$feature_class = self::get_feature_class( $optional_feature_key );
if ( $feature_class ) {
$default = isset( self::$optional_features[ $optional_feature_key ]['default'] ) ?
self::$optional_features[ $optional_feature_key ]['default'] :
'no';
// Check if the feature is currently being enabled, if it is continue.
/* phpcs:disable WordPress.Security.NonceVerification */
$feature_option = $feature_class::TOGGLE_OPTION_NAME;
if ( isset( $_POST[ $feature_option ] ) && '1' === $_POST[ $feature_option ] ) {
continue;
}
if ( 'yes' !== get_option( $feature_class::TOGGLE_OPTION_NAME, $default ) ) {
$optional_features_unavailable[] = $optional_feature_key;
}
}
}
return array_values( array_diff( $features, $optional_features_unavailable ) );
}
/**
* Check if a feature is enabled.
*
* @param string $feature Feature slug.
* @return bool
*/
public static function is_enabled( $feature ) {
$available_features = self::get_available_features();
return in_array( $feature, $available_features, true );
}
/**
* Enable a toggleable optional feature.
*
* @param string $feature Feature name.
* @return bool
*/
public static function enable( $feature ) {
$features = self::get_optional_feature_options();
if ( isset( $features[ $feature ] ) ) {
update_option( $features[ $feature ], 'yes' );
return true;
}
return false;
}
/**
* Disable a toggleable optional feature.
*
* @param string $feature Feature name.
* @return bool
*/
public static function disable( $feature ) {
$features = self::get_optional_feature_options();
if ( isset( $features[ $feature ] ) ) {
update_option( $features[ $feature ], 'no' );
return true;
}
return false;
}
/**
* Disable features when opting out of tracking.
*
* @param string $old_value Old value.
* @param string $value New value.
*/
public static function maybe_disable_features( $old_value, $value ) {
if ( 'yes' === $value ) {
return;
}
foreach ( self::$beta_features as $feature ) {
self::disable( $feature );
}
}
/**
* Adds the Features section to the advanced tab of WooCommerce Settings
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $sections Sections.
* @return array
*/
public static function add_features_section( $sections ) {
return $sections;
}
/**
* Adds the Features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $settings Settings.
* @param string $current_section Current section slug.
* @return array
*/
public static function add_features_settings( $settings, $current_section ) {
return $settings;
}
/**
* Conditionally loads the beta features tracking modal.
*
* @param string $hook Page hook.
*/
public static function maybe_load_beta_features_modal( $hook ) {
if (
'woocommerce_page_wc-settings' !== $hook ||
! isset( $_GET['tab'] ) || 'advanced' !== $_GET['tab'] || // phpcs:ignore CSRF ok.
! isset( $_GET['section'] ) || 'features' !== $_GET['section'] // phpcs:ignore CSRF ok.
) {
return;
}
$tracking_enabled = get_option( 'woocommerce_allow_tracking', 'no' );
if ( empty( self::$beta_features ) ) {
return;
}
if ( 'yes' === $tracking_enabled ) {
return;
}
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style(
'wc-admin-beta-features-tracking-modal',
WCAdminAssets::get_url( "beta-features-tracking-modal/style{$rtl}", 'css' ),
array( 'wp-components' ),
WCAdminAssets::get_file_version( 'css' )
);
wp_enqueue_script(
'wc-admin-beta-features-tracking-modal',
WCAdminAssets::get_url( 'wp-admin-scripts/beta-features-tracking-modal', 'js' ),
array( 'wp-i18n', 'wp-element', WC_ADMIN_APP ),
WCAdminAssets::get_file_version( 'js' ),
true
);
}
/**
* Loads the required scripts on the correct pages.
*/
public static function load_scripts() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
$features = self::get_features();
$enabled_features = array();
foreach ( $features as $key ) {
$enabled_features[ $key ] = self::is_enabled( $key );
}
wp_add_inline_script( WC_ADMIN_APP, 'window.wcAdminFeatures = ' . wp_json_encode( $enabled_features ), 'before' );
}
/**
* Adds body classes to the main wp-admin wrapper, allowing us to better target elements in specific scenarios.
*
* @param string $admin_body_class Body class to add.
*/
public static function add_admin_body_classes( $admin_body_class = '' ) {
if ( ! PageController::is_admin_or_embed_page() ) {
return $admin_body_class;
}
$classes = explode( ' ', trim( $admin_body_class ) );
$features = self::get_features();
foreach ( $features as $feature_key ) {
$classes[] = sanitize_html_class( 'woocommerce-feature-enabled-' . $feature_key );
}
$admin_body_class = implode( ' ', array_unique( $classes ) );
return " $admin_body_class ";
}
/**
* Alias internal features classes to make them backward compatible.
* We've moved our feature classes to src-internal as part of merging this
* repository with WooCommerce Core to form a monorepo.
* See https://wp.me/p90Yrv-2HY for details.
*/
private function register_internal_class_aliases() {
$aliases = array(
// new class => original class (this will be aliased).
'Automattic\WooCommerce\Internal\Admin\WCPayPromotion\Init' => 'Automattic\WooCommerce\Admin\Features\WcPayPromotion\Init',
'Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init' => 'Automattic\WooCommerce\Admin\Features\RemoteFreeExtensions\Init',
'Automattic\WooCommerce\Internal\Admin\ActivityPanels' => 'Automattic\WooCommerce\Admin\Features\ActivityPanels',
'Automattic\WooCommerce\Internal\Admin\Analytics' => 'Automattic\WooCommerce\Admin\Features\Analytics',
'Automattic\WooCommerce\Internal\Admin\Coupons' => 'Automattic\WooCommerce\Admin\Features\Coupons',
'Automattic\WooCommerce\Internal\Admin\CouponsMovedTrait' => 'Automattic\WooCommerce\Admin\Features\CouponsMovedTrait',
'Automattic\WooCommerce\Internal\Admin\CustomerEffortScoreTracks' => 'Automattic\WooCommerce\Admin\Features\CustomerEffortScoreTracks',
'Automattic\WooCommerce\Internal\Admin\Homescreen' => 'Automattic\WooCommerce\Admin\Features\Homescreen',
'Automattic\WooCommerce\Internal\Admin\Marketing' => 'Automattic\WooCommerce\Admin\Features\Marketing',
'Automattic\WooCommerce\Internal\Admin\MobileAppBanner' => 'Automattic\WooCommerce\Admin\Features\MobileAppBanner',
'Automattic\WooCommerce\Internal\Admin\RemoteInboxNotifications' => 'Automattic\WooCommerce\Admin\Features\RemoteInboxNotifications',
'Automattic\WooCommerce\Internal\Admin\SettingsNavigationFeature' => 'Automattic\WooCommerce\Admin\Features\Settings',
'Automattic\WooCommerce\Internal\Admin\ShippingLabelBanner' => 'Automattic\WooCommerce\Admin\Features\ShippingLabelBanner',
'Automattic\WooCommerce\Internal\Admin\ShippingLabelBannerDisplayRules' => 'Automattic\WooCommerce\Admin\Features\ShippingLabelBannerDisplayRules',
'Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage' => 'Automattic\WooCommerce\Admin\Features\WcPayWelcomePage',
);
foreach ( $aliases as $new_class => $orig_class ) {
class_alias( $new_class, $orig_class );
}
}
}
Admin/Features/Navigation/CoreMenu.php 0000644 00000030141 15153704477 0013713 0 ustar 00 <?php
/**
* WooCommerce Navigation Core Menu
*
* @package Woocommerce Admin
*/
namespace Automattic\WooCommerce\Admin\Features\Navigation;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\Navigation\Menu;
use Automattic\WooCommerce\Admin\Features\Navigation\Screen;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
/**
* CoreMenu class. Handles registering Core menu items.
*/
class CoreMenu {
/**
* Class instance.
*
* @var Menu instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'admin_menu', array( $this, 'register_post_types' ) );
// Add this after we've finished migrating menu items to avoid hiding these items.
add_action( 'admin_menu', array( $this, 'add_dashboard_menu_items' ), PHP_INT_MAX );
}
/**
* Add registered admin settings as menu items.
*/
public static function get_setting_items() {
// Let the Settings feature add pages to the navigation if enabled.
if ( Features::is_enabled( 'settings' ) ) {
return array();
}
// Calling this method adds pages to the below tabs filter on non-settings pages.
\WC_Admin_Settings::get_settings_pages();
$tabs = apply_filters( 'woocommerce_settings_tabs_array', array() );
$menu_items = array();
$order = 0;
foreach ( $tabs as $key => $setting ) {
$order += 10;
$menu_items[] = (
array(
'parent' => 'woocommerce-settings',
'title' => $setting,
'capability' => 'manage_woocommerce',
'id' => 'settings-' . $key,
'url' => 'admin.php?page=wc-settings&tab=' . $key,
'order' => $order,
)
);
}
return $menu_items;
}
/**
* Get unfulfilled order count
*
* @return array
*/
public static function get_shop_order_count() {
$status_counts = array_map( 'wc_orders_count', array( 'processing', 'on-hold' ) );
return array_sum( $status_counts );
}
/**
* Get all menu categories.
*
* @return array
*/
public static function get_categories() {
$analytics_enabled = Features::is_enabled( 'analytics' );
return array(
array(
'title' => __( 'Orders', 'woocommerce' ),
'id' => 'woocommerce-orders',
'badge' => self::get_shop_order_count(),
'order' => 10,
),
array(
'title' => __( 'Products', 'woocommerce' ),
'id' => 'woocommerce-products',
'order' => 20,
),
$analytics_enabled ?
array(
'title' => __( 'Analytics', 'woocommerce' ),
'id' => 'woocommerce-analytics',
'order' => 30,
) : null,
$analytics_enabled ?
array(
'title' => __( 'Reports', 'woocommerce' ),
'id' => 'woocommerce-reports',
'parent' => 'woocommerce-analytics',
'order' => 200,
) : null,
array(
'title' => __( 'Marketing', 'woocommerce' ),
'id' => 'woocommerce-marketing',
'order' => 40,
),
array(
'title' => __( 'Settings', 'woocommerce' ),
'id' => 'woocommerce-settings',
'menuId' => 'secondary',
'order' => 20,
'url' => 'admin.php?page=wc-settings',
),
array(
'title' => __( 'Tools', 'woocommerce' ),
'id' => 'woocommerce-tools',
'menuId' => 'secondary',
'order' => 30,
),
);
}
/**
* Get all menu items.
*
* @return array
*/
public static function get_items() {
$order_items = self::get_order_menu_items();
$product_items = Menu::get_post_type_items( 'product', array( 'parent' => 'woocommerce-products' ) );
$product_tag_items = Menu::get_taxonomy_items(
'product_tag',
array(
'parent' => 'woocommerce-products',
'order' => 30,
)
);
$product_cat_items = Menu::get_taxonomy_items(
'product_cat',
array(
'parent' => 'woocommerce-products',
'order' => 20,
)
);
$coupon_items = Menu::get_post_type_items( 'shop_coupon', array( 'parent' => 'woocommerce-marketing' ) );
$setting_items = self::get_setting_items();
$wca_items = array();
$wca_pages = \Automattic\WooCommerce\Admin\PageController::get_instance()->get_pages();
foreach ( $wca_pages as $page ) {
if ( ! isset( $page['nav_args'] ) ) {
continue;
}
$path = isset( $page['path'] ) ? $page['path'] : null;
$item = array_merge(
array(
'id' => $page['id'],
'url' => $path,
'title' => $page['title'][0],
'capability' => isset( $page['capability'] ) ? $page['capability'] : 'manage_woocommerce',
),
$page['nav_args']
);
// Don't allow top-level items to be added to the primary menu.
if ( ! isset( $item['parent'] ) || 'woocommerce' === $item['parent'] ) {
$item['menuId'] = 'plugins';
}
$wca_items[] = $item;
}
$home_item = array();
$setup_tasks_remaining = TaskLists::setup_tasks_remaining();
if ( defined( '\Automattic\WooCommerce\Internal\Admin\Homescreen::MENU_SLUG' ) ) {
$home_item = array(
'id' => 'woocommerce-home',
'title' => __( 'Home', 'woocommerce' ),
'url' => \Automattic\WooCommerce\Internal\Admin\Homescreen::MENU_SLUG,
'order' => 0,
'matchExpression' => 'page=wc-admin((?!path=).)*$',
'badge' => $setup_tasks_remaining ? $setup_tasks_remaining : null,
);
}
$customers_item = array();
if ( Features::is_enabled( 'analytics' ) ) {
$customers_item = array(
'id' => 'woocommerce-analytics-customers',
'title' => __( 'Customers', 'woocommerce' ),
'url' => 'wc-admin&path=/customers',
'order' => 50,
);
}
$add_product_mvp = array();
if ( Features::is_enabled( 'new-product-management-experience' ) ) {
$add_product_mvp = array(
'id' => 'woocommerce-add-product-mbp',
'title' => __( 'Add New (MVP)', 'woocommerce' ),
'url' => 'admin.php?page=wc-admin&path=/add-product',
'parent' => 'woocommerce-products',
'order' => 50,
);
}
return array_merge(
array(
$home_item,
$customers_item,
$order_items['all'],
$order_items['new'],
$product_items['all'],
$product_cat_items['default'],
$product_tag_items['default'],
array(
'id' => 'woocommerce-product-attributes',
'title' => __( 'Attributes', 'woocommerce' ),
'url' => 'edit.php?post_type=product&page=product_attributes',
'capability' => 'manage_product_terms',
'order' => 40,
'parent' => 'woocommerce-products',
'matchExpression' => 'edit.php(?=.*[?|&]page=product_attributes(&|$|#))|edit-tags.php(?=.*[?|&]taxonomy=pa_)(?=.*[?|&]post_type=product(&|$|#))',
),
array_merge( $product_items['new'], array( 'order' => 50 ) ),
$coupon_items['default'],
// Marketplace category.
array(
'title' => __( 'Marketplace', 'woocommerce' ),
'capability' => 'manage_woocommerce',
'id' => 'woocommerce-marketplace',
'url' => 'wc-addons',
'menuId' => 'secondary',
'order' => 10,
),
$add_product_mvp,
),
// Tools category.
self::get_tool_items(),
// WooCommerce Admin items.
$wca_items,
// Settings category.
$setting_items,
// Legacy report items.
self::get_legacy_report_items()
);
}
/**
* Supplies menu items for orders.
*
* This varies depending on whether we are actively using traditional post type-based orders or the new custom
* table-based orders.
*
* @return ?array
*/
private static function get_order_menu_items(): ?array {
if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
return Menu::get_post_type_items( 'shop_order', array( 'parent' => 'woocommerce-orders' ) );
}
$main_orders_menu = array(
'title' => __( 'Orders', 'woocommerce' ),
'capability' => 'edit_others_shop_orders',
'id' => 'woocommerce-orders-default',
'url' => 'admin.php?page=wc-orders',
'parent' => 'woocommerce-orders',
);
$all_orders_entry = $main_orders_menu;
$all_orders_entry['id'] = 'woocommerce-orders-all-items';
$all_orders_entry['order'] = 10;
$new_orders_entry = $main_orders_menu;
$new_orders_entry['title'] = __( 'Add order', 'woocommerce' );
$new_orders_entry['id'] = 'woocommerce-orders-add-item';
$new_orders_entry['url'] = 'admin.php?page=TBD';
$new_orders_entry['order'] = 20;
return array(
'default' => $main_orders_menu,
'all' => $all_orders_entry,
'new' => $new_orders_entry,
);
}
/**
* Get items for tools category.
*
* @return array
*/
public static function get_tool_items() {
$tabs = array(
'status' => __( 'System status', 'woocommerce' ),
'tools' => __( 'Utilities', 'woocommerce' ),
'logs' => __( 'Logs', 'woocommerce' ),
);
$tabs = apply_filters( 'woocommerce_admin_status_tabs', $tabs );
$order = 1;
$items = array(
array(
'parent' => 'woocommerce-tools',
'title' => __( 'Import / Export', 'woocommerce' ),
'capability' => 'import',
'id' => 'tools-import-export',
'url' => 'import.php',
'migrate' => false,
'order' => 0,
),
);
foreach ( $tabs as $key => $tab ) {
$items[] = array(
'parent' => 'woocommerce-tools',
'title' => $tab,
'capability' => 'manage_woocommerce',
'id' => 'tools-' . $key,
'url' => 'wc-status&tab=' . $key,
'order' => $order,
);
$order++;
}
return $items;
}
/**
* Get legacy report items.
*
* @return array
*/
public static function get_legacy_report_items() {
$reports = \WC_Admin_Reports::get_reports();
$menu_items = array();
$order = 0;
foreach ( $reports as $key => $report ) {
$menu_items[] = array(
'parent' => 'woocommerce-reports',
'title' => $report['title'],
'capability' => 'view_woocommerce_reports',
'id' => $key,
'url' => 'wc-reports&tab=' . $key,
'order' => $order,
);
$order++;
}
return $menu_items;
}
/**
* Register all core post types.
*/
public function register_post_types() {
Screen::register_post_type( 'shop_order' );
Screen::register_post_type( 'product' );
Screen::register_post_type( 'shop_coupon' );
}
/**
* Add the dashboard items to the WP menu to create a quick-access flyout menu.
*/
public function add_dashboard_menu_items() {
global $submenu, $menu;
$mapped_items = Menu::get_mapped_menu_items();
$top_level = $mapped_items['woocommerce'];
// phpcs:disable
if ( ! isset( $submenu['woocommerce'] ) || empty( $top_level ) ) {
return;
}
$menuIds = array(
'primary',
'secondary',
'favorites',
);
foreach ( $menuIds as $menuId ) {
foreach( $top_level[ $menuId ] as $item ) {
// Skip specific categories.
if (
in_array(
$item['id'],
array(
'woocommerce-tools',
),
true
)
) {
continue;
}
// Use the link from the first item if it's a category.
if ( ! isset( $item['url'] ) ) {
$categoryMenuId = $menuId === 'favorites' ? 'plugins' : $menuId;
$category_items = $mapped_items[ $item['id'] ][ $categoryMenuId ];
if ( ! empty( $category_items ) ) {
$first_item = $category_items[0];
$submenu['woocommerce'][] = array(
$item['title'],
$first_item['capability'],
isset( $first_item['url'] ) ? $first_item['url'] : null,
$item['title'],
);
}
continue;
}
// Show top-level items.
$submenu['woocommerce'][] = array(
$item['title'],
$item['capability'],
isset( $item['url'] ) ? $item['url'] : null,
$item['title'],
);
}
}
// phpcs:enable
}
/**
* Get items excluded from WooCommerce menu migration.
*
* @return array
*/
public static function get_excluded_items() {
$excluded_items = array(
'woocommerce',
'wc-reports',
'wc-settings',
'wc-status',
);
return apply_filters( 'woocommerce_navigation_core_excluded_items', $excluded_items );
}
}
Admin/Features/Navigation/Favorites.php 0000644 00000005171 15153704477 0014145 0 ustar 00 <?php
/**
* WooCommerce Navigation Favorite
*
* @package Woocommerce Navigation
*/
namespace Automattic\WooCommerce\Admin\Features\Navigation;
use Automattic\WooCommerce\Internal\Admin\WCAdminUser;
/**
* Contains logic for the WooCommerce Navigation menu.
*/
class Favorites {
/**
* Array index of menu capability.
*
* @var int
*/
const META_NAME = 'navigation_favorites';
/**
* Favorites instance.
*
* @var Favorites|null
*/
protected static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Set given favorites string to the user meta data.
*
* @param string|number $user_id User id.
* @param array $favorites Array of favorite values to set.
*/
private static function set_meta_value( $user_id, $favorites ) {
WCAdminUser::update_user_data_field( $user_id, self::META_NAME, wp_json_encode( (array) $favorites ) );
}
/**
* Add item to favorites
*
* @param string $item_id Identifier of item to add.
* @param string|number $user_id Identifier of user to add to.
* @return WP_Error|Boolean Throws exception if item already exists.
*/
public static function add_item( $item_id, $user_id ) {
$all_favorites = self::get_all( $user_id );
if ( in_array( $item_id, $all_favorites, true ) ) {
return new \WP_Error(
'woocommerce_favorites_already_exists',
__( 'Favorite already exists', 'woocommerce' )
);
}
$all_favorites[] = $item_id;
self::set_meta_value( $user_id, $all_favorites );
return true;
}
/**
* Remove item from favorites
*
* @param string $item_id Identifier of item to remove.
* @param string|number $user_id Identifier of user to remove from.
* @return \WP_Error|Boolean Throws exception if item does not exist.
*/
public static function remove_item( $item_id, $user_id ) {
$all_favorites = self::get_all( $user_id );
if ( ! in_array( $item_id, $all_favorites, true ) ) {
return new \WP_Error(
'woocommerce_favorites_does_not_exist',
__( 'Favorite item not found', 'woocommerce' )
);
}
$remaining = array_values( array_diff( $all_favorites, [ $item_id ] ) );
self::set_meta_value( $user_id, $remaining );
return true;
}
/**
* Get all registered favorites.
*
* @param string|number $user_id Identifier of user to query.
* @return WP_Error|Array
*/
public static function get_all( $user_id ) {
$response = WCAdminUser::get_user_data_field( $user_id, self::META_NAME );
return $response ? json_decode( $response, true ) : array();
}
}
Admin/Features/Navigation/Init.php 0000644 00000007714 15153704477 0013113 0 ustar 00 <?php
/**
* Navigation Experience
*
* @package Woocommerce Admin
*/
namespace Automattic\WooCommerce\Admin\Features\Navigation;
use Automattic\WooCommerce\Internal\Admin\Survey;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\Navigation\Screen;
use Automattic\WooCommerce\Admin\Features\Navigation\Menu;
use Automattic\WooCommerce\Admin\Features\Navigation\CoreMenu;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Contains logic for the Navigation
*/
class Init {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_navigation_enabled';
/**
* Determines if the feature has been toggled on or off.
*
* @var boolean
*/
protected static $is_updated = false;
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_opt_out_scripts' ) );
if ( Features::is_enabled( 'navigation' ) ) {
Menu::instance()->init();
CoreMenu::instance()->init();
Screen::instance()->init();
}
}
/**
* Add the feature toggle to the features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
return $features;
}
/**
* Determine if sufficient versions are present to support Navigation feature
*/
public function is_nav_compatible() {
include_once ABSPATH . 'wp-admin/includes/plugin.php';
$gutenberg_minimum_version = '9.0.0'; // https://github.com/WordPress/gutenberg/releases/tag/v9.0.0.
$wp_minimum_version = '5.6';
$has_gutenberg = is_plugin_active( 'gutenberg/gutenberg.php' );
$gutenberg_version = $has_gutenberg ? get_plugin_data( WP_PLUGIN_DIR . '/gutenberg/gutenberg.php' )['Version'] : false;
if ( $gutenberg_version && version_compare( $gutenberg_version, $gutenberg_minimum_version, '>=' ) ) {
return true;
}
// Get unmodified $wp_version.
include ABSPATH . WPINC . '/version.php';
// Strip '-src' from the version string. Messes up version_compare().
$wp_version = str_replace( '-src', '', $wp_version );
if ( version_compare( $wp_version, $wp_minimum_version, '>=' ) ) {
return true;
}
return false;
}
/**
* Reloads the page when the option is toggled to make sure all nav features are loaded.
*
* @param string $old_value Old value.
* @param string $value New value.
*/
public static function reload_page_on_toggle( $old_value, $value ) {
if ( $old_value === $value ) {
return;
}
if ( 'yes' !== $value ) {
update_option( 'woocommerce_navigation_show_opt_out', 'yes' );
}
self::$is_updated = true;
}
/**
* Reload the page if the setting has been updated.
*/
public static function maybe_reload_page() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) {
return;
}
wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) );
exit();
}
/**
* Enqueue the opt out scripts.
*/
public function maybe_enqueue_opt_out_scripts() {
if ( get_option( 'woocommerce_navigation_show_opt_out', 'no' ) !== 'yes' ) {
return;
}
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style(
'wc-admin-navigation-opt-out',
WCAdminAssets::get_url( "navigation-opt-out/style{$rtl}", 'css' ),
array( 'wp-components' ),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'navigation-opt-out', true );
wp_localize_script(
'wc-admin-navigation-opt-out',
'surveyData',
array(
'url' => Survey::get_url( '/new-navigation-opt-out' ),
)
);
delete_option( 'woocommerce_navigation_show_opt_out' );
}
}
Admin/Features/Navigation/Menu.php 0000644 00000054435 15153704477 0013116 0 ustar 00 <?php
/**
* WooCommerce Navigation Menu
*
* @package Woocommerce Navigation
*/
namespace Automattic\WooCommerce\Admin\Features\Navigation;
use Automattic\WooCommerce\Admin\Features\Navigation\Favorites;
use Automattic\WooCommerce\Admin\Features\Navigation\Screen;
use Automattic\WooCommerce\Admin\Features\Navigation\CoreMenu;
/**
* Contains logic for the WooCommerce Navigation menu.
*/
class Menu {
/**
* Class instance.
*
* @var Menu instance
*/
protected static $instance = null;
/**
* Array index of menu capability.
*
* @var int
*/
const CAPABILITY = 1;
/**
* Array index of menu callback.
*
* @var int
*/
const CALLBACK = 2;
/**
* Array index of menu callback.
*
* @var int
*/
const SLUG = 3;
/**
* Array index of menu CSS class string.
*
* @var int
*/
const CSS_CLASSES = 4;
/**
* Array of usable menu IDs.
*/
const MENU_IDS = array(
'primary',
'favorites',
'plugins',
'secondary',
);
/**
* Store menu items.
*
* @var array
*/
protected static $menu_items = array();
/**
* Store categories with menu item IDs.
*
* @var array
*/
protected static $categories = array(
'woocommerce' => array(),
);
/**
* Registered callbacks or URLs with migration boolean as key value pairs.
*
* @var array
*/
protected static $callbacks = array();
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'admin_menu', array( $this, 'add_core_items' ), 100 );
add_filter( 'admin_enqueue_scripts', array( $this, 'enqueue_data' ), 20 );
add_filter( 'admin_menu', array( $this, 'migrate_core_child_items' ), PHP_INT_MAX - 1 );
add_filter( 'admin_menu', array( $this, 'migrate_menu_items' ), PHP_INT_MAX - 2 );
}
/**
* Convert a WordPress menu callback to a URL.
*
* @param string $callback Menu callback.
* @return string
*/
public static function get_callback_url( $callback ) {
// Return the full URL.
if ( strpos( $callback, 'http' ) === 0 ) {
return $callback;
}
$pos = strpos( $callback, '?' );
$file = $pos > 0 ? substr( $callback, 0, $pos ) : $callback;
if ( file_exists( ABSPATH . "/wp-admin/$file" ) ) {
return $callback;
}
return 'admin.php?page=' . $callback;
}
/**
* Get the parent key if one exists.
*
* @param string $callback Callback or URL.
* @return string|null
*/
public static function get_parent_key( $callback ) {
global $submenu;
if ( ! $submenu ) {
return null;
}
// This is already a parent item.
if ( isset( $submenu[ $callback ] ) ) {
return null;
}
foreach ( $submenu as $key => $menu ) {
foreach ( $menu as $item ) {
if ( $item[ self::CALLBACK ] === $callback ) {
return $key;
}
}
}
return null;
}
/**
* Adds a top level menu item to the navigation.
*
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'id' => (string) The unique ID of the menu item. Required.
* 'title' => (string) Title of the menu item. Required.
* 'url' => (string) URL or callback to be used. Required.
* 'order' => (int) Menu item order.
* 'migrate' => (bool) Whether or not to hide the item in the wp admin menu.
* 'menuId' => (string) The ID of the menu to add the category to.
* ).
*/
private static function add_category( $args ) {
if ( ! isset( $args['id'] ) || isset( self::$menu_items[ $args['id'] ] ) ) {
return;
}
$defaults = array(
'id' => '',
'title' => '',
'order' => 100,
'migrate' => true,
'menuId' => 'primary',
'isCategory' => true,
);
$menu_item = wp_parse_args( $args, $defaults );
$menu_item['title'] = wp_strip_all_tags( wp_specialchars_decode( $menu_item['title'] ) );
unset( $menu_item['url'] );
unset( $menu_item['capability'] );
if ( ! isset( $menu_item['parent'] ) ) {
$menu_item['parent'] = 'woocommerce';
$menu_item['backButtonLabel'] = __(
'WooCommerce Home',
'woocommerce'
);
}
self::$menu_items[ $menu_item['id'] ] = $menu_item;
self::$categories[ $menu_item['id'] ] = array();
self::$categories[ $menu_item['parent'] ][] = $menu_item['id'];
if ( isset( $args['url'] ) ) {
self::$callbacks[ $args['url'] ] = $menu_item['migrate'];
}
}
/**
* Adds a child menu item to the navigation.
*
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'id' => (string) The unique ID of the menu item. Required.
* 'title' => (string) Title of the menu item. Required.
* 'parent' => (string) Parent menu item ID.
* 'capability' => (string) Capability to view this menu item.
* 'url' => (string) URL or callback to be used. Required.
* 'order' => (int) Menu item order.
* 'migrate' => (bool) Whether or not to hide the item in the wp admin menu.
* 'menuId' => (string) The ID of the menu to add the item to.
* 'matchExpression' => (string) A regular expression used to identify if the menu item is active.
* ).
*/
private static function add_item( $args ) {
if ( ! isset( $args['id'] ) ) {
return;
}
if ( isset( self::$menu_items[ $args['id'] ] ) ) {
error_log( // phpcs:ignore
sprintf(
/* translators: 1: Duplicate menu item path. */
esc_html__( 'You have attempted to register a duplicate item with WooCommerce Navigation: %1$s', 'woocommerce' ),
'`' . $args['id'] . '`'
)
);
return;
}
$defaults = array(
'id' => '',
'title' => '',
'capability' => 'manage_woocommerce',
'url' => '',
'order' => 100,
'migrate' => true,
'menuId' => 'primary',
);
$menu_item = wp_parse_args( $args, $defaults );
$menu_item['title'] = wp_strip_all_tags( wp_specialchars_decode( $menu_item['title'] ) );
$menu_item['url'] = self::get_callback_url( $menu_item['url'] );
if ( ! isset( $menu_item['parent'] ) ) {
$menu_item['parent'] = 'woocommerce';
}
$menu_item['menuId'] = self::get_item_menu_id( $menu_item );
self::$menu_items[ $menu_item['id'] ] = $menu_item;
self::$categories[ $menu_item['parent'] ][] = $menu_item['id'];
if ( isset( $args['url'] ) ) {
self::$callbacks[ $args['url'] ] = $menu_item['migrate'];
}
}
/**
* Get an item's menu ID from its parent.
*
* @param array $item Item args.
* @return string
*/
public static function get_item_menu_id( $item ) {
$favorites = Favorites::get_all( get_current_user_id() );
if ( is_array( $favorites ) && ! empty( $favorites ) && in_array( $item['id'], $favorites, true ) ) {
return 'favorites';
}
if ( isset( $item['parent'] ) && isset( self::$menu_items[ $item['parent'] ] ) ) {
$menu_id = self::$menu_items[ $item['parent'] ]['menuId'];
return 'favorites' === $menu_id
? 'plugins'
: $menu_id;
}
return $item['menuId'];
}
/**
* Adds a plugin category.
*
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'id' => (string) The unique ID of the menu item. Required.
* 'title' => (string) Title of the menu item. Required.
* 'url' => (string) URL or callback to be used. Required.
* 'migrate' => (bool) Whether or not to hide the item in the wp admin menu.
* 'order' => (int) Menu item order.
* ).
*/
public static function add_plugin_category( $args ) {
$category_args = array_merge(
$args,
array(
'menuId' => 'plugins',
)
);
if ( ! isset( $category_args['parent'] ) ) {
unset( $category_args['order'] );
}
$menu_id = self::get_item_menu_id( $category_args );
if ( ! in_array( $menu_id, array( 'plugins', 'favorites' ), true ) ) {
return;
}
$category_args['menuId'] = $menu_id;
self::add_category( $category_args );
}
/**
* Adds a plugin item.
*
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'id' => (string) The unique ID of the menu item. Required.
* 'title' => (string) Title of the menu item. Required.
* 'parent' => (string) Parent menu item ID.
* 'capability' => (string) Capability to view this menu item.
* 'url' => (string) URL or callback to be used. Required.
* 'migrate' => (bool) Whether or not to hide the item in the wp admin menu.
* 'order' => (int) Menu item order.
* 'matchExpression' => (string) A regular expression used to identify if the menu item is active.
* ).
*/
public static function add_plugin_item( $args ) {
if ( ! isset( $args['parent'] ) ) {
unset( $args['order'] );
}
$item_args = array_merge(
$args,
array(
'menuId' => 'plugins',
)
);
$menu_id = self::get_item_menu_id( $item_args );
if ( 'plugins' !== $menu_id ) {
return;
}
self::add_item( $item_args );
}
/**
* Adds a plugin setting item.
*
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'id' => (string) The unique ID of the menu item. Required.
* 'title' => (string) Title of the menu item. Required.
* 'capability' => (string) Capability to view this menu item.
* 'url' => (string) URL or callback to be used. Required.
* 'migrate' => (bool) Whether or not to hide the item in the wp admin menu.
* ).
*/
public static function add_setting_item( $args ) {
unset( $args['order'] );
if ( isset( $args['parent'] ) || isset( $args['menuId'] ) ) {
error_log( // phpcs:ignore
sprintf(
/* translators: 1: Duplicate menu item path. */
esc_html__( 'The item ID %1$s attempted to register using an invalid option. The arguments `menuId` and `parent` are not allowed for add_setting_item()', 'woocommerce' ),
'`' . $args['id'] . '`'
)
);
}
$item_args = array_merge(
$args,
array(
'menuId' => 'secondary',
'parent' => 'woocommerce-settings',
)
);
self::add_item( $item_args );
}
/**
* Get menu item templates for a given post type.
*
* @param string $post_type Post type to add.
* @param array $menu_args Arguments merged with the returned menu items.
* @return array
*/
public static function get_post_type_items( $post_type, $menu_args = array() ) {
$post_type_object = get_post_type_object( $post_type );
if ( ! $post_type_object || ! $post_type_object->show_in_menu ) {
return;
}
$parent = isset( $menu_args['parent'] ) ? $menu_args['parent'] . '-' : '';
$match_expression = isset( $_GET['post'] ) && get_post_type( intval( $_GET['post'] ) ) === $post_type // phpcs:ignore WordPress.Security.NonceVerification
? '(edit.php|post.php)'
: null;
return array(
'default' => array_merge(
array(
'title' => esc_attr( $post_type_object->labels->menu_name ),
'capability' => $post_type_object->cap->edit_posts,
'id' => $parent . $post_type,
'url' => "edit.php?post_type={$post_type}",
'matchExpression' => $match_expression,
),
$menu_args
),
'all' => array_merge(
array(
'title' => esc_attr( $post_type_object->labels->all_items ),
'capability' => $post_type_object->cap->edit_posts,
'id' => "{$parent}{$post_type}-all-items",
'url' => "edit.php?post_type={$post_type}",
'order' => 10,
'matchExpression' => $match_expression,
),
$menu_args
),
'new' => array_merge(
array(
'title' => esc_attr( $post_type_object->labels->add_new ),
'capability' => $post_type_object->cap->create_posts,
'id' => "{$parent}{$post_type}-add-new",
'url' => "post-new.php?post_type={$post_type}",
'order' => 20,
),
$menu_args
),
);
}
/**
* Get menu item templates for a given taxonomy.
*
* @param string $taxonomy Taxonomy to add.
* @param array $menu_args Arguments merged with the returned menu items.
* @return array
*/
public static function get_taxonomy_items( $taxonomy, $menu_args = array() ) {
$taxonomy_object = get_taxonomy( $taxonomy );
if ( ! $taxonomy_object || ! $taxonomy_object->show_in_menu ) {
return;
}
$parent = isset( $menu_args['parent'] ) ? $menu_args['parent'] . '-' : '';
$product_type_query = ! empty( $taxonomy_object->object_type )
? "&post_type={$taxonomy_object->object_type[0]}"
: '';
$match_expression = 'term.php'; // Match term.php pages.
$match_expression .= "(?=.*[?|&]taxonomy={$taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param.
$match_expression .= '|'; // Or.
$match_expression .= 'edit-tags.php'; // Match edit-tags.php pages.
$match_expression .= "(?=.*[?|&]taxonomy={$taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param.
return array(
'default' => array_merge(
array(
'title' => esc_attr( $taxonomy_object->labels->menu_name ),
'capability' => $taxonomy_object->cap->edit_terms,
'id' => $parent . $taxonomy,
'url' => "edit-tags.php?taxonomy={$taxonomy}{$product_type_query}",
'matchExpression' => $match_expression,
),
$menu_args
),
'all' => array_merge(
array(
'title' => esc_attr( $taxonomy_object->labels->all_items ),
'capability' => $taxonomy_object->cap->edit_terms,
'id' => "{$parent}{$taxonomy}-all-items",
'url' => "edit-tags.php?taxonomy={$taxonomy}{$product_type_query}",
'matchExpression' => $match_expression,
'order' => 10,
),
$menu_args
),
);
}
/**
* Add core menu items.
*/
public function add_core_items() {
$categories = CoreMenu::get_categories();
foreach ( $categories as $category ) {
self::add_category( $category );
}
$items = CoreMenu::get_items();
foreach ( $items as $item ) {
if ( isset( $item['is_category'] ) && $item['is_category'] ) {
self::add_category( $item );
} else {
self::add_item( $item );
}
}
}
/**
* Add an item or taxonomy.
*
* @param array $menu_item Menu item.
*/
public function add_item_and_taxonomy( $menu_item ) {
if ( in_array( $menu_item[2], CoreMenu::get_excluded_items(), true ) ) {
return;
}
$menu_item[2] = htmlspecialchars_decode( $menu_item[2] );
// Don't add already added items.
$callbacks = self::get_callbacks();
if ( array_key_exists( $menu_item[2], $callbacks ) ) {
return;
}
// Don't add these Product submenus because they are added elsewhere.
if ( in_array( $menu_item[2], array( 'product_importer', 'product_exporter', 'product_attributes' ), true ) ) {
return;
}
self::add_plugin_item(
array(
'title' => $menu_item[0],
'capability' => $menu_item[1],
'id' => sanitize_title( $menu_item[0] ),
'url' => $menu_item[2],
)
);
// Determine if migrated items are a taxonomy or post_type. If they are, register them.
$parsed_url = wp_parse_url( $menu_item[2] );
$query_string = isset( $parsed_url['query'] ) ? $parsed_url['query'] : false;
if ( $query_string ) {
$query = array();
parse_str( $query_string, $query );
if ( isset( $query['taxonomy'] ) ) {
Screen::register_taxonomy( $query['taxonomy'] );
} elseif ( isset( $query['post_type'] ) ) {
Screen::register_post_type( $query['post_type'] );
}
}
}
/**
* Migrate any remaining WooCommerce child items.
*
* @param array $menu Menu items.
* @return array
*/
public function migrate_core_child_items( $menu ) {
global $submenu;
if ( ! isset( $submenu['woocommerce'] ) && ! isset( $submenu['edit.php?post_type=product'] ) ) {
return $menu;
}
$main_items = isset( $submenu['woocommerce'] ) ? $submenu['woocommerce'] : array();
$product_items = isset( $submenu['edit.php?post_type=product'] ) ? $submenu['edit.php?post_type=product'] : array();
foreach ( $main_items as $key => $menu_item ) {
self::add_item_and_taxonomy( $menu_item );
// phpcs:disable
if ( ! isset( $menu_item[ self::CSS_CLASSES ] ) ) {
$submenu['woocommerce'][ $key ][] .= ' hide-if-js';
} else if ( strpos( $submenu['woocommerce'][ $key ][ self::CSS_CLASSES ], 'hide-if-js' ) !== false ) {
continue;
} else {
$submenu['woocommerce'][ $key ][ self::CSS_CLASSES ] .= ' hide-if-js';
}
// phpcs:enable
}
foreach ( $product_items as $key => $menu_item ) {
self::add_item_and_taxonomy( $menu_item );
}
return $menu;
}
/**
* Check if a menu item's callback is registered in the menu.
*
* @param array $menu_item Menu item args.
* @return bool
*/
public static function has_callback( $menu_item ) {
if ( ! $menu_item || ! isset( $menu_item[ self::CALLBACK ] ) ) {
return false;
}
$callback = $menu_item[ self::CALLBACK ];
if (
isset( self::$callbacks[ $callback ] ) &&
self::$callbacks[ $callback ]
) {
return true;
}
if (
isset( self::$callbacks[ self::get_callback_url( $callback ) ] ) &&
self::$callbacks[ self::get_callback_url( $callback ) ]
) {
return true;
}
return false;
}
/**
* Hides all WP admin menus items and adds screen IDs to check for new items.
*/
public static function migrate_menu_items() {
global $menu, $submenu;
foreach ( $menu as $key => $menu_item ) {
if ( self::has_callback( $menu_item ) ) {
// Disable phpcs since we need to override submenu classes.
// Note that `phpcs:ignore WordPress.Variables.GlobalVariables.OverrideProhibited` does not work to disable this check.
// phpcs:disable
$menu[ $key ][ self::CSS_CLASSES ] .= ' hide-if-js';
// phps:enable
continue;
}
// WordPress core menus make the parent item the same URL as the first child.
$has_children = isset( $submenu[ $menu_item[ self::CALLBACK ] ] ) && isset( $submenu[ $menu_item[ self::CALLBACK ] ][0] );
$first_child = $has_children ? $submenu[ $menu_item[ self::CALLBACK ] ][0] : null;
if ( 'woocommerce' !== $menu_item[2] && self::has_callback( $first_child ) ) {
// Disable phpcs since we need to override submenu classes.
// Note that `phpcs:ignore WordPress.Variables.GlobalVariables.OverrideProhibited` does not work to disable this check.
// phpcs:disable
$menu[ $key ][ self::CSS_CLASSES ] .= ' hide-if-js';
// phps:enable
}
}
// Remove excluded submenu items
if ( isset( $submenu['woocommerce'] ) ) {
foreach ( $submenu['woocommerce'] as $key => $submenu_item ) {
if ( in_array( $submenu_item[ self::CALLBACK ], CoreMenu::get_excluded_items(), true ) ) {
if ( isset( $submenu['woocommerce'][ $key ][ self::CSS_CLASSES ] ) ) {
$submenu['woocommerce'][ $key ][ self::CSS_CLASSES ] .= ' hide-if-js';
} else {
$submenu['woocommerce'][ $key ][] = 'hide-if-js';
}
}
}
}
foreach ( $submenu as $parent_key => $parent ) {
foreach ( $parent as $key => $menu_item ) {
if ( self::has_callback( $menu_item ) ) {
// Disable phpcs since we need to override submenu classes.
// Note that `phpcs:ignore WordPress.Variables.GlobalVariables.OverrideProhibited` does not work to disable this check.
// phpcs:disable
if ( ! isset( $menu_item[ self::SLUG ] ) ) {
$submenu[ $parent_key ][ $key ][] = '';
}
if ( ! isset( $menu_item[ self::CSS_CLASSES ] ) ) {
$submenu[ $parent_key ][ $key ][] .= ' hide-if-js';
} else {
$submenu[ $parent_key ][ $key ][ self::CSS_CLASSES ] .= ' hide-if-js';
}
// phps:enable
}
}
}
foreach ( array_keys( self::$callbacks ) as $callback ) {
Screen::add_screen( $callback );
}
}
/**
* Add a callback to identify and hide pages in the WP menu.
*/
public static function hide_wp_menu_item( $callback ) {
self::$callbacks[ $callback ] = true;
}
/**
* Get registered menu items.
*
* @return array
*/
public static function get_items() {
return apply_filters( 'woocommerce_navigation_menu_items', self::$menu_items );
}
/**
* Get registered menu items.
*
* @return array
*/
public static function get_category_items( $category ) {
if ( ! isset( self::$categories[ $category ] ) ) {
return array();
}
$menu_item_ids = self::$categories[ $category ];
$category_menu_items = array();
foreach ( $menu_item_ids as $id ) {
if ( isset( self::$menu_items[ $id ] ) ) {
$category_menu_items[] = self::$menu_items[ $id ];
}
}
return apply_filters( 'woocommerce_navigation_menu_category_items', $category_menu_items );
}
/**
* Get registered callbacks.
*
* @return array
*/
public static function get_callbacks() {
return apply_filters( 'woocommerce_navigation_callbacks', self::$callbacks );
}
/**
* Gets the menu item data mapped by category and menu ID.
*
* @return array
*/
public static function get_mapped_menu_items() {
$menu_items = self::get_items();
$mapped_items = array();
// Sort the items by order and title.
$order = array_column( $menu_items, 'order' );
$title = array_column( $menu_items, 'title' );
array_multisort( $order, SORT_ASC, $title, SORT_ASC, $menu_items );
foreach ( $menu_items as $id => $menu_item ) {
$category_id = $menu_item[ 'parent' ];
$menu_id = $menu_item[ 'menuId' ];
if ( ! isset( $mapped_items[ $category_id ] ) ) {
$mapped_items[ $category_id ] = array();
foreach ( self::MENU_IDS as $available_menu_id ) {
$mapped_items[ $category_id ][ $available_menu_id ] = array();
}
}
// Incorrect menu ID.
if ( ! isset( $mapped_items[ $category_id ][ $menu_id ] ) ) {
continue;
}
// Remove the item if the user cannot access it.
if ( isset( $menu_item[ 'capability' ] ) && ! current_user_can( $menu_item[ 'capability' ] ) ) {
continue;
}
$mapped_items[ $category_id ][ $menu_id ][] = $menu_item;
}
return $mapped_items;
}
/**
* Add the menu to the page output.
*
* @param array $menu Menu items.
* @return array
*/
public function enqueue_data( $menu ) {
$data = array(
'menuItems' => array_values( self::get_items() ),
'rootBackUrl' => get_dashboard_url(),
);
wp_add_inline_script( WC_ADMIN_APP, 'window.wcNavigation = ' . wp_json_encode( $data ), 'before' );
}
}
Admin/Features/Navigation/Screen.php 0000644 00000013106 15153704477 0013417 0 ustar 00 <?php
/**
* WooCommerce Navigation Screen
*
* @package Woocommerce Navigation
*/
namespace Automattic\WooCommerce\Admin\Features\Navigation;
use Automattic\WooCommerce\Admin\Features\Navigation\Menu;
/**
* Contains logic for the WooCommerce Navigation menu.
*/
class Screen {
/**
* Class instance.
*
* @var Screen instance
*/
protected static $instance = null;
/**
* Screen IDs of registered pages.
*
* @var array
*/
protected static $screen_ids = array();
/**
* Registered post types.
*
* @var array
*/
protected static $post_types = array();
/**
* Registered taxonomies.
*
* @var array
*/
protected static $taxonomies = array();
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_filter( 'admin_body_class', array( $this, 'add_body_class' ) );
}
/**
* Returns an array of filtered screen ids.
*/
public static function get_screen_ids() {
return apply_filters( 'woocommerce_navigation_screen_ids', self::$screen_ids );
}
/**
* Returns an array of registered post types.
*/
public static function get_post_types() {
return apply_filters( 'woocommerce_navigation_post_types', self::$post_types );
}
/**
* Returns an array of registered post types.
*/
public static function get_taxonomies() {
return apply_filters( 'woocommerce_navigation_taxonomies', self::$taxonomies );
}
/**
* Check if we're on a WooCommerce page
*
* @return bool
*/
public static function is_woocommerce_page() {
global $pagenow;
// Get taxonomy if on a taxonomy screen.
$taxonomy = '';
if ( in_array( $pagenow, array( 'edit-tags.php', 'term.php' ), true ) ) {
if ( isset( $_GET['taxonomy'] ) ) { // phpcs:ignore CSRF ok.
$taxonomy = sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ); // phpcs:ignore CSRF ok.
}
}
$taxonomies = self::get_taxonomies();
// Get post type if on a post screen.
$post_type = '';
if ( in_array( $pagenow, array( 'edit.php', 'post.php', 'post-new.php' ), true ) ) {
if ( isset( $_GET['post'] ) ) { // phpcs:ignore CSRF ok.
$post_type = get_post_type( (int) $_GET['post'] ); // phpcs:ignore CSRF ok.
} elseif ( isset( $_GET['post_type'] ) ) { // phpcs:ignore CSRF ok.
$post_type = sanitize_text_field( wp_unslash( $_GET['post_type'] ) ); // phpcs:ignore CSRF ok.
}
}
$post_types = self::get_post_types();
// Get current screen ID.
$current_screen = get_current_screen();
$screen_ids = self::get_screen_ids();
$current_screen_id = $current_screen ? $current_screen->id : null;
if (
in_array( $post_type, $post_types, true ) ||
in_array( $taxonomy, $taxonomies, true ) ||
self::is_woocommerce_core_taxonomy( $taxonomy ) ||
in_array( $current_screen_id, $screen_ids, true )
) {
return true;
}
return false;
}
/**
* Check if a given taxonomy is a WooCommerce core related taxonomy.
*
* @param string $taxonomy Taxonomy.
* @return bool
*/
public static function is_woocommerce_core_taxonomy( $taxonomy ) {
if ( in_array( $taxonomy, array( 'product_cat', 'product_tag' ), true ) ) {
return true;
}
if ( 'pa_' === substr( $taxonomy, 0, 3 ) ) {
return true;
}
return false;
}
/**
* Add navigation classes to body.
*
* @param string $classes Classes.
* @return string
*/
public function add_body_class( $classes ) {
if ( self::is_woocommerce_page() ) {
$classes .= ' has-woocommerce-navigation';
/**
* Adds the ability to skip disabling of the WP toolbar.
*
* @param boolean $bool WP Toolbar disabled.
*/
if ( apply_filters( 'woocommerce_navigation_wp_toolbar_disabled', true ) ) {
$classes .= ' is-wp-toolbar-disabled';
}
}
return $classes;
}
/**
* Adds a screen ID to the list of screens that use the navigtion.
* Finds the parent if none is given to grab the correct screen ID.
*
* @param string $callback Callback or URL for page.
* @param string|null $parent Parent screen ID.
*/
public static function add_screen( $callback, $parent = null ) {
global $submenu;
$plugin_page = self::get_plugin_page( $callback );
if ( ! $parent ) {
$parent = Menu::get_parent_key( $callback );
}
$screen_id = get_plugin_page_hookname( $plugin_page, $parent );
// This screen has already been added.
if ( in_array( $screen_id, self::$screen_ids, true ) ) {
return;
}
self::$screen_ids[] = $screen_id;
}
/**
* Get the plugin page slug.
*
* @param string $callback Callback.
* @return string
*/
public static function get_plugin_page( $callback ) {
$url = Menu::get_callback_url( $callback );
$parts = wp_parse_url( $url );
if ( ! isset( $parts['query'] ) ) {
return $callback;
}
parse_str( $parts['query'], $query );
if ( ! isset( $query['page'] ) ) {
return $callback;
}
$plugin_page = wp_unslash( $query['page'] );
$plugin_page = plugin_basename( $plugin_page );
return $plugin_page;
}
/**
* Register post type for use in WooCommerce Navigation screens.
*
* @param string $post_type Post type to add.
*/
public static function register_post_type( $post_type ) {
if ( ! in_array( $post_type, self::$post_types, true ) ) {
self::$post_types[] = $post_type;
}
}
/**
* Register taxonomy for use in WooCommerce Navigation screens.
*
* @param string $taxonomy Taxonomy to add.
*/
public static function register_taxonomy( $taxonomy ) {
if ( ! in_array( $taxonomy, self::$taxonomies, true ) ) {
self::$taxonomies[] = $taxonomy;
}
}
}
Admin/Features/NewProductManagementExperience.php 0000644 00000005022 15153704477 0016176 0 ustar 00 <?php
/**
* WooCommerce New Product Management Experience
*/
namespace Automattic\WooCommerce\Admin\Features;
use Automattic\WooCommerce\Admin\Features\TransientNotices;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
use WP_Block_Editor_Context;
/**
* Loads assets related to the new product management experience page.
*/
class NewProductManagementExperience {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_new_product_management_enabled';
/**
* Constructor
*/
public function __construct() {
$this->maybe_show_disabled_notice();
if ( ! Features::is_enabled( 'new-product-management-experience' ) ) {
return;
}
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
add_action( 'get_edit_post_link', array( $this, 'update_edit_product_link' ), 10, 2 );
}
/**
* Maybe show disabled notice.
*/
public function maybe_show_disabled_notice() {
$new_product_experience_param = 'new-product-experience-disabled';
if ( isset( $_GET[ $new_product_experience_param ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
TransientNotices::add(
array(
'user_id' => get_current_user_id(),
'id' => 'new-product-experience-disbled',
'status' => 'success',
'content' => __( '🌟 Thanks for the feedback. We’ll put it to good use!', 'woocommerce' ),
)
);
$url = isset( $_SERVER['REQUEST_URI'] ) ? wc_clean( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$url = remove_query_arg( 'new-product-experience-disabled', $url );
wp_safe_redirect( $url );
exit;
}
}
/**
* Enqueue styles needed for the rich text editor.
*/
public function enqueue_styles() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
wp_enqueue_style( 'wp-edit-blocks' );
wp_enqueue_style( 'wp-format-library' );
wp_enqueue_editor();
/**
* Enqueue any block editor related assets.
*
* @since 7.1.0
*/
do_action( 'enqueue_block_editor_assets' );
}
/**
* Update the edit product links when the new experience is enabled.
*
* @param string $link The edit link.
* @param int $post_id Post ID.
* @return string
*/
public function update_edit_product_link( $link, $post_id ) {
$product = wc_get_product( $post_id );
if ( ! $product ) {
return $link;
}
if ( $product->get_type() === 'simple' ) {
return admin_url( 'admin.php?page=wc-admin&path=/product/' . $product->get_id() );
}
return $link;
}
}
Admin/Features/Onboarding.php 0000644 00000006356 15153704477 0012174 0 ustar 00 <?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Admin\Features;
use Automattic\WooCommerce\Admin\DeprecatedClassFacade;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*
* @deprecated since 6.3.0, use WooCommerce\Internal\Admin\Onboarding.
*/
class Onboarding extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Features\Onboarding';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '6.3.0';
/**
* Hook into WooCommerce.
*/
public function __construct() {
}
/**
* Get a list of allowed industries for the onboarding wizard.
*
* @deprecated 6.3.0
* @return array
*/
public static function get_allowed_industries() {
wc_deprecated_function( 'get_allowed_industries', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingIndustries::get_allowed_industries()' );
return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries::get_allowed_industries();
}
/**
* Get a list of allowed product types for the onboarding wizard.
*
* @deprecated 6.3.0
* @return array
*/
public static function get_allowed_product_types() {
wc_deprecated_function( 'get_allowed_product_types', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingProducts::get_allowed_product_types()' );
return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts::get_allowed_product_types();
}
/**
* Get a list of themes for the onboarding wizard.
*
* @deprecated 6.3.0
* @return array
*/
public static function get_themes() {
wc_deprecated_function( 'get_themes', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingThemes::get_themes()' );
return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes::get_themes();
}
/**
* Get theme data used in onboarding theme browser.
*
* @deprecated 6.3.0
* @param WP_Theme $theme Theme to gather data from.
* @return array
*/
public static function get_theme_data( $theme ) {
wc_deprecated_function( 'get_theme_data', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingThemes::get_theme_data()' );
return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes::get_theme_data();
}
/**
* Gets an array of themes that can be installed & activated via the onboarding wizard.
*
* @deprecated 6.3.0
* @return array
*/
public static function get_allowed_themes() {
wc_deprecated_function( 'get_allowed_themes', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingThemes::get_allowed_themes()' );
return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes::get_allowed_themes();
}
/**
* Get dynamic product data from API.
*
* @deprecated 6.3.0
* @param array $product_types Array of product types.
* @return array
*/
public static function get_product_data( $product_types ) {
wc_deprecated_function( 'get_product_data', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingProducts::get_product_data()' );
return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts::get_product_data();
}
}
Admin/Features/OnboardingTasks/DeprecatedExtendedTask.php 0000644 00000006322 15153704477 0017537 0 ustar 00 <?php
/**
* A temporary class for creating tasks on the fly from deprecated tasks.
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
/**
* DeprecatedExtendedTask class.
*/
class DeprecatedExtendedTask extends Task {
/**
* ID.
*
* @var string
*/
public $id = '';
/**
* Additional info.
*
* @var string|null
*/
public $additional_info = '';
/**
* Content.
*
* @var string
*/
public $content = '';
/**
* Whether the task is complete or not.
*
* @var boolean
*/
public $is_complete = false;
/**
* Snoozeable.
*
* @var boolean
*/
public $is_snoozeable = false;
/**
* Dismissable.
*
* @var boolean
*/
public $is_dismissable = false;
/**
* Whether the store is capable of viewing the task.
*
* @var bool
*/
public $can_view = true;
/**
* Level.
*
* @var int
*/
public $level = 3;
/**
* Time.
*
* @var string|null
*/
public $time;
/**
* Title.
*
* @var string
*/
public $title = '';
/**
* Constructor.
*
* @param TaskList $task_list Parent task list.
* @param array $args Array of task args.
*/
public function __construct( $task_list, $args ) {
parent::__construct( $task_list );
$task_args = wp_parse_args(
$args,
array(
'id' => null,
'is_dismissable' => false,
'is_snoozeable' => false,
'can_view' => true,
'level' => 3,
'additional_info' => null,
'content' => '',
'title' => '',
'is_complete' => false,
'time' => null,
)
);
$this->id = $task_args['id'];
$this->additional_info = $task_args['additional_info'];
$this->content = $task_args['content'];
$this->is_complete = $task_args['is_complete'];
$this->is_dismissable = $task_args['is_dismissable'];
$this->is_snoozeable = $task_args['is_snoozeable'];
$this->can_view = $task_args['can_view'];
$this->level = $task_args['level'];
$this->time = $task_args['time'];
$this->title = $task_args['title'];
}
/**
* ID.
*
* @return string
*/
public function get_id() {
return $this->id;
}
/**
* Additional info.
*
* @return string
*/
public function get_additional_info() {
return $this->additional_info;
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return $this->content;
}
/**
* Level.
*
* @return int
*/
public function get_level() {
return $this->level;
}
/**
* Title
*
* @return string
*/
public function get_title() {
return $this->title;
}
/**
* Time
*
* @return string|null
*/
public function get_time() {
return $this->time;
}
/**
* Check if a task is snoozeable.
*
* @return bool
*/
public function is_snoozeable() {
return $this->is_snoozeable;
}
/**
* Check if a task is dismissable.
*
* @return bool
*/
public function is_dismissable() {
return $this->is_dismissable;
}
/**
* Check if a task is dismissable.
*
* @return bool
*/
public function is_complete() {
return $this->is_complete;
}
/**
* Check if a task is dismissable.
*
* @return bool
*/
public function can_view() {
return $this->can_view;
}
}
Admin/Features/OnboardingTasks/DeprecatedOptions.php 0000644 00000005000 15153704477 0016577 0 ustar 00 <?php
/**
* Filters for maintaining backwards compatibility with deprecated options.
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\TaskList;
use WC_Install;
/**
* DeprecatedOptions class.
*/
class DeprecatedOptions {
/**
* Initialize.
*/
public static function init() {
add_filter( 'pre_option_woocommerce_task_list_hidden', array( __CLASS__, 'get_deprecated_options' ), 10, 2 );
add_filter( 'pre_option_woocommerce_extended_task_list_hidden', array( __CLASS__, 'get_deprecated_options' ), 10, 2 );
add_action( 'pre_update_option_woocommerce_task_list_hidden', array( __CLASS__, 'update_deprecated_options' ), 10, 3 );
add_action( 'pre_update_option_woocommerce_extended_task_list_hidden', array( __CLASS__, 'update_deprecated_options' ), 10, 3 );
}
/**
* Get the values from the correct source when attempting to retrieve deprecated options.
*
* @param string $pre_option Pre option value.
* @param string $option Option name.
* @return string
*/
public static function get_deprecated_options( $pre_option, $option ) {
if ( defined( 'WC_INSTALLING' ) && WC_INSTALLING === true ) {
return $pre_option;
}
$hidden = get_option( 'woocommerce_task_list_hidden_lists', array() );
switch ( $option ) {
case 'woocommerce_task_list_hidden':
return in_array( 'setup', $hidden, true ) ? 'yes' : 'no';
case 'woocommerce_extended_task_list_hidden':
return in_array( 'extended', $hidden, true ) ? 'yes' : 'no';
}
}
/**
* Updates the new option names when deprecated options are updated.
* This is a temporary fallback until we can fully remove the old task list components.
*
* @param string $value New value.
* @param string $old_value Old value.
* @param string $option Option name.
* @return string
*/
public static function update_deprecated_options( $value, $old_value, $option ) {
switch ( $option ) {
case 'woocommerce_task_list_hidden':
$task_list = TaskLists::get_list( 'setup' );
if ( ! $task_list ) {
return;
}
$update = 'yes' === $value ? $task_list->hide() : $task_list->unhide();
delete_option( 'woocommerce_task_list_hidden' );
return false;
case 'woocommerce_extended_task_list_hidden':
$task_list = TaskLists::get_list( 'extended' );
if ( ! $task_list ) {
return;
}
$update = 'yes' === $value ? $task_list->hide() : $task_list->unhide();
delete_option( 'woocommerce_extended_task_list_hidden' );
return false;
}
}
}
Admin/Features/OnboardingTasks/Init.php 0000644 00000002116 15153704477 0014073 0 ustar 00 <?php
/**
* WooCommerce Onboarding Tasks
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedOptions;
/**
* Contains the logic for completing onboarding tasks.
*/
class Init {
/**
* Class instance.
*
* @var OnboardingTasks instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
public function __construct() {
DeprecatedOptions::init();
TaskLists::init();
}
/**
* Get task item data for settings filter.
*
* @return array
*/
public static function get_settings() {
$settings = array();
$wc_pay_is_connected = false;
if ( class_exists( '\WC_Payments' ) ) {
$wc_payments_gateway = \WC_Payments::get_gateway();
$wc_pay_is_connected = method_exists( $wc_payments_gateway, 'is_connected' )
? $wc_payments_gateway->is_connected()
: false;
}
return $settings;
}
}
Admin/Features/OnboardingTasks/Task.php 0000644 00000030111 15153704477 0014066 0 ustar 00 <?php
/**
* Handles task related methods.
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
use Automattic\WooCommerce\Internal\Admin\WCAdminUser;
/**
* Task class.
*/
abstract class Task {
/**
* Task traits.
*/
use TaskTraits;
/**
* Name of the dismiss option.
*
* @var string
*/
const DISMISSED_OPTION = 'woocommerce_task_list_dismissed_tasks';
/**
* Name of the snooze option.
*
* @var string
*
* @deprecated 7.2.0
*/
const SNOOZED_OPTION = 'woocommerce_task_list_remind_me_later_tasks';
/**
* Name of the actioned option.
*
* @var string
*/
const ACTIONED_OPTION = 'woocommerce_task_list_tracked_completed_actions';
/**
* Option name of completed tasks.
*
* @var string
*/
const COMPLETED_OPTION = 'woocommerce_task_list_tracked_completed_tasks';
/**
* Name of the active task transient.
*
* @var string
*/
const ACTIVE_TASK_TRANSIENT = 'wc_onboarding_active_task';
/**
* Parent task list.
*
* @var TaskList
*/
protected $task_list;
/**
* Duration to milisecond mapping.
*
* @var string
*/
protected $duration_to_ms = array(
'day' => DAY_IN_SECONDS * 1000,
'hour' => HOUR_IN_SECONDS * 1000,
'week' => WEEK_IN_SECONDS * 1000,
);
/**
* Constructor
*
* @param TaskList|null $task_list Parent task list.
*/
public function __construct( $task_list = null ) {
$this->task_list = $task_list;
}
/**
* ID.
*
* @return string
*/
abstract public function get_id();
/**
* Title.
*
* @return string
*/
abstract public function get_title();
/**
* Content.
*
* @return string
*/
abstract public function get_content();
/**
* Time.
*
* @return string
*/
abstract public function get_time();
/**
* Parent ID.
*
* @return string
*/
public function get_parent_id() {
if ( ! $this->task_list ) {
return '';
}
return $this->task_list->get_list_id();
}
/**
* Get task list options.
*
* @return array
*/
public function get_parent_options() {
if ( ! $this->task_list ) {
return array();
}
return $this->task_list->options;
}
/**
* Get custom option.
*
* @param string $option_name name of custom option.
* @return mixed|null
*/
public function get_parent_option( $option_name ) {
if ( $this->task_list && isset( $this->task_list->options[ $option_name ] ) ) {
return $this->task_list->options[ $option_name ];
}
return null;
}
/**
* Prefix event for track event naming.
*
* @param string $event_name Event name.
* @return string
*/
public function prefix_event( $event_name ) {
if ( ! $this->task_list ) {
return '';
}
return $this->task_list->prefix_event( $event_name );
}
/**
* Additional info.
*
* @return string
*/
public function get_additional_info() {
return '';
}
/**
* Additional data.
*
* @return mixed
*/
public function get_additional_data() {
return null;
}
/**
* Badge.
*
* @return string
*/
public function get_badge() {
return '';
}
/**
* Level.
*
* @deprecated 7.2.0
*
* @return string
*/
public function get_level() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
return 3;
}
/**
* Action label.
*
* @return string
*/
public function get_action_label() {
return __( "Let's go", 'woocommerce' );
}
/**
* Action URL.
*
* @return string
*/
public function get_action_url() {
return null;
}
/**
* Check if a task is dismissable.
*
* @return bool
*/
public function is_dismissable() {
return false;
}
/**
* Bool for task dismissal.
*
* @return bool
*/
public function is_dismissed() {
if ( ! $this->is_dismissable() ) {
return false;
}
$dismissed = get_option( self::DISMISSED_OPTION, array() );
return in_array( $this->get_id(), $dismissed, true );
}
/**
* Dismiss the task.
*
* @return bool
*/
public function dismiss() {
if ( ! $this->is_dismissable() ) {
return false;
}
$dismissed = get_option( self::DISMISSED_OPTION, array() );
$dismissed[] = $this->get_id();
$update = update_option( self::DISMISSED_OPTION, array_unique( $dismissed ) );
if ( $update ) {
$this->record_tracks_event( 'dismiss_task', array( 'task_name' => $this->get_id() ) );
}
return $update;
}
/**
* Undo task dismissal.
*
* @return bool
*/
public function undo_dismiss() {
$dismissed = get_option( self::DISMISSED_OPTION, array() );
$dismissed = array_diff( $dismissed, array( $this->get_id() ) );
$update = update_option( self::DISMISSED_OPTION, $dismissed );
if ( $update ) {
$this->record_tracks_event( 'undo_dismiss_task', array( 'task_name' => $this->get_id() ) );
}
return $update;
}
/**
* Check if a task is snoozeable.
*
* @deprecated 7.2.0
*
* @return bool
*/
public function is_snoozeable() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
return false;
}
/**
* Get the snoozed until datetime.
*
* @deprecated 7.2.0
*
* @return string
*/
public function get_snoozed_until() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
$snoozed_tasks = get_option( self::SNOOZED_OPTION, array() );
if ( isset( $snoozed_tasks[ $this->get_id() ] ) ) {
return $snoozed_tasks[ $this->get_id() ];
}
return null;
}
/**
* Bool for task snoozed.
*
* @deprecated 7.2.0
*
* @return bool
*/
public function is_snoozed() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
if ( ! $this->is_snoozeable() ) {
return false;
}
$snoozed = get_option( self::SNOOZED_OPTION, array() );
return isset( $snoozed[ $this->get_id() ] ) && $snoozed[ $this->get_id() ] > ( time() * 1000 );
}
/**
* Snooze the task.
*
* @param string $duration Duration to snooze. day|hour|week.
*
* @deprecated 7.2.0
*
* @return bool
*/
public function snooze( $duration = 'day' ) {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
if ( ! $this->is_snoozeable() ) {
return false;
}
$snoozed = get_option( self::SNOOZED_OPTION, array() );
$snoozed_until = $this->duration_to_ms[ $duration ] + ( time() * 1000 );
$snoozed[ $this->get_id() ] = $snoozed_until;
$update = update_option( self::SNOOZED_OPTION, $snoozed );
if ( $update ) {
if ( $update ) {
$this->record_tracks_event( 'remindmelater_task', array( 'task_name' => $this->get_id() ) );
}
}
return $update;
}
/**
* Undo task snooze.
*
* @deprecated 7.2.0
*
* @return bool
*/
public function undo_snooze() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
$snoozed = get_option( self::SNOOZED_OPTION, array() );
unset( $snoozed[ $this->get_id() ] );
$update = update_option( self::SNOOZED_OPTION, $snoozed );
if ( $update ) {
$this->record_tracks_event( 'undo_remindmelater_task', array( 'task_name' => $this->get_id() ) );
}
return $update;
}
/**
* Check if a task list has previously been marked as complete.
*
* @return bool
*/
public function has_previously_completed() {
$complete = get_option( self::COMPLETED_OPTION, array() );
return in_array( $this->get_id(), $complete, true );
}
/**
* Track task completion if task is viewable.
*/
public function possibly_track_completion() {
if ( ! $this->is_complete() ) {
return;
}
if ( $this->has_previously_completed() ) {
return;
}
$completed_tasks = get_option( self::COMPLETED_OPTION, array() );
$completed_tasks[] = $this->get_id();
update_option( self::COMPLETED_OPTION, $completed_tasks );
$this->record_tracks_event( 'task_completed', array( 'task_name' => $this->get_id() ) );
}
/**
* Set this as the active task across page loads.
*/
public function set_active() {
if ( $this->is_complete() ) {
return;
}
set_transient(
self::ACTIVE_TASK_TRANSIENT,
$this->get_id(),
DAY_IN_SECONDS
);
}
/**
* Check if this is the active task.
*/
public function is_active() {
return get_transient( self::ACTIVE_TASK_TRANSIENT ) === $this->get_id();
}
/**
* Check if the store is capable of viewing the task.
*
* @return bool
*/
public function can_view() {
return true;
}
/**
* Check if task is disabled.
*
* @deprecated 7.2.0
*
* @return bool
*/
public function is_disabled() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
return false;
}
/**
* Check if the task is complete.
*
* @return bool
*/
public function is_complete() {
return self::is_actioned();
}
/**
* Check if the task has been visited.
*
* @return bool
*/
public function is_visited() {
$user_id = get_current_user_id();
$response = WCAdminUser::get_user_data_field( $user_id, 'task_list_tracked_started_tasks' );
$tracked_tasks = $response ? json_decode( $response, true ) : array();
return isset( $tracked_tasks[ $this->get_id() ] ) && $tracked_tasks[ $this->get_id() ] > 0;
}
/**
* Check if should record event when task is viewed
*
* @return bool
*/
public function get_record_view_event(): bool {
return false;
}
/**
* Get the task as JSON.
*
* @return array
*/
public function get_json() {
$this->possibly_track_completion();
return array(
'id' => $this->get_id(),
'parentId' => $this->get_parent_id(),
'title' => $this->get_title(),
'badge' => $this->get_badge(),
'canView' => $this->can_view(),
'content' => $this->get_content(),
'additionalInfo' => $this->get_additional_info(),
'actionLabel' => $this->get_action_label(),
'actionUrl' => $this->get_action_url(),
'isComplete' => $this->is_complete(),
'time' => $this->get_time(),
'level' => 3,
'isActioned' => $this->is_actioned(),
'isDismissed' => $this->is_dismissed(),
'isDismissable' => $this->is_dismissable(),
'isSnoozed' => false,
'isSnoozeable' => false,
'isVisited' => $this->is_visited(),
'isDisabled' => false,
'snoozedUntil' => null,
'additionalData' => self::convert_object_to_camelcase( $this->get_additional_data() ),
'eventPrefix' => $this->prefix_event( '' ),
'recordViewEvent' => $this->get_record_view_event(),
);
}
/**
* Convert object keys to camelcase.
*
* @param array $data Data to convert.
* @return object
*/
public static function convert_object_to_camelcase( $data ) {
if ( ! is_array( $data ) ) {
return $data;
}
$new_object = (object) array();
foreach ( $data as $key => $value ) {
$new_key = lcfirst( implode( '', array_map( 'ucfirst', explode( '_', $key ) ) ) );
$new_object->$new_key = $value;
}
return $new_object;
}
/**
* Mark a task as actioned. Used to verify an action has taken place in some tasks.
*
* @return bool
*/
public function mark_actioned() {
$actioned = get_option( self::ACTIONED_OPTION, array() );
$actioned[] = $this->get_id();
$update = update_option( self::ACTIONED_OPTION, array_unique( $actioned ) );
if ( $update ) {
$this->record_tracks_event( 'actioned_task', array( 'task_name' => $this->get_id() ) );
}
return $update;
}
/**
* Check if a task has been actioned.
*
* @return bool
*/
public function is_actioned() {
return self::is_task_actioned( $this->get_id() );
}
/**
* Check if a provided task ID has been actioned.
*
* @param string $id Task ID.
* @return bool
*/
public static function is_task_actioned( $id ) {
$actioned = get_option( self::ACTIONED_OPTION, array() );
return in_array( $id, $actioned, true );
}
/**
* Sorting function for tasks.
*
* @param Task $a Task a.
* @param Task $b Task b.
* @param array $sort_by list of columns with sort order.
* @return int
*/
public static function sort( $a, $b, $sort_by = array() ) {
$result = 0;
foreach ( $sort_by as $data ) {
$key = $data['key'];
$a_val = $a->$key ?? false;
$b_val = $b->$key ?? false;
if ( 'asc' === $data['order'] ) {
$result = $a_val <=> $b_val;
} else {
$result = $b_val <=> $a_val;
}
if ( 0 !== $result ) {
break;
}
}
return $result;
}
}
Admin/Features/OnboardingTasks/TaskList.php 0000644 00000022162 15153704477 0014731 0 ustar 00 <?php
/**
* Handles storage and retrieval of a task list
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\WCAdminHelper;
/**
* Task List class.
*/
class TaskList {
/**
* Task traits.
*/
use TaskTraits;
/**
* Option name hidden task lists.
*/
const HIDDEN_OPTION = 'woocommerce_task_list_hidden_lists';
/**
* Option name of completed task lists.
*/
const COMPLETED_OPTION = 'woocommerce_task_list_completed_lists';
/**
* Option name of hidden reminder bar.
*/
const REMINDER_BAR_HIDDEN_OPTION = 'woocommerce_task_list_reminder_bar_hidden';
/**
* ID.
*
* @var string
*/
public $id = '';
/**
* ID.
*
* @var string
*/
public $hidden_id = '';
/**
* ID.
*
* @var boolean
*/
public $display_progress_header = false;
/**
* Title.
*
* @var string
*/
public $title = '';
/**
* Tasks.
*
* @var array
*/
public $tasks = array();
/**
* Sort keys.
*
* @var array
*/
public $sort_by = array();
/**
* Event prefix.
*
* @var string|null
*/
public $event_prefix = null;
/**
* Task list visibility.
*
* @var boolean
*/
public $visible = true;
/**
* Array of custom options.
*
* @var array
*/
public $options = array();
/**
* Array of TaskListSection.
*
* @deprecated 7.2.0
*
* @var array
*/
private $sections = array();
/**
* Key value map of task class and id used for sections.
*
* @deprecated 7.2.0
*
* @var array
*/
public $task_class_id_map = array();
/**
* Constructor
*
* @param array $data Task list data.
*/
public function __construct( $data = array() ) {
$defaults = array(
'id' => null,
'hidden_id' => null,
'title' => '',
'tasks' => array(),
'sort_by' => array(),
'event_prefix' => null,
'options' => array(),
'visible' => true,
'display_progress_header' => false,
);
$data = wp_parse_args( $data, $defaults );
$this->id = $data['id'];
$this->hidden_id = $data['hidden_id'];
$this->title = $data['title'];
$this->sort_by = $data['sort_by'];
$this->event_prefix = $data['event_prefix'];
$this->options = $data['options'];
$this->visible = $data['visible'];
$this->display_progress_header = $data['display_progress_header'];
foreach ( $data['tasks'] as $task_name ) {
$class = 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\\' . $task_name;
$task = new $class( $this );
$this->add_task( $task );
}
$this->possibly_remove_reminder_bar();
}
/**
* Check if the task list is hidden.
*
* @return bool
*/
public function is_hidden() {
$hidden = get_option( self::HIDDEN_OPTION, array() );
return in_array( $this->hidden_id ? $this->hidden_id : $this->id, $hidden, true );
}
/**
* Check if the task list is visible.
*
* @return bool
*/
public function is_visible() {
if ( ! $this->visible || ! count( $this->get_viewable_tasks() ) > 0 ) {
return false;
}
return ! $this->is_hidden();
}
/**
* Hide the task list.
*
* @return bool
*/
public function hide() {
if ( $this->is_hidden() ) {
return;
}
$viewable_tasks = $this->get_viewable_tasks();
$completed_count = array_reduce(
$viewable_tasks,
function( $total, $task ) {
return $task->is_complete() ? $total + 1 : $total;
},
0
);
$this->record_tracks_event(
'completed',
array(
'action' => 'remove_card',
'completed_task_count' => $completed_count,
'incomplete_task_count' => count( $viewable_tasks ) - $completed_count,
)
);
$hidden = get_option( self::HIDDEN_OPTION, array() );
$hidden[] = $this->hidden_id ? $this->hidden_id : $this->id;
$this->maybe_set_default_layout( $hidden );
return update_option( self::HIDDEN_OPTION, array_unique( $hidden ) );
}
/**
* Sets the default homepage layout to two_columns if "setup" tasklist is completed or hidden.
*
* @param array $completed_or_hidden_tasklist_ids Array of tasklist ids.
*/
public function maybe_set_default_layout( $completed_or_hidden_tasklist_ids ) {
if ( in_array( 'setup', $completed_or_hidden_tasklist_ids, true ) ) {
update_option( 'woocommerce_default_homepage_layout', 'two_columns' );
}
}
/**
* Undo hiding of the task list.
*
* @return bool
*/
public function unhide() {
$hidden = get_option( self::HIDDEN_OPTION, array() );
$hidden = array_diff( $hidden, array( $this->hidden_id ? $this->hidden_id : $this->id ) );
return update_option( self::HIDDEN_OPTION, $hidden );
}
/**
* Check if all viewable tasks are complete.
*
* @return bool
*/
public function is_complete() {
foreach ( $this->get_viewable_tasks() as $viewable_task ) {
if ( $viewable_task->is_complete() === false ) {
return false;
}
}
return true;
}
/**
* Check if a task list has previously been marked as complete.
*
* @return bool
*/
public function has_previously_completed() {
$complete = get_option( self::COMPLETED_OPTION, array() );
return in_array( $this->get_list_id(), $complete, true );
}
/**
* Add task to the task list.
*
* @param Task $task Task class.
*/
public function add_task( $task ) {
if ( ! is_subclass_of( $task, 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task' ) ) {
return new \WP_Error(
'woocommerce_task_list_invalid_task',
__( 'Task is not a subclass of `Task`', 'woocommerce' )
);
}
if ( array_search( $task, $this->tasks, true ) ) {
return;
}
$this->tasks[] = $task;
}
/**
* Get only visible tasks in list.
*
* @param string $task_id id of task.
* @return Task
*/
public function get_task( $task_id ) {
return current(
array_filter(
$this->tasks,
function( $task ) use ( $task_id ) {
return $task->get_id() === $task_id;
}
)
);
}
/**
* Get only visible tasks in list.
*
* @return array
*/
public function get_viewable_tasks() {
return array_values(
array_filter(
$this->tasks,
function( $task ) {
return $task->can_view();
}
)
);
}
/**
* Get task list sections.
*
* @deprecated 7.2.0
*
* @return array
*/
public function get_sections() {
wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' );
return $this->sections;
}
/**
* Track list completion of viewable tasks.
*/
public function possibly_track_completion() {
if ( ! $this->is_complete() ) {
return;
}
if ( $this->has_previously_completed() ) {
return;
}
$completed_lists = get_option( self::COMPLETED_OPTION, array() );
$completed_lists[] = $this->get_list_id();
update_option( self::COMPLETED_OPTION, $completed_lists );
$this->maybe_set_default_layout( $completed_lists );
$this->record_tracks_event( 'tasks_completed' );
}
/**
* Sorts the attached tasks array.
*
* @param array $sort_by list of columns with sort order.
* @return TaskList returns $this, for chaining.
*/
public function sort_tasks( $sort_by = array() ) {
$sort_by = count( $sort_by ) > 0 ? $sort_by : $this->sort_by;
if ( 0 !== count( $sort_by ) ) {
usort(
$this->tasks,
function( $a, $b ) use ( $sort_by ) {
return Task::sort( $a, $b, $sort_by );
}
);
}
return $this;
}
/**
* Prefix event for track event naming.
*
* @param string $event_name Event name.
* @return string
*/
public function prefix_event( $event_name ) {
if ( null !== $this->event_prefix ) {
return $this->event_prefix . $event_name;
}
return $this->get_list_id() . '_tasklist_' . $event_name;
}
/**
* Returns option to keep completed task list.
*
* @return string
*/
public function get_keep_completed_task_list() {
return get_option( 'woocommerce_task_list_keep_completed', 'no' );
}
/**
* Remove reminder bar four weeks after store creation.
*/
public static function possibly_remove_reminder_bar() {
$bar_hidden = get_option( self::REMINDER_BAR_HIDDEN_OPTION, 'no' );
$active_for_four_weeks = WCAdminHelper::is_wc_admin_active_for( WEEK_IN_SECONDS * 4 );
if ( 'yes' === $bar_hidden || ! $active_for_four_weeks ) {
return;
}
update_option( self::REMINDER_BAR_HIDDEN_OPTION, 'yes' );
}
/**
* Get the list for use in JSON.
*
* @return array
*/
public function get_json() {
$this->possibly_track_completion();
$tasks_json = array();
foreach ( $this->tasks as $task ) {
$json = $task->get_json();
if ( $json['canView'] ) {
$tasks_json[] = $json;
}
}
return array(
'id' => $this->get_list_id(),
'title' => $this->title,
'isHidden' => $this->is_hidden(),
'isVisible' => $this->is_visible(),
'isComplete' => $this->is_complete(),
'tasks' => $tasks_json,
'eventPrefix' => $this->prefix_event( '' ),
'displayProgressHeader' => $this->display_progress_header,
'keepCompletedTaskList' => $this->get_keep_completed_task_list(),
);
}
}
Admin/Features/OnboardingTasks/TaskListSection.php 0000644 00000004466 15153704477 0016265 0 ustar 00 <?php
/**
* Handles storage and retrieval of a task list section
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
/**
* Task List section class.
*
* @deprecated 7.2.0
*/
class TaskListSection {
/**
* Title.
*
* @var string
*/
public $id = '';
/**
* Title.
*
* @var string
*/
public $title = '';
/**
* Description.
*
* @var string
*/
public $description = '';
/**
* Image.
*
* @var string
*/
public $image = '';
/**
* Tasks.
*
* @var array
*/
public $task_names = array();
/**
* Parent task list.
*
* @var TaskList
*/
protected $task_list;
/**
* Constructor
*
* @param array $data Task list data.
* @param TaskList|null $task_list Parent task list.
*/
public function __construct( $data = array(), $task_list = null ) {
$defaults = array(
'id' => '',
'title' => '',
'description' => '',
'image' => '',
'tasks' => array(),
);
$data = wp_parse_args( $data, $defaults );
$this->task_list = $task_list;
$this->id = $data['id'];
$this->title = $data['title'];
$this->description = $data['description'];
$this->image = $data['image'];
$this->task_names = $data['task_names'];
}
/**
* Returns if section is complete.
*
* @return boolean;
*/
private function is_complete() {
$complete = true;
foreach ( $this->task_names as $task_name ) {
if ( null !== $this->task_list && isset( $this->task_list->task_class_id_map[ $task_name ] ) ) {
$task = $this->task_list->get_task( $this->task_list->task_class_id_map[ $task_name ] );
if ( $task->can_view() && ! $task->is_complete() ) {
$complete = false;
break;
}
}
}
return $complete;
}
/**
* Get the list for use in JSON.
*
* @return array
*/
public function get_json() {
return array(
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'image' => $this->image,
'tasks' => array_map(
function( $task_name ) {
if ( null !== $this->task_list && isset( $this->task_list->task_class_id_map[ $task_name ] ) ) {
return $this->task_list->task_class_id_map[ $task_name ];
}
return '';
},
$this->task_names
),
'isComplete' => $this->is_complete(),
);
}
}
Admin/Features/OnboardingTasks/TaskLists.php 0000644 00000025177 15153704477 0015125 0 ustar 00 <?php
/**
* Handles storage and retrieval of task lists
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\ReviewShippingOptions;
/**
* Task Lists class.
*/
class TaskLists {
/**
* Class instance.
*
* @var TaskLists instance
*/
protected static $instance = null;
/**
* An array of all registered lists.
*
* @var array
*/
protected static $lists = array();
/**
* Boolean value to indicate if default tasks have been added.
*
* @var boolean
*/
protected static $default_tasks_loaded = false;
/**
* The contents of this array is used in init_tasks() to run their init() methods.
* If the classes do not have an init() method then nothing is executed.
* Beyond that, adding tasks to this list has no effect, see init_default_lists() for the list of tasks.
* that are added for each task list.
*
* @var array
*/
const DEFAULT_TASKS = array(
'StoreDetails',
'Products',
'WooCommercePayments',
'Payments',
'Tax',
'Shipping',
'Marketing',
'Appearance',
'AdditionalPayments',
'ReviewShippingOptions',
'GetMobileApp',
);
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Initialize the task lists.
*/
public static function init() {
self::init_default_lists();
add_action( 'admin_init', array( __CLASS__, 'set_active_task' ), 5 );
add_action( 'init', array( __CLASS__, 'init_tasks' ) );
add_action( 'admin_menu', array( __CLASS__, 'menu_task_count' ) );
add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'task_list_preloaded_settings' ), 20 );
}
/**
* Check if an experiment is the treatment or control.
*
* @param string $name Name prefix of experiment.
* @return bool
*/
public static function is_experiment_treatment( $name ) {
$anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : '';
$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking' );
$abtest = new \WooCommerce\Admin\Experimental_Abtest(
$anon_id,
'woocommerce',
$allow_tracking
);
$date = new \DateTime();
$date->setTimeZone( new \DateTimeZone( 'UTC' ) );
$experiment_name = sprintf(
'%s_%s_%s',
$name,
$date->format( 'Y' ),
$date->format( 'm' )
);
return $abtest->get_variation( $experiment_name ) === 'treatment';
}
/**
* Initialize default lists.
*/
public static function init_default_lists() {
$tasks = array(
'CustomizeStore',
'StoreDetails',
'Products',
'Appearance',
'WooCommercePayments',
'Payments',
'Tax',
'Shipping',
'Marketing',
);
if ( Features::is_enabled( 'core-profiler' ) ) {
$key = array_search( 'StoreDetails', $tasks, true );
if ( false !== $key ) {
unset( $tasks[ $key ] );
}
}
// Remove the old Personalize your store task if the new CustomizeStore is enabled.
$task_to_remove = Features::is_enabled( 'customize-store' ) ? 'Appearance' : 'CustomizeStore';
$store_customisation_task_index = array_search( $task_to_remove, $tasks, true );
if ( false !== $store_customisation_task_index ) {
unset( $tasks[ $store_customisation_task_index ] );
}
self::add_list(
array(
'id' => 'setup',
'title' => __( 'Get ready to start selling', 'woocommerce' ),
'tasks' => $tasks,
'display_progress_header' => true,
'event_prefix' => 'tasklist_',
'options' => array(
'use_completed_title' => true,
),
'visible' => true,
)
);
self::add_list(
array(
'id' => 'extended',
'title' => __( 'Things to do next', 'woocommerce' ),
'sort_by' => array(
array(
'key' => 'is_complete',
'order' => 'asc',
),
array(
'key' => 'level',
'order' => 'asc',
),
),
'tasks' => array(
'AdditionalPayments',
'GetMobileApp',
),
)
);
if ( Features::is_enabled( 'shipping-smart-defaults' ) ) {
self::add_task(
'extended',
new ReviewShippingOptions(
self::get_list( 'extended' )
)
);
// Tasklist that will never be shown in homescreen,
// used for having tasks that are accessed by other means.
self::add_list(
array(
'id' => 'secret_tasklist',
'hidden_id' => 'setup',
'tasks' => array(
'ExperimentalShippingRecommendation',
),
'event_prefix' => 'secret_tasklist_',
'visible' => false,
)
);
}
if ( has_filter( 'woocommerce_admin_experimental_onboarding_tasklists' ) ) {
/**
* Filter to override default task lists.
*
* @since 7.4
* @param array $lists Array of tasklists.
*/
self::$lists = apply_filters( 'woocommerce_admin_experimental_onboarding_tasklists', self::$lists );
}
}
/**
* Initialize tasks.
*/
public static function init_tasks() {
foreach ( self::DEFAULT_TASKS as $task ) {
$class = 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\\' . $task;
if ( ! method_exists( $class, 'init' ) ) {
continue;
}
$class::init();
}
}
/**
* Temporarily store the active task to persist across page loads when necessary.
* Most tasks do not need this.
*/
public static function set_active_task() {
if ( ! isset( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) || ! current_user_can( 'manage_woocommerce' ) ) { // phpcs:ignore csrf ok.
return;
}
$referer = wp_get_referer();
if ( ! $referer || 0 !== strpos( $referer, wc_admin_url() ) ) {
return;
}
$task_id = sanitize_title_with_dashes( wp_unslash( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) ); // phpcs:ignore csrf ok.
$task = self::get_task( $task_id );
if ( ! $task ) {
return;
}
$task->set_active();
}
/**
* Add a task list.
*
* @param array $args Task list properties.
* @return \WP_Error|TaskList
*/
public static function add_list( $args ) {
if ( isset( self::$lists[ $args['id'] ] ) ) {
return new \WP_Error(
'woocommerce_task_list_exists',
__( 'Task list ID already exists', 'woocommerce' )
);
}
self::$lists[ $args['id'] ] = new TaskList( $args );
return self::$lists[ $args['id'] ];
}
/**
* Add task to a given task list.
*
* @param string $list_id List ID to add the task to.
* @param Task $task Task object.
*
* @return \WP_Error|Task
*/
public static function add_task( $list_id, $task ) {
if ( ! isset( self::$lists[ $list_id ] ) ) {
return new \WP_Error(
'woocommerce_task_list_invalid_list',
__( 'Task list ID does not exist', 'woocommerce' )
);
}
self::$lists[ $list_id ]->add_task( $task );
}
/**
* Add default extended task lists.
*
* @param array $extended_tasks list of extended tasks.
*/
public static function maybe_add_extended_tasks( $extended_tasks ) {
$tasks = $extended_tasks ?? array();
foreach ( self::$lists as $task_list ) {
if ( 'extended' !== substr( $task_list->id, 0, 8 ) ) {
continue;
}
foreach ( $tasks as $args ) {
$task = new DeprecatedExtendedTask( $task_list, $args );
$task_list->add_task( $task );
}
}
}
/**
* Get all task lists.
*
* @return array
*/
public static function get_lists() {
return self::$lists;
}
/**
* Get all task lists.
*
* @param array $ids list of task list ids.
* @return array
*/
public static function get_lists_by_ids( $ids ) {
return array_filter(
self::$lists,
function( $list ) use ( $ids ) {
return in_array( $list->get_list_id(), $ids, true );
}
);
}
/**
* Get all task list ids.
*
* @return array
*/
public static function get_list_ids() {
return array_keys( self::$lists );
}
/**
* Clear all task lists.
*/
public static function clear_lists() {
self::$lists = array();
return self::$lists;
}
/**
* Get visible task lists.
*/
public static function get_visible() {
return array_filter(
self::get_lists(),
function ( $task_list ) {
return $task_list->is_visible();
}
);
}
/**
* Retrieve a task list by ID.
*
* @param String $id Task list ID.
*
* @return TaskList|null
*/
public static function get_list( $id ) {
if ( isset( self::$lists[ $id ] ) ) {
return self::$lists[ $id ];
}
return null;
}
/**
* Retrieve single task.
*
* @param String $id Task ID.
* @param String $task_list_id Task list ID.
*
* @return Object
*/
public static function get_task( $id, $task_list_id = null ) {
$task_list = $task_list_id ? self::get_list( $task_list_id ) : null;
if ( $task_list_id && ! $task_list ) {
return null;
}
$tasks_to_search = $task_list ? $task_list->tasks : array_reduce(
self::get_lists(),
function ( $all, $curr ) {
return array_merge( $all, $curr->tasks );
},
array()
);
foreach ( $tasks_to_search as $task ) {
if ( $id === $task->get_id() ) {
return $task;
}
}
return null;
}
/**
* Return number of setup tasks remaining
*
* @return number
*/
public static function setup_tasks_remaining() {
$setup_list = self::get_list( 'setup' );
if ( ! $setup_list || $setup_list->is_hidden() || $setup_list->is_complete() ) {
return;
}
$remaining_tasks = array_values(
array_filter(
$setup_list->get_viewable_tasks(),
function( $task ) {
return ! $task->is_complete();
}
)
);
return count( $remaining_tasks );
}
/**
* Add badge to homescreen menu item for remaining tasks
*/
public static function menu_task_count() {
global $submenu;
$tasks_count = self::setup_tasks_remaining();
if ( ! $tasks_count || ! isset( $submenu['woocommerce'] ) ) {
return;
}
foreach ( $submenu['woocommerce'] as $key => $menu_item ) {
if ( 0 === strpos( $menu_item[0], _x( 'Home', 'Admin menu name', 'woocommerce' ) ) ) {
$submenu['woocommerce'][ $key ][0] .= ' <span class="awaiting-mod update-plugins remaining-tasks-badge woocommerce-task-list-remaining-tasks-badge"><span class="count-' . esc_attr( $tasks_count ) . '">' . absint( $tasks_count ) . '</span></span>'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
break;
}
}
}
/**
* Add visible list ids to component settings.
*
* @param array $settings Component settings.
*
* @return array
*/
public static function task_list_preloaded_settings( $settings ) {
$settings['visibleTaskListIds'] = array_keys( self::get_visible() );
return $settings;
}
}
Admin/Features/OnboardingTasks/TaskTraits.php 0000644 00000001713 15153704477 0015263 0 ustar 00 <?php
/**
* Task and TaskList Traits
*/
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks;
defined( 'ABSPATH' ) || exit;
/**
* TaskTraits class.
*/
trait TaskTraits {
/**
* Record a tracks event with the prefixed event name.
*
* @param string $event_name Event name.
* @param array $args Array of tracks arguments.
* @return string Prefixed event name.
*/
public function record_tracks_event( $event_name, $args = array() ) {
if ( ! $this->get_list_id() ) {
return;
}
$prefixed_event_name = $this->prefix_event( $event_name );
wc_admin_record_tracks_event(
$prefixed_event_name,
$args
);
return $prefixed_event_name;
}
/**
* Get the task list ID.
*
* @return string
*/
public function get_list_id() {
$namespaced_class = get_class( $this );
return is_subclass_of( $namespaced_class, 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task' )
? $this->get_parent_id()
: $this->id;
}
}
Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php 0000644 00000011066 15153704477 0020052 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Payments;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\WooCommercePayments;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init;
/**
* Payments Task
*/
class AdditionalPayments extends Payments {
/**
* Used to cache is_complete() method result.
*
* @var null
*/
private $is_complete_result = null;
/**
* Used to cache can_view() method result.
*
* @var null
*/
private $can_view_result = null;
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'payments';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __(
'Set up additional payment options',
'woocommerce'
);
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
'Choose payment providers and enable payment methods at checkout.',
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
if ( null === $this->is_complete_result ) {
$this->is_complete_result = self::has_enabled_additional_gateways();
}
return $this->is_complete_result;
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
if ( ! Features::is_enabled( 'payment-gateway-suggestions' ) ) {
// Hide task if feature not enabled.
return false;
}
if ( null !== $this->can_view_result ) {
return $this->can_view_result;
}
// Show task if woocommerce-payments is connected or if there are any suggested gateways in other category enabled.
$this->can_view_result = (
WooCommercePayments::is_connected() ||
self::has_enabled_other_category_gateways()
);
// Early return if task is not visible.
if ( ! $this->can_view_result ) {
return false;
}
// Show task if there are any suggested gateways in additional category.
$this->can_view_result = ! empty( self::get_suggestion_gateways( 'category_additional' ) );
return $this->can_view_result;
}
/**
* Check if the store has any enabled gateways in other category.
*
* @return bool
*/
private static function has_enabled_other_category_gateways() {
$other_gateways = self::get_suggestion_gateways( 'category_other' );
$other_gateways_ids = wp_list_pluck( $other_gateways, 'id' );
return self::has_enabled_gateways(
function( $gateway ) use ( $other_gateways_ids ) {
return in_array( $gateway->id, $other_gateways_ids, true );
}
);
}
/**
* Check if the store has any enabled gateways in additional category.
*
* @return bool
*/
private static function has_enabled_additional_gateways() {
$additional_gateways = self::get_suggestion_gateways( 'category_additional' );
$additional_gateways_ids = wp_list_pluck( $additional_gateways, 'id' );
return self::has_enabled_gateways(
function( $gateway ) use ( $additional_gateways_ids ) {
return 'yes' === $gateway->enabled
&& in_array( $gateway->id, $additional_gateways_ids, true );
}
);
}
/**
* Check if the store has any enabled gateways based on the given criteria.
*
* @param callable|null $filter A callback function to filter the gateways.
* @return bool
*/
private static function has_enabled_gateways( $filter = null ) {
$gateways = WC()->payment_gateways->get_available_payment_gateways();
$enabled_gateways = array_filter(
$gateways,
function( $gateway ) use ( $filter ) {
if ( is_callable( $filter ) ) {
return 'yes' === $gateway->enabled && call_user_func( $filter, $gateway );
} else {
return 'yes' === $gateway->enabled;
}
}
);
return ! empty( $enabled_gateways );
}
/**
* Get the list of gateways to suggest.
*
* @param string $filter_by Filter by category. "category_additional" or "category_other".
*
* @return array
*/
private static function get_suggestion_gateways( $filter_by = 'category_additional' ) {
$country = wc_get_base_location()['country'];
$plugin_suggestions = Init::get_suggestions();
$plugin_suggestions = array_filter(
$plugin_suggestions,
function( $plugin ) use ( $country, $filter_by ) {
if ( ! isset( $plugin->{$filter_by} ) || ! isset( $plugin->plugins[0] ) ) {
return false;
}
return in_array( $country, $plugin->{$filter_by}, true );
}
);
return $plugin_suggestions;
}
}
Admin/Features/OnboardingTasks/Tasks/Appearance.php 0000644 00000002513 15153704477 0016315 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Products;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Appearance Task
*/
class Appearance extends Task {
/**
* Constructor.
*/
public function __construct() {
if ( ! $this->is_complete() ) {
add_action( 'load-theme-install.php', array( $this, 'mark_actioned' ) );
}
}
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'appearance';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'Choose your theme', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
"Choose a theme that best fits your brand's look and feel, then make it your own. Change the colors, add your logo, and create pages.",
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Action label.
*
* @return string
*/
public function get_action_label() {
return __( 'Choose theme', 'woocommerce' );
}
}
Admin/Features/OnboardingTasks/Tasks/CustomizeStore.php 0000644 00000013237 15153704477 0017262 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Jetpack_Gutenberg;
/**
* Customize Your Store Task
*/
class CustomizeStore extends Task {
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_site_editor_scripts' ) );
add_action( 'after_switch_theme', array( $this, 'mark_task_as_complete' ) );
}
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'customize-store';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'Customize your store ', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return get_option( 'woocommerce_admin_customize_store_completed' ) === 'yes';
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
return true;
}
/**
* Possibly add site editor scripts.
*/
public function possibly_add_site_editor_scripts() {
$is_customize_store_pages = (
isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
isset( $_GET['path'] ) &&
str_starts_with( wc_clean( wp_unslash( $_GET['path'] ) ), '/customize-store' )
);
if ( ! $is_customize_store_pages ) {
return;
}
// See: https://github.com/WordPress/WordPress/blob/master/wp-admin/site-editor.php.
if ( ! wp_is_block_theme() ) {
wp_die( esc_html__( 'The theme you are currently using is not compatible.', 'woocommerce' ) );
}
global $editor_styles;
// Flag that we're loading the block editor.
$current_screen = get_current_screen();
$current_screen->is_block_editor( true );
// Default to is-fullscreen-mode to avoid jumps in the UI.
add_filter(
'admin_body_class',
static function( $classes ) {
return "$classes is-fullscreen-mode";
}
);
$block_editor_context = new \WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) );
$indexed_template_types = array();
foreach ( get_default_block_template_types() as $slug => $template_type ) {
$template_type['slug'] = (string) $slug;
$indexed_template_types[] = $template_type;
}
$custom_settings = array(
'siteUrl' => site_url(),
'postsPerPage' => get_option( 'posts_per_page' ),
'styles' => get_block_editor_theme_styles(),
'defaultTemplateTypes' => $indexed_template_types,
'defaultTemplatePartAreas' => get_allowed_block_template_part_areas(),
'supportsLayout' => wp_theme_has_theme_json(),
'supportsTemplatePartsMode' => ! wp_is_block_theme() && current_theme_supports( 'block-template-parts' ),
);
// Add additional back-compat patterns registered by `current_screen` et al.
$custom_settings['__experimentalAdditionalBlockPatterns'] = \WP_Block_Patterns_Registry::get_instance()->get_all_registered( true );
$custom_settings['__experimentalAdditionalBlockPatternCategories'] = \WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered( true );
$editor_settings = get_block_editor_settings( $custom_settings, $block_editor_context );
$active_global_styles_id = \WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
$active_theme = get_stylesheet();
$preload_paths = array(
array( '/wp/v2/media', 'OPTIONS' ),
'/wp/v2/types?context=view',
'/wp/v2/types/wp_template?context=edit',
'/wp/v2/types/wp_template-part?context=edit',
'/wp/v2/templates?context=edit&per_page=-1',
'/wp/v2/template-parts?context=edit&per_page=-1',
'/wp/v2/themes?context=edit&status=active',
'/wp/v2/global-styles/' . $active_global_styles_id . '?context=edit',
'/wp/v2/global-styles/' . $active_global_styles_id,
'/wp/v2/global-styles/themes/' . $active_theme,
);
block_editor_rest_api_preload( $preload_paths, $block_editor_context );
wp_add_inline_script(
'wp-blocks',
sprintf(
'window.wcBlockSettings = %s;',
wp_json_encode( $editor_settings )
)
);
// Preload server-registered block schemas.
wp_add_inline_script(
'wp-blocks',
'wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( get_block_editor_server_block_settings() ) . ');'
);
wp_add_inline_script(
'wp-blocks',
sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( isset( $editor_settings['blockCategories'] ) ? $editor_settings['blockCategories'] : array() ) ),
'after'
);
wp_enqueue_script( 'wp-editor' );
wp_enqueue_script( 'wp-format-library' ); // Not sure if this is needed.
wp_enqueue_script( 'wp-router' );
wp_enqueue_style( 'wp-editor' );
wp_enqueue_style( 'wp-edit-site' );
wp_enqueue_style( 'wp-format-library' );
wp_enqueue_media();
if (
current_theme_supports( 'wp-block-styles' ) &&
( ! is_array( $editor_styles ) || count( $editor_styles ) === 0 )
) {
wp_enqueue_style( 'wp-block-library-theme' );
}
/** This action is documented in wp-admin/edit-form-blocks.php
*
* @since 8.0.3
*/
do_action( 'enqueue_block_editor_assets' );
// Load Jetpack's block editor assets because they are not enqueued by default.
if ( class_exists( 'Jetpack_Gutenberg' ) ) {
Jetpack_Gutenberg::enqueue_block_editor_assets();
}
}
/**
* Mark task as complete.
*/
public function mark_task_as_complete() {
update_option( 'woocommerce_admin_customize_store_completed', 'yes' );
}
}
Admin/Features/OnboardingTasks/Tasks/ExperimentalShippingRecommendation.php 0000644 00000003216 15153704477 0023303 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Shipping Task
*/
class ExperimentalShippingRecommendation extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'shipping-recommendation';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'Set up shipping', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return self::has_plugins_active() && self::has_jetpack_connected();
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
return Features::is_enabled( 'shipping-smart-defaults' );
}
/**
* Action URL.
*
* @return string
*/
public function get_action_url() {
return '';
}
/**
* Check if the store has any shipping zones.
*
* @return bool
*/
public static function has_plugins_active() {
return PluginsHelper::is_plugin_active( 'woocommerce-services' ) &&
PluginsHelper::is_plugin_active( 'jetpack' );
}
/**
* Check if the Jetpack is connected.
*
* @return bool
*/
public static function has_jetpack_connected() {
if ( class_exists( '\Jetpack' ) && is_callable( '\Jetpack::is_connection_ready' ) ) {
return \Jetpack::is_connection_ready();
}
return false;
}
}
Admin/Features/OnboardingTasks/Tasks/GetMobileApp.php 0000644 00000005014 15153704477 0016565 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\Jetpack\Connection\Manager; // https://github.com/Automattic/jetpack/blob/trunk/projects/packages/connection/src/class-manager.php .
/**
* Get Mobile App Task
*/
class GetMobileApp extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'get-mobile-app';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'Get the free WooCommerce mobile app', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return get_option( 'woocommerce_admin_dismissed_mobile_app_modal' ) === 'yes';
}
/**
* Task visibility.
* Can view under these conditions:
* - Jetpack is installed and connected && current site user has a wordpress.com account connected to jetpack
* - Jetpack is not connected && current user is capable of installing plugins
*
* @return bool
*/
public function can_view() {
$jetpack_can_be_installed = current_user_can( 'manage_woocommerce' ) && current_user_can( 'install_plugins' ) && ! self::is_jetpack_connected();
$jetpack_is_installed_and_current_user_connected = self::is_current_user_connected();
return $jetpack_can_be_installed || $jetpack_is_installed_and_current_user_connected;
}
/**
* Determines if site has any users connected to WordPress.com via JetPack
*
* @return bool
*/
private static function is_jetpack_connected() {
if ( class_exists( '\Automattic\Jetpack\Connection\Manager' ) && method_exists( '\Automattic\Jetpack\Connection\Manager', 'is_active' ) ) {
$connection = new Manager();
return $connection->is_active();
}
return false;
}
/**
* Determines if the current user is connected to Jetpack.
*
* @return bool
*/
private static function is_current_user_connected() {
if ( class_exists( '\Automattic\Jetpack\Connection\Manager' ) && method_exists( '\Automattic\Jetpack\Connection\Manager', 'is_user_connected' ) ) {
$connection = new Manager();
return $connection->is_connection_owner();
}
return false;
}
/**
* Action URL.
*
* @return string
*/
public function get_action_url() {
return admin_url( 'admin.php?page=wc-admin&mobileAppModal=true' );
}
}
Admin/Features/OnboardingTasks/Tasks/Marketing.php 0000644 00000004736 15153704477 0016210 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init as RemoteFreeExtensions;
/**
* Marketing Task
*/
class Marketing extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'marketing';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
if ( true === $this->get_parent_option( 'use_completed_title' ) ) {
if ( $this->is_complete() ) {
return __( 'You added sales channels', 'woocommerce' );
}
return __( 'Get more sales', 'woocommerce' );
}
return __( 'Set up marketing tools', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
'Add recommended marketing tools to reach new customers and grow your business',
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return self::has_installed_extensions();
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
return Features::is_enabled( 'remote-free-extensions' ) && count( self::get_plugins() ) > 0;
}
/**
* Get the marketing plugins.
*
* @return array
*/
public static function get_plugins() {
$bundles = RemoteFreeExtensions::get_extensions(
array(
'task-list/reach',
'task-list/grow',
)
);
return array_reduce(
$bundles,
function( $plugins, $bundle ) {
$visible = array();
foreach ( $bundle['plugins'] as $plugin ) {
if ( $plugin->is_visible ) {
$visible[] = $plugin;
}
}
return array_merge( $plugins, $visible );
},
array()
);
}
/**
* Check if the store has installed marketing extensions.
*
* @return bool
*/
public static function has_installed_extensions() {
$plugins = self::get_plugins();
$remaining = array();
$installed = array();
foreach ( $plugins as $plugin ) {
if ( ! $plugin->is_installed ) {
$remaining[] = $plugin;
} else {
$installed[] = $plugin;
}
}
// Make sure the task has been actioned and a marketing extension has been installed.
if ( count( $installed ) > 0 && Task::is_task_actioned( 'marketing' ) ) {
return true;
}
return false;
}
}
Admin/Features/OnboardingTasks/Tasks/Payments.php 0000644 00000003746 15153704477 0016067 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Payments Task
*/
class Payments extends Task {
/**
* Used to cache is_complete() method result.
* @var null
*/
private $is_complete_result = null;
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'payments';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
if ( true === $this->get_parent_option( 'use_completed_title' ) ) {
if ( $this->is_complete() ) {
return __( 'You set up payments', 'woocommerce' );
}
return __( 'Set up payments', 'woocommerce' );
}
return __( 'Set up payments', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
'Choose payment providers and enable payment methods at checkout.',
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
if ( $this->is_complete_result === null ) {
$this->is_complete_result = self::has_gateways();
}
return $this->is_complete_result;
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
$woocommerce_payments = $this->task_list->get_task( 'woocommerce-payments' );
return Features::is_enabled( 'payment-gateway-suggestions' ) && ! $woocommerce_payments->can_view();
}
/**
* Check if the store has any enabled gateways.
*
* @return bool
*/
public static function has_gateways() {
$gateways = WC()->payment_gateways->get_available_payment_gateways();
$enabled_gateways = array_filter(
$gateways,
function( $gateway ) {
return 'yes' === $gateway->enabled && 'woocommerce_payments' !== $gateway->id;
}
);
return ! empty( $enabled_gateways );
}
}
Admin/Features/OnboardingTasks/Tasks/Products.php 0000644 00000007634 15153704477 0016072 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Products Task
*/
class Products extends Task {
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_manual_return_notice_script' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_import_return_notice_script' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_load_sample_return_notice_script' ) );
}
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'products';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
if ( $this->get_parent_option( 'use_completed_title' ) === true ) {
if ( $this->is_complete() ) {
return __( 'You added products', 'woocommerce' );
}
return __( 'Add products', 'woocommerce' );
}
return __( 'Add my products', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
'Start by adding the first product to your store. You can add your products manually, via CSV, or import them from another service.',
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '1 minute per product', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return self::has_products();
}
/**
* Addtional data.
*
* @return array
*/
public function get_additional_data() {
return array(
'has_products' => self::has_products(),
);
}
/**
* Adds a return to task list notice when completing the manual product task.
*
* @param string $hook Page hook.
*/
public function possibly_add_manual_return_notice_script( $hook ) {
global $post;
if ( $hook !== 'post.php' || $post->post_type !== 'product' ) {
return;
}
if ( ! $this->is_active() || ! $this->is_complete() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-product-notice', true );
// Clear the active task transient to only show notice once per active session.
delete_transient( self::ACTIVE_TASK_TRANSIENT );
}
/**
* Adds a return to task list notice when completing the import product task.
*
* @param string $hook Page hook.
*/
public function possibly_add_import_return_notice_script( $hook ) {
$step = isset( $_GET['step'] ) ? $_GET['step'] : ''; // phpcs:ignore csrf ok, sanitization ok.
if ( $hook !== 'product_page_product_importer' || $step !== 'done' ) {
return;
}
if ( ! $this->is_active() || $this->is_complete() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-product-import-notice', true );
}
/**
* Adds a return to task list notice when completing the loading sample products action.
*
* @param string $hook Page hook.
*/
public function possibly_add_load_sample_return_notice_script( $hook ) {
if ( $hook !== 'edit.php' || get_query_var( 'post_type' ) !== 'product' ) {
return;
}
$referer = wp_get_referer();
if ( ! $referer || strpos( $referer, wc_admin_url() ) !== 0 ) {
return;
}
if ( ! isset( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) ) {
return;
}
$task_id = sanitize_title_with_dashes( wp_unslash( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) );
if ( $task_id !== $this->get_id() || ! $this->is_complete() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-load-sample-products-notice', true );
}
/**
* Check if the store has any published products.
*
* @return bool
*/
public static function has_products() {
$counts = wp_count_posts('product');
return isset( $counts->publish ) && $counts->publish > 0;
}
}
Admin/Features/OnboardingTasks/Tasks/Purchase.php 0000644 00000012127 15153704477 0016032 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Purchase Task
*/
class Purchase extends Task {
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'update_option_woocommerce_onboarding_profile', array( $this, 'clear_dismissal' ), 10, 2 );
}
/**
* Clear dismissal on onboarding product type changes.
*
* @param array $old_value Old value.
* @param array $new_value New value.
*/
public function clear_dismissal( $old_value, $new_value ) {
$product_types = isset( $new_value['product_types'] ) ? (array) $new_value['product_types'] : array();
$previous_product_types = isset( $old_value['product_types'] ) ? (array) $old_value['product_types'] : array();
if ( empty( array_diff( $product_types, $previous_product_types ) ) ) {
return;
}
$this->undo_dismiss();
}
/**
* Get the task arguments.
* ID.
*
* @return string
*/
public function get_id() {
return 'purchase';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
$products = $this->get_paid_products_and_themes();
$first_product = count( $products['purchaseable'] ) >= 1 ? $products['purchaseable'][0] : false;
if ( ! $first_product ) {
return null;
}
$product_label = isset( $first_product['label'] ) ? $first_product['label'] : $first_product['title'];
$additional_count = count( $products['purchaseable'] ) - 1;
if ( $this->get_parent_option( 'use_completed_title' ) && $this->is_complete() ) {
return count( $products['purchaseable'] ) === 1
? sprintf(
/* translators: %1$s: a purchased product name */
__(
'You added %1$s',
'woocommerce'
),
$product_label
)
: sprintf(
/* translators: %1$s: a purchased product name, %2$d the number of other products purchased */
_n(
'You added %1$s and %2$d other product',
'You added %1$s and %2$d other products',
$additional_count,
'woocommerce'
),
$product_label,
$additional_count
);
}
return count( $products['purchaseable'] ) === 1
? sprintf(
/* translators: %1$s: a purchaseable product name */
__(
'Add %s to my store',
'woocommerce'
),
$product_label
)
: sprintf(
/* translators: %1$s: a purchaseable product name, %2$d the number of other products to purchase */
_n(
'Add %1$s and %2$d more product to my store',
'Add %1$s and %2$d more products to my store',
$additional_count,
'woocommerce'
),
$product_label,
$additional_count
);
}
/**
* Content.
*
* @return string
*/
public function get_content() {
$products = $this->get_paid_products_and_themes();
if ( count( $products['remaining'] ) === 1 ) {
return isset( $products['purchaseable'][0]['description'] ) ? $products['purchaseable'][0]['description'] : $products['purchaseable'][0]['excerpt'];
}
return sprintf(
/* translators: %1$s: list of product names comma separated, %2%s the last product name */
__(
'Good choice! You chose to add %1$s and %2$s to your store.',
'woocommerce'
),
implode( ', ', array_slice( $products['remaining'], 0, -1 ) ) . ( count( $products['remaining'] ) > 2 ? ',' : '' ),
end( $products['remaining'] )
);
}
/**
* Action label.
*
* @return string
*/
public function get_action_label() {
return __( 'Purchase & install now', 'woocommerce' );
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
$products = $this->get_paid_products_and_themes();
return count( $products['remaining'] ) === 0;
}
/**
* Dismissable.
*
* @return bool
*/
public function is_dismissable() {
return true;
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
$products = $this->get_paid_products_and_themes();
return count( $products['purchaseable'] ) > 0;
}
/**
* Get purchaseable and remaining products.
*
* @return array purchaseable and remaining products and themes.
*/
public static function get_paid_products_and_themes() {
$relevant_products = OnboardingProducts::get_relevant_products();
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$theme = isset( $profiler_data['theme'] ) ? $profiler_data['theme'] : null;
$paid_theme = $theme ? OnboardingThemes::get_paid_theme_by_slug( $theme ) : null;
if ( $paid_theme ) {
$relevant_products['purchaseable'][] = $paid_theme;
if ( isset( $paid_theme['is_installed'] ) && false === $paid_theme['is_installed'] ) {
$relevant_products['remaining'][] = $paid_theme['title'];
}
}
return $relevant_products;
}
}
Admin/Features/OnboardingTasks/Tasks/ReviewShippingOptions.php 0000644 00000002177 15153704477 0020603 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Review Shipping Options Task
*/
class ReviewShippingOptions extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'review-shipping';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'Review shipping options', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return get_option( 'woocommerce_admin_reviewed_default_shipping_zones' ) === 'yes';
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
return get_option( 'woocommerce_admin_created_default_shipping_zones' ) === 'yes';
}
/**
* Action URL.
*
* @return string
*/
public function get_action_url() {
return admin_url( 'admin.php?page=wc-settings&tab=shipping' );
}
}
Admin/Features/OnboardingTasks/Tasks/Shipping.php 0000644 00000012244 15153704477 0016041 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use WC_Data_Store;
/**
* Shipping Task
*/
class Shipping extends Task {
const ZONE_COUNT_TRANSIENT_NAME = 'woocommerce_shipping_task_zone_count_transient';
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
*/
public function __construct( $task_list = null ) {
parent::__construct( $task_list );
// wp_ajax_woocommerce_shipping_zone_methods_save_changes
// and wp_ajax_woocommerce_shipping_zones_save_changes get fired
// when a new zone is added or an existing one has been changed.
add_action( 'wp_ajax_woocommerce_shipping_zones_save_changes', array( __CLASS__, 'delete_zone_count_transient' ), 9 );
add_action( 'wp_ajax_woocommerce_shipping_zone_methods_save_changes', array( __CLASS__, 'delete_zone_count_transient' ), 9 );
add_action( 'woocommerce_shipping_zone_method_added', array( __CLASS__, 'delete_zone_count_transient' ), 9 );
add_action( 'woocommerce_after_shipping_zone_object_save', array( __CLASS__, 'delete_zone_count_transient' ), 9 );
}
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'shipping';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
if ( true === $this->get_parent_option( 'use_completed_title' ) ) {
if ( $this->is_complete() ) {
return __( 'You added shipping costs', 'woocommerce' );
}
return __( 'Add shipping costs', 'woocommerce' );
}
return __( 'Set up shipping', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
"Set your store location and where you'll ship to.",
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '1 minute', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return self::has_shipping_zones();
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
if ( Features::is_enabled( 'shipping-smart-defaults' ) ) {
if ( 'yes' === get_option( 'woocommerce_admin_created_default_shipping_zones' ) ) {
// If the user has already created a default shipping zone, we don't need to show the task.
return false;
}
/**
* Do not display the task when:
* - The store sells digital products only
* Display the task when:
* - We don't know where the store's located
* - The store is located in the UK, Australia or Canada
*/
if ( self::is_selling_digital_type_only() ) {
return false;
}
$default_store_country = wc_format_country_state_string( get_option( 'woocommerce_default_country', '' ) )['country'];
// Check if a store address is set so that we don't default to WooCommerce's default country US.
// Similar logic: https://github.com/woocommerce/woocommerce/blob/059d542394b48468587f252dcb6941c6425cd8d3/plugins/woocommerce-admin/client/profile-wizard/steps/store-details/index.js#L511-L516.
$store_country = '';
if ( ! empty( get_option( 'woocommerce_store_address', '' ) ) || 'US' !== $default_store_country ) {
$store_country = $default_store_country;
}
// Unknown country.
if ( empty( $store_country ) ) {
return true;
}
return in_array( $store_country, array( 'CA', 'AU', 'GB', 'ES', 'IT', 'DE', 'FR', 'MX', 'CO', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true );
}
return self::has_physical_products();
}
/**
* Action URL.
*
* @return string
*/
public function get_action_url() {
return self::has_shipping_zones()
? admin_url( 'admin.php?page=wc-settings&tab=shipping' )
: null;
}
/**
* Check if the store has any shipping zones.
*
* @return bool
*/
public static function has_shipping_zones() {
$zone_count = get_transient( self::ZONE_COUNT_TRANSIENT_NAME );
if ( false !== $zone_count ) {
return (int) $zone_count > 0;
}
$zone_count = count( WC_Data_Store::load( 'shipping-zone' )->get_zones() );
set_transient( self::ZONE_COUNT_TRANSIENT_NAME, $zone_count );
return $zone_count > 0;
}
/**
* Check if the store has physical products.
*
* @return bool
*/
public static function has_physical_products() {
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
return in_array( 'physical', $product_types, true );
}
/**
* Delete the zone count transient used in has_shipping_zones() method
* to refresh the cache.
*/
public static function delete_zone_count_transient() {
delete_transient( self::ZONE_COUNT_TRANSIENT_NAME );
}
/**
* Check if the store sells digital products only.
*
* @return bool
*/
private static function is_selling_digital_type_only() {
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
return array( 'downloads' ) === $product_types;
}
}
Admin/Features/OnboardingTasks/Tasks/StoreCreation.php 0000644 00000002044 15153704477 0017036 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Store Details Task
*/
class StoreCreation extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'store_creation';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
/* translators: Store name */
return sprintf( __( 'You created %s', 'woocommerce' ), get_bloginfo( 'name' ) );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_action_url() {
return '';
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return true;
}
/**
* Check if task is disabled.
*
* @return bool
*/
public function is_disabled() {
return true;
}
}
Admin/Features/OnboardingTasks/Tasks/StoreDetails.php 0000644 00000004207 15153704477 0016662 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Store Details Task
*/
class StoreDetails extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'store_details';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
if ( true === $this->get_parent_option( 'use_completed_title' ) ) {
if ( $this->is_complete() ) {
return __( 'You added store details', 'woocommerce' );
}
return __( 'Add store details', 'woocommerce' );
}
return __( 'Store details', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
'Your store address is required to set the origin country for shipping, currencies, and payment options.',
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '4 minutes', 'woocommerce' );
}
/**
* Time.
*
* @return string
*/
public function get_action_url() {
return ! $this->is_complete() ? admin_url( 'admin.php?page=wc-settings&tab=general&tutorial=true' ) : admin_url( 'admin.php?page=wc-settings&tab=general' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
$country = WC()->countries->get_base_country();
$country_locale = WC()->countries->get_country_locale();
$locale = $country_locale[ $country ] ?? array();
$hide_postcode = $locale['postcode']['hidden'] ?? false;
// If postcode is hidden, just check that the store address and city are set.
if ( $hide_postcode ) {
return get_option( 'woocommerce_store_address', '' ) !== '' && get_option( 'woocommerce_store_city', '' ) !== '';
}
// Mark as completed if the store address, city and postcode are set. We don't need to check the country because it's set by default.
return get_option( 'woocommerce_store_address', '' ) !== '' && get_option( 'woocommerce_store_city', '' ) !== '' &&
get_option( 'woocommerce_store_postcode', '' ) !== '';
}
}
Admin/Features/OnboardingTasks/Tasks/Tax.php 0000644 00000010045 15153704477 0015011 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxDataStore;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Tax Task
*/
class Tax extends Task {
/**
* Used to cache is_complete() method result.
* @var null
*/
private $is_complete_result = null;
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_return_notice_script' ) );
}
/**
* Adds a return to task list notice when completing the task.
*/
public function possibly_add_return_notice_script() {
$page = isset( $_GET['page'] ) ? $_GET['page'] : ''; // phpcs:ignore csrf ok, sanitization ok.
$tab = isset( $_GET['tab'] ) ? $_GET['tab'] : ''; // phpcs:ignore csrf ok, sanitization ok.
if ( $page !== 'wc-settings' || $tab !== 'tax' ) {
return;
}
if ( ! $this->is_active() || $this->is_complete() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-tax-notice', true );
}
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'tax';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
if ( $this->get_parent_option( 'use_completed_title' ) === true ) {
if ( $this->is_complete() ) {
return __( 'You added tax rates', 'woocommerce' );
}
return __( 'Add tax rates', 'woocommerce' );
}
return __( 'Set up tax rates', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return self::can_use_automated_taxes()
? __(
'Good news! WooCommerce Services and Jetpack can automate your sales tax calculations for you.',
'woocommerce'
)
: __(
'Set your store location and configure tax rate settings.',
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '1 minute', 'woocommerce' );
}
/**
* Action label.
*
* @return string
*/
public function get_action_label() {
return self::can_use_automated_taxes()
? __( 'Yes please', 'woocommerce' )
: __( "Let's go", 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
if ( $this->is_complete_result === null ) {
$wc_connect_taxes_enabled = get_option( 'wc_connect_taxes_enabled' );
$is_wc_connect_taxes_enabled = ( $wc_connect_taxes_enabled === 'yes' ) || ( $wc_connect_taxes_enabled === true ); // seems that in some places boolean is used, and other places 'yes' | 'no' is used
$this->is_complete_result = $is_wc_connect_taxes_enabled ||
count( TaxDataStore::get_taxes( array() ) ) > 0 ||
get_option( 'woocommerce_no_sales_tax' ) !== false;
}
return $this->is_complete_result;
}
/**
* Addtional data.
*
* @return array
*/
public function get_additional_data() {
return array(
'avalara_activated' => PluginsHelper::is_plugin_active( 'woocommerce-avatax' ),
'tax_jar_activated' => class_exists( 'WC_Taxjar' ),
'woocommerce_tax_countries' => self::get_automated_support_countries(),
);
}
/**
* Check if the store has any enabled gateways.
*
* @return bool
*/
public static function can_use_automated_taxes() {
if ( ! class_exists( 'WC_Taxjar' ) ) {
return false;
}
return in_array( WC()->countries->get_base_country(), self::get_automated_support_countries(), true );
}
/**
* Get an array of countries that support automated tax.
*
* @return array
*/
public static function get_automated_support_countries() {
// https://developers.taxjar.com/api/reference/#countries .
$tax_supported_countries = array_merge(
array( 'US', 'CA', 'AU', 'GB' ),
WC()->countries->get_european_union_countries()
);
return $tax_supported_countries;
}
}
Admin/Features/OnboardingTasks/Tasks/TourInAppMarketplace.php 0000644 00000002277 15153704477 0020317 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Tour In-App Marketplace task
*/
class TourInAppMarketplace extends Task {
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'tour-in-app-marketplace';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __(
'Discover ways of extending your store with a tour of the Woo Marketplace',
'woocommerce'
);
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
return get_option( 'woocommerce_admin_dismissed_in_app_marketplace_tour' ) === 'yes';
}
/**
* Action URL.
*
* @return string
*/
public function get_action_url() {
return admin_url( 'admin.php?page=wc-admin&path=%2Fextensions&tutorial=true' );
}
/**
* Check if should record event when task is viewed
*
* @return bool
*/
public function get_record_view_event(): bool {
return true;
}
}
Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php 0000644 00000011433 15153704477 0020217 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions;
use Automattic\WooCommerce\Internal\Admin\WCPayPromotion\Init as WCPayPromotionInit;
/**
* WooCommercePayments Task
*/
class WooCommercePayments extends Task {
/**
* Used to cache is_complete() method result.
*
* @var null
*/
private $is_complete_result = null;
/**
* ID.
*
* @return string
*/
public function get_id() {
return 'woocommerce-payments';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'Set up WooPayments', 'woocommerce' );
}
/**
* Badge.
*
* @return string
*/
public function get_badge() {
/**
* Filter WooPayments onboarding task badge.
*
* @param string $badge Badge content.
* @since 8.2.0
*/
return apply_filters( 'woocommerce_admin_woopayments_onboarding_task_badge', '' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __(
"You're only one step away from getting paid. Verify your business details to start managing transactions with WooPayments.",
'woocommerce'
);
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Action label.
*
* @return string
*/
public function get_action_label() {
return __( 'Finish setup', 'woocommerce' );
}
/**
* Additional info.
*
* @return string
*/
public function get_additional_info() {
if ( WCPayPromotionInit::is_woopay_eligible() ) {
return __(
'By using WooPayments you agree to be bound by our <a href="https://wordpress.com/tos/" target="_blank">Terms of Service</a> (including WooPay <a href="https://wordpress.com/tos/#more-woopay-specifically" target="_blank">merchant terms</a>) and acknowledge that you have read our <a href="https://automattic.com/privacy/" target="_blank">Privacy Policy</a>',
'woocommerce'
);
}
return __(
'By using WooPayments you agree to be bound by our <a href="https://wordpress.com/tos/" target="_blank">Terms of Service</a> and acknowledge that you have read our <a href="https://automattic.com/privacy/" target="_blank">Privacy Policy</a>',
'woocommerce'
);
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
if ( null === $this->is_complete_result ) {
$this->is_complete_result = self::is_connected();
}
return $this->is_complete_result;
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
$payments = $this->task_list->get_task( 'payments' );
return ! $payments->is_complete() && // Do not re-display the task if the "add payments" task has already been completed.
self::is_installed() &&
self::is_supported();
}
/**
* Check if the plugin was requested during onboarding.
*
* @return bool
*/
public static function is_requested() {
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
$business_extensions = isset( $profiler_data['business_extensions'] ) ? $profiler_data['business_extensions'] : array();
$subscriptions_and_us = in_array( 'subscriptions', $product_types, true ) && 'US' === WC()->countries->get_base_country();
return in_array( 'woocommerce-payments', $business_extensions, true ) || $subscriptions_and_us;
}
/**
* Check if the plugin is installed.
*
* @return bool
*/
public static function is_installed() {
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
return in_array( 'woocommerce-payments', $installed_plugins, true );
}
/**
* Check if WooCommerce Payments is connected.
*
* @return bool
*/
public static function is_connected() {
if ( class_exists( '\WC_Payments' ) ) {
$wc_payments_gateway = \WC_Payments::get_gateway();
return method_exists( $wc_payments_gateway, 'is_connected' )
? $wc_payments_gateway->is_connected()
: false;
}
return false;
}
/**
* Check if the store is in a supported country.
*
* @return bool
*/
public static function is_supported() {
$suggestions = Suggestions::get_suggestions();
$suggestion_plugins = array_merge(
...array_filter(
array_column( $suggestions, 'plugins' ),
function( $plugins ) {
return is_array( $plugins );
}
)
);
$woocommerce_payments_ids = array_search( 'woocommerce-payments', $suggestion_plugins, true );
if ( false !== $woocommerce_payments_ids ) {
return true;
}
return false;
}
}
Admin/Features/PaymentGatewaySuggestions/DefaultPaymentGateways.php 0000644 00000106474 15153704477 0021735 0 ustar 00 <?php
/**
* Gets a list of fallback methods if remote fetching is disabled.
*/
namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions;
defined( 'ABSPATH' ) || exit;
/**
* Default Payment Gateways
*/
class DefaultPaymentGateways {
/**
* This is the default priority for countries that are not in the $recommendation_priority_map.
* Priority is used to determine which payment gateway to recommend first.
* The lower the number, the higher the priority.
*
* @var array
*/
private static $recommendation_priority = array(
'woocommerce_payments' => 1,
'woocommerce_payments:with-in-person-payments' => 1,
'woocommerce_payments:without-in-person-payments' => 1,
'stripe' => 2,
'woo-mercado-pago-custom' => 3,
// PayPal Payments.
'ppcp-gateway' => 4,
'mollie_wc_gateway_banktransfer' => 5,
'razorpay' => 5,
'payfast' => 5,
'payubiz' => 6,
'square_credit_card' => 6,
'klarna_payments' => 6,
// Klarna Checkout.
'kco' => 6,
'paystack' => 6,
'eway' => 7,
'amazon_payments_advanced' => 7,
'affirm' => 8,
'afterpay' => 9,
'zipmoney' => 10,
'payoneer-checkout' => 11,
);
/**
* Get default specs.
*
* @return array Default specs.
*/
public static function get_all() {
$payment_gateways = array(
array(
'id' => 'affirm',
'title' => __( 'Affirm', 'woocommerce' ),
'content' => __( 'Affirm’s tailored Buy Now Pay Later programs remove price as a barrier, turning browsers into buyers, increasing average order value, and expanding your customer base.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/affirm.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/affirm.png',
'plugins' => array(),
'external_link' => 'https://woocommerce.com/products/woocommerce-gateway-affirm',
'is_visible' => array(
self::get_rules_for_countries(
array(
'US',
'CA',
)
),
),
'category_other' => array(),
'category_additional' => array(
'US',
'CA',
),
),
array(
'id' => 'afterpay',
'title' => __( 'Afterpay', 'woocommerce' ),
'content' => __( 'Afterpay allows customers to receive products immediately and pay for purchases over four installments, always interest-free.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/afterpay.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/afterpay.png',
'plugins' => array( 'afterpay-gateway-for-woocommerce' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'US',
'CA',
'AU',
)
),
),
'category_other' => array(),
'category_additional' => array(
'US',
'CA',
'AU',
),
),
array(
'id' => 'amazon_payments_advanced',
'title' => __( 'Amazon Pay', 'woocommerce' ),
'content' => __( 'Enable a familiar, fast checkout for hundreds of millions of active Amazon customers globally.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/amazonpay.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/amazonpay.png',
'plugins' => array( 'woocommerce-gateway-amazon-payments-advanced' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'US',
'AT',
'BE',
'CY',
'DK',
'ES',
'FR',
'DE',
'GB',
'HU',
'IE',
'IT',
'LU',
'NL',
'PT',
'SL',
'SE',
'JP',
)
),
),
'category_other' => array(),
'category_additional' => array(
'US',
'AT',
'BE',
'CY',
'DK',
'ES',
'FR',
'DE',
'GB',
'HU',
'IE',
'IT',
'LU',
'NL',
'PT',
'SL',
'SE',
'JP',
),
),
array(
'id' => 'bacs',
'title' => __( 'Direct bank transfer', 'woocommerce' ),
'content' => __( 'Take payments via bank transfer.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/bacs.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/bacs.png',
'is_visible' => array(
self::get_rules_for_cbd( false ),
),
'is_offline' => true,
),
array(
'id' => 'cod',
'title' => __( 'Cash on delivery', 'woocommerce' ),
'content' => __( 'Take payments in cash upon delivery.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/cod.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/cod.png',
'is_visible' => array(
self::get_rules_for_cbd( false ),
),
'is_offline' => true,
),
array(
'id' => 'eway',
'title' => __( 'Eway', 'woocommerce' ),
'content' => __( 'The Eway extension for WooCommerce allows you to take credit card payments directly on your store without redirecting your customers to a third party site to make payment.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/eway.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/eway.png',
'plugins' => array( 'woocommerce-gateway-eway' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'NZ',
'HK',
'SG',
'AU',
)
),
self::get_rules_for_cbd( false ),
),
'category_other' => array(
'NZ',
'HK',
'SG',
'AU',
),
'category_additional' => array(),
),
array(
'id' => 'kco',
'title' => __( 'Klarna Checkout', 'woocommerce' ),
'content' => __( 'Choose the payment that you want, pay now, pay later or slice it. No credit card numbers, no passwords, no worries.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/klarna-black.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/klarna.png',
'plugins' => array( 'klarna-checkout-for-woocommerce' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'NO',
'SE',
'FI',
)
),
self::get_rules_for_cbd( false ),
),
'category_other' => array(
'NO',
'SE',
'FI',
),
'category_additional' => array(),
),
array(
'id' => 'klarna_payments',
'title' => __( 'Klarna Payments', 'woocommerce' ),
'content' => __( 'Choose the payment that you want, pay now, pay later or slice it. No credit card numbers, no passwords, no worries.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/klarna-black.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/klarna.png',
'plugins' => array( 'klarna-payments-for-woocommerce' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'MX',
'US',
'CA',
'AT',
'BE',
'CH',
'DK',
'ES',
'FI',
'FR',
'DE',
'GB',
'IT',
'NL',
'NO',
'PL',
'SE',
'NZ',
'AU',
)
),
self::get_rules_for_cbd( false ),
),
'category_other' => array(),
'category_additional' => array(
'MX',
'US',
'CA',
'AT',
'BE',
'CH',
'DK',
'ES',
'FI',
'FR',
'DE',
'GB',
'IT',
'NL',
'NO',
'PL',
'SE',
'NZ',
'AU',
),
),
array(
'id' => 'mollie_wc_gateway_banktransfer',
'title' => __( 'Mollie', 'woocommerce' ),
'content' => __( 'Effortless payments by Mollie: Offer global and local payment methods, get onboarded in minutes, and supported in your language.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/mollie.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/mollie.png',
'plugins' => array( 'mollie-payments-for-woocommerce' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'AT',
'BE',
'CH',
'ES',
'FI',
'FR',
'DE',
'GB',
'IT',
'NL',
'PL',
)
),
),
'category_other' => array(
'AT',
'BE',
'CH',
'ES',
'FI',
'FR',
'DE',
'GB',
'IT',
'NL',
'PL',
),
'category_additional' => array(),
),
array(
'id' => 'payfast',
'title' => __( 'Payfast', 'woocommerce' ),
'content' => __( 'The Payfast extension for WooCommerce enables you to accept payments by Credit Card and EFT via one of South Africa’s most popular payment gateways. No setup fees or monthly subscription costs. Selecting this extension will configure your store to use South African rands as the selected currency.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/payfast.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/payfast.png',
'plugins' => array( 'woocommerce-payfast-gateway' ),
'is_visible' => array(
self::get_rules_for_countries( array( 'ZA' ) ),
self::get_rules_for_cbd( false ),
),
'category_other' => array( 'ZA' ),
'category_additional' => array(),
),
array(
'id' => 'payoneer-checkout',
'title' => __( 'Payoneer Checkout', 'woocommerce' ),
'content' => __( 'Payoneer Checkout is the next generation of payment processing platforms, giving merchants around the world the solutions and direction they need to succeed in today’s hyper-competitive global market.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/payoneer.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/payoneer.png',
'plugins' => array( 'payoneer-checkout' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'HK',
'CN',
)
),
),
'category_other' => array(),
'category_additional' => array(
'HK',
'CN',
),
),
array(
'id' => 'paystack',
'title' => __( 'Paystack', 'woocommerce' ),
'content' => __( 'Paystack helps African merchants accept one-time and recurring payments online with a modern, safe, and secure payment gateway.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/paystack.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/paystack.png',
'plugins' => array( 'woo-paystack' ),
'is_visible' => array(
self::get_rules_for_countries( array( 'ZA', 'GH', 'NG' ) ),
self::get_rules_for_cbd( false ),
),
'category_other' => array( 'ZA', 'GH', 'NG' ),
'category_additional' => array(),
),
array(
'id' => 'payubiz',
'title' => __( 'PayU for WooCommerce', 'woocommerce' ),
'content' => __( 'Enable PayU’s exclusive plugin for WooCommerce to start accepting payments in 100+ payment methods available in India including credit cards, debit cards, UPI, & more!', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/payu.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/payu.png',
'plugins' => array( 'payu-india' ),
'is_visible' => array(
(object) array(
'type' => 'base_location_country',
'value' => 'IN',
'operation' => '=',
),
self::get_rules_for_cbd( false ),
),
'category_other' => array( 'IN' ),
'category_additional' => array(),
),
array(
'id' => 'ppcp-gateway',
'title' => __( 'PayPal Payments', 'woocommerce' ),
'content' => __( "Safe and secure payments using credit cards or your customer's PayPal account.", 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/paypal.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/paypal.png',
'plugins' => array( 'woocommerce-paypal-payments' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'US',
'CA',
'MX',
'BR',
'AR',
'CL',
'CO',
'EC',
'PE',
'UY',
'VE',
'AT',
'BE',
'BG',
'HR',
'CH',
'CY',
'CZ',
'DK',
'EE',
'ES',
'FI',
'FR',
'DE',
'GB',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'SK',
'SL',
'SE',
'AU',
'NZ',
'HK',
'JP',
'SG',
'CN',
'ID',
'IN',
)
),
self::get_rules_for_cbd( false ),
),
'category_other' => array(
'US',
'CA',
'MX',
'BR',
'AR',
'CL',
'CO',
'EC',
'PE',
'UY',
'VE',
'AT',
'BE',
'BG',
'HR',
'CH',
'CY',
'CZ',
'DK',
'EE',
'ES',
'FI',
'FR',
'DE',
'GB',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'SK',
'SL',
'SE',
'AU',
'NZ',
'HK',
'JP',
'SG',
'CN',
'ID',
),
'category_additional' => array(
'US',
'CA',
'ZA',
'NG',
'GH',
'EC',
'VE',
'AR',
'CL',
'CO',
'PE',
'UY',
'MX',
'BR',
'AT',
'BE',
'BG',
'HR',
'CH',
'CY',
'CZ',
'DK',
'EE',
'ES',
'FI',
'FR',
'DE',
'GB',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'SK',
'SL',
'SE',
'AU',
'NZ',
'HK',
'JP',
'SG',
'CN',
'ID',
'IN',
),
),
array(
'id' => 'razorpay',
'title' => __( 'Razorpay', 'woocommerce' ),
'content' => __( 'The official Razorpay extension for WooCommerce allows you to accept credit cards, debit cards, netbanking, wallet, and UPI payments.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/razorpay.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/razorpay.png',
'plugins' => array( 'woo-razorpay' ),
'is_visible' => array(
(object) array(
'type' => 'base_location_country',
'value' => 'IN',
'operation' => '=',
),
self::get_rules_for_cbd( false ),
),
'category_other' => array( 'IN' ),
'category_additional' => array(),
),
array(
'id' => 'square_credit_card',
'title' => __( 'Square', 'woocommerce' ),
'content' => __( 'Securely accept credit and debit cards with one low rate, no surprise fees (custom rates available). Sell online and in store and track sales and inventory in one place.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/square-black.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/square.png',
'plugins' => array( 'woocommerce-square' ),
'is_visible' => array(
(object) array(
'type' => 'or',
'operands' => (object) array(
array(
self::get_rules_for_countries( array( 'US' ) ),
self::get_rules_for_cbd( true ),
),
array(
self::get_rules_for_countries(
array(
'US',
'CA',
'IE',
'ES',
'FR',
'GB',
'AU',
'JP',
)
),
(object) array(
'type' => 'or',
'operands' => (object) array(
self::get_rules_for_selling_venues( array( 'brick-mortar', 'brick-mortar-other' ) ),
self::get_rules_selling_offline(),
),
),
),
),
),
),
'category_other' => array(
'US',
'CA',
'IE',
'ES',
'FR',
'GB',
'AU',
'JP',
),
'category_additional' => array(),
),
array(
'id' => 'stripe',
'title' => __( ' Stripe', 'woocommerce' ),
'content' => __( 'Accept debit and credit cards in 135+ currencies, methods such as Alipay, and one-touch checkout with Apple Pay.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/stripe.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/stripe.png',
'plugins' => array( 'woocommerce-gateway-stripe' ),
'is_visible' => array(
// https://stripe.com/global.
self::get_rules_for_countries(
array(
'US',
'CA',
'MX',
'BR',
'AT',
'BE',
'BG',
'CH',
'CY',
'CZ',
'DK',
'EE',
'ES',
'FI',
'FR',
'DE',
'GB',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'SK',
'SL',
'SE',
'AU',
'NZ',
'HK',
'JP',
'SG',
'ID',
'IN',
)
),
self::get_rules_for_cbd( false ),
),
'category_other' => array(
'US',
'CA',
'MX',
'BR',
'AT',
'BE',
'BG',
'CH',
'CY',
'CZ',
'DK',
'EE',
'ES',
'FI',
'FR',
'DE',
'GB',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'SK',
'SL',
'SE',
'AU',
'NZ',
'HK',
'JP',
'SG',
'ID',
'IN',
),
'category_additional' => array(),
),
array(
'id' => 'woo-mercado-pago-custom',
'title' => __( 'Mercado Pago Checkout Pro & Custom', 'woocommerce' ),
'content' => __( 'Accept credit and debit cards, offline (cash or bank transfer) and logged-in payments with money in Mercado Pago. Safe and secure payments with the leading payment processor in LATAM.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/mercadopago.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/mercadopago.png',
'plugins' => array( 'woocommerce-mercadopago' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'AR',
'CL',
'CO',
'EC',
'PE',
'UY',
'MX',
'BR',
)
),
),
'is_local_partner' => true,
'category_other' => array(
'AR',
'CL',
'CO',
'EC',
'PE',
'UY',
'MX',
'BR',
),
'category_additional' => array(),
),
// This is for backwards compatibility only (WC < 5.10.0-dev or WCA < 2.9.0-dev).
array(
'id' => 'woocommerce_payments',
'title' => __( 'WooPayments', 'woocommerce' ),
'content' => __(
'Manage transactions without leaving your WordPress Dashboard. Only with WooPayments.',
'woocommerce'
),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg',
'plugins' => array( 'woocommerce-payments' ),
'description' => __( 'With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies. Track cash flow and manage recurring revenue directly from your store’s dashboard - with no setup costs or monthly fees.', 'woocommerce' ),
'is_visible' => array(
self::get_rules_for_cbd( false ),
self::get_rules_for_countries( self::get_wcpay_countries() ),
(object) array(
'type' => 'plugin_version',
'plugin' => 'woocommerce',
'version' => '5.10.0-dev',
'operator' => '<',
),
(object) array(
'type' => 'or',
'operands' => (object) array(
(object) array(
'type' => 'not',
'operand' => [
(object) array(
'type' => 'plugins_activated',
'plugins' => [ 'woocommerce-admin' ],
),
],
),
(object) array(
'type' => 'plugin_version',
'plugin' => 'woocommerce-admin',
'version' => '2.9.0-dev',
'operator' => '<',
),
),
),
),
),
array(
'id' => 'woocommerce_payments:without-in-person-payments',
'title' => __( 'WooPayments', 'woocommerce' ),
'content' => __(
'Manage transactions without leaving your WordPress Dashboard. Only with WooPayments.',
'woocommerce'
),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg',
'plugins' => array( 'woocommerce-payments' ),
'description' => __( 'With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies. Track cash flow and manage recurring revenue directly from your store’s dashboard - with no setup costs or monthly fees.', 'woocommerce' ),
'is_visible' => array(
self::get_rules_for_cbd( false ),
self::get_rules_for_countries( array_diff( self::get_wcpay_countries(), array( 'US', 'CA' ) ) ),
(object) array(
'type' => 'or',
// Older versions of WooCommerce Admin require the ID to be `woocommerce-payments` to show the suggestion card.
'operands' => (object) array(
(object) array(
'type' => 'plugin_version',
'plugin' => 'woocommerce-admin',
'version' => '2.9.0-dev',
'operator' => '>=',
),
(object) array(
'type' => 'plugin_version',
'plugin' => 'woocommerce',
'version' => '5.10.0-dev',
'operator' => '>=',
),
),
),
),
),
// This is the same as the above, but with a different description for countries that support in-person payments such as US and CA.
array(
'id' => 'woocommerce_payments:with-in-person-payments',
'title' => __( 'WooPayments', 'woocommerce' ),
'content' => __(
'Manage transactions without leaving your WordPress Dashboard. Only with WooPayments.',
'woocommerce'
),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg',
'plugins' => array( 'woocommerce-payments' ),
'description' => __( 'With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies – with no setup costs or monthly fees – and you can now accept in-person payments with the Woo mobile app.', 'woocommerce' ),
'is_visible' => array(
self::get_rules_for_cbd( false ),
self::get_rules_for_countries( array( 'US', 'CA' ) ),
(object) array(
'type' => 'or',
// Older versions of WooCommerce Admin require the ID to be `woocommerce-payments` to show the suggestion card.
'operands' => (object) array(
(object) array(
'type' => 'plugin_version',
'plugin' => 'woocommerce-admin',
'version' => '2.9.0-dev',
'operator' => '>=',
),
(object) array(
'type' => 'plugin_version',
'plugin' => 'woocommerce',
'version' => '5.10.0-dev',
'operator' => '>=',
),
),
),
),
),
array(
'id' => 'zipmoney',
'title' => __( 'Zip Co - Buy Now, Pay Later', 'woocommerce' ),
'content' => __( 'Give your customers the power to pay later, interest free and watch your sales grow.', 'woocommerce' ),
'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/zipco.png',
'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/zipco.png',
'plugins' => array( 'zipmoney-payments-woocommerce' ),
'is_visible' => array(
self::get_rules_for_countries(
array(
'US',
'NZ',
'AU',
)
),
),
'category_other' => array(),
'category_additional' => array(
'US',
'NZ',
'AU',
),
),
);
$base_location = wc_get_base_location();
$country = $base_location['country'];
foreach ( $payment_gateways as $index => $payment_gateway ) {
$payment_gateways[ $index ]['recommendation_priority'] = self::get_recommendation_priority( $payment_gateway['id'], $country );
}
return $payment_gateways;
}
/**
* Get array of countries supported by WCPay depending on feature flag.
*
* @return array Array of countries.
*/
public static function get_wcpay_countries() {
return array( 'US', 'PR', 'AU', 'CA', 'CY', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'IE', 'IT', 'LU', 'LT', 'LV', 'NO', 'NZ', 'MT', 'AT', 'BE', 'NL', 'PL', 'PT', 'CH', 'HK', 'SI', 'SK', 'SG', 'BG', 'CZ', 'HR', 'HU', 'RO', 'SE', 'JP', 'AE' );
}
/**
* Get rules that match the store base location to one of the provided countries.
*
* @param array $countries Array of countries to match.
* @return object Rules to match.
*/
public static function get_rules_for_countries( $countries ) {
$rules = array();
foreach ( $countries as $country ) {
$rules[] = (object) array(
'type' => 'base_location_country',
'value' => $country,
'operation' => '=',
);
}
return (object) array(
'type' => 'or',
'operands' => $rules,
);
}
/**
* Get rules that match the store's selling venues.
*
* @param array $selling_venues Array of venues to match.
* @return object Rules to match.
*/
public static function get_rules_for_selling_venues( $selling_venues ) {
$rules = array();
foreach ( $selling_venues as $venue ) {
$rules[] = (object) array(
'type' => 'option',
'transformers' => array(
(object) array(
'use' => 'dot_notation',
'arguments' => (object) array(
'path' => 'selling_venues',
),
),
),
'option_name' => 'woocommerce_onboarding_profile',
'operation' => '=',
'value' => $venue,
'default' => array(),
);
}
return (object) array(
'type' => 'or',
'operands' => $rules,
);
}
/**
* Get rules for when selling offline for core profiler.
*
* @return object Rules to match.
*/
public static function get_rules_selling_offline() {
return (object) array(
'type' => 'option',
'transformers' => array(
(object) array(
'use' => 'dot_notation',
'arguments' => (object) array(
'path' => 'selling_online_answer',
),
),
),
'option_name' => 'woocommerce_onboarding_profile',
'operation' => 'contains',
'value' => 'no_im_selling_offline',
'default' => array(),
);
}
/**
* Get default rules for CBD based on given argument.
*
* @param bool $should_have Whether or not the store should have CBD as an industry (true) or not (false).
* @return array Rules to match.
*/
public static function get_rules_for_cbd( $should_have ) {
return (object) array(
'type' => 'option',
'transformers' => array(
(object) array(
'use' => 'dot_notation',
'arguments' => (object) array(
'path' => 'industry',
),
),
(object) array(
'use' => 'array_column',
'arguments' => (object) array(
'key' => 'slug',
),
),
),
'option_name' => 'woocommerce_onboarding_profile',
'operation' => $should_have ? 'contains' : '!contains',
'value' => 'cbd-other-hemp-derived-products',
'default' => array(),
);
}
/**
* Get recommendation priority for a given payment gateway by id and country.
* If country is not supported, return null.
*
* @param string $gateway_id Payment gateway id.
* @param string $country_code Store country code.
* @return int|null Priority. Priority is 0-indexed, so 0 is the highest priority.
*/
private static function get_recommendation_priority( $gateway_id, $country_code ) {
$recommendation_priority_map = array(
'US' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'square_credit_card',
'amazon_payments_advanced',
'affirm',
'afterpay',
'klarna_payments',
'zipmoney',
],
'CA' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'square_credit_card',
'affirm',
'afterpay',
'klarna_payments',
],
'AT' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
'amazon_payments_advanced',
],
'BE' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
'amazon_payments_advanced',
],
'BG' => [ 'stripe', 'ppcp-gateway' ],
'HR' => [ 'ppcp-gateway' ],
'CH' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
],
'CY' => [ 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ],
'CZ' => [ 'stripe', 'ppcp-gateway' ],
'DK' => [
'stripe',
'ppcp-gateway',
'klarna_payments',
'amazon_payments_advanced',
],
'EE' => [ 'stripe', 'ppcp-gateway' ],
'ES' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'square_credit_card',
'klarna_payments',
'amazon_payments_advanced',
],
'FI' => [
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'kco',
'klarna_payments',
],
'FR' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'square_credit_card',
'klarna_payments',
'amazon_payments_advanced',
],
'DE' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
'amazon_payments_advanced',
],
'GB' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'square_credit_card',
'klarna_payments',
'amazon_payments_advanced',
],
'GR' => [ 'stripe', 'ppcp-gateway' ],
'HU' => [ 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ],
'IE' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'square_credit_card',
'amazon_payments_advanced',
],
'IT' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
'amazon_payments_advanced',
],
'LV' => [ 'stripe', 'ppcp-gateway' ],
'LT' => [ 'stripe', 'ppcp-gateway' ],
'LU' => [ 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ],
'MT' => [ 'stripe', 'ppcp-gateway' ],
'NL' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
'amazon_payments_advanced',
],
'NO' => [ 'stripe', 'ppcp-gateway', 'kco', 'klarna_payments' ],
'PL' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'mollie_wc_gateway_banktransfer',
'klarna_payments',
],
'PT' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'amazon_payments_advanced',
],
'RO' => [ 'stripe', 'ppcp-gateway' ],
'SK' => [ 'stripe', 'ppcp-gateway' ],
'SL' => [ 'stripe', 'ppcp-gateway', 'amazon_payments_advanced' ],
'SE' => [
'stripe',
'ppcp-gateway',
'kco',
'klarna_payments',
'amazon_payments_advanced',
],
'MX' => [
'stripe',
'woo-mercado-pago-custom',
'ppcp-gateway',
'klarna_payments',
],
'BR' => [ 'stripe', 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'AR' => [ 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'BO' => [],
'CL' => [ 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'CO' => [ 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'EC' => [ 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'FK' => [],
'GF' => [],
'GY' => [],
'PY' => [],
'PE' => [ 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'SR' => [],
'UY' => [ 'woo-mercado-pago-custom', 'ppcp-gateway' ],
'VE' => [ 'ppcp-gateway' ],
'AU' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'square_credit_card',
'eway',
'afterpay',
'klarna_payments',
'zipmoney',
],
'NZ' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'eway',
'klarna_payments',
'zipmoney',
],
'HK' => [
'woocommerce_payments',
'stripe',
'ppcp-gateway',
'eway',
'payoneer-checkout',
],
'JP' => [
'stripe',
'ppcp-gateway',
'square_credit_card',
'amazon_payments_advanced',
],
'SG' => [ 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'eway' ],
'CN' => [ 'ppcp-gateway', 'payoneer-checkout' ],
'FJ' => [],
'GU' => [],
'ID' => [ 'stripe', 'ppcp-gateway' ],
'IN' => [ 'stripe', 'razorpay', 'payubiz', 'ppcp-gateway' ],
'ZA' => [ 'payfast', 'paystack' ],
'NG' => [ 'paystack' ],
'GH' => [ 'paystack' ],
);
// If the country code is not in the list, return default priority.
if ( ! isset( $recommendation_priority_map[ $country_code ] ) ) {
return self::get_default_recommendation_priority( $gateway_id );
}
$index = array_search( $gateway_id, $recommendation_priority_map[ $country_code ], true );
// If the gateway is not in the list, return the last index + 1.
if ( false === $index ) {
return count( $recommendation_priority_map[ $country_code ] );
}
return $index;
}
/**
* Get the default recommendation priority for a payment gateway.
* This is used when a country is not in the $recommendation_priority_map array.
*
* @param string $id Payment gateway id.
* @return int Priority.
*/
private static function get_default_recommendation_priority( $id ) {
if ( ! $id || ! array_key_exists( $id, self::$recommendation_priority ) ) {
return null;
}
return self::$recommendation_priority[ $id ];
}
}
Admin/Features/PaymentGatewaySuggestions/EvaluateSuggestion.php 0000644 00000001534 15153704477 0021113 0 ustar 00 <?php
/**
* Evaluates the spec and returns a status.
*/
namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RuleEvaluator;
/**
* Evaluates the spec and returns the evaluated suggestion.
*/
class EvaluateSuggestion {
/**
* Evaluates the spec and returns the suggestion.
*
* @param object|array $spec The suggestion to evaluate.
* @return object The evaluated suggestion.
*/
public static function evaluate( $spec ) {
$rule_evaluator = new RuleEvaluator();
$suggestion = is_array( $spec ) ? (object) $spec : clone $spec;
if ( isset( $suggestion->is_visible ) ) {
$is_visible = $rule_evaluator->evaluate( $suggestion->is_visible );
$suggestion->is_visible = $is_visible;
}
return $suggestion;
}
}
Admin/Features/PaymentGatewaySuggestions/Init.php 0000644 00000005517 15153704477 0016205 0 ustar 00 <?php
/**
* Handles running payment gateway suggestion specs
*/
namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaysController;
/**
* Remote Payment Methods engine.
* This goes through the specs and gets eligible payment gateways.
*/
class Init {
/**
* Option name for dismissed payment method suggestions.
*/
const RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION = 'woocommerce_setting_payments_recommendations_hidden';
/**
* Constructor.
*/
public function __construct() {
PaymentGatewaysController::init();
}
/**
* Go through the specs and run them.
*
* @param array|null $specs payment suggestion spec array.
* @return array
*/
public static function get_suggestions( array $specs = null ) {
$suggestions = array();
if ( null === $specs ) {
$specs = self::get_specs();
}
foreach ( $specs as $spec ) {
$suggestion = EvaluateSuggestion::evaluate( $spec );
$suggestions[] = $suggestion;
}
return array_values(
array_filter(
$suggestions,
function( $suggestion ) {
return ! property_exists( $suggestion, 'is_visible' ) || $suggestion->is_visible;
}
)
);
}
/**
* Delete the specs transient.
*/
public static function delete_specs_transient() {
PaymentGatewaySuggestionsDataSourcePoller::get_instance()->delete_specs_transient();
}
/**
* Get specs or fetch remotely if they don't exist.
*/
public static function get_specs() {
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', DefaultPaymentGateways::get_all() );
}
$specs = PaymentGatewaySuggestionsDataSourcePoller::get_instance()->get_specs_from_data_sources();
// Fetch specs if they don't yet exist.
if ( false === $specs || ! is_array( $specs ) || 0 === count( $specs ) ) {
return apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', DefaultPaymentGateways::get_all() );
}
return apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', $specs );
}
/**
* Check if suggestions should be shown in the settings screen.
*
* @return bool
*/
public static function should_display() {
if ( 'yes' === get_option( self::RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION, 'no' ) ) {
return false;
}
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return false;
}
return apply_filters( 'woocommerce_allow_payment_recommendations', true );
}
/**
* Dismiss the suggestions.
*/
public static function dismiss() {
return update_option( self::RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION, 'yes' );
}
}
Admin/Features/PaymentGatewaySuggestions/PaymentGatewaySuggestionsDataSourcePoller.php 0000644 00000002154 15153704477 0025617 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions;
use Automattic\WooCommerce\Admin\DataSourcePoller;
/**
* Specs data source poller class for payment gateway suggestions.
*/
class PaymentGatewaySuggestionsDataSourcePoller extends DataSourcePoller {
/**
* Data Source Poller ID.
*/
const ID = 'payment_gateway_suggestions';
/**
* Default data sources array.
*/
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/payment-gateway-suggestions/1.0/suggestions.json',
);
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
// Add country query param to data sources.
$base_location = wc_get_base_location();
$data_sources = array_map(
function( $url ) use ( $base_location ) {
return add_query_arg(
'country',
$base_location['country'],
$url
);
},
self::DATA_SOURCES
);
self::$instance = new self( self::ID, $data_sources );
}
return self::$instance;
}
}
Admin/Features/PaymentGatewaySuggestions/PaymentGatewaysController.php 0000644 00000010671 15153704477 0022465 0 ustar 00 <?php
/**
* Logic for extending WC_REST_Payment_Gateways_Controller.
*/
namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions;
use Automattic\WooCommerce\Admin\Features\TransientNotices;
defined( 'ABSPATH' ) || exit;
/**
* PaymentGateway class
*/
class PaymentGatewaysController {
/**
* Initialize payment gateway changes.
*/
public static function init() {
add_filter( 'woocommerce_rest_prepare_payment_gateway', array( __CLASS__, 'extend_response' ), 10, 3 );
add_filter( 'admin_init', array( __CLASS__, 'possibly_do_connection_return_action' ) );
add_action( 'woocommerce_admin_payment_gateway_connection_return', array( __CLASS__, 'handle_successfull_connection' ) );
}
/**
* Add necessary fields to REST API response.
*
* @param WP_REST_Response $response Response data.
* @param WC_Payment_Gateway $gateway Payment gateway object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public static function extend_response( $response, $gateway, $request ) {
$data = $response->get_data();
$data['needs_setup'] = $gateway->needs_setup();
$data['post_install_scripts'] = self::get_post_install_scripts( $gateway );
$data['settings_url'] = method_exists( $gateway, 'get_settings_url' )
? $gateway->get_settings_url()
: admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . strtolower( $gateway->id ) );
$return_url = wc_admin_url( '&task=payments&connection-return=' . strtolower( $gateway->id ) . '&_wpnonce=' . wp_create_nonce( 'connection-return' ) );
$data['connection_url'] = method_exists( $gateway, 'get_connection_url' )
? $gateway->get_connection_url( $return_url )
: null;
$data['setup_help_text'] = method_exists( $gateway, 'get_setup_help_text' )
? $gateway->get_setup_help_text()
: null;
$data['required_settings_keys'] = method_exists( $gateway, 'get_required_settings_keys' )
? $gateway->get_required_settings_keys()
: array();
$response->set_data( $data );
return $response;
}
/**
* Get payment gateway scripts for post-install.
*
* @param WC_Payment_Gateway $gateway Payment gateway object.
* @return array Install scripts.
*/
public static function get_post_install_scripts( $gateway ) {
$scripts = array();
$wp_scripts = wp_scripts();
$handles = method_exists( $gateway, 'get_post_install_script_handles' )
? $gateway->get_post_install_script_handles()
: array();
foreach ( $handles as $handle ) {
if ( isset( $wp_scripts->registered[ $handle ] ) ) {
$scripts[] = $wp_scripts->registered[ $handle ];
}
}
return $scripts;
}
/**
* Call an action after a gating has been successfully returned.
*/
public static function possibly_do_connection_return_action() {
if (
! isset( $_GET['page'] ) ||
'wc-admin' !== $_GET['page'] ||
! isset( $_GET['task'] ) ||
'payments' !== $_GET['task'] ||
! isset( $_GET['connection-return'] ) ||
! isset( $_GET['_wpnonce'] ) ||
! wp_verify_nonce( wc_clean( wp_unslash( $_GET['_wpnonce'] ) ), 'connection-return' )
) {
return;
}
$gateway_id = sanitize_text_field( wp_unslash( $_GET['connection-return'] ) );
do_action( 'woocommerce_admin_payment_gateway_connection_return', $gateway_id );
}
/**
* Handle a successful gateway connection.
*
* @param string $gateway_id Gateway ID.
*/
public static function handle_successfull_connection( $gateway_id ) {
// phpcs:disable WordPress.Security.NonceVerification
if ( ! isset( $_GET['success'] ) || 1 !== intval( $_GET['success'] ) ) {
return;
}
// phpcs:enable WordPress.Security.NonceVerification
$payment_gateways = WC()->payment_gateways()->payment_gateways();
$payment_gateway = isset( $payment_gateways[ $gateway_id ] ) ? $payment_gateways[ $gateway_id ] : null;
if ( ! $payment_gateway ) {
return;
}
$payment_gateway->update_option( 'enabled', 'yes' );
TransientNotices::add(
array(
'user_id' => get_current_user_id(),
'id' => 'payment-gateway-connection-return-' . str_replace( ',', '-', $gateway_id ),
'status' => 'success',
'content' => sprintf(
/* translators: the title of the payment gateway */
__( '%s connected successfully', 'woocommerce' ),
$payment_gateway->method_title
),
)
);
wc_admin_record_tracks_event(
'tasklist_payment_connect_method',
array(
'payment_method' => $gateway_id,
)
);
wp_safe_redirect( wc_admin_url() );
}
}
Admin/Features/ProductBlockEditor/BlockRegistry.php 0000644 00000007700 15153704477 0016431 0 ustar 00 <?php
/**
* WooCommerce Product Editor Block Registration
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Product block registration and style registration functionality.
*/
class BlockRegistry {
/**
* The directory where blocks are stored after build.
*/
const BLOCKS_DIR = 'product-editor/blocks';
/**
* Array of all available product blocks.
*/
const PRODUCT_BLOCKS = [
'woocommerce/conditional',
'woocommerce/product-catalog-visibility-field',
'woocommerce/product-checkbox-field',
'woocommerce/product-collapsible',
'woocommerce/product-description-field',
'woocommerce/product-images-field',
'woocommerce/product-inventory-email-field',
'woocommerce/product-sku-field',
'woocommerce/product-name-field',
'woocommerce/product-pricing-field',
'woocommerce/product-radio-field',
'woocommerce/product-regular-price-field',
'woocommerce/product-sale-price-field',
'woocommerce/product-schedule-sale-fields',
'woocommerce/product-section',
'woocommerce/product-shipping-class-field',
'woocommerce/product-shipping-dimensions-fields',
'woocommerce/product-summary-field',
'woocommerce/product-tab',
'woocommerce/product-tag-field',
'woocommerce/product-inventory-quantity-field',
'woocommerce/product-toggle-field',
'woocommerce/product-variation-items-field',
'woocommerce/product-variations-fields',
'woocommerce/product-password-field',
'woocommerce/product-has-variations-notice',
'woocommerce/product-taxonomy-field',
];
/**
* Get a file path for a given block file.
*
* @param string $path File path.
*/
private function get_file_path( $path ) {
return WC_ABSPATH . WCAdminAssets::get_path( 'js' ) . trailingslashit( self::BLOCKS_DIR ) . $path;
}
/**
* Initialize all blocks.
*/
public function init() {
add_filter( 'block_categories_all', array( $this, 'register_categories' ), 10, 2 );
$this->register_product_blocks();
}
/**
* Register all the product blocks.
*/
private function register_product_blocks() {
foreach ( self::PRODUCT_BLOCKS as $block_name ) {
$this->register_block( $block_name );
}
}
/**
* Register product related block categories.
*
* @param array[] $block_categories Array of categories for block types.
* @param WP_Block_Editor_Context $editor_context The current block editor context.
*/
public function register_categories( $block_categories, $editor_context ) {
if ( INIT::EDITOR_CONTEXT_NAME === $editor_context->name ) {
$block_categories[] = array(
'slug' => 'woocommerce',
'title' => __( 'WooCommerce', 'woocommerce' ),
'icon' => null,
);
}
return $block_categories;
}
/**
* Get the block name without the "woocommerce/" prefix.
*
* @param string $block_name Block name.
*
* @return string
*/
private function remove_block_prefix( $block_name ) {
if ( 0 === strpos( $block_name, 'woocommerce/' ) ) {
return substr_replace( $block_name, '', 0, strlen( 'woocommerce/' ) );
}
return $block_name;
}
/**
* Register a single block.
*
* @param string $block_name Block name.
*
* @return WP_Block_Type|false The registered block type on success, or false on failure.
*/
private function register_block( $block_name ) {
$block_name = $this->remove_block_prefix( $block_name );
$block_json_file = $this->get_file_path( $block_name . '/block.json' );
if ( ! file_exists( $block_json_file ) ) {
return false;
}
// phpcs:disable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$metadata = json_decode( file_get_contents( $block_json_file ), true );
if ( ! is_array( $metadata ) || ! $metadata['name'] ) {
return false;
}
$registry = \WP_Block_Type_Registry::get_instance();
if ( $registry->is_registered( $metadata['name'] ) ) {
$registry->unregister( $metadata['name'] );
}
return register_block_type_from_metadata( $block_json_file );
}
}
Admin/Features/ProductBlockEditor/Init.php 0000644 00000015121 15153704477 0014545 0 ustar 00 <?php
/**
* WooCommerce Product Block Editor
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SimpleProductTemplate;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplateRegistry;
use WP_Block_Editor_Context;
/**
* Loads assets related to the product block editor.
*/
class Init {
/**
* The context name used to identify the editor.
*/
const EDITOR_CONTEXT_NAME = 'woocommerce/edit-product';
/**
* Supported post types.
*
* @var array
*/
private $supported_post_types = array( 'simple' );
/**
* Redirection controller.
*
* @var RedirectionController
*/
private $redirection_controller;
/**
* Constructor
*/
public function __construct() {
if ( Features::is_enabled( 'product-variation-management' ) ) {
array_push( $this->supported_post_types, 'variable' );
}
$this->redirection_controller = new RedirectionController( $this->supported_post_types );
if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {
// Register the product block template.
$template_registry = wc_get_container()->get( BlockTemplateRegistry::class );
$template_registry->register( new SimpleProductTemplate() );
if ( ! Features::is_enabled( 'new-product-management-experience' ) ) {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'dequeue_conflicting_styles' ), 100 );
add_action( 'get_edit_post_link', array( $this, 'update_edit_product_link' ), 10, 2 );
}
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_filter( 'woocommerce_register_post_type_product', array( $this, 'add_product_template' ) );
add_action( 'current_screen', array( $this, 'set_current_screen_to_block_editor_if_wc_admin' ) );
$block_registry = new BlockRegistry();
$block_registry->init();
$tracks = new Tracks();
$tracks->init();
}
}
/**
* Enqueue scripts needed for the product form block editor.
*/
public function enqueue_scripts() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
$post_type_object = get_post_type_object( 'product' );
$block_editor_context = new WP_Block_Editor_Context( array( 'name' => self::EDITOR_CONTEXT_NAME ) );
$editor_settings = array();
if ( ! empty( $post_type_object->template ) ) {
$editor_settings['template'] = $post_type_object->template;
$editor_settings['templateLock'] = ! empty( $post_type_object->template_lock ) ? $post_type_object->template_lock : false;
}
$editor_settings = get_block_editor_settings( $editor_settings, $block_editor_context );
$script_handle = 'wc-admin-edit-product';
wp_register_script( $script_handle, '', array(), '0.1.0', true );
wp_enqueue_script( $script_handle );
wp_add_inline_script(
$script_handle,
'var productBlockEditorSettings = productBlockEditorSettings || ' . wp_json_encode( $editor_settings ) . ';',
'before'
);
wp_add_inline_script(
$script_handle,
sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( $editor_settings['blockCategories'] ) ),
'before'
);
wp_tinymce_inline_scripts();
wp_enqueue_media();
}
/**
* Enqueue styles needed for the rich text editor.
*/
public function enqueue_styles() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
wp_enqueue_style( 'wp-edit-blocks' );
wp_enqueue_style( 'wp-format-library' );
wp_enqueue_editor();
/**
* Enqueue any block editor related assets.
*
* @since 7.1.0
*/
do_action( 'enqueue_block_editor_assets' );
}
/**
* Dequeue conflicting styles.
*/
public function dequeue_conflicting_styles() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Dequeing this to avoid conflicts, until we remove the 'woocommerce-page' class.
wp_dequeue_style( 'woocommerce-blocktheme' );
}
/**
* Update the edit product links when the new experience is enabled.
*
* @param string $link The edit link.
* @param int $post_id Post ID.
* @return string
*/
public function update_edit_product_link( $link, $post_id ) {
$product = wc_get_product( $post_id );
if ( ! $product ) {
return $link;
}
if ( $product->get_type() === 'simple' ) {
return admin_url( 'admin.php?page=wc-admin&path=/product/' . $product->get_id() );
}
return $link;
}
/**
* Enqueue styles needed for the rich text editor.
*
* @param array $args Array of post type arguments.
* @return array Array of post type arguments.
*/
public function add_product_template( $args ) {
if ( ! isset( $args['template'] ) ) {
// Get the template from the registry.
$template_registry = wc_get_container()->get( BlockTemplateRegistry::class );
$template = $template_registry->get_registered( 'simple-product' );
if ( isset( $template ) ) {
$args['template_lock'] = 'all';
$args['template'] = $template->get_formatted_template();
}
}
return $args;
}
/**
* Adds fields so that we can store user preferences for the variations block.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'variable_product_block_tour_shown',
'product_block_variable_options_notice_dismissed',
'variable_items_without_price_notice_dismissed'
)
);
}
/**
* Sets the current screen to the block editor if a wc-admin page.
*/
public function set_current_screen_to_block_editor_if_wc_admin() {
$screen = get_current_screen();
// phpcs:ignore Squiz.PHP.CommentedOutCode.Found
// (no idea why I need that phpcs:ignore above, but I'm tired trying to re-write this comment to get it to pass)
// we can't check the 'path' query param because client-side routing is used within wc-admin,
// so this action handler is only called on the initial page load from the server, which might
// not be the product edit page (it mostly likely isn't).
if ( PageController::is_admin_page() ) {
$screen->is_block_editor( true );
wp_add_inline_script(
'wp-blocks',
'wp.blocks && wp.blocks.unstable__bootstrapServerSideBlockDefinitions && wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( get_block_editor_server_block_settings() ) . ');'
);
}
}
}
Admin/Features/ProductBlockEditor/ProductTemplates/AbstractProductFormTemplate.php 0000644 00000003572 15153704477 0024574 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\AbstractBlockTemplate;
/**
* Block template class.
*/
abstract class AbstractProductFormTemplate extends AbstractBlockTemplate implements ProductFormTemplateInterface {
/**
* Get the template area.
*/
public function get_area(): string {
return 'product-form';
}
/**
* Get a group block by ID.
*
* @param string $group_id The group block ID.
* @throws \UnexpectedValueException If block is not of type GroupInterface.
*/
public function get_group_by_id( string $group_id ): ?GroupInterface {
$group = $this->get_block( $group_id );
if ( $group && ! $group instanceof GroupInterface ) {
throw new \UnexpectedValueException( 'Block with specified ID is not a group.' );
}
return $group;
}
/**
* Get a section block by ID.
*
* @param string $section_id The section block ID.
* @throws \UnexpectedValueException If block is not of type SectionInterface.
*/
public function get_section_by_id( string $section_id ): ?SectionInterface {
$section = $this->get_block( $section_id );
if ( $section && ! $section instanceof SectionInterface ) {
throw new \UnexpectedValueException( 'Block with specified ID is not a section.' );
}
return $section;
}
/**
* Get a block by ID.
*
* @param string $block_id The block block ID.
*/
public function get_block_by_id( string $block_id ): ?BlockInterface {
return $this->get_block( $block_id );
}
/**
* Add a custom block type to this template.
*
* @param array $block_config The block data.
*/
public function add_group( array $block_config ): GroupInterface {
$block = new Group( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}
Admin/Features/ProductBlockEditor/ProductTemplates/Group.php 0000644 00000003727 15153704477 0020246 0 ustar 00 <?php
/**
* WooCommerce Product Group Block class.
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\BlockContainerTrait;
/**
* Class for Group block.
*/
class Group extends ProductBlock implements GroupInterface {
use BlockContainerTrait;
/**
* Group Block constructor.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param ContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
* @throws \InvalidArgumentException If blockName key and value are passed into block configuration.
*/
public function __construct( array $config, BlockTemplateInterface &$root_template, ContainerInterface &$parent = null ) {
if ( ! empty( $config['blockName'] ) ) {
throw new \InvalidArgumentException( 'Unexpected key "blockName", this defaults to "woocommerce/product-tab".' );
}
if ( $config['id'] && ( empty( $config['attributes'] ) || empty( $config['attributes']['id'] ) ) ) {
$config['attributes'] = empty( $config['attributes'] ) ? [] : $config['attributes'];
$config['attributes']['id'] = $config['id'];
}
parent::__construct( array_merge( array( 'blockName' => 'woocommerce/product-tab' ), $config ), $root_template, $parent );
}
/**
* Add a section block type to this template.
*
* @param array $block_config The block data.
*/
public function add_section( array $block_config ): SectionInterface {
$block = new Section( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}
Admin/Features/ProductBlockEditor/ProductTemplates/GroupInterface.php 0000644 00000001312 15153704477 0022053 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
/**
* Interface for block containers.
*/
interface GroupInterface extends BlockContainerInterface {
/**
* Adds a new section block.
*
* @param array $block_config block config.
* @return SectionInterface new block section.
*/
public function add_section( array $block_config ): SectionInterface;
/**
* Adds a new block to the section block.
*
* @param array $block_config block config.
*/
public function add_block( array $block_config ): BlockInterface;
}
Admin/Features/ProductBlockEditor/ProductTemplates/ProductBlock.php 0000644 00000001523 15153704477 0021535 0 ustar 00 <?php
/**
* WooCommerce Product Block class.
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\AbstractBlock;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\BlockContainerTrait;
/**
* Class for Product block.
*/
class ProductBlock extends AbstractBlock implements ContainerInterface {
use BlockContainerTrait;
/**
* Adds block to the section block.
*
* @param array $block_config The block data.
*/
public function &add_block( array $block_config ): BlockInterface {
$block = new ProductBlock( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}
Admin/Features/ProductBlockEditor/ProductTemplates/ProductFormTemplateInterface.php 0000644 00000002124 15153704477 0024721 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Interface for block containers.
*/
interface ProductFormTemplateInterface extends BlockTemplateInterface {
/**
* Adds a new group block.
*
* @param array $block_config block config.
* @return BlockInterface new block section.
*/
public function add_group( array $block_config ): GroupInterface;
/**
* Gets Group block by id.
*
* @param string $group_id group id.
* @return GroupInterface|null
*/
public function get_group_by_id( string $group_id ): ?GroupInterface;
/**
* Gets Section block by id.
*
* @param string $section_id section id.
* @return SectionInterface|null
*/
public function get_section_by_id( string $section_id ): ?SectionInterface;
/**
* Gets Block by id.
*
* @param string $block_id block id.
* @return BlockInterface|null
*/
public function get_block_by_id( string $block_id ): ?BlockInterface;
}
Admin/Features/ProductBlockEditor/ProductTemplates/Section.php 0000644 00000003200 15153704477 0020540 0 ustar 00 <?php
/**
* WooCommerce Section Block class.
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Class for Section block.
*/
class Section extends ProductBlock implements SectionInterface {
/**
* Section Block constructor.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param ContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
* @throws \InvalidArgumentException If blockName key and value are passed into block configuration.
*/
public function __construct( array $config, BlockTemplateInterface &$root_template, ContainerInterface &$parent = null ) {
if ( ! empty( $config['blockName'] ) ) {
throw new \InvalidArgumentException( 'Unexpected key "blockName", this defaults to "woocommerce/product-section".' );
}
parent::__construct( array_merge( array( 'blockName' => 'woocommerce/product-section' ), $config ), $root_template, $parent );
}
/**
* Add a section block type to this template.
*
* @param array $block_config The block data.
*/
public function add_section( array $block_config ): SectionInterface {
$block = new Section( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}
Admin/Features/ProductBlockEditor/ProductTemplates/SectionInterface.php 0000644 00000001315 15153704477 0022366 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
/**
* Interface for block containers.
*/
interface SectionInterface extends BlockContainerInterface {
/**
* Adds a new section block.
*
* @param array $block_config block config.
* @return SectionInterface new block section.
*/
public function add_section( array $block_config ): SectionInterface;
/**
* Adds a new block to the section block.
*
* @param array $block_config block config.
*/
public function add_block( array $block_config ): BlockInterface;
}
Admin/Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.php 0000644 00000057715 15153704477 0023446 0 ustar 00 <?php
/**
* SimpleProductTemplate
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates;
use Automattic\WooCommerce\Admin\Features\Features;
/**
* Simple Product Template.
*/
class SimpleProductTemplate extends AbstractProductFormTemplate implements ProductFormTemplateInterface {
/**
* The context name used to identify the editor.
*/
const GROUP_IDS = array(
'GENERAL' => 'general',
'ORGANIZATION' => 'organization',
'PRICING' => 'pricing',
'INVENTORY' => 'inventory',
'SHIPPING' => 'shipping',
'VARIATIONS' => 'variations',
);
/**
* SimpleProductTemplate constructor.
*/
public function __construct() {
$this->add_group_blocks();
$this->add_general_group_blocks();
$this->add_organization_group_blocks();
$this->add_pricing_group_blocks();
$this->add_inventory_group_blocks();
$this->add_shipping_group_blocks();
$this->add_variation_group_blocks();
}
/**
* Get the template ID.
*/
public function get_id(): string {
return 'simple-product';
}
/**
* Get the template title.
*/
public function get_title(): string {
return __( 'Simple Product Template', 'woocommerce' );
}
/**
* Get the template description.
*/
public function get_description(): string {
return __( 'Template for the simple product form', 'woocommerce' );
}
/**
* Adds the group blocks to the template.
*/
private function add_group_blocks() {
$this->add_group(
[
'id' => $this::GROUP_IDS['GENERAL'],
'order' => 10,
'attributes' => [
'title' => __( 'General', 'woocommerce' ),
],
]
);
$this->add_group(
[
'id' => $this::GROUP_IDS['ORGANIZATION'],
'order' => 15,
'attributes' => [
'title' => __( 'Organization', 'woocommerce' ),
],
]
);
$this->add_group(
[
'id' => $this::GROUP_IDS['PRICING'],
'order' => 20,
'attributes' => [
'title' => __( 'Pricing', 'woocommerce' ),
],
]
);
$this->add_group(
[
'id' => $this::GROUP_IDS['INVENTORY'],
'order' => 30,
'attributes' => [
'title' => __( 'Inventory', 'woocommerce' ),
],
]
);
$this->add_group(
[
'id' => $this::GROUP_IDS['SHIPPING'],
'order' => 40,
'attributes' => [
'title' => __( 'Shipping', 'woocommerce' ),
],
]
);
if ( Features::is_enabled( 'product-variation-management' ) ) {
$this->add_group(
[
'id' => $this::GROUP_IDS['VARIATIONS'],
'order' => 50,
'attributes' => [
'title' => __( 'Variations', 'woocommerce' ),
],
]
);
}
}
/**
* Adds the general group blocks to the template.
*/
private function add_general_group_blocks() {
$general_group = $this->get_group_by_id( $this::GROUP_IDS['GENERAL'] );
// Basic Details Section.
$basic_details = $general_group->add_section(
[
'id' => 'basic-details',
'order' => 10,
'attributes' => [
'title' => __( 'Basic details', 'woocommerce' ),
'description' => __( 'This info will be displayed on the product page, category pages, social media, and search results.', 'woocommerce' ),
],
]
);
$basic_details->add_block(
[
'id' => 'product-name',
'blockName' => 'woocommerce/product-name-field',
'order' => 10,
'attributes' => [
'name' => 'Product name',
'autoFocus' => true,
],
]
);
$basic_details->add_block(
[
'id' => 'product-summary',
'blockName' => 'woocommerce/product-summary-field',
'order' => 20,
]
);
$pricing_columns = $basic_details->add_block(
[
'id' => 'product-pricing-columns',
'blockName' => 'core/columns',
'order' => 30,
]
);
$pricing_column_1 = $pricing_columns->add_block(
[
'id' => 'product-pricing-column-1',
'blockName' => 'core/column',
'order' => 10,
'attributes' => [
'templateLock' => 'all',
],
]
);
$pricing_column_1->add_block(
[
'id' => 'product-regular-price',
'blockName' => 'woocommerce/product-regular-price-field',
'order' => 10,
'attributes' => [
'name' => 'regular_price',
'label' => __( 'List price', 'woocommerce' ),
/* translators: PricingTab: This is a link tag to the pricing tab. */
'help' => __( 'Manage more settings in <PricingTab>Pricing.</PricingTab>', 'woocommerce' ),
],
]
);
$pricing_column_2 = $pricing_columns->add_block(
[
'id' => 'product-pricing-column-2',
'blockName' => 'core/column',
'order' => 20,
'attributes' => [
'templateLock' => 'all',
],
]
);
$pricing_column_2->add_block(
[
'id' => 'product-sale-price',
'blockName' => 'woocommerce/product-sale-price-field',
'order' => 10,
'attributes' => [
'label' => __( 'Sale price', 'woocommerce' ),
],
]
);
// Description section.
$description_section = $general_group->add_section(
[
'id' => 'product-description-section',
'order' => 20,
'attributes' => [
'title' => __( 'Description', 'woocommerce' ),
'description' => __( 'What makes this product unique? What are its most important features? Enrich the product page by adding rich content using blocks.', 'woocommerce' ),
],
]
);
$description_section->add_block(
[
'id' => 'product-description',
'blockName' => 'woocommerce/product-description-field',
'order' => 10,
]
);
// Images section.
$images_section = $general_group->add_section(
[
'id' => 'product-images-section',
'order' => 30,
'attributes' => [
'title' => __( 'Images', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Images guide link opening tag. %2$s: Images guide link closing tag. */
__( 'Drag images, upload new ones or select files from your library. For best results, use JPEG files that are 1000 by 1000 pixels or larger. %1$sHow to prepare images?%2$s', 'woocommerce' ),
'<a href="http://woocommerce.com/#" target="_blank" rel="noreferrer">',
'</a>'
),
],
]
);
$images_section->add_block(
[
'id' => 'product-images',
'blockName' => 'woocommerce/product-images-field',
'order' => 10,
'attributes' => [
'images' => [],
],
]
);
}
/**
* Adds the organization group blocks to the template.
*/
private function add_organization_group_blocks() {
$organization_group = $this->get_group_by_id( $this::GROUP_IDS['ORGANIZATION'] );
// Product Catalog Section.
$product_catalog_section = $organization_group->add_section(
[
'id' => 'product-catalog-section',
'order' => 10,
'attributes' => [
'title' => __( 'Product catalog', 'woocommerce' ),
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-categories',
'blockName' => 'woocommerce/product-taxonomy-field',
'order' => 10,
'attributes' => [
'slug' => 'product_cat',
'property' => 'categories',
'label' => __( 'Categories', 'woocommerce' ),
'createTitle' => __( 'Create new category', 'woocommerce' ),
'dialogNameHelpText' => __( 'Shown to customers on the product page.', 'woocommerce' ),
'parentTaxonomyText' => __( 'Parent category', 'woocommerce' ),
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-tags',
'blockName' => 'woocommerce/product-tag-field',
'attributes' => [
'name' => 'tags',
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-catalog-search-visibility',
'blockName' => 'woocommerce/product-catalog-visibility-field',
'order' => 20,
'attributes' => [
'label' => __( 'Hide in product catalog', 'woocommerce' ),
'visibility' => 'search',
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-catalog-catalog-visibility',
'blockName' => 'woocommerce/product-catalog-visibility-field',
'order' => 30,
'attributes' => [
'label' => __( 'Hide from search results', 'woocommerce' ),
'visibility' => 'catalog',
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-enable-product-reviews',
'blockName' => 'woocommerce/product-checkbox-field',
'order' => 40,
'attributes' => [
'label' => __( 'Enable product reviews', 'woocommerce' ),
'property' => 'reviews_allowed',
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-post-password',
'blockName' => 'woocommerce/product-password-field',
'order' => 50,
'attributes' => [
'label' => __( 'Require a password', 'woocommerce' ),
],
]
);
// Attributes section.
$product_catalog_section = $organization_group->add_section(
[
'id' => 'product-attributes-section',
'order' => 20,
'attributes' => [
'title' => __( 'Attributes', 'woocommerce' ),
],
]
);
$product_catalog_section->add_block(
[
'id' => 'product-attributes',
'blockName' => 'woocommerce/product-attributes-field',
'order' => 10,
]
);
}
/**
* Adds the pricing group blocks to the template.
*/
private function add_pricing_group_blocks() {
$pricing_group = $this->get_group_by_id( $this::GROUP_IDS['PRICING'] );
$pricing_group->add_block(
[
'id' => 'pricing-has-variations-notice',
'blockName' => 'woocommerce/product-has-variations-notice',
'order' => 10,
'attributes' => [
'content' => __( 'This product has options, such as size or color. You can now manage each variation\'s price and other details individually.', 'woocommerce' ),
'buttonText' => __( 'Go to Variations', 'woocommerce' ),
'type' => 'info',
],
]
);
// Product Pricing Section.
$product_pricing_section = $pricing_group->add_section(
[
'id' => 'product-pricing-section',
'order' => 20,
'attributes' => [
'title' => __( 'Pricing', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Images guide link opening tag. %2$s: Images guide link closing tag.*/
__( 'Set a competitive price, put the product on sale, and manage tax calculations. %1$sHow to price your product?%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/posts/how-to-price-products-strategies-expert-tips/" target="_blank" rel="noreferrer">',
'</a>'
),
'blockGap' => 'unit-40',
],
]
);
$pricing_columns = $product_pricing_section->add_block(
[
'id' => 'product-pricing-group-pricing-columns',
'blockName' => 'core/columns',
'order' => 10,
]
);
$pricing_column_1 = $pricing_columns->add_block(
[
'id' => 'product-pricing-group-pricing-column-1',
'blockName' => 'core/column',
'order' => 10,
'attributes' => [
'templateLock' => 'all',
],
]
);
$pricing_column_1->add_block(
[
'id' => 'product-pricing-regular-price',
'blockName' => 'woocommerce/product-regular-price-field',
'order' => 10,
'attributes' => [
'name' => 'regular_price',
'label' => __( 'List price', 'woocommerce' ),
],
]
);
$pricing_column_2 = $pricing_columns->add_block(
[
'id' => 'product-pricing-group-pricing-column-2',
'blockName' => 'core/column',
'order' => 20,
'attributes' => [
'templateLock' => 'all',
],
]
);
$pricing_column_2->add_block(
[
'id' => 'product-pricing-sale-price',
'blockName' => 'woocommerce/product-sale-price-field',
'order' => 10,
'attributes' => [
'label' => __( 'Sale price', 'woocommerce' ),
],
]
);
$product_pricing_section->add_block(
[
'id' => 'product-pricing-schedule-sale-fields',
'blockName' => 'woocommerce/product-schedule-sale-fields',
'order' => 20,
]
);
$product_pricing_section->add_block(
[
'id' => 'product-sale-tax',
'blockName' => 'woocommerce/product-radio-field',
'order' => 30,
'attributes' => [
'title' => __( 'Charge sales tax on', 'woocommerce' ),
'property' => 'tax_status',
'options' => [
[
'label' => __( 'Product and shipping', 'woocommerce' ),
'value' => 'taxable',
],
[
'label' => __( 'Only shipping', 'woocommerce' ),
'value' => 'shipping',
],
[
'label' => __( "Don't charge tax", 'woocommerce' ),
'value' => 'none',
],
],
],
]
);
$pricing_advanced_block = $product_pricing_section->add_block(
[
'id' => 'product-pricing-advanced',
'blockName' => 'woocommerce/product-collapsible',
'order' => 40,
'attributes' => [
'toggleText' => __( 'Advanced', 'woocommerce' ),
'initialCollapsed' => true,
'persistRender' => true,
],
]
);
$pricing_advanced_block->add_block(
[
'id' => 'product-tax-class',
'blockName' => 'woocommerce/product-radio-field',
'order' => 10,
'attributes' => [
'title' => __( 'Tax class', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
__( 'Apply a tax rate if this product qualifies for tax reduction or exemption. %1$sLearn more%2$s.', 'woocommerce' ),
'<a href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class" target="_blank" rel="noreferrer">',
'</a>'
),
'property' => 'tax_class',
'options' => [
[
'label' => __( 'Standard', 'woocommerce' ),
'value' => '',
],
[
'label' => __( 'Reduced rate', 'woocommerce' ),
'value' => 'reduced-rate',
],
[
'label' => __( 'Zero rate', 'woocommerce' ),
'value' => 'zero-rate',
],
],
],
]
);
}
/**
* Adds the inventory group blocks to the template.
*/
private function add_inventory_group_blocks() {
$inventory_group = $this->get_group_by_id( $this::GROUP_IDS['INVENTORY'] );
$inventory_group->add_block(
[
'id' => 'product_variation_notice_inventory_tab',
'blockName' => 'woocommerce/product-has-variations-notice',
'order' => 10,
'attributes' => [
'content' => __( 'This product has options, such as size or color. You can now manage each variation\'s price and other details individually.', 'woocommerce' ),
'buttonText' => __( 'Go to Variations', 'woocommerce' ),
'type' => 'info',
],
]
);
// Product Pricing Section.
$product_inventory_section = $inventory_group->add_section(
[
'id' => 'product-inventory-section',
'order' => 20,
'attributes' => [
'title' => __( 'Inventory', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Inventory settings link opening tag. %2$s: Inventory settings link closing tag.*/
__( 'Set up and manage inventory for this product, including status and available quantity. %1$sManage store inventory settings%2$s', 'woocommerce' ),
'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products§ion=inventory' ) . '" target="_blank" rel="noreferrer">',
'</a>'
),
'blockGap' => 'unit-40',
],
]
);
$product_inventory_inner_section = $product_inventory_section->add_section(
[
'id' => 'product-inventory-inner-section',
'order' => 10,
]
);
$product_inventory_inner_section->add_block(
[
'id' => 'product-sku-field',
'blockName' => 'woocommerce/product-sku-field',
'order' => 10,
]
);
$product_inventory_inner_section->add_block(
[
'id' => 'product-track-stock',
'blockName' => 'woocommerce/product-toggle-field',
'order' => 20,
'attributes' => [
'label' => __( 'Track stock quantity for this product', 'woocommerce' ),
'property' => 'manage_stock',
'disabled' => 'yes' !== get_option( 'woocommerce_manage_stock' ),
'disabledCopy' => sprintf(
/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
__( 'Per your %1$sstore settings%2$s, inventory management is <strong>disabled</strong>.', 'woocommerce' ),
'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products§ion=inventory' ) . '" target="_blank" rel="noreferrer">',
'</a>'
),
],
]
);
$product_inventory_quantity_conditional = $product_inventory_inner_section->add_block(
[
'id' => 'product-inventory-quantity-conditional-wrapper',
'blockName' => 'woocommerce/conditional',
'order' => 30,
'attributes' => [
'mustMatch' => [
'manage_stock' => [ true ],
],
],
]
);
$product_inventory_quantity_conditional->add_block(
[
'id' => 'product-inventory-quantity',
'blockName' => 'woocommerce/product-inventory-quantity-field',
'order' => 10,
]
);
$product_stock_status_conditional = $product_inventory_section->add_block(
[
'id' => 'product-stock-status-conditional-wrapper',
'blockName' => 'woocommerce/conditional',
'order' => 20,
'attributes' => [
'mustMatch' => [
'manage_stock' => [ false ],
],
],
]
);
$product_stock_status_conditional->add_block(
[
'id' => 'product-stock-status',
'blockName' => 'woocommerce/product-radio-field',
'order' => 10,
'attributes' => [
'title' => __( 'Stock status', 'woocommerce' ),
'property' => 'stock_status',
'options' => [
[
'label' => __( 'In stock', 'woocommerce' ),
'value' => 'instock',
],
[
'label' => __( 'Out of stock', 'woocommerce' ),
'value' => 'outofstock',
],
[
'label' => __( 'On backorder', 'woocommerce' ),
'value' => 'onbackorder',
],
],
],
]
);
$product_inventory_advanced = $product_inventory_section->add_block(
[
'id' => 'product-inventory-advanced',
'blockName' => 'woocommerce/product-collapsible',
'order' => 30,
'attributes' => [
'toggleText' => __( 'Advanced', 'woocommerce' ),
'initialCollapsed' => true,
'persistRender' => true,
],
]
);
$product_inventory_advanced_wrapper = $product_inventory_advanced->add_block(
[
'blockName' => 'woocommerce/product-section',
'order' => 10,
'attributes' => [
'blockGap' => 'unit-40',
],
]
);
$product_out_of_stock_conditional = $product_inventory_advanced_wrapper->add_block(
[
'id' => 'product-out-of-stock-conditional-wrapper',
'blockName' => 'woocommerce/conditional',
'order' => 10,
'attributes' => [
'mustMatch' => [
'manage_stock' => [ true ],
],
],
]
);
$product_out_of_stock_conditional->add_block(
[
'id' => 'product-out-of-stock',
'blockName' => 'woocommerce/product-radio-field',
'order' => 10,
'attributes' => [
'title' => __( 'When out of stock', 'woocommerce' ),
'property' => 'backorders',
'options' => [
[
'label' => __( 'Allow purchases', 'woocommerce' ),
'value' => 'yes',
],
[
'label' => __(
'Allow purchases, but notify customers',
'woocommerce'
),
'value' => 'notify',
],
[
'label' => __( "Don't allow purchases", 'woocommerce' ),
'value' => 'no',
],
],
],
]
);
$product_out_of_stock_conditional->add_block(
[
'id' => 'product-inventory-email',
'blockName' => 'woocommerce/product-inventory-email-field',
'order' => 20,
]
);
$product_inventory_advanced_wrapper->add_block(
[
'id' => 'product-limit-purchase',
'blockName' => 'woocommerce/product-checkbox-field',
'order' => 20,
'attributes' => [
'title' => __(
'Restrictions',
'woocommerce'
),
'label' => __(
'Limit purchases to 1 item per order',
'woocommerce'
),
'property' => 'sold_individually',
'tooltip' => __(
'When checked, customers will be able to purchase only 1 item in a single order. This is particularly useful for items that have limited quantity, like art or handmade goods.',
'woocommerce'
),
],
]
);
}
/**
* Adds the shipping group blocks to the template.
*/
private function add_shipping_group_blocks() {
$shipping_group = $this->get_group_by_id( $this::GROUP_IDS['SHIPPING'] );
$shipping_group->add_block(
[
'id' => 'product_variation_notice_shipping_tab',
'blockName' => 'woocommerce/product-has-variations-notice',
'order' => 10,
'attributes' => [
'content' => __( 'This product has options, such as size or color. You can now manage each variation\'s price and other details individually.', 'woocommerce' ),
'buttonText' => __( 'Go to Variations', 'woocommerce' ),
'type' => 'info',
],
]
);
// Product Pricing Section.
$product_fee_and_dimensions_section = $shipping_group->add_section(
[
'id' => 'product-fee-and-dimensions-section',
'order' => 20,
'attributes' => [
'title' => __( 'Fees & dimensions', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: How to get started? link opening tag. %2$s: How to get started? link closing tag.*/
__( 'Set up shipping costs and enter dimensions used for accurate rate calculations. %1$sHow to get started?%2$s.', 'woocommerce' ),
'<a href="https://woocommerce.com/posts/how-to-calculate-shipping-costs-for-your-woocommerce-store/" target="_blank" rel="noreferrer">',
'</a>'
),
],
]
);
$product_fee_and_dimensions_section->add_block(
[
'id' => 'product-shipping-class',
'blockName' => 'woocommerce/product-shipping-class-field',
'order' => 10,
]
);
$product_fee_and_dimensions_section->add_block(
[
'id' => 'product-shipping-dimensions',
'blockName' => 'woocommerce/product-shipping-dimensions-fields',
'order' => 20,
]
);
}
/**
* Adds the variation group blocks to the template.
*/
private function add_variation_group_blocks() {
$variation_group = $this->get_group_by_id( $this::GROUP_IDS['VARIATIONS'] );
if ( ! $variation_group ) {
return;
}
$variation_fields = $variation_group->add_block(
[
'id' => 'product_variation-field-group',
'blockName' => 'woocommerce/product-variations-fields',
'order' => 10,
'attributes' => [
'description' => sprintf(
/* translators: %1$s: Sell your product in multiple variations like size or color. strong opening tag. %2$s: Sell your product in multiple variations like size or color. strong closing tag.*/
__( '%1$sSell your product in multiple variations like size or color.%2$s Get started by adding options for the buyers to choose on the product page.', 'woocommerce' ),
'<strong>',
'</strong>'
),
],
]
);
$variation_options_section = $variation_fields->add_block(
[
'id' => 'product-variation-options-section',
'blockName' => 'woocommerce/product-section',
'order' => 10,
'attributes' => [
'title' => __( 'Variation options', 'woocommerce' ),
],
]
);
$variation_options_section->add_block(
[
'id' => 'product-variation-options',
'blockName' => 'woocommerce/product-variations-options-field',
]
);
$variation_section = $variation_fields->add_block(
[
'id' => 'product-variation-section',
'blockName' => 'woocommerce/product-section',
'order' => 20,
'attributes' => [
'title' => __( 'Variations', 'woocommerce' ),
],
]
);
$variation_section->add_block(
[
'id' => 'product-variation-items',
'blockName' => 'woocommerce/product-variation-items-field',
'order' => 10,
]
);
}
}
Admin/Features/ProductBlockEditor/RedirectionController.php 0000644 00000010131 15153704477 0020151 0 ustar 00 <?php
/**
* WooCommerce Product Editor Redirection Controller
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Handle redirecting to the old or new editor based on features and support.
*/
class RedirectionController {
/**
* Supported post types.
*
* @var array
*/
private $supported_post_types;
/**
* Set up the hooks used for redirection.
*
* @param array $supported_post_types Array of supported post types.
*/
public function __construct( $supported_post_types ) {
$this->supported_post_types = $supported_post_types;
if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {
add_action( 'current_screen', array( $this, 'maybe_redirect_to_new_editor' ), 30, 0 );
add_action( 'current_screen', array( $this, 'redirect_non_supported_product_types' ), 30, 0 );
} else {
add_action( 'current_screen', array( $this, 'maybe_redirect_to_old_editor' ), 30, 0 );
}
}
/**
* Check if the current screen is the legacy add product screen.
*/
protected function is_legacy_add_new_screen(): bool {
$screen = get_current_screen();
return 'post' === $screen->base && 'product' === $screen->post_type && 'add' === $screen->action;
}
/**
* Check if the current screen is the legacy edit product screen.
*/
protected function is_legacy_edit_screen(): bool {
$screen = get_current_screen();
return 'post' === $screen->base
&& 'product' === $screen->post_type
&& isset( $_GET['post'] )
&& isset( $_GET['action'] )
&& 'edit' === $_GET['action'];
}
/**
* Check if a product is supported by the new experience.
*
* @param integer $product_id Product ID.
*/
protected function is_product_supported( $product_id ): bool {
$product = $product_id ? wc_get_product( $product_id ) : null;
$digital_product = $product->is_downloadable() || $product->is_virtual();
return $product && in_array( $product->get_type(), $this->supported_post_types, true ) && ! $digital_product;
}
/**
* Redirects from old product form to the new product form if the
* feature `product_block_editor` is enabled.
*/
public function maybe_redirect_to_new_editor(): void {
if ( $this->is_legacy_add_new_screen() ) {
wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&path=/add-product' ) );
exit();
}
if ( $this->is_legacy_edit_screen() ) {
$product_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : null;
if ( ! $this->is_product_supported( $product_id ) ) {
return;
}
wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&path=/product/' . $product_id ) );
exit();
}
}
/**
* Redirects from new product form to the old product form if the
* feature `product_block_editor` is enabled.
*/
public function maybe_redirect_to_old_editor(): void {
$route = $this->get_parsed_route();
if ( 'add-product' === $route['page'] ) {
wp_safe_redirect( admin_url( 'post-new.php?post_type=product' ) );
exit();
}
if ( 'product' === $route['page'] ) {
wp_safe_redirect( admin_url( 'post.php?post=' . $route['product_id'] . '&action=edit' ) );
exit();
}
}
/**
* Get the parsed WooCommerce Admin path.
*/
protected function get_parsed_route(): array {
if ( ! \Automattic\WooCommerce\Admin\PageController::is_admin_page() || ! isset( $_GET['path'] ) ) {
return array(
'page' => null,
'product_id' => null,
);
}
$path = esc_url_raw( wp_unslash( $_GET['path'] ) );
$path_pieces = explode( '/', wp_parse_url( $path, PHP_URL_PATH ) );
return array(
'page' => $path_pieces[1],
'product_id' => 'product' === $path_pieces[1] ? absint( $path_pieces[2] ) : null,
);
}
/**
* Redirect non supported product types to legacy editor.
*/
public function redirect_non_supported_product_types(): void {
$route = $this->get_parsed_route();
$product_id = $route['product_id'];
if ( 'product' === $route['page'] && ! $this->is_product_supported( $product_id ) ) {
wp_safe_redirect( admin_url( 'post.php?post=' . $route['product_id'] . '&action=edit' ) );
exit();
}
}
}
Admin/Features/ProductBlockEditor/Tracks.php 0000644 00000002263 15153704477 0015074 0 ustar 00 <?php
/**
* WooCommerce Product Block Editor
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor;
/**
* Add tracks for the product block editor.
*/
class Tracks {
/**
* Initialize the tracks.
*/
public function init() {
add_filter( 'woocommerce_product_source', array( $this, 'add_product_source' ) );
}
/**
* Check if a URL is a product editor page.
*
* @param string $url Url to check.
* @return boolean
*/
protected function is_product_editor_page( $url ) {
$query_string = wp_parse_url( wp_get_referer(), PHP_URL_QUERY );
parse_str( $query_string, $query );
if ( ! isset( $query['page'] ) || 'wc-admin' !== $query['page'] || ! isset( $query['path'] ) ) {
return false;
}
$path_pieces = explode( '/', $query['path'] );
$route = $path_pieces[1];
return 'add-product' === $route || 'product' === $route;
}
/**
* Update the product source if we're on the product editor page.
*
* @param string $source Source of product.
* @return string
*/
public function add_product_source( $source ) {
if ( $this->is_product_editor_page( wp_get_referer() ) ) {
return 'product-block-editor-v1';
}
return $source;
}
}
Admin/Features/ShippingPartnerSuggestions/DefaultShippingPartners.php 0000644 00000020727 15153704477 0022265 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions;
/**
* Default Shipping Partners
*/
class DefaultShippingPartners {
/**
* Get default specs.
*
* @return array Default specs.
*/
public static function get_all() {
$asset_base_url = WC()->plugin_url() . '/assets/images/shipping_partners/';
$column_layout_features = array(
array(
'icon' => $asset_base_url . 'timer.svg',
'title' => __( 'Save time', 'woocommerce' ),
'description' => __(
'Automatically import order information to quickly print your labels.',
'woocommerce'
),
),
array(
'icon' => $asset_base_url . 'discount.svg',
'title' => __( 'Save money', 'woocommerce' ),
'description' => __(
'Shop for the best shipping rates, and access pre-negotiated discounted rates.',
'woocommerce'
),
),
array(
'icon' => $asset_base_url . 'star.svg',
'title' => __( 'Wow your shoppers', 'woocommerce' ),
'description' => __(
'Keep your customers informed with tracking notifications.',
'woocommerce'
),
),
);
$check_icon = $asset_base_url . 'check.svg';
return array(
array(
'name' => 'ShipStation',
'slug' => 'woocommerce-shipstation-integration',
'description' => __( 'Powerful yet easy-to-use solution:', 'woocommerce' ),
'layout_column' => array(
'image' => $asset_base_url . 'shipstation-column.svg',
'features' => $column_layout_features,
),
'layout_row' => array(
'image' => $asset_base_url . 'shipstation-row.svg',
'features' => array(
array(
'icon' => $check_icon,
'description' => __(
'Print labels from Royal Mail, Parcel Force, DPD, and many more',
'woocommerce'
),
),
array(
'icon' => $check_icon,
'description' => __(
'Shop for the best rates, in real-time',
'woocommerce'
),
),
array(
'icon' => $check_icon,
'description' => __( 'Connect selling channels easily', 'woocommerce' ),
),
array(
'icon' => $check_icon,
'description' => __( 'Advance automated workflows', 'woocommerce' ),
),
array(
'icon' => $check_icon,
'description' => __( '30-days free trial', 'woocommerce' ),
),
),
),
'learn_more_link' => 'https://wordpress.org/plugins/woocommerce-shipstation-integration/',
'is_visible' => array(
self::get_rules_for_countries( array( 'AU', 'CA', 'GB' ) ),
),
'available_layouts' => array( 'row', 'column' ),
),
array(
'name' => 'Skydropx',
'slug' => 'skydropx-cotizador-y-envios',
'layout_column' => array(
'image' => $asset_base_url . 'skydropx-column.svg',
'features' => $column_layout_features,
),
'description' => '',
'learn_more_link' => 'https://wordpress.org/plugins/skydropx-cotizador-y-envios/',
'is_visible' => array(
self::get_rules_for_countries( array( 'MX', 'CO' ) ),
),
'available_layouts' => array( 'column' ),
),
array(
'name' => 'Envia',
'slug' => '',
'description' => '',
'layout_column' => array(
'image' => $asset_base_url . 'envia-column.svg',
'features' => $column_layout_features,
),
'learn_more_link' => 'https://woocommerce.com/products/envia-shipping-and-fulfillment/',
'is_visible' => array(
self::get_rules_for_countries( array( 'CL', 'AR', 'PE', 'BR', 'UY', 'GT' ) ),
),
'available_layouts' => array( 'column' ),
),
array(
'name' => 'Sendcloud',
'slug' => 'sendcloud-shipping',
'description' => __( 'All-in-one shipping tool:', 'woocommerce' ),
'layout_column' => array(
'image' => $asset_base_url . 'sendcloud-column.svg',
'features' => $column_layout_features,
),
'layout_row' => array(
'image' => $asset_base_url . 'sendcloud-row.svg',
'features' => array(
array(
'icon' => $check_icon,
'description' => __( 'Print labels from 80+ carriers', 'woocommerce' ),
),
array(
'icon' => $check_icon,
'description' => __(
'Process orders in just a few clicks',
'woocommerce'
),
),
array(
'icon' => $check_icon,
'description' => __( 'Customize checkout options', 'woocommerce' ),
),
array(
'icon' => $check_icon,
'description' => __( 'Self-service tracking & returns', 'woocommerce' ),
),
array(
'icon' => $check_icon,
'description' => __( 'Start with a free plan', 'woocommerce' ),
),
),
),
'learn_more_link' => 'https://wordpress.org/plugins/sendcloud-shipping/',
'is_visible' => array(
self::get_rules_for_countries( array( 'NL', 'AT', 'BE', 'FR', 'DE', 'ES', 'GB', 'IT' ) ),
),
'available_layouts' => array( 'row', 'column' ),
),
array(
'name' => 'Packlink',
'slug' => 'packlink-pro-shipping',
'description' => __( 'Optimize your full shipping process:', 'woocommerce' ),
'layout_column' => array(
'image' => $asset_base_url . 'packlink-column.svg',
'features' => $column_layout_features,
),
'layout_row' => array(
'image' => $asset_base_url . 'packlink-row.svg',
'features' => array(
array(
'icon' => $check_icon,
'description' => __(
'Automated, real-time order import',
'woocommerce'
),
),
array(
'icon' => $check_icon,
'description' => __(
'Direct access to leading carriers',
'woocommerce'
),
),
array(
'icon' => $check_icon,
'description' => __(
'Access competitive shipping prices',
'woocommerce'
),
),
array(
'icon' => $check_icon,
'description' => __( 'Quickly bulk print labels', 'woocommerce' ),
),
array(
'icon' => $check_icon,
'description' => __( 'Free shipping platform', 'woocommerce' ),
),
),
),
'learn_more_link' => 'https://wordpress.org/plugins/packlink-pro-shipping/',
'is_visible' => array(
self::get_rules_for_countries( array( 'FR', 'DE', 'ES', 'IT' ) ),
),
'available_layouts' => array( 'row', 'column' ),
),
array(
'name' => 'WooCommerce Shipping',
'slug' => 'woocommerce-services',
'description' => __( 'Save time and money by printing your shipping labels right from your computer with WooCommerce Shipping. Try WooCommerce Shipping for free.', 'woocommerce' ),
'dependencies' => array( 'jetpack' ),
'layout_column' => array(
'image' => $asset_base_url . 'wcs-column.svg',
'features' => array(
array(
'icon' => $asset_base_url . 'printer.svg',
'title' => __( 'Buy postage when you need it', 'woocommerce' ),
'description' => __( 'No need to wonder where that stampbook went.', 'woocommerce' ),
),
array(
'icon' => $asset_base_url . 'paper.svg',
'title' => __( 'Print at home', 'woocommerce' ),
'description' => __( 'Pick up an order, then just pay, print, package and post.', 'woocommerce' ),
),
array(
'icon' => $asset_base_url . 'discount.svg',
'title' => __( 'Discounted rates', 'woocommerce' ),
'description' => __( 'Access discounted shipping rates with DHL and USPS.', 'woocommerce' ),
),
),
),
'learn_more_link' => 'https://woocommerce.com/products/shipping/',
'is_visible' => array(
self::get_rules_for_countries( array( 'US' ) ),
),
'available_layouts' => array( 'column' ),
),
);
}
/**
* Get rules that match the store base location to one of the provided countries.
*
* @param array $countries Array of countries to match.
* @return object Rules to match.
*/
public static function get_rules_for_countries( $countries ) {
$rules = array();
foreach ( $countries as $country ) {
$rules[] = (object) array(
'type' => 'base_location_country',
'value' => $country,
'operation' => '=',
);
}
return (object) array(
'type' => 'or',
'operands' => $rules,
);
}
}
Admin/Features/ShippingPartnerSuggestions/ShippingPartnerSuggestions.php 0000644 00000003676 15153704477 0023034 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RuleEvaluator;
/**
* Class ShippingPartnerSuggestions
*/
class ShippingPartnerSuggestions {
/**
* Go through the specs and run them.
*
* @param array|null $specs shipping partner suggestion spec array.
* @return array
*/
public static function get_suggestions( $specs = null ) {
$suggestions = array();
if ( null === $specs ) {
$specs = self::get_specs_from_datasource();
}
$rule_evaluator = new RuleEvaluator();
foreach ( $specs as &$spec ) {
$spec = is_array( $spec ) ? (object) $spec : $spec;
if ( isset( $spec->is_visible ) ) {
$is_visible = $rule_evaluator->evaluate( $spec->is_visible );
if ( $is_visible ) {
$spec->is_visible = true;
$suggestions[] = $spec;
}
}
}
return $suggestions;
}
/**
* Get specs or fetch remotely if they don't exist.
*/
public static function get_specs_from_datasource() {
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
/**
* It can be used to modify shipping partner suggestions spec.
*
* @since 7.4.1
*/
return apply_filters( 'woocommerce_admin_shipping_partner_suggestions_specs', DefaultShippingPartners::get_all() );
}
$specs = ShippingPartnerSuggestionsDataSourcePoller::get_instance()->get_specs_from_data_sources();
// Fetch specs if they don't yet exist.
if ( false === $specs || ! is_array( $specs ) || 0 === count( $specs ) ) {
/**
* It can be used to modify shipping partner suggestions spec.
*
* @since 7.4.1
*/
return apply_filters( 'woocommerce_admin_shipping_partner_suggestions_specs', DefaultShippingPartners::get_all() );
}
/**
* It can be used to modify shipping partner suggestions spec.
*
* @since 7.4.1
*/
return apply_filters( 'woocommerce_admin_shipping_partner_suggestions_specs', $specs );
}
}
Admin/Features/ShippingPartnerSuggestions/ShippingPartnerSuggestionsDataSourcePoller.php 0000644 00000001552 15153704477 0026154 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions;
use Automattic\WooCommerce\Admin\DataSourcePoller;
/**
* Specs data source poller class for shipping partner suggestions.
*/
class ShippingPartnerSuggestionsDataSourcePoller extends DataSourcePoller {
/**
* Data Source Poller ID.
*/
const ID = 'shipping_partner_suggestions';
/**
* Default data sources array.
*/
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/shipping-partner-suggestions/1.0/suggestions.json',
);
/**
* Class instance.
*
* @var ShippingPartnerSuggestionsDataSourcePoller instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self( self::ID, self::DATA_SOURCES );
}
return self::$instance;
}
}
Admin/Features/TransientNotices.php 0000644 00000005450 15153704477 0013400 0 ustar 00 <?php
/**
* WooCommerce Transient Notices
*/
namespace Automattic\WooCommerce\Admin\Features;
use Automattic\WooCommerce\Internal\Admin\Loader;
/**
* Shows print shipping label banner on edit order page.
*/
class TransientNotices {
/**
* Option name for the queue.
*/
const QUEUE_OPTION = 'woocommerce_admin_transient_notices_queue';
/**
* Constructor
*/
public function __construct() {
add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) );
}
/**
* Get all notices in the queue.
*
* @return array
*/
public static function get_queue() {
return get_option( self::QUEUE_OPTION, array() );
}
/**
* Get all notices in the queue by a given user ID.
*
* @param int $user_id User ID.
* @return array
*/
public static function get_queue_by_user( $user_id ) {
$notices = self::get_queue();
return array_filter(
$notices,
function( $notice ) use ( $user_id ) {
return ! isset( $notice['user_id'] ) ||
null === $notice['user_id'] ||
$user_id === $notice['user_id'];
}
);
}
/**
* Get a notice by ID.
*
* @param array $notice_id Notice of ID to get.
* @return array|null
*/
public static function get( $notice_id ) {
$queue = self::get_queue();
if ( isset( $queue[ $notice_id ] ) ) {
return $queue[ $notice_id ];
}
return null;
}
/**
* Add a notice to be shown.
*
* @param array $notice Notice.
* $notice = array(
* 'id' => (string) Unique ID for the notice. Required.
* 'user_id' => (int|null) User ID to show the notice to.
* 'status' => (string) info|error|success
* 'content' => (string) Content to be shown for the notice. Required.
* 'options' => (array) Array of options to be passed to the notice component.
* See https://developer.wordpress.org/block-editor/reference-guides/data/data-core-notices/#createNotice for available options.
* ).
*/
public static function add( $notice ) {
$queue = self::get_queue();
$defaults = array(
'user_id' => null,
'status' => 'info',
'options' => array(),
);
$notice_data = array_merge( $defaults, $notice );
$notice_data['options'] = (object) $notice_data['options'];
$queue[ $notice['id'] ] = $notice_data;
update_option( self::QUEUE_OPTION, $queue );
}
/**
* Remove a notice by ID.
*
* @param array $notice_id Notice of ID to remove.
*/
public static function remove( $notice_id ) {
$queue = self::get_queue();
unset( $queue[ $notice_id ] );
update_option( self::QUEUE_OPTION, $queue );
}
/**
* Preload options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_options( $options ) {
$options[] = self::QUEUE_OPTION;
return $options;
}
}
Admin/Loader.php 0000644 00000005304 15153704477 0007532 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin;
use Automattic\WooCommerce\Admin\DeprecatedClassFacade;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Loader Class.
*
* @deprecated since 6.3.0, use WooCommerce\Internal\Admin\Loader.
*/
class Loader extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Loader';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '6.3.0';
/**
* Returns if a specific wc-admin feature is enabled.
*
* @param string $feature Feature slug.
* @return bool Returns true if the feature is enabled.
*
* @deprecated since 5.0.0, use Features::is_enabled( $feature )
*/
public static function is_feature_enabled( $feature ) {
wc_deprecated_function( 'is_feature_enabled', '5.0', '\Automattic\WooCommerce\Internal\Admin\Features\Features::is_enabled()' );
return Features::is_enabled( $feature );
}
/**
* Returns true if we are on a JS powered admin page or
* a "classic" (non JS app) powered admin page (an embedded page).
*
* @deprecated 6.3.0
*/
public static function is_admin_or_embed_page() {
wc_deprecated_function( 'is_admin_or_embed_page', '6.3', '\Automattic\WooCommerce\Admin\PageController::is_admin_or_embed_page()' );
return PageController::is_admin_or_embed_page();
}
/**
* Returns true if we are on a JS powered admin page.
*
* @deprecated 6.3.0
*/
public static function is_admin_page() {
wc_deprecated_function( 'is_admin_page', '6.3', '\Automattic\WooCommerce\Admin\PageController::is_admin_page()' );
return PageController::is_admin_page();
}
/**
* Returns true if we are on a "classic" (non JS app) powered admin page.
*
* @deprecated 6.3.0
*/
public static function is_embed_page() {
wc_deprecated_function( 'is_embed_page', '6.3', '\Automattic\WooCommerce\Admin\PageController::is_embed_page()' );
return PageController::is_embed_page();
}
/**
* Determines if a minified JS file should be served.
*
* @param boolean $script_debug Only serve unminified files if script debug is on.
* @return boolean If js asset should use minified version.
*
* @deprecated since 6.3.0, use WCAdminAssets::should_use_minified_js_file( $script_debug )
*/
public static function should_use_minified_js_file( $script_debug ) {
// Bail if WC isn't initialized (This can be called from WCAdmin's entrypoint).
if ( ! defined( 'WC_ABSPATH' ) ) {
return;
}
return WCAdminAssets::should_use_minified_js_file( $script_debug );
}
}
Admin/Marketing/InstalledExtensions.php 0000644 00000042437 15153704477 0014254 0 ustar 00 <?php
/**
* InstalledExtensions class file.
*/
namespace Automattic\WooCommerce\Admin\Marketing;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Installed Marketing Extensions class.
*/
class InstalledExtensions {
/**
* Gets an array of plugin data for the "Installed marketing extensions" card.
*
* Valid extensions statuses are: installed, activated, configured
*/
public static function get_data() {
$data = [];
$automatewoo = self::get_automatewoo_extension_data();
$aw_referral = self::get_aw_referral_extension_data();
$aw_birthdays = self::get_aw_birthdays_extension_data();
$mailchimp = self::get_mailchimp_extension_data();
$facebook = self::get_facebook_extension_data();
$pinterest = self::get_pinterest_extension_data();
$google = self::get_google_extension_data();
$amazon_ebay = self::get_amazon_ebay_extension_data();
$mailpoet = self::get_mailpoet_extension_data();
$klaviyo = self::get_klaviyo_extension_data();
$creative_mail = self::get_creative_mail_extension_data();
$tiktok = self::get_tiktok_extension_data();
$jetpack_crm = self::get_jetpack_crm_extension_data();
$zapier = self::get_zapier_extension_data();
$salesforce = self::get_salesforce_extension_data();
$vimeo = self::get_vimeo_extension_data();
$trustpilot = self::get_trustpilot_extension_data();
if ( $automatewoo ) {
$data[] = $automatewoo;
}
if ( $aw_referral ) {
$data[] = $aw_referral;
}
if ( $aw_birthdays ) {
$data[] = $aw_birthdays;
}
if ( $mailchimp ) {
$data[] = $mailchimp;
}
if ( $facebook ) {
$data[] = $facebook;
}
if ( $pinterest ) {
$data[] = $pinterest;
}
if ( $google ) {
$data[] = $google;
}
if ( $amazon_ebay ) {
$data[] = $amazon_ebay;
}
if ( $mailpoet ) {
$data[] = $mailpoet;
}
if ( $klaviyo ) {
$data[] = $klaviyo;
}
if ( $creative_mail ) {
$data[] = $creative_mail;
}
if ( $tiktok ) {
$data[] = $tiktok;
}
if ( $jetpack_crm ) {
$data[] = $jetpack_crm;
}
if ( $zapier ) {
$data[] = $zapier;
}
if ( $salesforce ) {
$data[] = $salesforce;
}
if ( $vimeo ) {
$data[] = $vimeo;
}
if ( $trustpilot ) {
$data[] = $trustpilot;
}
return $data;
}
/**
* Get allowed plugins.
*
* @return array
*/
public static function get_allowed_plugins() {
return [
'automatewoo',
'mailchimp-for-woocommerce',
'creative-mail-by-constant-contact',
'facebook-for-woocommerce',
'pinterest-for-woocommerce',
'google-listings-and-ads',
'hubspot-for-woocommerce',
'woocommerce-amazon-ebay-integration',
'mailpoet',
];
}
/**
* Get AutomateWoo extension data.
*
* @return array|bool
*/
protected static function get_automatewoo_extension_data() {
$slug = 'automatewoo';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/automatewoo.svg';
if ( 'activated' === $data['status'] && function_exists( 'AW' ) ) {
$data['settingsUrl'] = admin_url( 'admin.php?page=automatewoo-settings' );
$data['docsUrl'] = 'https://automatewoo.com/docs/';
$data['status'] = 'configured'; // Currently no configuration step.
}
return $data;
}
/**
* Get AutomateWoo Refer a Friend extension data.
*
* @return array|bool
*/
protected static function get_aw_referral_extension_data() {
$slug = 'automatewoo-referrals';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/automatewoo.svg';
if ( 'activated' === $data['status'] ) {
$data['docsUrl'] = 'https://automatewoo.com/docs/refer-a-friend/';
$data['status'] = 'configured';
if ( function_exists( 'AW_Referrals' ) ) {
$data['settingsUrl'] = admin_url( 'admin.php?page=automatewoo-settings&tab=referrals' );
}
}
return $data;
}
/**
* Get AutomateWoo Birthdays extension data.
*
* @return array|bool
*/
protected static function get_aw_birthdays_extension_data() {
$slug = 'automatewoo-birthdays';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/automatewoo.svg';
if ( 'activated' === $data['status'] ) {
$data['docsUrl'] = 'https://automatewoo.com/docs/getting-started-with-birthdays/';
$data['status'] = 'configured';
if ( function_exists( 'AW_Birthdays' ) ) {
$data['settingsUrl'] = admin_url( 'admin.php?page=automatewoo-settings&tab=birthdays' );
}
}
return $data;
}
/**
* Get MailChimp extension data.
*
* @return array|bool
*/
protected static function get_mailchimp_extension_data() {
$slug = 'mailchimp-for-woocommerce';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/mailchimp.svg';
if ( 'activated' === $data['status'] && function_exists( 'mailchimp_is_configured' ) ) {
$data['docsUrl'] = 'https://mailchimp.com/help/connect-or-disconnect-mailchimp-for-woocommerce/';
$data['settingsUrl'] = admin_url( 'admin.php?page=mailchimp-woocommerce' );
if ( mailchimp_is_configured() ) {
$data['status'] = 'configured';
}
}
return $data;
}
/**
* Get Facebook extension data.
*
* @return array|bool
*/
protected static function get_facebook_extension_data() {
$slug = 'facebook-for-woocommerce';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/facebook-icon.svg';
if ( 'activated' === $data['status'] && function_exists( 'facebook_for_woocommerce' ) ) {
$integration = facebook_for_woocommerce()->get_integration();
if ( $integration->is_configured() ) {
$data['status'] = 'configured';
}
$data['settingsUrl'] = facebook_for_woocommerce()->get_settings_url();
$data['docsUrl'] = facebook_for_woocommerce()->get_documentation_url();
}
return $data;
}
/**
* Get Pinterest extension data.
*
* @return array|bool
*/
protected static function get_pinterest_extension_data() {
$slug = 'pinterest-for-woocommerce';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/pinterest.svg';
$data['docsUrl'] = 'https://woocommerce.com/document/pinterest-for-woocommerce/?utm_medium=product';
if ( 'activated' === $data['status'] && class_exists( 'Pinterest_For_Woocommerce' ) ) {
$pinterest_onboarding_completed = Pinterest_For_Woocommerce()::is_setup_complete();
if ( $pinterest_onboarding_completed ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/pinterest/settings' );
} else {
$data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/pinterest/landing' );
}
}
return $data;
}
/**
* Get Google extension data.
*
* @return array|bool
*/
protected static function get_google_extension_data() {
$slug = 'google-listings-and-ads';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/google.svg';
if ( 'activated' === $data['status'] && function_exists( 'woogle_get_container' ) && class_exists( '\Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService' ) ) {
$merchant_center = woogle_get_container()->get( \Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService::class );
if ( $merchant_center->is_setup_complete() ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/google/settings' );
} else {
$data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/google/start' );
}
$data['docsUrl'] = 'https://woocommerce.com/document/google-listings-and-ads/?utm_medium=product';
}
return $data;
}
/**
* Get Amazon / Ebay extension data.
*
* @return array|bool
*/
protected static function get_amazon_ebay_extension_data() {
$slug = 'woocommerce-amazon-ebay-integration';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/amazon-ebay.svg';
if ( 'activated' === $data['status'] && class_exists( '\CodistoConnect' ) ) {
$codisto_merchantid = get_option( 'codisto_merchantid' );
// Use same check as codisto admin tabs.
if ( is_numeric( $codisto_merchantid ) ) {
$data['status'] = 'configured';
}
$data['settingsUrl'] = admin_url( 'admin.php?page=codisto-settings' );
$data['docsUrl'] = 'https://woocommerce.com/document/multichannel-for-woocommerce-google-amazon-ebay-walmart-integration/?utm_medium=product';
}
return $data;
}
/**
* Get MailPoet extension data.
*
* @return array|bool
*/
protected static function get_mailpoet_extension_data() {
$slug = 'mailpoet';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/mailpoet.svg';
if ( 'activated' === $data['status'] && class_exists( '\MailPoet\API\API' ) ) {
$mailpoet_api = \MailPoet\API\API::MP( 'v1' );
if ( ! method_exists( $mailpoet_api, 'isSetupComplete' ) || $mailpoet_api->isSetupComplete() ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=mailpoet-settings' );
} else {
$data['settingsUrl'] = admin_url( 'admin.php?page=mailpoet-newsletters' );
}
$data['docsUrl'] = 'https://kb.mailpoet.com/';
$data['supportUrl'] = 'https://www.mailpoet.com/support/';
}
return $data;
}
/**
* Get Klaviyo extension data.
*
* @return array|bool
*/
protected static function get_klaviyo_extension_data() {
$slug = 'klaviyo';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = plugins_url( 'assets/images/marketing/klaviyo.png', WC_PLUGIN_FILE );
if ( 'activated' === $data['status'] ) {
$klaviyo_options = get_option( 'klaviyo_settings' );
if ( isset( $klaviyo_options['klaviyo_public_api_key'] ) ) {
$data['status'] = 'configured';
}
$data['settingsUrl'] = admin_url( 'admin.php?page=klaviyo_settings' );
}
return $data;
}
/**
* Get Creative Mail for WooCommerce extension data.
*
* @return array|bool
*/
protected static function get_creative_mail_extension_data() {
$slug = 'creative-mail-by-constant-contact';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/creative-mail-by-constant-contact.png';
if ( 'activated' === $data['status'] && class_exists( '\CreativeMail\Helpers\OptionsHelper' ) ) {
if ( ! method_exists( '\CreativeMail\Helpers\OptionsHelper', 'get_instance_id' ) || \CreativeMail\Helpers\OptionsHelper::get_instance_id() !== null ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=creativemail_settings' );
} else {
$data['settingsUrl'] = admin_url( 'admin.php?page=creativemail' );
}
$data['docsUrl'] = 'https://app.creativemail.com/kb/help/WooCommerce';
$data['supportUrl'] = 'https://app.creativemail.com/kb/help/';
}
return $data;
}
/**
* Get TikTok for WooCommerce extension data.
*
* @return array|bool
*/
protected static function get_tiktok_extension_data() {
$slug = 'tiktok-for-business';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/tiktok.jpg';
if ( 'activated' === $data['status'] ) {
if ( false !== get_option( 'tt4b_access_token' ) ) {
$data['status'] = 'configured';
}
$data['settingsUrl'] = admin_url( 'admin.php?page=tiktok' );
$data['docsUrl'] = 'https://woocommerce.com/document/tiktok-for-woocommerce/';
$data['supportUrl'] = 'https://ads.tiktok.com/athena/user-feedback/?identify_key=6a1e079024806640c5e1e695d13db80949525168a052299b4970f9c99cb5ac78';
}
return $data;
}
/**
* Get Jetpack CRM for WooCommerce extension data.
*
* @return array|bool
*/
protected static function get_jetpack_crm_extension_data() {
$slug = 'zero-bs-crm';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/jetpack-crm.png';
if ( 'activated' === $data['status'] ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=zerobscrm-plugin-settings' );
$data['docsUrl'] = 'https://kb.jetpackcrm.com/';
$data['supportUrl'] = 'https://kb.jetpackcrm.com/crm-support/';
}
return $data;
}
/**
* Get WooCommerce Zapier extension data.
*
* @return array|bool
*/
protected static function get_zapier_extension_data() {
$slug = 'woocommerce-zapier';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/zapier.png';
if ( 'activated' === $data['status'] ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=wc-settings&tab=wc_zapier' );
$data['docsUrl'] = 'https://docs.om4.io/woocommerce-zapier/';
}
return $data;
}
/**
* Get Salesforce extension data.
*
* @return array|bool
*/
protected static function get_salesforce_extension_data() {
$slug = 'integration-with-salesforce';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/salesforce.jpg';
if ( 'activated' === $data['status'] && class_exists( '\Integration_With_Salesforce_Admin' ) ) {
if ( ! method_exists( '\Integration_With_Salesforce_Admin', 'get_connection_status' ) || \Integration_With_Salesforce_Admin::get_connection_status() ) {
$data['status'] = 'configured';
}
$data['settingsUrl'] = admin_url( 'admin.php?page=integration-with-salesforce' );
$data['docsUrl'] = 'https://woocommerce.com/document/salesforce-integration/';
$data['supportUrl'] = 'https://wpswings.com/submit-query/';
}
return $data;
}
/**
* Get Vimeo extension data.
*
* @return array|bool
*/
protected static function get_vimeo_extension_data() {
$slug = 'vimeo';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/vimeo.png';
if ( 'activated' === $data['status'] && class_exists( '\Tribe\Vimeo_WP\Vimeo\Vimeo_Auth' ) ) {
if ( method_exists( '\Tribe\Vimeo_WP\Vimeo\Vimeo_Auth', 'has_access_token' ) ) {
$vimeo_auth = new \Tribe\Vimeo_WP\Vimeo\Vimeo_Auth();
if ( $vimeo_auth->has_access_token() ) {
$data['status'] = 'configured';
}
} else {
$data['status'] = 'configured';
}
$data['settingsUrl'] = admin_url( 'options-general.php?page=vimeo_settings' );
$data['docsUrl'] = 'https://woocommerce.com/document/vimeo/';
$data['supportUrl'] = 'https://vimeo.com/help/contact';
}
return $data;
}
/**
* Get Trustpilot extension data.
*
* @return array|bool
*/
protected static function get_trustpilot_extension_data() {
$slug = 'trustpilot-reviews';
if ( ! PluginsHelper::is_plugin_installed( $slug ) ) {
return false;
}
$data = self::get_extension_base_data( $slug );
$data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/trustpilot.png';
if ( 'activated' === $data['status'] ) {
$data['status'] = 'configured';
$data['settingsUrl'] = admin_url( 'admin.php?page=woocommerce-trustpilot-settings-page' );
$data['docsUrl'] = 'https://woocommerce.com/document/trustpilot-reviews/';
$data['supportUrl'] = 'https://support.trustpilot.com/hc/en-us/requests/new';
}
return $data;
}
/**
* Get an array of basic data for a given extension.
*
* @param string $slug Plugin slug.
*
* @return array|false
*/
protected static function get_extension_base_data( $slug ) {
$status = PluginsHelper::is_plugin_active( $slug ) ? 'activated' : 'installed';
$plugin_data = PluginsHelper::get_plugin_data( $slug );
if ( ! $plugin_data ) {
return false;
}
return [
'slug' => $slug,
'status' => $status,
'name' => $plugin_data['Name'],
'description' => html_entity_decode( wp_trim_words( $plugin_data['Description'], 20 ) ),
'supportUrl' => 'https://woocommerce.com/my-account/create-a-ticket/?utm_medium=product',
];
}
}
Admin/Marketing/MarketingCampaign.php 0000644 00000004537 15153704477 0013635 0 ustar 00 <?php
/**
* Represents a marketing/ads campaign for marketing channels.
*
* Marketing channels (implementing MarketingChannelInterface) can use this class to map their campaign data and present it to WooCommerce core.
*/
namespace Automattic\WooCommerce\Admin\Marketing;
/**
* MarketingCampaign class
*
* @since x.x.x
*/
class MarketingCampaign {
/**
* The unique identifier.
*
* @var string
*/
protected $id;
/**
* The marketing campaign type.
*
* @var MarketingCampaignType
*/
protected $type;
/**
* Title of the marketing campaign.
*
* @var string
*/
protected $title;
/**
* The URL to the channel's campaign management page.
*
* @var string
*/
protected $manage_url;
/**
* The cost of the marketing campaign with the currency.
*
* @var Price
*/
protected $cost;
/**
* MarketingCampaign constructor.
*
* @param string $id The marketing campaign's unique identifier.
* @param MarketingCampaignType $type The marketing campaign type.
* @param string $title The title of the marketing campaign.
* @param string $manage_url The URL to the channel's campaign management page.
* @param Price|null $cost The cost of the marketing campaign with the currency.
*/
public function __construct( string $id, MarketingCampaignType $type, string $title, string $manage_url, Price $cost = null ) {
$this->id = $id;
$this->type = $type;
$this->title = $title;
$this->manage_url = $manage_url;
$this->cost = $cost;
}
/**
* Returns the marketing campaign's unique identifier.
*
* @return string
*/
public function get_id(): string {
return $this->id;
}
/**
* Returns the marketing campaign type.
*
* @return MarketingCampaignType
*/
public function get_type(): MarketingCampaignType {
return $this->type;
}
/**
* Returns the title of the marketing campaign.
*
* @return string
*/
public function get_title(): string {
return $this->title;
}
/**
* Returns the URL to manage the marketing campaign.
*
* @return string
*/
public function get_manage_url(): string {
return $this->manage_url;
}
/**
* Returns the cost of the marketing campaign with the currency.
*
* @return Price|null
*/
public function get_cost(): ?Price {
return $this->cost;
}
}
Admin/Marketing/MarketingCampaignType.php 0000644 00000005577 15153704477 0014504 0 ustar 00 <?php
/**
* Represents a marketing campaign type supported by a marketing channel.
*
* Marketing channels (implementing MarketingChannelInterface) can use this class to define what kind of campaigns they support.
*/
namespace Automattic\WooCommerce\Admin\Marketing;
/**
* MarketingCampaignType class
*
* @since x.x.x
*/
class MarketingCampaignType {
/**
* The unique identifier.
*
* @var string
*/
protected $id;
/**
* The marketing channel that this campaign type belongs to.
*
* @var MarketingChannelInterface
*/
protected $channel;
/**
* Name of the marketing campaign type.
*
* @var string
*/
protected $name;
/**
* Description of the marketing campaign type.
*
* @var string
*/
protected $description;
/**
* The URL to the create campaign page.
*
* @var string
*/
protected $create_url;
/**
* The URL to an image/icon for the campaign type.
*
* @var string
*/
protected $icon_url;
/**
* MarketingCampaignType constructor.
*
* @param string $id A unique identifier for the campaign type.
* @param MarketingChannelInterface $channel The marketing channel that this campaign type belongs to.
* @param string $name Name of the marketing campaign type.
* @param string $description Description of the marketing campaign type.
* @param string $create_url The URL to the create campaign page.
* @param string $icon_url The URL to an image/icon for the campaign type.
*/
public function __construct( string $id, MarketingChannelInterface $channel, string $name, string $description, string $create_url, string $icon_url ) {
$this->id = $id;
$this->channel = $channel;
$this->name = $name;
$this->description = $description;
$this->create_url = $create_url;
$this->icon_url = $icon_url;
}
/**
* Returns the marketing campaign's unique identifier.
*
* @return string
*/
public function get_id(): string {
return $this->id;
}
/**
* Returns the marketing channel that this campaign type belongs to.
*
* @return MarketingChannelInterface
*/
public function get_channel(): MarketingChannelInterface {
return $this->channel;
}
/**
* Returns the name of the marketing campaign type.
*
* @return string
*/
public function get_name(): string {
return $this->name;
}
/**
* Returns the description of the marketing campaign type.
*
* @return string
*/
public function get_description(): string {
return $this->description;
}
/**
* Returns the URL to the create campaign page.
*
* @return string
*/
public function get_create_url(): string {
return $this->create_url;
}
/**
* Returns the URL to an image/icon for the campaign type.
*
* @return string
*/
public function get_icon_url(): string {
return $this->icon_url;
}
}
Admin/Marketing/MarketingChannelInterface.php 0000644 00000004377 15153704477 0015311 0 ustar 00 <?php
/**
* Represents a marketing channel for the multichannel-marketing feature.
*
* This interface will be implemented by third-party extensions to register themselves as marketing channels.
*/
namespace Automattic\WooCommerce\Admin\Marketing;
/**
* MarketingChannelInterface interface
*
* @since x.x.x
*/
interface MarketingChannelInterface {
public const PRODUCT_LISTINGS_NOT_APPLICABLE = 'not-applicable';
public const PRODUCT_LISTINGS_SYNC_IN_PROGRESS = 'sync-in-progress';
public const PRODUCT_LISTINGS_SYNC_FAILED = 'sync-failed';
public const PRODUCT_LISTINGS_SYNCED = 'synced';
/**
* Returns the unique identifier string for the marketing channel extension, also known as the plugin slug.
*
* @return string
*/
public function get_slug(): string;
/**
* Returns the name of the marketing channel.
*
* @return string
*/
public function get_name(): string;
/**
* Returns the description of the marketing channel.
*
* @return string
*/
public function get_description(): string;
/**
* Returns the path to the channel icon.
*
* @return string
*/
public function get_icon_url(): string;
/**
* Returns the setup status of the marketing channel.
*
* @return bool
*/
public function is_setup_completed(): bool;
/**
* Returns the URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet.
*
* @return string
*/
public function get_setup_url(): string;
/**
* Returns the status of the marketing channel's product listings.
*
* @return string
*/
public function get_product_listings_status(): string;
/**
* Returns the number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.).
*
* @return int The number of issues to resolve, or 0 if there are no issues with the channel.
*/
public function get_errors_count(): int;
/**
* Returns an array of marketing campaign types that the channel supports.
*
* @return MarketingCampaignType[] Array of marketing campaign type objects.
*/
public function get_supported_campaign_types(): array;
/**
* Returns an array of the channel's marketing campaigns.
*
* @return MarketingCampaign[]
*/
public function get_campaigns(): array;
}
Admin/Marketing/MarketingChannels.php 0000644 00000003130 15153704477 0013635 0 ustar 00 <?php
/**
* Handles the registration of marketing channels and acts as their repository.
*/
namespace Automattic\WooCommerce\Admin\Marketing;
use Exception;
/**
* MarketingChannels repository class
*
* @since x.x.x
*/
class MarketingChannels {
/**
* The registered marketing channels.
*
* @var MarketingChannelInterface[]
*/
private $registered_channels = [];
/**
* Registers a marketing channel.
*
* @param MarketingChannelInterface $channel The marketing channel to register.
*
* @return void
*
* @throws Exception If the given marketing channel is already registered.
*/
public function register( MarketingChannelInterface $channel ): void {
if ( isset( $this->registered_channels[ $channel->get_slug() ] ) ) {
throw new Exception( __( 'Marketing channel cannot be registered because there is already a channel registered with the same slug!', 'woocommerce' ) );
}
$this->registered_channels[ $channel->get_slug() ] = $channel;
}
/**
* Unregisters all marketing channels.
*
* @return void
*/
public function unregister_all(): void {
unset( $this->registered_channels );
}
/**
* Returns an array of all registered marketing channels.
*
* @return MarketingChannelInterface[]
*/
public function get_registered_channels(): array {
/**
* Filter the list of registered marketing channels.
*
* @param MarketingChannelInterface[] $channels Array of registered marketing channels.
*
* @since x.x.x
*/
$channels = apply_filters( 'woocommerce_marketing_channels', $this->registered_channels );
return array_values( $channels );
}
}
Admin/Marketing/Price.php 0000644 00000001523 15153704477 0011306 0 ustar 00 <?php
/**
* Represents a price with a currency.
*/
namespace Automattic\WooCommerce\Admin\Marketing;
/**
* Price class
*
* @since x.x.x
*/
class Price {
/**
* The price.
*
* @var string
*/
protected $value;
/**
* The currency of the price.
*
* @var string
*/
protected $currency;
/**
* Price constructor.
*
* @param string $value The value of the price.
* @param string $currency The currency of the price.
*/
public function __construct( string $value, string $currency ) {
$this->value = $value;
$this->currency = $currency;
}
/**
* Get value of the price.
*
* @return string
*/
public function get_value(): string {
return $this->value;
}
/**
* Get the currency of the price.
*
* @return string
*/
public function get_currency(): string {
return $this->currency;
}
}
Admin/Notes/DataStore.php 0000644 00000042757 15153704477 0011317 0 ustar 00 <?php
/**
* WC Admin Note Data_Store class file.
*/
namespace Automattic\WooCommerce\Admin\Notes;
defined( 'ABSPATH' ) || exit;
/**
* WC Admin Note Data Store (Custom Tables)
*/
class DataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface {
// Extensions should define their own contexts and use them to avoid applying woocommerce_note_where_clauses when not needed.
const WC_ADMIN_NOTE_OPER_GLOBAL = 'global';
/**
* Method to create a new note in the database.
*
* @param Note $note Admin note.
*/
public function create( &$note ) {
$date_created = time();
$note->set_date_created( $date_created );
global $wpdb;
$note_to_be_inserted = array(
'name' => $note->get_name(),
'type' => $note->get_type(),
'locale' => $note->get_locale(),
'title' => $note->get_title(),
'content' => $note->get_content(),
'status' => $note->get_status(),
'source' => $note->get_source(),
'is_snoozable' => (int) $note->get_is_snoozable(),
'layout' => $note->get_layout(),
'image' => $note->get_image(),
'is_deleted' => (int) $note->get_is_deleted(),
);
$note_to_be_inserted['content_data'] = wp_json_encode( $note->get_content_data() );
$note_to_be_inserted['date_created'] = gmdate( 'Y-m-d H:i:s', $date_created );
$note_to_be_inserted['date_reminder'] = null;
$wpdb->insert( $wpdb->prefix . 'wc_admin_notes', $note_to_be_inserted );
$note_id = $wpdb->insert_id;
$note->set_id( $note_id );
$this->save_actions( $note );
$note->apply_changes();
/**
* Fires when an admin note is created.
*
* @param int $note_id Note ID.
*/
do_action( 'woocommerce_note_created', $note_id );
}
/**
* Method to read a note.
*
* @param Note $note Admin note.
* @throws \Exception Throws exception when invalid data is found.
*/
public function read( &$note ) {
global $wpdb;
$note->set_defaults();
$note_row = false;
$note_id = $note->get_id();
if ( 0 !== $note_id || '0' !== $note_id ) {
$note_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wc_admin_notes WHERE note_id = %d LIMIT 1",
$note->get_id()
)
);
}
if ( 0 === $note->get_id() || '0' === $note->get_id() ) {
$this->read_actions( $note );
$note->set_object_read( true );
/**
* Fires when an admin note is loaded.
*
* @param int $note_id Note ID.
*/
do_action( 'woocommerce_note_loaded', $note );
} elseif ( $note_row ) {
$note->set_name( $note_row->name );
$note->set_type( $note_row->type );
$note->set_locale( $note_row->locale );
$note->set_title( $note_row->title );
$note->set_content( $note_row->content );
// The default for 'content_value' used to be an array, so there might be rows with invalid data!
$content_data = json_decode( $note_row->content_data );
if ( ! $content_data ) {
$content_data = new \stdClass();
} elseif ( is_array( $content_data ) ) {
$content_data = (object) $content_data;
}
$note->set_content_data( $content_data );
$note->set_status( $note_row->status );
$note->set_source( $note_row->source );
$note->set_date_created( $note_row->date_created );
$note->set_date_reminder( $note_row->date_reminder );
$note->set_is_snoozable( $note_row->is_snoozable );
$note->set_is_deleted( (bool) $note_row->is_deleted );
isset( $note_row->is_read ) && $note->set_is_read( (bool) $note_row->is_read );
$note->set_layout( $note_row->layout );
$note->set_image( $note_row->image );
$this->read_actions( $note );
$note->set_object_read( true );
/**
* Fires when an admin note is loaded.
*
* @param int $note_id Note ID.
*/
do_action( 'woocommerce_note_loaded', $note );
} else {
throw new \Exception( __( 'Invalid admin note', 'woocommerce' ) );
}
}
/**
* Updates a note in the database.
*
* @param Note $note Admin note.
*/
public function update( &$note ) {
global $wpdb;
if ( $note->get_id() ) {
$date_created = $note->get_date_created();
$date_created_timestamp = $date_created->getTimestamp();
$date_created_to_db = gmdate( 'Y-m-d H:i:s', $date_created_timestamp );
$date_reminder = $note->get_date_reminder();
if ( is_null( $date_reminder ) ) {
$date_reminder_to_db = null;
} else {
$date_reminder_timestamp = $date_reminder->getTimestamp();
$date_reminder_to_db = gmdate( 'Y-m-d H:i:s', $date_reminder_timestamp );
}
$wpdb->update(
$wpdb->prefix . 'wc_admin_notes',
array(
'name' => $note->get_name(),
'type' => $note->get_type(),
'locale' => $note->get_locale(),
'title' => $note->get_title(),
'content' => $note->get_content(),
'content_data' => wp_json_encode( $note->get_content_data() ),
'status' => $note->get_status(),
'source' => $note->get_source(),
'date_created' => $date_created_to_db,
'date_reminder' => $date_reminder_to_db,
'is_snoozable' => $note->get_is_snoozable(),
'layout' => $note->get_layout(),
'image' => $note->get_image(),
'is_deleted' => $note->get_is_deleted(),
'is_read' => $note->get_is_read(),
),
array( 'note_id' => $note->get_id() )
);
}
$this->save_actions( $note );
$note->apply_changes();
/**
* Fires when an admin note is updated.
*
* @param int $note_id Note ID.
*/
do_action( 'woocommerce_note_updated', $note->get_id() );
}
/**
* Deletes a note from the database.
*
* @param Note $note Admin note.
* @param array $args Array of args to pass to the delete method (not used).
*/
public function delete( &$note, $args = array() ) {
$note_id = $note->get_id();
if ( $note_id ) {
global $wpdb;
$wpdb->delete( $wpdb->prefix . 'wc_admin_notes', array( 'note_id' => $note_id ) );
$wpdb->delete( $wpdb->prefix . 'wc_admin_note_actions', array( 'note_id' => $note_id ) );
$note->set_id( null );
}
/**
* Fires when an admin note is deleted.
*
* @param int $note_id Note ID.
*/
do_action( 'woocommerce_note_deleted', $note_id );
}
/**
* Read actions from the database.
*
* @param Note $note Admin note.
*/
private function read_actions( &$note ) {
global $wpdb;
$db_actions = $wpdb->get_results(
$wpdb->prepare(
"SELECT action_id, name, label, query, status, actioned_text, nonce_action, nonce_name
FROM {$wpdb->prefix}wc_admin_note_actions
WHERE note_id = %d",
$note->get_id()
)
);
$note_actions = array();
if ( $db_actions ) {
foreach ( $db_actions as $action ) {
$note_actions[] = (object) array(
'id' => (int) $action->action_id,
'name' => $action->name,
'label' => $action->label,
'query' => $action->query,
'status' => $action->status,
'actioned_text' => $action->actioned_text,
'nonce_action' => $action->nonce_action,
'nonce_name' => $action->nonce_name,
);
}
}
$note->set_actions( $note_actions );
}
/**
* Save actions to the database.
* This function clears old actions, then re-inserts new if any changes are found.
*
* @param Note $note Note object.
*
* @return bool|void
*/
private function save_actions( &$note ) {
global $wpdb;
$changed_props = array_keys( $note->get_changes() );
if ( ! in_array( 'actions', $changed_props, true ) ) {
return false;
}
// Process action removal. Actions are removed from
// the note if they aren't part of the changeset.
// See Note::add_action().
$changed_actions = $note->get_actions( 'edit' );
$actions_to_keep = array();
foreach ( $changed_actions as $action ) {
if ( ! empty( $action->id ) ) {
$actions_to_keep[] = (int) $action->id;
}
}
$clear_actions_query = $wpdb->prepare(
"DELETE FROM {$wpdb->prefix}wc_admin_note_actions WHERE note_id = %d",
$note->get_id()
);
if ( $actions_to_keep ) {
$clear_actions_query .= sprintf( ' AND action_id NOT IN (%s)', implode( ',', $actions_to_keep ) );
}
$wpdb->query( $clear_actions_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// Update/insert the actions in this changeset.
foreach ( $changed_actions as $action ) {
$action_data = array(
'note_id' => $note->get_id(),
'name' => $action->name,
'label' => $action->label,
'query' => $action->query,
'status' => $action->status,
'actioned_text' => $action->actioned_text,
'nonce_action' => $action->nonce_action,
'nonce_name' => $action->nonce_name,
);
$data_format = array(
'%d',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
'%s',
);
if ( ! empty( $action->id ) ) {
$action_data['action_id'] = $action->id;
$data_format[] = '%d';
}
$wpdb->replace(
$wpdb->prefix . 'wc_admin_note_actions',
$action_data,
$data_format
);
}
// Update actions from DB (to grab new IDs).
$this->read_actions( $note );
}
/**
* Return an ordered list of notes.
*
* @param array $args Query arguments.
* @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed.
* @return array An array of objects containing a note id.
*/
public function get_notes( $args = array(), $context = self::WC_ADMIN_NOTE_OPER_GLOBAL ) {
global $wpdb;
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'date_created',
);
$args = wp_parse_args( $args, $defaults );
$offset = $args['per_page'] * ( $args['page'] - 1 );
$where_clauses = $this->get_notes_where_clauses( $args, $context );
// sanitize order and orderby.
$order_by = '`' . str_replace( '`', '', $args['orderby'] ) . '`';
$order_dir = 'asc' === strtolower( $args['order'] ) ? 'ASC' : 'DESC';
$query = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$wpdb->prefix}wc_admin_notes WHERE 1=1{$where_clauses} ORDER BY {$order_by} {$order_dir} LIMIT %d, %d",
$offset,
$args['per_page']
);
return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Return an ordered list of notes, without paging or applying the 'woocommerce_note_where_clauses' filter.
* INTERNAL: This method is not intended to be used by external code, and may change without notice.
*
* @param array $args Query arguments.
* @return array An array of database records.
*/
public function lookup_notes( $args = array() ) {
global $wpdb;
$defaults = array(
'order' => 'DESC',
'orderby' => 'date_created',
);
$args = wp_parse_args( $args, $defaults );
$where_clauses = $this->args_to_where_clauses( $args );
// sanitize order and orderby.
$order_by = '`' . str_replace( '`', '', $args['orderby'] ) . '`';
$order_dir = 'asc' === strtolower( $args['order'] ) ? 'ASC' : 'DESC';
$query = "SELECT * FROM {$wpdb->prefix}wc_admin_notes WHERE 1=1{$where_clauses} ORDER BY {$order_by} {$order_dir}";
return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Return a count of notes.
*
* @param string $type Comma separated list of note types.
* @param string $status Comma separated list of statuses.
* @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed.
* @return string Count of objects with given type, status and context.
*/
public function get_notes_count( $type = array(), $status = array(), $context = self::WC_ADMIN_NOTE_OPER_GLOBAL ) {
global $wpdb;
$where_clauses = $this->get_notes_where_clauses(
array(
'type' => $type,
'status' => $status,
),
$context
);
if ( ! empty( $where_clauses ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_admin_notes WHERE 1=1{$where_clauses}" );
}
return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_admin_notes" );
}
/**
* Parses the query arguments passed in as arrays and escapes the values.
*
* @param array $args the query arguments.
* @param string $key the key of the specific argument.
* @param array|null $allowed_types optional allowed_types if only a specific set is allowed.
* @return array the escaped array of argument values.
*/
private function get_escaped_arguments_array_by_key( $args = array(), $key = '', $allowed_types = null ) {
$arg_array = array();
if ( isset( $args[ $key ] ) ) {
foreach ( $args[ $key ] as $args_type ) {
$args_type = trim( $args_type );
$allowed = is_null( $allowed_types ) || in_array( $args_type, $allowed_types, true );
if ( $allowed ) {
$arg_array[] = sprintf( "'%s'", esc_sql( $args_type ) );
}
}
}
return $arg_array;
}
/**
* Return where clauses for getting notes by status and type. For use in both the count and listing queries.
* Applies woocommerce_note_where_clauses filter.
*
* @uses args_to_where_clauses
* @param array $args Array of args to pass.
* @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed.
* @return string Where clauses for the query.
*/
public function get_notes_where_clauses( $args = array(), $context = self::WC_ADMIN_NOTE_OPER_GLOBAL ) {
$where_clauses = $this->args_to_where_clauses( $args );
/**
* Filter the notes WHERE clause before retrieving the data.
*
* Allows modification of the notes select criterial.
*
* @param string $where_clauses The generated WHERE clause.
* @param array $args The original arguments for the request.
* @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed.
*/
return apply_filters( 'woocommerce_note_where_clauses', $where_clauses, $args, $context );
}
/**
* Return where clauses for notes queries without applying woocommerce_note_where_clauses filter.
* INTERNAL: This method is not intended to be used by external code, and may change without notice.
*
* @param array $args Array of arguments for query conditionals.
* @return string Where clauses.
*/
protected function args_to_where_clauses( $args = array() ) {
$allowed_types = Note::get_allowed_types();
$where_type_array = $this->get_escaped_arguments_array_by_key( $args, 'type', $allowed_types );
$allowed_statuses = Note::get_allowed_statuses();
$where_status_array = $this->get_escaped_arguments_array_by_key( $args, 'status', $allowed_statuses );
$escaped_is_deleted = '';
if ( isset( $args['is_deleted'] ) ) {
$escaped_is_deleted = esc_sql( $args['is_deleted'] );
}
$where_name_array = $this->get_escaped_arguments_array_by_key( $args, 'name' );
$where_excluded_name_array = $this->get_escaped_arguments_array_by_key( $args, 'excluded_name' );
$where_source_array = $this->get_escaped_arguments_array_by_key( $args, 'source' );
$escaped_where_types = implode( ',', $where_type_array );
$escaped_where_status = implode( ',', $where_status_array );
$escaped_where_names = implode( ',', $where_name_array );
$escaped_where_excluded_names = implode( ',', $where_excluded_name_array );
$escaped_where_source = implode( ',', $where_source_array );
$where_clauses = '';
if ( ! empty( $escaped_where_types ) ) {
$where_clauses .= " AND type IN ($escaped_where_types)";
}
if ( ! empty( $escaped_where_status ) ) {
$where_clauses .= " AND status IN ($escaped_where_status)";
}
if ( ! empty( $escaped_where_names ) ) {
$where_clauses .= " AND name IN ($escaped_where_names)";
}
if ( ! empty( $escaped_where_excluded_names ) ) {
$where_clauses .= " AND name NOT IN ($escaped_where_excluded_names)";
}
if ( ! empty( $escaped_where_source ) ) {
$where_clauses .= " AND source IN ($escaped_where_source)";
}
if ( isset( $args['is_read'] ) ) {
$where_clauses .= $args['is_read'] ? ' AND is_read = 1' : ' AND is_read = 0';
}
$where_clauses .= $escaped_is_deleted ? ' AND is_deleted = 1' : ' AND is_deleted = 0';
return $where_clauses;
}
/**
* Find all the notes with a given name.
*
* @param string $name Name to search for.
* @return array An array of matching note ids.
*/
public function get_notes_with_name( $name ) {
global $wpdb;
return $wpdb->get_col(
$wpdb->prepare(
"SELECT note_id FROM {$wpdb->prefix}wc_admin_notes WHERE name = %s ORDER BY note_id ASC",
$name
)
);
}
/**
* Find the ids of all notes with a given type.
*
* @param string $note_type Type to search for.
* @return array An array of matching note ids.
*/
public function get_note_ids_by_type( $note_type ) {
global $wpdb;
return $wpdb->get_col(
$wpdb->prepare(
"SELECT note_id FROM {$wpdb->prefix}wc_admin_notes WHERE type = %s ORDER BY note_id ASC",
$note_type
)
);
}
}
Admin/Notes/DeprecatedNotes.php 0000644 00000035462 15153704477 0012475 0 ustar 00 <?php
/**
* Define deprecated classes to support changing the naming convention of
* admin notes.
*/
namespace Automattic\WooCommerce\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\DeprecatedClassFacade;
// phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
/**
* WC_Admin_Note.
*
* @deprecated since 4.8.0, use Note
*/
class WC_Admin_Note extends DeprecatedClassFacade {
// These constants must be redeclared as to not break plugins that use them.
const E_WC_ADMIN_NOTE_ERROR = Note::E_WC_ADMIN_NOTE_ERROR;
const E_WC_ADMIN_NOTE_WARNING = Note::E_WC_ADMIN_NOTE_WARNING;
const E_WC_ADMIN_NOTE_UPDATE = Note::E_WC_ADMIN_NOTE_UPDATE;
const E_WC_ADMIN_NOTE_INFORMATIONAL = Note::E_WC_ADMIN_NOTE_INFORMATIONAL;
const E_WC_ADMIN_NOTE_MARKETING = Note::E_WC_ADMIN_NOTE_MARKETING;
const E_WC_ADMIN_NOTE_SURVEY = Note::E_WC_ADMIN_NOTE_SURVEY;
const E_WC_ADMIN_NOTE_PENDING = Note::E_WC_ADMIN_NOTE_PENDING;
const E_WC_ADMIN_NOTE_UNACTIONED = Note::E_WC_ADMIN_NOTE_UNACTIONED;
const E_WC_ADMIN_NOTE_ACTIONED = Note::E_WC_ADMIN_NOTE_ACTIONED;
const E_WC_ADMIN_NOTE_SNOOZED = Note::E_WC_ADMIN_NOTE_SNOOZED;
const E_WC_ADMIN_NOTE_EMAIL = Note::E_WC_ADMIN_NOTE_EMAIL;
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\Note';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
/**
* Note constructor. Loads note data.
*
* @param mixed $data Note data, object, or ID.
*/
public function __construct( $data = '' ) {
$this->instance = new static::$facade_over_classname( $data );
}
}
/**
* WC_Admin_Notes.
*
* @deprecated since 4.8.0, use Notes
*/
class WC_Admin_Notes extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\Notes';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Coupon_Page_Moved.
*
* @deprecated since 4.8.0, use CouponPageMoved
*/
class WC_Admin_Notes_Coupon_Page_Moved extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\CouponPageMoved';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Customize_Store_With_Blocks.
*
* @deprecated since 4.8.0, use CustomizeStoreWithBlocks
*/
class WC_Admin_Notes_Customize_Store_With_Blocks extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\CustomizeStoreWithBlocks';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Edit_Products_On_The_Move.
*
* @deprecated since 4.8.0, use EditProductsOnTheMove
*/
class WC_Admin_Notes_Edit_Products_On_The_Move extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\EditProductsOnTheMove';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_EU_VAT_Number.
*
* @deprecated since 4.8.0, use EUVATNumber
*/
class WC_Admin_Notes_EU_VAT_Number extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\EUVATNumber';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Facebook_Marketing_Expert.
*
* @deprecated since 4.8.0, use FacebookMarketingExpert
*/
class WC_Admin_Notes_Facebook_Marketing_Expert extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\FacebookMarketingExpert';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_First_Product.
*
* @deprecated since 4.8.0, use FirstProduct
*/
class WC_Admin_Notes_First_Product extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\FirstProduct';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Giving_Feedback_Notes.
*
* @deprecated since 4.8.0, use GivingFeedbackNotes
*/
class WC_Admin_Notes_Giving_Feedback_Notes extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\GivingFeedbackNotes';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Install_JP_And_WCS_Plugins.
*
* @deprecated since 4.8.0, use InstallJPAndWCSPlugins
*/
class WC_Admin_Notes_Install_JP_And_WCS_Plugins extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Launch_Checklist.
*
* @deprecated since 4.8.0, use LaunchChecklist
*/
class WC_Admin_Notes_Launch_Checklist extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\LaunchChecklist';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Migrate_From_Shopify.
*
* @deprecated since 4.8.0, use MigrateFromShopify
*/
class WC_Admin_Notes_Migrate_From_Shopify extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\MigrateFromShopify';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Mobile_App.
*
* @deprecated since 4.8.0, use MobileApp
*/
class WC_Admin_Notes_Mobile_App extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\MobileApp';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_New_Sales_Record.
*
* @deprecated since 4.8.0, use NewSalesRecord
*/
class WC_Admin_Notes_New_Sales_Record extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\NewSalesRecord';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Onboarding_Email_Marketing.
*
* @deprecated since 4.8.0, use OnboardingEmailMarketing
*/
class WC_Admin_Notes_Onboarding_Email_Marketing extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\OnboardingEmailMarketing';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Onboarding_Payments.
*
* @deprecated since 4.8.0, use OnboardingPayments
*/
class WC_Admin_Notes_Onboarding_Payments extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\OnboardingPayments';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Online_Clothing_Store.
*
* @deprecated since 4.8.0, use OnlineClothingStore
*/
class WC_Admin_Notes_Online_Clothing_Store extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\OnlineClothingStore';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Order_Milestones.
*
* @deprecated since 4.8.0, use OrderMilestones
*/
class WC_Admin_Notes_Order_Milestones extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Performance_On_Mobile.
*
* @deprecated since 4.8.0, use PerformanceOnMobile
*/
class WC_Admin_Notes_Performance_On_Mobile extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Personalize_Store.
*
* @deprecated since 4.8.0, use PersonalizeStore
*/
class WC_Admin_Notes_Personalize_Store extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Real_Time_Order_Alerts.
*
* @deprecated since 4.8.0, use RealTimeOrderAlerts
*/
class WC_Admin_Notes_Real_Time_Order_Alerts extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Selling_Online_Courses.
*
* @deprecated since 4.8.0, use SellingOnlineCourses
*/
class WC_Admin_Notes_Selling_Online_Courses extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Test_Checkout.
*
* @deprecated since 4.8.0, use TestCheckout
*/
class WC_Admin_Notes_Test_Checkout extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Tracking_Opt_In.
*
* @deprecated since 4.8.0, use TrackingOptIn
*/
class WC_Admin_Notes_Tracking_Opt_In extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_Woo_Subscriptions_Notes.
*
* @deprecated since 4.8.0, use WooSubscriptionsNotes
*/
class WC_Admin_Notes_Woo_Subscriptions_Notes extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_WooCommerce_Payments.
*
* @deprecated since 4.8.0, use WooCommercePayments
*/
class WC_Admin_Notes_WooCommerce_Payments extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
/**
* WC_Admin_Notes_WooCommerce_Subscriptions.
*
* @deprecated since 4.8.0, use WooCommerceSubscriptions
*/
class WC_Admin_Notes_WooCommerce_Subscriptions extends DeprecatedClassFacade {
/**
* The name of the non-deprecated class that this facade covers.
*
* @var string
*/
protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\WooCommerceSubscriptions';
/**
* The version that this class was deprecated in.
*
* @var string
*/
protected static $deprecated_in_version = '4.8.0';
}
Admin/Notes/Note.php 0000644 00000045753 15153704477 0010335 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) Notes.
*
* The WooCommerce admin notes class gets admin notes data from storage and checks validity.
*/
namespace Automattic\WooCommerce\Admin\Notes;
defined( 'ABSPATH' ) || exit;
/**
* Note class.
*/
class Note extends \WC_Data {
// Note types.
const E_WC_ADMIN_NOTE_ERROR = 'error'; // used for presenting error conditions.
const E_WC_ADMIN_NOTE_WARNING = 'warning'; // used for presenting warning conditions.
const E_WC_ADMIN_NOTE_UPDATE = 'update'; // i.e. used when a new version is available.
const E_WC_ADMIN_NOTE_INFORMATIONAL = 'info'; // used for presenting informational messages.
const E_WC_ADMIN_NOTE_MARKETING = 'marketing'; // used for adding marketing messages.
const E_WC_ADMIN_NOTE_SURVEY = 'survey'; // used for adding survey messages.
const E_WC_ADMIN_NOTE_EMAIL = 'email'; // used for adding notes that will be sent by email.
// Note status codes.
const E_WC_ADMIN_NOTE_PENDING = 'pending'; // the note is pending - hidden but not actioned.
const E_WC_ADMIN_NOTE_UNACTIONED = 'unactioned'; // the note has not yet been actioned by a user.
const E_WC_ADMIN_NOTE_ACTIONED = 'actioned'; // the note has had its action completed by a user.
const E_WC_ADMIN_NOTE_SNOOZED = 'snoozed'; // the note has been snoozed by a user.
const E_WC_ADMIN_NOTE_SENT = 'sent'; // the note has been sent by email to the user.
/**
* This is the name of this object type.
*
* @var string
*/
protected $object_type = 'admin-note';
/**
* Cache group.
*
* @var string
*/
protected $cache_group = 'admin-note';
/**
* Note constructor. Loads note data.
*
* @param mixed $data Note data, object, or ID.
*/
public function __construct( $data = '' ) {
// Set default data here to allow `content_data` to be an object.
$this->data = array(
'name' => '-',
'type' => self::E_WC_ADMIN_NOTE_INFORMATIONAL,
'locale' => 'en_US',
'title' => '-',
'content' => '-',
'content_data' => new \stdClass(),
'status' => self::E_WC_ADMIN_NOTE_UNACTIONED,
'source' => 'woocommerce',
'date_created' => '0000-00-00 00:00:00',
'date_reminder' => '',
'is_snoozable' => false,
'actions' => array(),
'layout' => 'plain',
'image' => '',
'is_deleted' => false,
'is_read' => false,
);
parent::__construct( $data );
if ( $data instanceof Note ) {
$this->set_id( absint( $data->get_id() ) );
} elseif ( is_numeric( $data ) ) {
$this->set_id( $data );
} elseif ( is_object( $data ) && ! empty( $data->note_id ) ) {
$this->set_id( $data->note_id );
unset( $data->icon ); // Icons are deprecated.
$this->set_props( (array) $data );
$this->set_object_read( true );
} else {
$this->set_object_read( true );
}
$this->data_store = Notes::load_data_store();
if ( $this->get_id() > 0 ) {
$this->data_store->read( $this );
}
}
/**
* Merge changes with data and clear.
*
* @since 3.0.0
*/
public function apply_changes() {
$this->data = array_replace_recursive( $this->data, $this->changes ); // @codingStandardsIgnoreLine
// Note actions need to be replaced wholesale.
// Merging arrays doesn't allow for deleting note actions.
if ( isset( $this->changes['actions'] ) ) {
$this->data['actions'] = $this->changes['actions'];
}
$this->changes = array();
}
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
|
| Methods for getting allowed types, statuses.
|
*/
/**
* Get allowed types.
*
* @return array
*/
public static function get_allowed_types() {
$allowed_types = array(
self::E_WC_ADMIN_NOTE_ERROR,
self::E_WC_ADMIN_NOTE_WARNING,
self::E_WC_ADMIN_NOTE_UPDATE,
self::E_WC_ADMIN_NOTE_INFORMATIONAL,
self::E_WC_ADMIN_NOTE_MARKETING,
self::E_WC_ADMIN_NOTE_SURVEY,
self::E_WC_ADMIN_NOTE_EMAIL,
);
return apply_filters( 'woocommerce_note_types', $allowed_types );
}
/**
* Get allowed statuses.
*
* @return array
*/
public static function get_allowed_statuses() {
$allowed_statuses = array(
self::E_WC_ADMIN_NOTE_PENDING,
self::E_WC_ADMIN_NOTE_ACTIONED,
self::E_WC_ADMIN_NOTE_UNACTIONED,
self::E_WC_ADMIN_NOTE_SNOOZED,
self::E_WC_ADMIN_NOTE_SENT,
);
return apply_filters( 'woocommerce_note_statuses', $allowed_statuses );
}
/*
|--------------------------------------------------------------------------
| Getters
|--------------------------------------------------------------------------
|
| Methods for getting data from the note object.
|
*/
/**
* Returns all data for this object.
*
* Override \WC_Data::get_data() to avoid errantly including meta data
* from ID collisions with the posts table.
*
* @return array
*/
public function get_data() {
return array_merge( array( 'id' => $this->get_id() ), $this->data );
}
/**
* Get note name.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_name( $context = 'view' ) {
return $this->get_prop( 'name', $context );
}
/**
* Get note type.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_type( $context = 'view' ) {
return $this->get_prop( 'type', $context );
}
/**
* Get note locale.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_locale( $context = 'view' ) {
return $this->get_prop( 'locale', $context );
}
/**
* Get note title.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_title( $context = 'view' ) {
return $this->get_prop( 'title', $context );
}
/**
* Get note content.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_content( $context = 'view' ) {
return $this->get_prop( 'content', $context );
}
/**
* Get note content data (i.e. values that would be needed for re-localization)
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return object
*/
public function get_content_data( $context = 'view' ) {
return $this->get_prop( 'content_data', $context );
}
/**
* Get note status.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_status( $context = 'view' ) {
return $this->get_prop( 'status', $context );
}
/**
* Get note source.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return string
*/
public function get_source( $context = 'view' ) {
return $this->get_prop( 'source', $context );
}
/**
* Get date note was created.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return WC_DateTime|NULL object if the date is set or null if there is no date.
*/
public function get_date_created( $context = 'view' ) {
return $this->get_prop( 'date_created', $context );
}
/**
* Get date on which user should be reminded of the note (if any).
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return WC_DateTime|NULL object if the date is set or null if there is no date.
*/
public function get_date_reminder( $context = 'view' ) {
return $this->get_prop( 'date_reminder', $context );
}
/**
* Get note snoozability.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return bool Whether or not the note can be snoozed.
*/
public function get_is_snoozable( $context = 'view' ) {
return $this->get_prop( 'is_snoozable', $context );
}
/**
* Get actions on the note (if any).
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return array
*/
public function get_actions( $context = 'view' ) {
return $this->get_prop( 'actions', $context );
}
/**
* Get action by action name on the note.
*
* @param string $action_name The action name.
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return object the action.
*/
public function get_action( $action_name, $context = 'view' ) {
$actions = $this->get_prop( 'actions', $context );
$matching_action = null;
foreach ( $actions as $i => $action ) {
if ( $action->name === $action_name ) {
$matching_action =& $actions[ $i ];
break;
}
}
return $matching_action;
}
/**
* Get note layout (the old notes won't have one).
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return array
*/
public function get_layout( $context = 'view' ) {
return $this->get_prop( 'layout', $context );
}
/**
* Get note image (if any).
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return array
*/
public function get_image( $context = 'view' ) {
return $this->get_prop( 'image', $context );
}
/**
* Get deleted status.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return array
*/
public function get_is_deleted( $context = 'view' ) {
return $this->get_prop( 'is_deleted', $context );
}
/**
* Get is_read status.
*
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return array
*/
public function get_is_read( $context = 'view' ) {
return $this->get_prop( 'is_read', $context );
}
/*
|--------------------------------------------------------------------------
| Setters
|--------------------------------------------------------------------------
|
| Methods for setting note data. These should not update anything in the
| database itself and should only change what is stored in the class
| object.
|
*/
/**
* Set note name.
*
* @param string $name Note name.
*/
public function set_name( $name ) {
// Don't allow empty names.
if ( empty( $name ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note name prop cannot be empty.', 'woocommerce' ) );
}
$this->set_prop( 'name', $name );
}
/**
* Set note type.
*
* @param string $type Note type.
*/
public function set_type( $type ) {
if ( empty( $type ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note type prop cannot be empty.', 'woocommerce' ) );
}
if ( ! in_array( $type, self::get_allowed_types(), true ) ) {
$this->error(
'admin_note_invalid_data',
sprintf(
/* translators: %s: admin note type. */
__( 'The admin note type prop (%s) is not one of the supported types.', 'woocommerce' ),
$type
)
);
}
$this->set_prop( 'type', $type );
}
/**
* Set note locale.
*
* @param string $locale Note locale.
*/
public function set_locale( $locale ) {
if ( empty( $locale ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note locale prop cannot be empty.', 'woocommerce' ) );
}
$this->set_prop( 'locale', $locale );
}
/**
* Set note title.
*
* @param string $title Note title.
*/
public function set_title( $title ) {
if ( empty( $title ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note title prop cannot be empty.', 'woocommerce' ) );
}
$this->set_prop( 'title', $title );
}
/**
* Set note icon (Deprecated).
*
* @param string $icon Note icon.
*/
public function set_icon( $icon ) {
wc_deprecated_function( 'set_icon', '4.3' );
}
/**
* Set note content.
*
* @param string $content Note content.
*/
public function set_content( $content ) {
$allowed_html = array(
'br' => array(),
'em' => array(),
'strong' => array(),
'a' => array(
'href' => true,
'rel' => true,
'name' => true,
'target' => true,
'download' => array(
'valueless' => 'y',
),
),
'p' => array(),
);
$content = wp_kses( $content, $allowed_html );
if ( empty( $content ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note content prop cannot be empty.', 'woocommerce' ) );
}
$this->set_prop( 'content', $content );
}
/**
* Set note data for potential re-localization.
*
* @todo Set a default empty array? https://github.com/woocommerce/woocommerce-admin/pull/1763#pullrequestreview-212442921.
* @param object $content_data Note data.
*/
public function set_content_data( $content_data ) {
$allowed_type = false;
// Make sure $content_data is stdClass Object or an array.
if ( ! ( $content_data instanceof \stdClass ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note content_data prop must be an instance of stdClass.', 'woocommerce' ) );
}
$this->set_prop( 'content_data', $content_data );
}
/**
* Set note status.
*
* @param string $status Note status.
*/
public function set_status( $status ) {
if ( empty( $status ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note status prop cannot be empty.', 'woocommerce' ) );
}
if ( ! in_array( $status, self::get_allowed_statuses(), true ) ) {
$this->error(
'admin_note_invalid_data',
sprintf(
/* translators: %s: admin note status property. */
__( 'The admin note status prop (%s) is not one of the supported statuses.', 'woocommerce' ),
$status
)
);
}
$this->set_prop( 'status', $status );
}
/**
* Set note source.
*
* @param string $source Note source.
*/
public function set_source( $source ) {
if ( empty( $source ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note source prop cannot be empty.', 'woocommerce' ) );
}
$this->set_prop( 'source', $source );
}
/**
* Set date note was created. NULL is not allowed
*
* @param string|integer $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed.
*/
public function set_date_created( $date ) {
if ( empty( $date ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note date prop cannot be empty.', 'woocommerce' ) );
}
if ( is_string( $date ) ) {
$date = wc_string_to_timestamp( $date );
}
$this->set_date_prop( 'date_created', $date );
}
/**
* Set date admin should be reminded of note. NULL IS allowed
*
* @param string|integer|null $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. Null if there is no date.
*/
public function set_date_reminder( $date ) {
if ( is_string( $date ) ) {
$date = wc_string_to_timestamp( $date );
}
$this->set_date_prop( 'date_reminder', $date );
}
/**
* Set note snoozability.
*
* @param bool $is_snoozable Whether or not the note can be snoozed.
*/
public function set_is_snoozable( $is_snoozable ) {
return $this->set_prop( 'is_snoozable', $is_snoozable );
}
/**
* Clear actions from a note.
*/
public function clear_actions() {
$this->set_prop( 'actions', array() );
}
/**
* Set note layout.
*
* @param string $layout Note layout.
*/
public function set_layout( $layout ) {
// If we don't receive a layout we will set it by default as "plain".
if ( empty( $layout ) ) {
$layout = 'plain';
}
$valid_layouts = array( 'banner', 'plain', 'thumbnail' );
if ( in_array( $layout, $valid_layouts, true ) ) {
$this->set_prop( 'layout', $layout );
} else {
$this->error( 'admin_note_invalid_data', __( 'The admin note layout has a wrong prop value.', 'woocommerce' ) );
}
}
/**
* Set note image.
*
* @param string $image Note image.
*/
public function set_image( $image ) {
$this->set_prop( 'image', $image );
}
/**
* Set note deleted status. NULL is not allowed
*
* @param bool $is_deleted Note deleted status.
*/
public function set_is_deleted( $is_deleted ) {
$this->set_prop( 'is_deleted', $is_deleted );
}
/**
* Set note is_read status. NULL is not allowed
*
* @param bool $is_read Note is_read status.
*/
public function set_is_read( $is_read ) {
$this->set_prop( 'is_read', $is_read );
}
/**
* Add an action to the note
*
* @param string $name Action name (not presented to user).
* @param string $label Action label (presented as button label).
* @param string $url Action URL, if navigation needed. Optional.
* @param string $status Status to transition parent Note to upon click. Defaults to 'actioned'.
* @param boolean $primary Deprecated since version 3.4.0.
* @param string $actioned_text The label to display after the note has been actioned but before it is dismissed in the UI.
*/
public function add_action(
$name,
$label,
$url = '',
$status = self::E_WC_ADMIN_NOTE_ACTIONED,
$primary = false,
$actioned_text = ''
) {
$name = wc_clean( $name );
$label = wc_clean( $label );
$query = esc_url_raw( $url );
$status = wc_clean( $status );
$actioned_text = wc_clean( $actioned_text );
if ( empty( $name ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note action name prop cannot be empty.', 'woocommerce' ) );
}
if ( empty( $label ) ) {
$this->error( 'admin_note_invalid_data', __( 'The admin note action label prop cannot be empty.', 'woocommerce' ) );
}
$action = array(
'name' => $name,
'label' => $label,
'query' => $query,
'status' => $status,
'actioned_text' => $actioned_text,
'nonce_name' => null,
'nonce_action' => null,
);
$note_actions = $this->get_prop( 'actions', 'edit' );
$note_actions[] = (object) $action;
$this->set_prop( 'actions', $note_actions );
}
/**
* Set actions on a note.
*
* @param array $actions Note actions.
*/
public function set_actions( $actions ) {
$this->set_prop( 'actions', $actions );
}
/**
* Add a nonce to an existing note action.
*
* @link https://codex.wordpress.org/WordPress_Nonces
*
* @param string $note_action_name Name of action to add a nonce to.
* @param string $nonce_action The nonce action.
* @param string $nonce_name The nonce Name. This is used as the paramater name in the resulting URL for the action.
* @return void
* @throws \Exception If note name cannot be found.
*/
public function add_nonce_to_action( string $note_action_name, string $nonce_action, string $nonce_name ) {
$actions = $this->get_prop( 'actions', 'edit' );
$matching_action = null;
foreach ( $actions as $i => $action ) {
if ( $action->name === $note_action_name ) {
$matching_action =& $actions[ $i ];
}
}
if ( empty( $matching_action ) ) {
throw new \Exception( sprintf( 'Could not find action %s in note %s', $note_action_name, $this->get_name() ) );
}
$matching_action->nonce_action = $nonce_action;
$matching_action->nonce_name = $nonce_name;
$this->set_actions( $actions );
}
}
Admin/Notes/NoteTraits.php 0000644 00000016143 15153704477 0011513 0 ustar 00 <?php
/**
* WC Admin Note Traits
*
* WC Admin Note Traits class that houses shared functionality across notes.
*/
namespace Automattic\WooCommerce\Admin\Notes;
use Automattic\WooCommerce\Admin\WCAdminHelper;
defined( 'ABSPATH' ) || exit;
/**
* NoteTraits class.
*/
trait NoteTraits {
/**
* Test how long WooCommerce Admin has been active.
*
* @param int $seconds Time in seconds to check.
* @return bool Whether or not WooCommerce admin has been active for $seconds.
*/
private static function wc_admin_active_for( $seconds ) {
return WCAdminHelper::is_wc_admin_active_for( $seconds );
}
/**
* Test if WooCommerce Admin has been active within a pre-defined range.
*
* @param string $range range available in WC_ADMIN_STORE_AGE_RANGES.
* @param int $custom_start custom start in range.
* @return bool Whether or not WooCommerce admin has been active within the range.
*/
private static function is_wc_admin_active_in_date_range( $range, $custom_start = null ) {
return WCAdminHelper::is_wc_admin_active_in_date_range( $range, $custom_start );
}
/**
* Check if the note has been previously added.
*
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function note_exists() {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
return ! empty( $note_ids );
}
/**
* Checks if a note can and should be added.
*
* @return bool
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function can_be_added() {
$note = self::get_note();
if ( ! $note instanceof Note && ! $note instanceof WC_Admin_Note ) {
return;
}
if ( self::note_exists() ) {
return false;
}
if (
'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) &&
Note::E_WC_ADMIN_NOTE_MARKETING === $note->get_type()
) {
return false;
}
return true;
}
/**
* Add the note if it passes predefined conditions.
*
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function possibly_add_note() {
$note = self::get_note();
if ( ! self::can_be_added() ) {
return;
}
$note->save();
}
/**
* Alias this method for backwards compatibility.
*
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function add_note() {
self::possibly_add_note();
}
/**
* Should this note exist? (Default implementation is generous. Override as needed.)
*/
public static function is_applicable() {
return true;
}
/**
* Delete this note if it is not applicable, unless has been soft-deleted or actioned already.
*/
public static function delete_if_not_applicable() {
if ( ! self::is_applicable() ) {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note = Notes::get_note( $note_ids[0] );
if ( ! $note->get_is_deleted() && ( Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) ) {
return self::possibly_delete_note();
}
}
}
}
/**
* Possibly delete the note, if it exists in the database. Note that this
* is a hard delete, for where it doesn't make sense to soft delete or
* action the note.
*
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function possibly_delete_note() {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
foreach ( $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
if ( $note ) {
$data_store->delete( $note );
}
}
}
/**
* Update the note if it passes predefined conditions.
*
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function possibly_update_note() {
$note_in_db = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note_in_db ) {
return;
}
if ( ! method_exists( self::class, 'get_note' ) ) {
return;
}
$note = self::get_note();
if ( ! $note instanceof Note && ! $note instanceof WC_Admin_Note ) {
return;
}
$need_save = in_array(
true,
array(
self::update_note_field_if_changed( $note_in_db, $note, 'title' ),
self::update_note_field_if_changed( $note_in_db, $note, 'content' ),
self::update_note_field_if_changed( $note_in_db, $note, 'content_data' ),
self::update_note_field_if_changed( $note_in_db, $note, 'type' ),
self::update_note_field_if_changed( $note_in_db, $note, 'locale' ),
self::update_note_field_if_changed( $note_in_db, $note, 'source' ),
self::update_note_field_if_changed( $note_in_db, $note, 'actions' )
),
true
);
if ( $need_save ) {
$note_in_db->save();
}
}
/**
* Get if the note has been actioned.
*
* @return bool
* @throws NotesUnavailableException Throws exception when notes are unavailable.
*/
public static function has_note_been_actioned() {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note = Notes::get_note( $note_ids[0] );
if ( Note::E_WC_ADMIN_NOTE_ACTIONED === $note->get_status() ) {
return true;
}
}
return false;
}
/**
* Update a note field of note1 if it's different from note2 with getter and setter.
*
* @param Note $note1 Note to update.
* @param Note $note2 Note to compare against.
* @param string $field_name Field to update.
* @return bool True if the field was updated.
*/
private static function update_note_field_if_changed( $note1, $note2, $field_name ) {
// We need to serialize the stdObject to compare it.
$note1_field_value = self::possibly_convert_object_to_array(
call_user_func( array( $note1, 'get_' . $field_name ) )
);
$note2_field_value = self::possibly_convert_object_to_array(
call_user_func( array( $note2, 'get_' . $field_name ) )
);
if ( 'actions' === $field_name ) {
// We need to individually compare the action fields because action object from db is different from action object of note.
// For example, action object from db has "id".
$diff = array_udiff(
$note1_field_value,
$note2_field_value,
function( $action1, $action2 ) {
if ( $action1->name === $action2->name &&
$action1->label === $action2->label &&
$action1->query === $action2->query ) {
return 0;
}
return -1;
}
);
$need_update = count( $diff ) > 0;
} else {
$need_update = $note1_field_value !== $note2_field_value;
}
if ( $need_update ) {
call_user_func(
array( $note1, 'set_' . $field_name ),
// Get note2 field again because it may have been changed during the comparison.
call_user_func( array( $note2, 'get_' . $field_name ) )
);
return true;
}
return false;
}
/**
* Convert a value to array if it's a stdClass.
*
* @param mixed $obj variable to convert.
* @return mixed
*/
private static function possibly_convert_object_to_array( $obj ) {
if ( $obj instanceof \stdClass ) {
return (array) $obj;
}
return $obj;
}
}
Admin/Notes/Notes.php 0000644 00000033451 15153704477 0010510 0 ustar 00 <?php
/**
* Handles storage and retrieval of admin notes
*/
namespace Automattic\WooCommerce\Admin\Notes;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Admin Notes class.
*/
class Notes {
/**
* Hook used for recurring "unsnooze" action.
*/
const UNSNOOZE_HOOK = 'wc_admin_unsnooze_admin_notes';
/**
* Hook appropriate actions.
*/
public static function init() {
add_action( 'admin_init', array( __CLASS__, 'schedule_unsnooze_notes' ) );
add_action( 'admin_init', array( __CLASS__, 'possibly_delete_survey_notes' ) );
add_action( 'update_option_woocommerce_show_marketplace_suggestions', array( __CLASS__, 'possibly_delete_marketing_notes' ), 10, 2 );
}
/**
* Get notes from the database.
*
* @param string $context Getting notes for what context. Valid values: view, edit.
* @param array $args Arguments to pass to the query( e.g. per_page and page).
* @return array Array of arrays.
*/
public static function get_notes( $context = 'edit', $args = array() ) {
$data_store = self::load_data_store();
$raw_notes = $data_store->get_notes( $args );
$notes = array();
foreach ( (array) $raw_notes as $raw_note ) {
try {
$note = new Note( $raw_note );
/**
* Filter the note from db. This is used to modify the note before it is returned.
*
* @since 6.9.0
* @param Note $note The note object from the database.
*/
$note = apply_filters( 'woocommerce_get_note_from_db', $note );
$note_id = $note->get_id();
$notes[ $note_id ] = $note->get_data();
$notes[ $note_id ]['name'] = $note->get_name( $context );
$notes[ $note_id ]['type'] = $note->get_type( $context );
$notes[ $note_id ]['locale'] = $note->get_locale( $context );
$notes[ $note_id ]['title'] = $note->get_title( $context );
$notes[ $note_id ]['content'] = $note->get_content( $context );
$notes[ $note_id ]['content_data'] = $note->get_content_data( $context );
$notes[ $note_id ]['status'] = $note->get_status( $context );
$notes[ $note_id ]['source'] = $note->get_source( $context );
$notes[ $note_id ]['date_created'] = $note->get_date_created( $context );
$notes[ $note_id ]['date_reminder'] = $note->get_date_reminder( $context );
$notes[ $note_id ]['actions'] = $note->get_actions( $context );
$notes[ $note_id ]['layout'] = $note->get_layout( $context );
$notes[ $note_id ]['image'] = $note->get_image( $context );
$notes[ $note_id ]['is_deleted'] = $note->get_is_deleted( $context );
} catch ( \Exception $e ) {
wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, array( $note_id ) );
}
}
return $notes;
}
/**
* Get admin note using it's ID
*
* @param int $note_id Note ID.
* @return Note|bool
*/
public static function get_note( $note_id ) {
if ( false !== $note_id ) {
try {
return new Note( $note_id );
} catch ( \Exception $e ) {
wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, array( $note_id ) );
return false;
}
}
return false;
}
/**
* Get admin note using its name.
*
* This is a shortcut for the common pattern of looking up note ids by name and then passing the first id to get_note().
* It will behave unpredictably when more than one note with the given name exists.
*
* @param string $note_name Note name.
* @return Note|bool
**/
public static function get_note_by_name( $note_name ) {
$data_store = self::load_data_store();
$note_ids = $data_store->get_notes_with_name( $note_name );
if ( empty( $note_ids ) ) {
return false;
}
return self::get_note( $note_ids[0] );
}
/**
* Get the total number of notes
*
* @param string $type Comma separated list of note types.
* @param string $status Comma separated list of statuses.
* @return int
*/
public static function get_notes_count( $type = array(), $status = array() ) {
$data_store = self::load_data_store();
return $data_store->get_notes_count( $type, $status );
}
/**
* Deletes admin notes with a given name.
*
* @param string|array $names Name(s) to search for.
*/
public static function delete_notes_with_name( $names ) {
if ( is_string( $names ) ) {
$names = array( $names );
} elseif ( ! is_array( $names ) ) {
return;
}
$data_store = self::load_data_store();
foreach ( $names as $name ) {
$note_ids = $data_store->get_notes_with_name( $name );
foreach ( (array) $note_ids as $note_id ) {
$note = self::get_note( $note_id );
if ( $note ) {
$note->delete();
}
}
}
}
/**
* Update a note.
*
* @param Note $note The note that will be updated.
* @param array $requested_updates a list of requested updates.
*/
public static function update_note( $note, $requested_updates ) {
$note_changed = false;
if ( isset( $requested_updates['status'] ) ) {
$note->set_status( $requested_updates['status'] );
$note_changed = true;
}
if ( isset( $requested_updates['date_reminder'] ) ) {
$note->set_date_reminder( $requested_updates['date_reminder'] );
$note_changed = true;
}
if ( isset( $requested_updates['is_deleted'] ) ) {
$note->set_is_deleted( $requested_updates['is_deleted'] );
$note_changed = true;
}
if ( isset( $requested_updates['is_read'] ) ) {
$note->set_is_read( $requested_updates['is_read'] );
$note_changed = true;
}
if ( $note_changed ) {
$note->save();
}
}
/**
* Soft delete of a note.
*
* @param Note $note The note that will be deleted.
*/
public static function delete_note( $note ) {
$note->set_is_deleted( 1 );
$note->save();
}
/**
* Soft delete of all the admin notes. Returns the deleted items.
*
* @param array $args Arguments to pass to the query (ex: status).
* @return array Array of notes.
*/
public static function delete_all_notes( $args = array() ) {
$data_store = self::load_data_store();
$defaults = array(
'order' => 'desc',
'orderby' => 'date_created',
'per_page' => 25,
'page' => 1,
'type' => array(
Note::E_WC_ADMIN_NOTE_INFORMATIONAL,
Note::E_WC_ADMIN_NOTE_MARKETING,
Note::E_WC_ADMIN_NOTE_WARNING,
Note::E_WC_ADMIN_NOTE_SURVEY,
),
'is_deleted' => 0,
);
$args = wp_parse_args( $args, $defaults );
// Here we filter for the same params we are using to show the note list in client side.
$raw_notes = $data_store->get_notes( $args );
$notes = array();
foreach ( (array) $raw_notes as $raw_note ) {
$note = self::get_note( $raw_note->note_id );
if ( $note ) {
self::delete_note( $note );
array_push( $notes, $note );
}
}
return $notes;
}
/**
* Clear note snooze status if the reminder date has been reached.
*/
public static function unsnooze_notes() {
$data_store = self::load_data_store();
$raw_notes = $data_store->get_notes(
array(
'status' => array( Note::E_WC_ADMIN_NOTE_SNOOZED ),
)
);
$now = new \DateTime();
foreach ( $raw_notes as $raw_note ) {
$note = self::get_note( $raw_note->note_id );
if ( false === $note ) {
continue;
}
$date_reminder = $note->get_date_reminder( 'edit' );
if ( $date_reminder < $now ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_UNACTIONED );
$note->set_date_reminder( null );
$note->save();
}
}
}
/**
* Schedule unsnooze notes event.
*/
public static function schedule_unsnooze_notes() {
if ( ! wp_next_scheduled( self::UNSNOOZE_HOOK ) ) {
wp_schedule_event( time() + 5, 'hourly', self::UNSNOOZE_HOOK );
}
}
/**
* Unschedule unsnooze notes event.
*/
public static function clear_queued_actions() {
wp_clear_scheduled_hook( self::UNSNOOZE_HOOK );
}
/**
* Delete marketing notes if marketing has been opted out.
*
* @param string $old_value Old value.
* @param string $value New value.
*/
public static function possibly_delete_marketing_notes( $old_value, $value ) {
if ( 'no' !== $value ) {
return;
}
$data_store = self::load_data_store();
$note_ids = $data_store->get_note_ids_by_type( Note::E_WC_ADMIN_NOTE_MARKETING );
foreach ( $note_ids as $note_id ) {
$note = self::get_note( $note_id );
if ( $note ) {
$note->delete();
}
}
}
/**
* Delete actioned survey notes.
*/
public static function possibly_delete_survey_notes() {
$data_store = self::load_data_store();
$note_ids = $data_store->get_note_ids_by_type( Note::E_WC_ADMIN_NOTE_SURVEY );
foreach ( $note_ids as $note_id ) {
$note = self::get_note( $note_id );
if ( $note && ( $note->get_status() === Note::E_WC_ADMIN_NOTE_ACTIONED ) ) {
$note->set_is_deleted( 1 );
$note->save();
}
}
}
/**
* Get the status of a given note by name.
*
* @param string $note_name Name of the note.
* @return string|bool The note status.
*/
public static function get_note_status( $note_name ) {
$note = self::get_note_by_name( $note_name );
if ( ! $note ) {
return false;
}
return $note->get_status();
}
/**
* Get action by id.
*
* @param Note $note The note that has of the action.
* @param int $action_id Action ID.
* @return object|bool The found action.
*/
public static function get_action_by_id( $note, $action_id ) {
$actions = $note->get_actions( 'edit' );
$found_action = false;
foreach ( $actions as $action ) {
if ( $action->id === $action_id ) {
$found_action = $action;
}
}
return $found_action;
}
/**
* Trigger note action.
*
* @param Note $note The note that has the triggered action.
* @param object $triggered_action The triggered action.
* @return Note|bool
*/
public static function trigger_note_action( $note, $triggered_action ) {
/**
* Fires when an admin note action is taken.
*
* @param string $name The triggered action name.
* @param Note $note The corresponding Note.
*/
do_action( 'woocommerce_note_action', $triggered_action->name, $note );
/**
* Fires when an admin note action is taken.
* For more specific targeting of note actions.
*
* @param Note $note The corresponding Note.
*/
do_action( 'woocommerce_note_action_' . $triggered_action->name, $note );
// Update the note with the status for this action.
if ( ! empty( $triggered_action->status ) ) {
$note->set_status( $triggered_action->status );
}
$note->save();
$event_params = array(
'note_name' => $note->get_name(),
'note_type' => $note->get_type(),
'note_title' => $note->get_title(),
'note_content' => $note->get_content(),
'action_name' => $triggered_action->name,
'action_label' => $triggered_action->label,
'screen' => self::get_screen_name(),
);
if ( in_array( $note->get_type(), array( 'error', 'update' ), true ) ) {
wc_admin_record_tracks_event( 'store_alert_action', $event_params );
} else {
self::record_tracks_event_without_cookies( 'inbox_action_click', $event_params );
}
return $note;
}
/**
* Record tracks event for a specific user.
*
* @param int $user_id The user id we want to record for the event.
* @param string $event_name Name of the event to record.
* @param array $params The params to send to the event recording.
*/
public static function record_tracks_event_with_user( $user_id, $event_name, $params ) {
// We save the current user id to set it back after the event recording.
$current_user_id = get_current_user_id();
wp_set_current_user( $user_id );
self::record_tracks_event_without_cookies( $event_name, $params );
wp_set_current_user( $current_user_id );
}
/**
* Record tracks event without using cookies.
*
* @param string $event_name Name of the event to record.
* @param array $params The params to send to the event recording.
*/
private static function record_tracks_event_without_cookies( $event_name, $params ) {
// We save the cookie to set it back after the event recording.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$anon_id = isset( $_COOKIE['tk_ai'] ) ? $_COOKIE['tk_ai'] : null;
unset( $_COOKIE['tk_ai'] );
wc_admin_record_tracks_event( $event_name, $params );
if ( isset( $anon_id ) ) {
setcookie( 'tk_ai', $anon_id );
}
}
/**
* Get screen name.
*
* @return string The screen name.
*/
public static function get_screen_name() {
$screen_name = '';
if ( isset( $_SERVER['HTTP_REFERER'] ) ) {
parse_str( wp_parse_url( $_SERVER['HTTP_REFERER'], PHP_URL_QUERY ), $queries ); // phpcs:ignore sanitization ok.
}
if ( isset( $queries ) ) {
$page = isset( $queries['page'] ) ? $queries['page'] : null;
$path = isset( $queries['path'] ) ? $queries['path'] : null;
$post_type = isset( $queries['post_type'] ) ? $queries['post_type'] : null;
$post = isset( $queries['post'] ) ? get_post_type( $queries['post'] ) : null;
}
if ( isset( $page ) ) {
$current_page = 'wc-admin' === $page ? 'home_screen' : $page;
$screen_name = isset( $path ) ? substr( str_replace( '/', '_', $path ), 1 ) : $current_page;
} elseif ( isset( $post_type ) ) {
$screen_name = $post_type;
} elseif ( isset( $post ) ) {
$screen_name = $post;
}
return $screen_name;
}
/**
* Loads the data store.
*
* If the "admin-note" data store is unavailable, attempts to load it
* will result in an exception.
* This method catches that exception and throws a custom one instead.
*
* @return \WC_Data_Store The "admin-note" data store.
* @throws NotesUnavailableException Throws exception if data store loading fails.
*/
public static function load_data_store() {
try {
return \WC_Data_Store::load( 'admin-note' );
} catch ( \Exception $e ) {
throw new NotesUnavailableException(
'woocommerce_admin_notes_unavailable',
__( 'Notes are unavailable because the "admin-note" data store cannot be loaded.', 'woocommerce' )
);
}
}
}
Admin/Notes/NotesUnavailableException.php 0000644 00000000541 15153704477 0014525 0 ustar 00 <?php
/**
* WooCommerce Admin Notes Unavailable Exception Class
*
* Exception class thrown when an attempt to use notes is made but notes are unavailable.
*/
namespace Automattic\WooCommerce\Admin\Notes;
defined( 'ABSPATH' ) || exit;
/**
* Notes\NotesUnavailableException class.
*/
class NotesUnavailableException extends \WC_Data_Exception {}
Admin/Overrides/Order.php 0000644 00000007123 15153704477 0011342 0 ustar 00 <?php
/**
* WC Admin Order
*
* WC Admin Order class that adds some functionality on top of general WooCommerce WC_Order.
*/
namespace Automattic\WooCommerce\Admin\Overrides;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
/**
* WC_Order subclass.
*/
class Order extends \WC_Order {
/**
* Order traits.
*/
use OrderTraits;
/**
* Holds refund amounts and quantities for the order.
*
* @var void|array
*/
protected $refunded_line_items;
/**
* Caches the customer ID.
*
* @var int
*/
public $customer_id = null;
/**
* Get only core class data in array format.
*
* @return array
*/
public function get_data_without_line_items() {
return array_merge(
array(
'id' => $this->get_id(),
),
$this->data,
array(
'number' => $this->get_order_number(),
'meta_data' => $this->get_meta_data(),
)
);
}
/**
* Get order line item data by type.
*
* @param string $type Order line item type.
* @return array|bool Array of line items on success, boolean false on failure.
*/
public function get_line_item_data( $type ) {
$type_to_items = array(
'line_items' => 'line_item',
'tax_lines' => 'tax',
'shipping_lines' => 'shipping',
'fee_lines' => 'fee',
'coupon_lines' => 'coupon',
);
if ( isset( $type_to_items[ $type ] ) ) {
return $this->get_items( $type_to_items[ $type ] );
}
return false;
}
/**
* Add filter(s) required to hook this class to substitute WC_Order.
*/
public static function add_filters() {
add_filter( 'woocommerce_order_class', array( __CLASS__, 'order_class_name' ), 10, 3 );
}
/**
* Filter function to swap class WC_Order for this one in cases when it's suitable.
*
* @param string $classname Name of the class to be created.
* @param string $order_type Type of order object to be created.
* @param number $order_id Order id to create.
*
* @return string
*/
public static function order_class_name( $classname, $order_type, $order_id ) {
// @todo - Only substitute class when necessary (during sync).
if ( 'WC_Order' === $classname ) {
return '\Automattic\WooCommerce\Admin\Overrides\Order';
} else {
return $classname;
}
}
/**
* Get the customer ID used for reports in the customer lookup table.
*
* @return int
*/
public function get_report_customer_id() {
if ( is_null( $this->customer_id ) ) {
$this->customer_id = CustomersDataStore::get_or_create_customer_from_order( $this );
}
return $this->customer_id;
}
/**
* Returns true if the customer has made an earlier order.
*
* @return bool
*/
public function is_returning_customer() {
return OrdersStatsDataStore::is_returning_customer( $this, $this->get_report_customer_id() );
}
/**
* Get the customer's first name.
*/
public function get_customer_first_name() {
if ( $this->get_user_id() ) {
return get_user_meta( $this->get_user_id(), 'first_name', true );
}
if ( '' !== $this->get_billing_first_name( 'edit' ) ) {
return $this->get_billing_first_name( 'edit' );
} else {
return $this->get_shipping_first_name( 'edit' );
}
}
/**
* Get the customer's last name.
*/
public function get_customer_last_name() {
if ( $this->get_user_id() ) {
return get_user_meta( $this->get_user_id(), 'last_name', true );
}
if ( '' !== $this->get_billing_last_name( 'edit' ) ) {
return $this->get_billing_last_name( 'edit' );
} else {
return $this->get_shipping_last_name( 'edit' );
}
}
}
Admin/Overrides/OrderRefund.php 0000644 00000004034 15153704477 0012504 0 ustar 00 <?php
/**
* WC Admin Order Refund
*
* WC Admin Order Refund class that adds some functionality on top of general WooCommerce WC_Order_Refund.
*/
namespace Automattic\WooCommerce\Admin\Overrides;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
/**
* WC_Order_Refund subclass.
*/
class OrderRefund extends \WC_Order_Refund {
/**
* Order traits.
*/
use OrderTraits;
/**
* Caches the customer ID.
*
* @var int
*/
public $customer_id = null;
/**
* Add filter(s) required to hook this class to substitute WC_Order_Refund.
*/
public static function add_filters() {
add_filter( 'woocommerce_order_class', array( __CLASS__, 'order_class_name' ), 10, 3 );
}
/**
* Filter function to swap class WC_Order_Refund for this one in cases when it's suitable.
*
* @param string $classname Name of the class to be created.
* @param string $order_type Type of order object to be created.
* @param number $order_id Order id to create.
*
* @return string
*/
public static function order_class_name( $classname, $order_type, $order_id ) {
// @todo - Only substitute class when necessary (during sync).
if ( 'WC_Order_Refund' === $classname ) {
return '\Automattic\WooCommerce\Admin\Overrides\OrderRefund';
} else {
return $classname;
}
}
/**
* Get the customer ID of the parent order used for reports in the customer lookup table.
*
* @return int|bool Customer ID of parent order, or false if parent order not found.
*/
public function get_report_customer_id() {
if ( is_null( $this->customer_id ) ) {
$parent_order = \wc_get_order( $this->get_parent_id() );
if ( ! $parent_order ) {
$this->customer_id = false;
}
$this->customer_id = CustomersDataStore::get_or_create_customer_from_order( $parent_order );
}
return $this->customer_id;
}
/**
* Returns null since refunds should not be counted towards returning customer counts.
*
* @return null
*/
public function is_returning_customer() {
return null;
}
}
Admin/Overrides/OrderTraits.php 0000644 00000005077 15153704477 0012537 0 ustar 00 <?php
/**
* WC Admin Order Trait
*
* WC Admin Order Trait class that houses shared functionality across order and refund classes.
*/
namespace Automattic\WooCommerce\Admin\Overrides;
defined( 'ABSPATH' ) || exit;
/**
* OrderTraits class.
*/
trait OrderTraits {
/**
* Calculate shipping amount for line item/product as a total shipping amount ratio based on quantity.
*
* @param WC_Order_Item $item Line item from order.
*
* @return float|int
*/
public function get_item_shipping_amount( $item ) {
// Shipping amount loosely based on woocommerce code in includes/admin/meta-boxes/views/html-order-item(s).php
// distributed simply based on number of line items.
$product_qty = $item->get_quantity( 'edit' );
$order_items = $this->get_item_count();
if ( 0 === $order_items ) {
return 0;
}
$total_shipping_amount = (float) $this->get_shipping_total();
return $total_shipping_amount / $order_items * $product_qty;
}
/**
* Calculate shipping tax amount for line item/product as a total shipping tax amount ratio based on quantity.
*
* Loosely based on code in includes/admin/meta-boxes/views/html-order-item(s).php.
*
* @todo If WC is currently not tax enabled, but it was before (or vice versa), would this work correctly?
*
* @param WC_Order_Item $item Line item from order.
*
* @return float|int
*/
public function get_item_shipping_tax_amount( $item ) {
$order_items = $this->get_item_count();
if ( 0 === $order_items ) {
return 0;
}
$product_qty = $item->get_quantity( 'edit' );
$order_taxes = $this->get_taxes();
$line_items_shipping = $this->get_items( 'shipping' );
$total_shipping_tax_amount = 0;
foreach ( $line_items_shipping as $item_id => $shipping_item ) {
$tax_data = $shipping_item->get_taxes();
if ( $tax_data ) {
foreach ( $order_taxes as $tax_item ) {
$tax_item_id = $tax_item->get_rate_id();
$tax_item_total = isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
$total_shipping_tax_amount += $tax_item_total;
}
}
}
return $total_shipping_tax_amount / $order_items * $product_qty;
}
/**
* Calculates coupon amount for specified line item/product.
*
* Coupon calculation based on woocommerce code in includes/admin/meta-boxes/views/html-order-item.php.
*
* @param WC_Order_Item $item Line item from order.
*
* @return float
*/
public function get_item_coupon_amount( $item ) {
return floatval( $item->get_subtotal( 'edit' ) - $item->get_total( 'edit' ) );
}
}
Admin/Overrides/ThemeUpgrader.php 0000644 00000004004 15153704477 0013016 0 ustar 00 <?php
/**
* Theme upgrader used in REST API response.
*/
namespace Automattic\WooCommerce\Admin\Overrides;
defined( 'ABSPATH' ) || exit;
/**
* Admin\Overrides\ThemeUpgrader Class.
*/
class ThemeUpgrader extends \Theme_Upgrader {
/**
* Install a theme package.
*
* @param string $package The full local path or URI of the package.
* @param array $args {
* Optional. Other arguments for installing a theme package. Default empty array.
*
* @type bool $clear_update_cache Whether to clear the updates cache if successful.
* Default true.
* }
*
* @return bool|WP_Error True if the installation was successful, false or a WP_Error object otherwise.
*/
public function install( $package, $args = array() ) {
$defaults = array(
'clear_update_cache' => true,
);
$parsed_args = wp_parse_args( $args, $defaults );
$this->init();
$this->install_strings();
add_filter( 'upgrader_source_selection', array( $this, 'check_package' ) );
add_filter( 'upgrader_post_install', array( $this, 'check_parent_theme_filter' ), 10, 3 );
if ( $parsed_args['clear_update_cache'] ) {
// Clear cache so wp_update_themes() knows about the new theme.
add_action( 'upgrader_process_complete', 'wp_clean_themes_cache', 9, 0 );
}
$result = $this->run(
array(
'package' => $package,
'destination' => get_theme_root(),
'clear_destination' => false, // Do not overwrite files.
'clear_working' => true,
'hook_extra' => array(
'type' => 'theme',
'action' => 'install',
),
)
);
remove_action( 'upgrader_process_complete', 'wp_clean_themes_cache', 9 );
remove_filter( 'upgrader_source_selection', array( $this, 'check_package' ) );
remove_filter( 'upgrader_post_install', array( $this, 'check_parent_theme_filter' ) );
if ( $result && ! is_wp_error( $result ) ) {
// Refresh the Theme Update information.
wp_clean_themes_cache( $parsed_args['clear_update_cache'] );
}
return $result;
}
}
Admin/Overrides/ThemeUpgraderSkin.php 0000644 00000001446 15153704477 0013652 0 ustar 00 <?php
/**
* Theme upgrader skin used in REST API response.
*/
namespace Automattic\WooCommerce\Admin\Overrides;
defined( 'ABSPATH' ) || exit;
/**
* Admin\Overrides\ThemeUpgraderSkin Class.
*/
class ThemeUpgraderSkin extends \Theme_Upgrader_Skin {
/**
* Avoid undefined property error from \Theme_Upgrader::check_parent_theme_filter().
*
* @var array
*/
public $api;
/**
* Hide the skin header display.
*/
public function header() {}
/**
* Hide the skin footer display.
*/
public function footer() {}
/**
* Hide the skin feedback display.
*
* @param string $string String to display.
* @param mixed ...$args Optional text replacements.
*/
public function feedback( $string, ...$args ) {}
/**
* Hide the skin after display.
*/
public function after() {}
}
Admin/PageController.php 0000644 00000042127 15153704477 0011250 0 ustar 00 <?php
/**
* PageController
*/
namespace Automattic\WooCommerce\Admin;
use Automattic\WooCommerce\Admin\Features\Navigation\Screen;
use Automattic\WooCommerce\Internal\Admin\Loader;
defined( 'ABSPATH' ) || exit;
/**
* PageController
*/
class PageController {
/**
* App entry point.
*/
const APP_ENTRY_POINT = 'wc-admin';
// JS-powered page root.
const PAGE_ROOT = 'wc-admin';
/**
* Singleton instance of self.
*
* @var PageController
*/
private static $instance = false;
/**
* Current page ID (or false if not registered with this controller).
*
* @var string
*/
private $current_page = null;
/**
* Registered pages
* Contains information (breadcrumbs, menu info) about JS powered pages and classic WooCommerce pages.
*
* @var array
*/
private $pages = array();
/**
* We want a single instance of this class so we can accurately track registered menus and pages.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_page_handler' ) );
add_action( 'admin_menu', array( $this, 'register_store_details_page' ) );
// priority is 20 to run after https://github.com/woocommerce/woocommerce/blob/a55ae325306fc2179149ba9b97e66f32f84fdd9c/includes/admin/class-wc-admin-menus.php#L165.
add_action( 'admin_head', array( $this, 'remove_app_entry_page_menu_item' ), 20 );
}
/**
* Connect an existing page to wc-admin.
*
* @param array $options {
* Array describing the page.
*
* @type string id Id to reference the page.
* @type string|array title Page title. Used in menus and breadcrumbs.
* @type string|null parent Parent ID. Null for new top level page.
* @type string path Path for this page. E.g. admin.php?page=wc-settings&tab=checkout
* @type string capability Capability needed to access the page.
* @type string icon Icon. Dashicons helper class, base64-encoded SVG, or 'none'.
* @type int position Menu item position.
* @type boolean js_page If this is a JS-powered page.
* }
*/
public function connect_page( $options ) {
if ( ! is_array( $options['title'] ) ) {
$options['title'] = array( $options['title'] );
}
/**
* Filter the options when connecting or registering a page.
*
* Use the `js_page` option to determine if registering.
*
* @param array $options {
* Array describing the page.
*
* @type string id Id to reference the page.
* @type string|array title Page title. Used in menus and breadcrumbs.
* @type string|null parent Parent ID. Null for new top level page.
* @type string screen_id The screen ID that represents the connected page. (Not required for registering).
* @type string path Path for this page. E.g. admin.php?page=wc-settings&tab=checkout
* @type string capability Capability needed to access the page.
* @type string icon Icon. Dashicons helper class, base64-encoded SVG, or 'none'.
* @type int position Menu item position.
* @type boolean js_page If this is a JS-powered page.
* }
*/
$options = apply_filters( 'woocommerce_navigation_connect_page_options', $options );
// @todo check for null ID, or collision.
$this->pages[ $options['id'] ] = $options;
}
/**
* Determine the current page ID, if it was registered with this controller.
*/
public function determine_current_page() {
$current_url = '';
$current_screen_id = $this->get_current_screen_id();
if ( isset( $_SERVER['REQUEST_URI'] ) ) {
$current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
}
$current_query = wp_parse_url( $current_url, PHP_URL_QUERY );
parse_str( (string) $current_query, $current_pieces );
$current_path = empty( $current_pieces['page'] ) ? '' : $current_pieces['page'];
$current_path .= empty( $current_pieces['path'] ) ? '' : '&path=' . $current_pieces['path'];
foreach ( $this->pages as $page ) {
if ( isset( $page['js_page'] ) && $page['js_page'] ) {
// Check registered admin pages.
if (
$page['path'] === $current_path
) {
$this->current_page = $page;
return;
}
} else {
// Check connected admin pages.
if (
isset( $page['screen_id'] ) &&
$page['screen_id'] === $current_screen_id
) {
$this->current_page = $page;
return;
}
}
}
$this->current_page = false;
}
/**
* Get breadcrumbs for WooCommerce Admin Page navigation.
*
* @return array Navigation pieces (breadcrumbs).
*/
public function get_breadcrumbs() {
$current_page = $this->get_current_page();
// Bail if this isn't a page registered with this controller.
if ( false === $current_page ) {
// Filter documentation below.
return apply_filters( 'woocommerce_navigation_get_breadcrumbs', array( '' ), $current_page );
}
if ( 1 === count( $current_page['title'] ) ) {
$breadcrumbs = $current_page['title'];
} else {
// If this page has multiple title pieces, only link the first one.
$breadcrumbs = array_merge(
array(
array( $current_page['path'], reset( $current_page['title'] ) ),
),
array_slice( $current_page['title'], 1 )
);
}
if ( isset( $current_page['parent'] ) ) {
$parent_id = $current_page['parent'];
while ( $parent_id ) {
if ( isset( $this->pages[ $parent_id ] ) ) {
$parent = $this->pages[ $parent_id ];
if ( 0 === strpos( $parent['path'], self::PAGE_ROOT ) ) {
$parent['path'] = 'admin.php?page=' . $parent['path'];
}
array_unshift( $breadcrumbs, array( $parent['path'], reset( $parent['title'] ) ) );
$parent_id = isset( $parent['parent'] ) ? $parent['parent'] : false;
} else {
$parent_id = false;
}
}
}
$woocommerce_breadcrumb = array( 'admin.php?page=' . self::PAGE_ROOT, __( 'WooCommerce', 'woocommerce' ) );
array_unshift( $breadcrumbs, $woocommerce_breadcrumb );
/**
* The navigation breadcrumbs for the current page.
*
* @param array $breadcrumbs Navigation pieces (breadcrumbs).
* @param array|boolean $current_page The connected page data or false if not identified.
*/
return apply_filters( 'woocommerce_navigation_get_breadcrumbs', $breadcrumbs, $current_page );
}
/**
* Get the current page.
*
* @return array|boolean Current page or false if not registered with this controller.
*/
public function get_current_page() {
// If 'current_screen' hasn't fired yet, the current page calculation
// will fail which causes `false` to be returned for all subsquent calls.
if ( ! did_action( 'current_screen' ) ) {
_doing_it_wrong( __FUNCTION__, esc_html__( 'Current page retrieval should be called on or after the `current_screen` hook.', 'woocommerce' ), '0.16.0' );
}
if ( is_null( $this->current_page ) ) {
$this->determine_current_page();
}
return $this->current_page;
}
/**
* Returns the current screen ID.
*
* This is slightly different from WP's get_current_screen, in that it attaches an action,
* so certain pages like 'add new' pages can have different breadcrumbs or handling.
* It also catches some more unique dynamic pages like taxonomy/attribute management.
*
* Format:
* - {$current_screen->action}-{$current_screen->action}-tab-section
* - {$current_screen->action}-{$current_screen->action}-tab
* - {$current_screen->action}-{$current_screen->action} if no tab is present
* - {$current_screen->action} if no action or tab is present
*
* @return string Current screen ID.
*/
public function get_current_screen_id() {
$current_screen = get_current_screen();
if ( ! $current_screen ) {
// Filter documentation below.
return apply_filters( 'woocommerce_navigation_current_screen_id', false, $current_screen );
}
$screen_pieces = array( $current_screen->id );
if ( $current_screen->action ) {
$screen_pieces[] = $current_screen->action;
}
if (
! empty( $current_screen->taxonomy ) &&
isset( $current_screen->post_type ) &&
'product' === $current_screen->post_type
) {
// Editing a product attribute.
if ( 0 === strpos( $current_screen->taxonomy, 'pa_' ) ) {
$screen_pieces = array( 'product_page_product_attribute-edit' );
}
// Editing a product taxonomy term.
if ( ! empty( $_GET['tag_ID'] ) ) {
$screen_pieces = array( $current_screen->taxonomy );
}
}
// Pages with default tab values.
$pages_with_tabs = apply_filters(
'woocommerce_navigation_pages_with_tabs',
array(
'wc-reports' => 'orders',
'wc-settings' => 'general',
'wc-status' => 'status',
'wc-addons' => 'browse-extensions',
)
);
// Tabs that have sections as well.
$wc_emails = \WC_Emails::instance();
$wc_email_ids = array_map( 'sanitize_title', array_keys( $wc_emails->get_emails() ) );
$tabs_with_sections = apply_filters(
'woocommerce_navigation_page_tab_sections',
array(
'products' => array( '', 'inventory', 'downloadable' ),
'shipping' => array( '', 'options', 'classes' ),
'checkout' => array( 'bacs', 'cheque', 'cod', 'paypal' ),
'email' => $wc_email_ids,
'advanced' => array(
'',
'keys',
'webhooks',
'legacy_api',
'woocommerce_com',
),
'browse-extensions' => array( 'helper' ),
)
);
if ( ! empty( $_GET['page'] ) ) {
$page = wc_clean( wp_unslash( $_GET['page'] ) );
if ( in_array( $page, array_keys( $pages_with_tabs ) ) ) {
if ( ! empty( $_GET['tab'] ) ) {
$tab = wc_clean( wp_unslash( $_GET['tab'] ) );
} else {
$tab = $pages_with_tabs[ $page ];
}
$screen_pieces[] = $tab;
if ( ! empty( $_GET['section'] ) ) {
$section = wc_clean( wp_unslash( $_GET['section'] ) );
if (
isset( $tabs_with_sections[ $tab ] ) &&
in_array( $section, array_keys( $tabs_with_sections[ $tab ] ) )
) {
$screen_pieces[] = $section;
}
}
// Editing a shipping zone.
if ( ( 'shipping' === $tab ) && isset( $_GET['zone_id'] ) ) {
$screen_pieces[] = 'edit_zone';
}
}
}
/**
* The current screen id.
*
* Used for identifying pages to render the WooCommerce Admin header.
*
* @param string|boolean $screen_id The screen id or false if not identified.
* @param WP_Screen $current_screen The current WP_Screen.
*/
return apply_filters( 'woocommerce_navigation_current_screen_id', implode( '-', $screen_pieces ), $current_screen );
}
/**
* Returns the path from an ID.
*
* @param string $id ID to get path for.
* @return string Path for the given ID, or the ID on lookup miss.
*/
public function get_path_from_id( $id ) {
if ( isset( $this->pages[ $id ] ) && isset( $this->pages[ $id ]['path'] ) ) {
return $this->pages[ $id ]['path'];
}
return $id;
}
/**
* Returns true if we are on a page connected to this controller.
*
* @return boolean
*/
public function is_connected_page() {
$current_page = $this->get_current_page();
if ( false === $current_page ) {
$is_connected_page = false;
} else {
$is_connected_page = isset( $current_page['js_page'] ) ? ! $current_page['js_page'] : true;
}
// Disable embed on the block editor.
$current_screen = did_action( 'current_screen' ) ? get_current_screen() : false;
if ( ! empty( $current_screen ) && method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor() ) {
$is_connected_page = false;
}
/**
* Whether or not the current page is an existing page connected to this controller.
*
* Used to determine if the WooCommerce Admin header should be rendered.
*
* @param boolean $is_connected_page True if the current page is connected.
* @param array|boolean $current_page The connected page data or false if not identified.
*/
return apply_filters( 'woocommerce_navigation_is_connected_page', $is_connected_page, $current_page );
}
/**
* Returns true if we are on a page registed with this controller.
*
* @return boolean
*/
public function is_registered_page() {
$current_page = $this->get_current_page();
if ( false === $current_page ) {
$is_registered_page = false;
} else {
$is_registered_page = isset( $current_page['js_page'] ) && $current_page['js_page'];
}
/**
* Whether or not the current page was registered with this controller.
*
* Used to determine if this is a JS-powered WooCommerce Admin page.
*
* @param boolean $is_registered_page True if the current page was registered with this controller.
* @param array|boolean $current_page The registered page data or false if not identified.
*/
return apply_filters( 'woocommerce_navigation_is_registered_page', $is_registered_page, $current_page );
}
/**
* Adds a JS powered page to wc-admin.
*
* @param array $options {
* Array describing the page.
*
* @type string id Id to reference the page.
* @type string title Page title. Used in menus and breadcrumbs.
* @type string|null parent Parent ID. Null for new top level page.
* @type string path Path for this page, full path in app context; ex /analytics/report
* @type string capability Capability needed to access the page.
* @type string icon Icon. Dashicons helper class, base64-encoded SVG, or 'none'.
* @type int position Menu item position.
* @type int order Navigation item order.
* }
*/
public function register_page( $options ) {
$defaults = array(
'id' => null,
'parent' => null,
'title' => '',
'capability' => 'view_woocommerce_reports',
'path' => '',
'icon' => '',
'position' => null,
'js_page' => true,
);
$options = wp_parse_args( $options, $defaults );
if ( 0 !== strpos( $options['path'], self::PAGE_ROOT ) ) {
$options['path'] = self::PAGE_ROOT . '&path=' . $options['path'];
}
if ( null !== $options['position'] ) {
$options['position'] = intval( round( $options['position'] ) );
}
if ( is_null( $options['parent'] ) ) {
add_menu_page(
$options['title'],
$options['title'],
$options['capability'],
$options['path'],
array( __CLASS__, 'page_wrapper' ),
$options['icon'],
$options['position']
);
} else {
$parent_path = $this->get_path_from_id( $options['parent'] );
// @todo check for null path.
add_submenu_page(
$parent_path,
$options['title'],
$options['title'],
$options['capability'],
$options['path'],
array( __CLASS__, 'page_wrapper' )
);
}
$this->connect_page( $options );
}
/**
* Get registered pages.
*
* @return array
*/
public function get_pages() {
return $this->pages;
}
/**
* Set up a div for the app to render into.
*/
public static function page_wrapper() {
Loader::page_wrapper();
}
/**
* Connects existing WooCommerce pages.
*
* @todo The entry point for the embed needs moved to this class as well.
*/
public function register_page_handler() {
require_once WC_ADMIN_ABSPATH . 'includes/react-admin/connect-existing-pages.php';
}
/**
* Registers the store details (profiler) page.
*/
public function register_store_details_page() {
wc_admin_register_page(
array(
'title' => __( 'Setup Wizard', 'woocommerce' ),
'parent' => '',
'path' => '/setup-wizard',
)
);
}
/**
* Remove the menu item for the app entry point page.
*/
public function remove_app_entry_page_menu_item() {
global $submenu;
// User does not have capabilites to see the submenu.
if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) {
return;
}
$wc_admin_key = null;
foreach ( $submenu['woocommerce'] as $submenu_key => $submenu_item ) {
// Our app entry page menu item has no title.
if ( is_null( $submenu_item[0] ) && self::APP_ENTRY_POINT === $submenu_item[2] ) {
$wc_admin_key = $submenu_key;
break;
}
}
if ( ! $wc_admin_key ) {
return;
}
unset( $submenu['woocommerce'][ $wc_admin_key ] );
}
/**
* Returns true if we are on a JS powered admin page or
* a "classic" (non JS app) powered admin page (an embedded page).
*/
public static function is_admin_or_embed_page() {
return self::is_admin_page() || self::is_embed_page();
}
/**
* Returns true if we are on a JS powered admin page.
*/
public static function is_admin_page() {
// phpcs:disable WordPress.Security.NonceVerification
return isset( $_GET['page'] ) && 'wc-admin' === $_GET['page'];
// phpcs:enable WordPress.Security.NonceVerification
}
/**
* Returns true if we are on a "classic" (non JS app) powered admin page.
*
* TODO: See usage in `admin.php`. This needs refactored and implemented properly in core.
*/
public static function is_embed_page() {
return wc_admin_is_connected_page() || ( ! self::is_admin_page() && class_exists( 'Automattic\WooCommerce\Admin\Features\Navigation\Screen' ) && Screen::is_woocommerce_page() );
}
}
Admin/PluginsHelper.php 0000644 00000034472 15153704477 0011115 0 ustar 00 <?php
/**
* PluginsHelper
*
* Helper class for the site's plugins.
*/
namespace Automattic\WooCommerce\Admin;
use ActionScheduler;
use ActionScheduler_DBStore;
use ActionScheduler_QueueRunner;
use Automatic_Upgrader_Skin;
use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsyncPluginsInstallLogger;
use Automattic\WooCommerce\Admin\PluginsInstallLoggers\PluginsInstallLogger;
use Plugin_Upgrader;
use WP_Error;
use WP_Upgrader;
defined( 'ABSPATH' ) || exit;
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
/**
* Class PluginsHelper
*/
class PluginsHelper {
/**
* Initialize hooks.
*/
public static function init() {
add_action( 'woocommerce_plugins_install_callback', array( __CLASS__, 'install_plugins' ), 10, 2 );
add_action( 'woocommerce_plugins_install_and_activate_async_callback', array( __CLASS__, 'install_and_activate_plugins_async_callback' ), 10, 2 );
add_action( 'woocommerce_plugins_activate_callback', array( __CLASS__, 'activate_plugins' ), 10, 2 );
}
/**
* Get the path to the plugin file relative to the plugins directory from the plugin slug.
*
* E.g. 'woocommerce' returns 'woocommerce/woocommerce.php'
*
* @param string $slug Plugin slug to get path for.
*
* @return string|false
*/
public static function get_plugin_path_from_slug( $slug ) {
$plugins = get_plugins();
if ( strstr( $slug, '/' ) ) {
// The slug is already a plugin path.
return $slug;
}
foreach ( $plugins as $plugin_path => $data ) {
$path_parts = explode( '/', $plugin_path );
if ( $path_parts[0] === $slug ) {
return $plugin_path;
}
}
return false;
}
/**
* Get an array of installed plugin slugs.
*
* @return array
*/
public static function get_installed_plugin_slugs() {
return array_map(
function ( $plugin_path ) {
$path_parts = explode( '/', $plugin_path );
return $path_parts[0];
},
array_keys( get_plugins() )
);
}
/**
* Get an array of installed plugins with their file paths as a key value pair.
*
* @return array
*/
public static function get_installed_plugins_paths() {
$plugins = get_plugins();
$installed_plugins = array();
foreach ( $plugins as $path => $plugin ) {
$path_parts = explode( '/', $path );
$slug = $path_parts[0];
$installed_plugins[ $slug ] = $path;
}
return $installed_plugins;
}
/**
* Get an array of active plugin slugs.
*
* @return array
*/
public static function get_active_plugin_slugs() {
return array_map(
function ( $plugin_path ) {
$path_parts = explode( '/', $plugin_path );
return $path_parts[0];
},
get_option( 'active_plugins', array() )
);
}
/**
* Checks if a plugin is installed.
*
* @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name.
*
* @return bool
*/
public static function is_plugin_installed( $plugin ) {
$plugin_path = self::get_plugin_path_from_slug( $plugin );
return $plugin_path ? array_key_exists( $plugin_path, get_plugins() ) : false;
}
/**
* Checks if a plugin is active.
*
* @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name.
*
* @return bool
*/
public static function is_plugin_active( $plugin ) {
$plugin_path = self::get_plugin_path_from_slug( $plugin );
return $plugin_path ? in_array( $plugin_path, get_option( 'active_plugins', array() ), true ) : false;
}
/**
* Get plugin data.
*
* @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name.
*
* @return array|false
*/
public static function get_plugin_data( $plugin ) {
$plugin_path = self::get_plugin_path_from_slug( $plugin );
$plugins = get_plugins();
return isset( $plugins[ $plugin_path ] ) ? $plugins[ $plugin_path ] : false;
}
/**
* Install an array of plugins.
*
* @param array $plugins Plugins to install.
* @param PluginsInstallLogger|null $logger an optional logger.
*
* @return array
*/
public static function install_plugins( $plugins, PluginsInstallLogger $logger = null ) {
/**
* Filter the list of plugins to install.
*
* @param array $plugins A list of the plugins to install.
*
* @since 6.4.0
*/
$plugins = apply_filters( 'woocommerce_admin_plugins_pre_install', $plugins );
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' )
);
}
require_once ABSPATH . 'wp-admin/includes/plugin.php';
include_once ABSPATH . '/wp-admin/includes/admin.php';
include_once ABSPATH . '/wp-admin/includes/plugin-install.php';
include_once ABSPATH . '/wp-admin/includes/plugin.php';
include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php';
include_once ABSPATH . '/wp-admin/includes/class-plugin-upgrader.php';
$existing_plugins = self::get_installed_plugins_paths();
$installed_plugins = array();
$results = array();
$time = array();
$errors = new WP_Error();
$install_start_time = time();
foreach ( $plugins as $plugin ) {
$slug = sanitize_key( $plugin );
$logger && $logger->install_requested( $plugin );
if ( isset( $existing_plugins[ $slug ] ) ) {
$installed_plugins[] = $plugin;
$logger && $logger->installed( $plugin, 0 );
continue;
}
$start_time = microtime( true );
$api = plugins_api(
'plugin_information',
array(
'slug' => $slug,
'fields' => array(
'sections' => false,
),
)
);
if ( is_wp_error( $api ) ) {
$properties = array(
'error_message' => sprintf(
// translators: %s: plugin slug (example: woocommerce-services).
__(
'The requested plugin `%s` could not be installed. Plugin API call failed.',
'woocommerce'
),
$slug
),
'api_error_message' => $api->get_error_message(),
'slug' => $slug,
);
wc_admin_record_tracks_event( 'install_plugin_error', $properties );
/**
* Action triggered when a plugin API call failed.
*
* @param string $slug The plugin slug.
* @param WP_Error $api The API response.
*
* @since 6.4.0
*/
do_action( 'woocommerce_plugins_install_api_error', $slug, $api );
$error_message = sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ),
$slug
);
$errors->add( $plugin, $error_message );
$logger && $logger->add_error( $plugin, $error_message );
continue;
}
$upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
$result = $upgrader->install( $api->download_link );
// result can be false or WP_Error.
$results[ $plugin ] = $result;
$time[ $plugin ] = round( ( microtime( true ) - $start_time ) * 1000 );
if ( is_wp_error( $result ) || is_null( $result ) ) {
$properties = array(
'error_message' => sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__(
'The requested plugin `%s` could not be installed.',
'woocommerce'
),
$slug
),
'slug' => $slug,
'api_version' => $api->version,
'api_download_link' => $api->download_link,
'upgrader_skin_message' => implode( ',', $upgrader->skin->get_upgrade_messages() ),
'result' => is_wp_error( $result ) ? $result->get_error_message() : 'null',
);
wc_admin_record_tracks_event( 'install_plugin_error', $properties );
/**
* Action triggered when a plugin installation fails.
*
* @param string $slug The plugin slug.
* @param object $api The plugin API object.
* @param WP_Error|null $result The result of the plugin installation.
* @param Plugin_Upgrader $upgrader The plugin upgrader.
*
* @since 6.4.0
*/
do_action( 'woocommerce_plugins_install_error', $slug, $api, $result, $upgrader );
$install_error_message = sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__( 'The requested plugin `%s` could not be installed. Upgrader install failed.', 'woocommerce' ),
$slug
);
$errors->add(
$plugin,
$install_error_message
);
$logger && $logger->add_error( $plugin, $install_error_message );
continue;
}
$installed_plugins[] = $plugin;
$logger && $logger->installed( $plugin, $time[ $plugin ] );
}
$data = array(
'installed' => $installed_plugins,
'results' => $results,
'errors' => $errors,
'time' => $time,
);
$logger && $logger->complete( array_merge( $data, array( 'start_time' => $install_start_time ) ) );
return $data;
}
/**
* Callback regsitered by OnboardingPlugins::install_and_activate_async.
*
* It is used to call install_plugins and activate_plugins with a custom logger.
*
* @param array $plugins A list of plugins to install.
* @param string $job_id An unique job I.D.
* @return bool
*/
public static function install_and_activate_plugins_async_callback( array $plugins, string $job_id ) {
$option_name = 'woocommerce_onboarding_plugins_install_and_activate_async_' . $job_id;
$logger = new AsyncPluginsInstallLogger( $option_name );
self::install_plugins( $plugins, $logger );
self::activate_plugins( $plugins, $logger );
return true;
}
/**
* Schedule plugin installation.
*
* @param array $plugins Plugins to install.
*
* @return string Job ID.
*/
public static function schedule_install_plugins( $plugins ) {
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' ),
404
);
}
$job_id = uniqid();
WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_install_callback', array( $plugins ) );
return $job_id;
}
/**
* Activate the requested plugins.
*
* @param array $plugins Plugins.
* @param PluginsInstallLogger|null $logger Logger.
*
* @return WP_Error|array Plugin Status
*/
public static function activate_plugins( $plugins, PluginsInstallLogger $logger = null ) {
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' ),
404
);
}
require_once ABSPATH . 'wp-admin/includes/plugin.php';
// the mollie-payments-for-woocommerce plugin calls `WP_Filesystem()` during it's activation hook, which crashes without this include.
require_once ABSPATH . 'wp-admin/includes/file.php';
/**
* Filter the list of plugins to activate.
*
* @param array $plugins A list of the plugins to activate.
*
* @since 6.4.0
*/
$plugins = apply_filters( 'woocommerce_admin_plugins_pre_activate', $plugins );
$plugin_paths = self::get_installed_plugins_paths();
$errors = new WP_Error();
$activated_plugins = array();
foreach ( $plugins as $plugin ) {
$slug = $plugin;
$path = isset( $plugin_paths[ $slug ] ) ? $plugin_paths[ $slug ] : false;
if ( ! $path ) {
/* translators: %s: plugin slug (example: woocommerce-services) */
$message = sprintf( __( 'The requested plugin `%s`. is not yet installed.', 'woocommerce' ), $slug );
$errors->add(
$plugin,
$message
);
$logger && $logger->add_error( $plugin, $message );
continue;
}
$result = activate_plugin( $path );
if ( ! is_plugin_active( $path ) ) {
/**
* Action triggered when a plugin activation fails.
*
* @param string $slug The plugin slug.
* @param null|WP_Error $result The result of the plugin activation.
*
* @since 6.4.0
*/
do_action( 'woocommerce_plugins_activate_error', $slug, $result );
/* translators: %s: plugin slug (example: woocommerce-services) */
$message = sprintf( __( 'The requested plugin `%s` could not be activated.', 'woocommerce' ), $slug );
$errors->add(
$plugin,
$message
);
$logger && $logger->add_error( $plugin, $message );
continue;
}
$activated_plugins[] = $plugin;
$logger && $logger->activated( $plugin );
}
$data = array(
'activated' => $activated_plugins,
'active' => self::get_active_plugin_slugs(),
'errors' => $errors,
);
return $data;
}
/**
* Schedule plugin activation.
*
* @param array $plugins Plugins to activate.
*
* @return string Job ID.
*/
public static function schedule_activate_plugins( $plugins ) {
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' ),
404
);
}
$job_id = uniqid();
WC()->queue()->schedule_single(
time() + 5,
'woocommerce_plugins_activate_callback',
array( $plugins, $job_id )
);
return $job_id;
}
/**
* Installation status.
*
* @param int $job_id Job ID.
*
* @return array Job data.
*/
public static function get_installation_status( $job_id = null ) {
$actions = WC()->queue()->search(
array(
'hook' => 'woocommerce_plugins_install_callback',
'search' => $job_id,
'orderby' => 'date',
'order' => 'DESC',
)
);
return self::get_action_data( $actions );
}
/**
* Gets the plugin data for the first action.
*
* @param array $actions Array of AS actions.
*
* @return array Array of action data.
*/
public static function get_action_data( $actions ) {
$data = array();
foreach ( $actions as $action_id => $action ) {
$store = new ActionScheduler_DBStore();
$args = $action->get_args();
$data[] = array(
'job_id' => $args[1],
'plugins' => $args[0],
'status' => $store->get_status( $action_id ),
);
}
return $data;
}
/**
* Activation status.
*
* @param int $job_id Job ID.
*
* @return array Array of action data.
*/
public static function get_activation_status( $job_id = null ) {
$actions = WC()->queue()->search(
array(
'hook' => 'woocommerce_plugins_activate_callback',
'search' => $job_id,
'orderby' => 'date',
'order' => 'DESC',
)
);
return self::get_action_data( $actions );
}
}
Admin/PluginsInstallLoggers/AsyncPluginsInstallLogger.php 0000644 00000011725 15153704477 0017731 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\PluginsInstallLoggers;
/**
* A logger to log plugin installation progress in real time to an option.
*/
class AsyncPluginsInstallLogger implements PluginsInstallLogger {
/**
* Variable to store logs.
*
* @var string $option_name option name to store logs.
*/
private $option_name;
/**
* Constructor.
*
* @param string $option_name option name.
*/
public function __construct( string $option_name ) {
$this->option_name = $option_name;
add_option(
$this->option_name,
array(
'created_time' => time(),
'status' => 'pending',
'plugins' => array(),
),
'',
'no'
);
// Set status as failed in case we run out of exectuion time.
register_shutdown_function(
function () {
$error = error_get_last();
if ( isset( $error['type'] ) && E_ERROR === $error['type'] ) {
$option = $this->get();
$option['status'] = 'failed';
$this->update( $option );
}
}
);
}
/**
* Update the option.
*
* @param array $data New data.
*
* @return bool
*/
private function update( array $data ) {
return update_option( $this->option_name, $data );
}
/**
* Retreive the option.
*
* @return false|mixed|void
*/
private function get() {
return get_option( $this->option_name );
}
/**
* Add requested plugin.
*
* @param string $plugin_name plugin name.
*
* @return void
*/
public function install_requested( string $plugin_name ) {
$option = $this->get();
if ( ! isset( $option['plugins'][ $plugin_name ] ) ) {
$option['plugins'][ $plugin_name ] = array(
'status' => 'installing',
'errors' => array(),
'install_duration' => 0,
);
}
$this->update( $option );
}
/**
* Add installed plugin.
*
* @param string $plugin_name plugin name.
* @param int $duration time took to install plugin.
*
* @return void
*/
public function installed( string $plugin_name, int $duration ) {
$option = $this->get();
$option['plugins'][ $plugin_name ]['status'] = 'installed';
$option['plugins'][ $plugin_name ]['install_duration'] = $duration;
$this->update( $option );
}
/**
* Change status to activated.
*
* @param string $plugin_name plugin name.
*
* @return void
*/
public function activated( string $plugin_name ) {
$option = $this->get();
$option['plugins'][ $plugin_name ]['status'] = 'activated';
$this->update( $option );
}
/**
* Add an error.
*
* @param string $plugin_name plugin name.
* @param string|null $error_message error message.
*
* @return void
*/
public function add_error( string $plugin_name, string $error_message = null ) {
$option = $this->get();
$option['plugins'][ $plugin_name ]['errors'][] = $error_message;
$option['plugins'][ $plugin_name ]['status'] = 'failed';
$option['status'] = 'failed';
$this->update( $option );
}
/**
* Record completed_time.
*
* @param array $data return data from install_plugins().
* @return void
*/
public function complete( $data = array() ) {
$option = $this->get();
$option['complete_time'] = time();
$option['status'] = 'complete';
$this->track( $data );
$this->update( $option );
}
private function get_plugin_track_key( $id ) {
$slug = explode( ':', $id )[0];
$key = preg_match( '/^woocommerce(-|_)payments$/', $slug )
? 'wcpay'
: explode( ':', str_replace( '-', '_', $slug ) )[0];
return $key;
}
/**
* Returns time frame for a given time in milliseconds.
*
* @param int $timeInMs - time in milliseconds
*
* @return string - Time frame.
*/
function get_timeframe( $timeInMs ) {
$time_frames = [
[
'name' => '0-2s',
'max' => 2,
],
[
'name' => '2-5s',
'max' => 5,
],
[
'name' => '5-10s',
'max' => 10,
],
[
'name' => '10-15s',
'max' => 15,
],
[
'name' => '15-20s',
'max' => 20,
],
[
'name' => '20-30s',
'max' => 30,
],
[
'name' => '30-60s',
'max' => 60,
],
[ 'name' => '>60s' ],
];
foreach ( $time_frames as $time_frame ) {
if ( ! isset( $time_frame['max'] ) ) {
return $time_frame['name'];
}
if ( $timeInMs < $time_frame['max'] * 1000 ) {
return $time_frame['name'];
}
}
}
private function track( $data ) {
$track_data = array(
'success' => true,
'installed_extensions' => array_map(
function( $extension ) {
return $this->get_plugin_track_key( $extension );
},
$data['installed']
),
'total_time' => $this->get_timeframe( ( time() - $data['start_time'] ) * 1000 ),
);
foreach ( $data['installed'] as $plugin ) {
if ( ! isset( $data['time'][ $plugin ] ) ) {
continue;
}
$track_data[ 'install_time_' . $this->get_plugin_track_key( $plugin ) ] = $this->get_timeframe( $data['time'][ $plugin ] );
}
wc_admin_record_tracks_event( 'coreprofiler_store_extensions_installed_and_activated', $track_data );
}
}
Admin/PluginsInstallLoggers/PluginsInstallLogger.php 0000644 00000002376 15153704477 0016735 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\PluginsInstallLoggers;
/**
* A logger used in PluginsHelper::install_plugins to log the installation progress.
*/
interface PluginsInstallLogger {
/**
* Called when a plugin install requested.
*
* @param string $plugin_name plugin name.
* @return mixed
*/
public function install_requested( string $plugin_name );
/**
* Called when a plugin installed successfully.
*
* @param string $plugin_name plugin name.
* @param int $duration # of seconds it took to install $plugin_name.
* @return mixed
*/
public function installed( string $plugin_name, int $duration);
/**
* Called when a plugin activated successfully.
*
* @param string $plugin_name plugin name.
* @return mixed
*/
public function activated( string $plugin_name );
/**
* Called when an error occurred while installing a plugin.
*
* @param string $plugin_name plugin name.
* @param string|null $error_message error message.
* @return mixed
*/
public function add_error( string $plugin_name, string $error_message = null);
/**
* Called when all plugins are processed.
*
* @param array $data return data from install_plugins().
* @return mixed
*/
public function complete( $data = array() );
}
Admin/PluginsInstaller.php 0000644 00000006564 15153704477 0011634 0 ustar 00 <?php
/**
* PluginsInstaller
*
* Installer to allow plugin installation via URL query.
*/
namespace Automattic\WooCommerce\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Plugins;
use Automattic\WooCommerce\Admin\Features\TransientNotices;
/**
* Class PluginsInstaller
*/
class PluginsInstaller {
/**
* Constructor
*/
public static function init() {
add_action( 'admin_init', array( __CLASS__, 'possibly_install_activate_plugins' ) );
}
/**
* Check if an install or activation is being requested via URL query.
*/
public static function possibly_install_activate_plugins() {
/* phpcs:disable WordPress.Security.NonceVerification.Recommended */
if (
! isset( $_GET['plugin_action'] ) ||
! isset( $_GET['plugins'] ) ||
! current_user_can( 'install_plugins' ) ||
! isset( $_GET['nonce'] )
) {
return;
}
$nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'install-plugin' ) ) {
wp_nonce_ays( 'install-plugin' );
}
$plugins = sanitize_text_field( wp_unslash( $_GET['plugins'] ) );
$plugin_action = sanitize_text_field( wp_unslash( $_GET['plugin_action'] ) );
/* phpcs:enable WordPress.Security.NonceVerification.Recommended */
$plugins_api = new Plugins();
$install_result = null;
$activate_result = null;
switch ( $plugin_action ) {
case 'install':
$install_result = $plugins_api->install_plugins( array( 'plugins' => $plugins ) );
break;
case 'activate':
$activate_result = $plugins_api->activate_plugins( array( 'plugins' => $plugins ) );
break;
case 'install-activate':
$install_result = $plugins_api->install_plugins( array( 'plugins' => $plugins ) );
$activate_result = $plugins_api->activate_plugins( array( 'plugins' => implode( ',', $install_result['data']['installed'] ) ) );
break;
}
self::cache_results( $plugins, $install_result, $activate_result );
self::redirect_to_referer();
}
/**
* Display the results of installation and activation on the page.
*
* @param string $plugins Comma separated list of plugins.
* @param array $install_result Result of installation.
* @param array $activate_result Result of activation.
*/
public static function cache_results( $plugins, $install_result, $activate_result ) {
if ( ! $install_result && ! $activate_result ) {
return;
}
if ( is_wp_error( $install_result ) || is_wp_error( $activate_result ) ) {
$message = $activate_result ? $activate_result->get_error_message() : $install_result->get_error_message();
} else {
$message = $activate_result ? $activate_result['message'] : $install_result['message'];
}
TransientNotices::add(
array(
'user_id' => get_current_user_id(),
'id' => 'plugin-installer-' . str_replace( ',', '-', $plugins ),
'status' => 'success',
'content' => $message,
)
);
}
/**
* Redirect back to the referring page if one exists.
*/
public static function redirect_to_referer() {
$referer = wp_get_referer();
if ( $referer && 0 !== strpos( $referer, wp_login_url() ) ) {
wp_safe_redirect( $referer );
exit();
}
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return;
}
$url = remove_query_arg( 'plugin_action', wp_unslash( $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore sanitization ok.
$url = remove_query_arg( 'plugins', $url );
wp_safe_redirect( $url );
exit();
}
}
Admin/PluginsProvider/PluginsProvider.php 0000644 00000003510 15153704477 0014611 0 ustar 00 <?php
/**
* A provider for getting access to plugin queries.
*/
namespace Automattic\WooCommerce\Admin\PluginsProvider;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProviderInterface;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Plugins Provider.
*
* Uses the live PluginsHelper.
*/
class PluginsProvider implements PluginsProviderInterface {
/**
* The deactivated plugin slug.
*
* @var string
*/
private static $deactivated_plugin_slug = '';
/**
* Get an array of active plugin slugs.
*
* @return array
*/
public function get_active_plugin_slugs() {
return array_filter(
PluginsHelper::get_active_plugin_slugs(),
function( $p ) {
return $p !== self::$deactivated_plugin_slug;
}
);
}
/**
* Set the deactivated plugin. This is needed because the deactivated_plugin
* hook happens before the option is updated which means that getting the
* active plugins includes the deactivated plugin.
*
* @param string $plugin_path The path to the plugin being deactivated.
*/
public static function set_deactivated_plugin( $plugin_path ) {
self::$deactivated_plugin_slug = explode( '/', $plugin_path )[0];
}
/**
* Get plugin data.
*
* @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name.
*
* @return array|false
*/
public function get_plugin_data( $plugin ) {
return PluginsHelper::get_plugin_data( $plugin );
}
/**
* Get the path to the plugin file relative to the plugins directory from the plugin slug.
*
* E.g. 'woocommerce' returns 'woocommerce/woocommerce.php'
*
* @param string $slug Plugin slug to get path for.
*
* @return string|false
*/
public function get_plugin_path_from_slug( $slug ) {
return PluginsHelper::get_plugin_path_from_slug( $slug );
}
}
Admin/PluginsProvider/PluginsProviderInterface.php 0000644 00000001650 15153704477 0016435 0 ustar 00 <?php
/**
* Interface for a provider for getting access to plugin queries,
* designed to be mockable for unit tests.
*/
namespace Automattic\WooCommerce\Admin\PluginsProvider;
defined( 'ABSPATH' ) || exit;
/**
* Plugins Provider Interface
*/
interface PluginsProviderInterface {
/**
* Get an array of active plugin slugs.
*
* @return array
*/
public function get_active_plugin_slugs();
/**
* Get plugin data.
*
* @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name.
*
* @return array|false
*/
public function get_plugin_data( $plugin );
/**
* Get the path to the plugin file relative to the plugins directory from the plugin slug.
*
* E.g. 'woocommerce' returns 'woocommerce/woocommerce.php'
*
* @param string $slug Plugin slug to get path for.
*
* @return string|false
*/
public function get_plugin_path_from_slug( $slug );
}
Admin/RemoteInboxNotifications/BaseLocationCountryRuleProcessor.php 0000644 00000003560 15153704477 0021772 0 ustar 00 <?php
/**
* Rule processor that performs a comparison operation against the base
* location - country.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs a comparison operation against the base
* location - country.
*/
class BaseLocationCountryRuleProcessor implements RuleProcessorInterface {
/**
* Performs a comparison operation against the base location - country.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$base_location = wc_get_base_location();
if ( ! $base_location ) {
return false;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
$is_address_default = 'US' === $base_location['country'] && 'CA' === $base_location['state'] && empty( get_option( 'woocommerce_store_address', '' ) );
$is_store_country_set = isset( $onboarding_profile['is_store_country_set'] ) && $onboarding_profile['is_store_country_set'];
// Return false if the location is the default country and if onboarding hasn't been finished or the store address not been updated.
if ( $is_address_default && OnboardingProfile::needs_completion() && ! $is_store_country_set ) {
return false;
}
return ComparisonOperation::compare(
$base_location['country'],
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/BaseLocationStateRuleProcessor.php 0000644 00000002256 15153704477 0021410 0 ustar 00 <?php
/**
* Rule processor that performs a comparison operation against the base
* location - state.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs a comparison operation against the base
* location - state.
*/
class BaseLocationStateRuleProcessor implements RuleProcessorInterface {
/**
* Performs a comparison operation against the base location - state.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$base_location = wc_get_base_location();
if ( ! $base_location ) {
return false;
}
return ComparisonOperation::compare(
$base_location['state'],
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/ComparisonOperation.php 0000644 00000003510 15153704477 0017301 0 ustar 00 <?php
/**
* Compare two operands using the specified operation.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Compare two operands using the specified operation.
*/
class ComparisonOperation {
/**
* Compare two operands using the specified operation.
*
* @param object $left_operand The left hand operand.
* @param object $right_operand The right hand operand.
* @param string $operation The operation used to compare the operands.
*/
public static function compare( $left_operand, $right_operand, $operation ) {
switch ( $operation ) {
case '=':
return $left_operand === $right_operand;
case '<':
return $left_operand < $right_operand;
case '<=':
return $left_operand <= $right_operand;
case '>':
return $left_operand > $right_operand;
case '>=':
return $left_operand >= $right_operand;
case '!=':
return $left_operand !== $right_operand;
case 'contains':
if ( is_array( $left_operand ) && is_string( $right_operand ) ) {
return in_array( $right_operand, $left_operand, true );
}
return strpos( $right_operand, $left_operand ) !== false;
case '!contains':
if ( is_array( $left_operand ) && is_string( $right_operand ) ) {
return ! in_array( $right_operand, $left_operand, true );
}
return strpos( $right_operand, $left_operand ) === false;
case 'in':
if ( is_array( $right_operand ) && is_string( $left_operand ) ) {
return in_array( $left_operand, $right_operand, true );
}
return strpos( $left_operand, $right_operand ) !== false;
case '!in':
if ( is_array( $right_operand ) && is_string( $left_operand ) ) {
return ! in_array( $left_operand, $right_operand, true );
}
return strpos( $left_operand, $right_operand ) === false;
}
return false;
}
}
Admin/RemoteInboxNotifications/DataSourcePoller.php 0000644 00000012316 15153704477 0016522 0 ustar 00 <?php
/**
* Handles polling and storage of specs
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Specs data source poller class.
* This handles polling specs from JSON endpoints, and
* stores the specs in to the database as an option.
*/
class DataSourcePoller extends \Automattic\WooCommerce\Admin\DataSourcePoller {
const ID = 'remote_inbox_notifications';
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/inbox-notifications/1.0/notifications.json',
);
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self(
self::ID,
self::DATA_SOURCES,
array(
'spec_key' => 'slug',
)
);
}
return self::$instance;
}
/**
* Validate the spec.
*
* @param object $spec The spec to validate.
* @param string $url The url of the feed that provided the spec.
*
* @return bool The result of the validation.
*/
protected function validate_spec( $spec, $url ) {
$logger = self::get_logger();
$logger_context = array( 'source' => $url );
if ( ! isset( $spec->slug ) ) {
$logger->error(
'Spec is invalid because the slug is missing in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
if ( ! isset( $spec->status ) ) {
$logger->error(
'Spec is invalid because the status is missing in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
if ( ! isset( $spec->locales ) || ! is_array( $spec->locales ) ) {
$logger->error(
'Spec is invalid because the status is missing or empty in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
if ( null === SpecRunner::get_locale( $spec->locales ) ) {
$logger->error(
'Spec is invalid because the locale could not be retrieved in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
if ( ! isset( $spec->type ) ) {
$logger->error(
'Spec is invalid because the type is missing in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
if ( isset( $spec->actions ) && is_array( $spec->actions ) ) {
foreach ( $spec->actions as $action ) {
if ( ! $this->validate_action( $action, $url ) ) {
$logger->error(
'Spec is invalid because an action is invalid in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
}
}
if ( isset( $spec->rules ) && is_array( $spec->rules ) ) {
foreach ( $spec->rules as $rule ) {
if ( ! isset( $rule->type ) ) {
$logger->error(
'Spec is invalid because a rule type is empty in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $rule, true ), $logger_context );
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
$processor = GetRuleProcessor::get_processor( $rule->type );
if ( ! $processor->validate( $rule ) ) {
$logger->error(
'Spec is invalid because a rule is invalid in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $rule, true ), $logger_context );
// phpcs:ignore
$logger->error( print_r( $spec, true ), $logger_context );
return false;
}
}
}
return true;
}
/**
* Validate the action.
*
* @param object $action The action to validate.
* @param string $url The url of the feed containing the action (for error reporting).
*
* @return bool The result of the validation.
*/
private function validate_action( $action, $url ) {
$logger = self::get_logger();
$logger_context = array( 'source' => $url );
if ( ! isset( $action->locales ) || ! is_array( $action->locales ) ) {
$logger->error(
'Action is invalid because it has empty or missing locales in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $action, true ), $logger_context );
return false;
}
if ( null === SpecRunner::get_action_locale( $action->locales ) ) {
$logger->error(
'Action is invalid because the locale could not be retrieved in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $action, true ), $logger_context );
return false;
}
if ( ! isset( $action->name ) ) {
$logger->error(
'Action is invalid because the name is missing in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $action, true ), $logger_context );
return false;
}
if ( ! isset( $action->status ) ) {
$logger->error(
'Action is invalid because the status is missing in feed',
$logger_context
);
// phpcs:ignore
$logger->error( print_r( $action, true ), $logger_context );
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/EvaluateAndGetStatus.php 0000644 00000003026 15153704477 0017345 0 ustar 00 <?php
/**
* Evaluates the spec and returns a status.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
/**
* Evaluates the spec and returns a status.
*/
class EvaluateAndGetStatus {
/**
* Evaluates the spec and returns a status.
*
* @param array $spec The spec to evaluate.
* @param string $current_status The note's current status.
* @param object $stored_state Stored state.
* @param object $rule_evaluator Evaluates rules into true/false.
*
* @return string The evaluated status.
*/
public static function evaluate( $spec, $current_status, $stored_state, $rule_evaluator ) {
// No rules should leave the note alone.
if ( ! isset( $spec->rules ) ) {
return $current_status;
}
$evaluated_result = $rule_evaluator->evaluate(
$spec->rules,
$stored_state,
array(
'slug' => $spec->slug,
'source' => 'remote-inbox-notifications',
)
);
// Pending notes should be the spec status if the spec passes,
// left alone otherwise.
if ( Note::E_WC_ADMIN_NOTE_PENDING === $current_status ) {
return $evaluated_result
? $spec->status
: Note::E_WC_ADMIN_NOTE_PENDING;
}
// When allow_redisplay isn't set, just leave the note alone.
if ( ! isset( $spec->allow_redisplay ) || ! $spec->allow_redisplay ) {
return $current_status;
}
// allow_redisplay is set, unaction the note if eval to true.
return $evaluated_result
? Note::E_WC_ADMIN_NOTE_UNACTIONED
: $current_status;
}
}
Admin/RemoteInboxNotifications/EvaluationLogger.php 0000644 00000003360 15153704477 0016560 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
/**
* Class EvaluationLogger
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications
*/
class EvaluationLogger {
/**
* Slug of the spec.
*
* @var string
*/
private $slug;
/**
* Results of rules in the given spec.
*
* @var array
*/
private $results = array();
/**
* Logger class to use.
*
* @var WC_Logger_Interface|null
*/
private $logger;
/**
* Logger source.
*
* @var string logger source.
*/
private $source = '';
/**
* EvaluationLogger constructor.
*
* @param string $slug Slug of a spec that is being evaluated.
* @param null $source Logger source.
* @param \WC_Logger_Interface $logger Logger class to use.
*/
public function __construct( $slug, $source = null, \WC_Logger_Interface $logger = null ) {
$this->slug = $slug;
if ( null === $logger ) {
$logger = wc_get_logger();
}
if ( $source ) {
$this->source = $source;
}
$this->logger = $logger;
}
/**
* Add evaluation result of a rule.
*
* @param string $rule_type name of the rule being tested.
* @param boolean $result result of a given rule.
*/
public function add_result( $rule_type, $result ) {
array_push(
$this->results,
array(
'rule' => $rule_type,
'result' => $result ? 'passed' : 'failed',
)
);
}
/**
* Log the results.
*/
public function log() {
if ( false === defined( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ) || true !== constant( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ) ) {
return;
}
foreach ( $this->results as $result ) {
$this->logger->debug(
"[{$this->slug}] {$result['rule']}: {$result['result']}",
array( 'source' => $this->source )
);
}
}
}
Admin/RemoteInboxNotifications/FailRuleProcessor.php 0000644 00000001261 15153704477 0016712 0 ustar 00 <?php
/**
* Rule processor that fails.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that fails.
*/
class FailRuleProcessor implements RuleProcessorInterface {
/**
* Fails the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Always false.
*/
public function process( $rule, $stored_state ) {
return false;
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
return true;
}
}
Admin/RemoteInboxNotifications/GetRuleProcessor.php 0000644 00000003745 15153704477 0016567 0 ustar 00 <?php
/**
* Gets the processor for the specified rule type.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Class encapsulating getting the processor for a given rule type.
*/
class GetRuleProcessor {
/**
* Get the processor for the specified rule type.
*
* @param string $rule_type The rule type.
*
* @return RuleProcessorInterface The matching processor for the specified rule type, or a FailRuleProcessor if no matching processor is found.
*/
public static function get_processor( $rule_type ) {
switch ( $rule_type ) {
case 'plugins_activated':
return new PluginsActivatedRuleProcessor();
case 'publish_after_time':
return new PublishAfterTimeRuleProcessor();
case 'publish_before_time':
return new PublishBeforeTimeRuleProcessor();
case 'not':
return new NotRuleProcessor();
case 'or':
return new OrRuleProcessor();
case 'fail':
return new FailRuleProcessor();
case 'pass':
return new PassRuleProcessor();
case 'plugin_version':
return new PluginVersionRuleProcessor();
case 'stored_state':
return new StoredStateRuleProcessor();
case 'order_count':
return new OrderCountRuleProcessor();
case 'wcadmin_active_for':
return new WCAdminActiveForRuleProcessor();
case 'product_count':
return new ProductCountRuleProcessor();
case 'onboarding_profile':
return new OnboardingProfileRuleProcessor();
case 'is_ecommerce':
return new IsEcommerceRuleProcessor();
case 'base_location_country':
return new BaseLocationCountryRuleProcessor();
case 'base_location_state':
return new BaseLocationStateRuleProcessor();
case 'note_status':
return new NoteStatusRuleProcessor();
case 'option':
return new OptionRuleProcessor();
case 'wca_updated':
return new WooCommerceAdminUpdatedRuleProcessor();
case 'total_payments_value':
return new TotalPaymentsVolumeProcessor();
}
return new FailRuleProcessor();
}
}
Admin/RemoteInboxNotifications/IsEcommerceRuleProcessor.php 0000644 00000002161 15153704477 0020232 0 ustar 00 <?php
/**
* Rule processor that passes (or fails) when the site is on the eCommerce
* plan.
*
* @package WooCommerce\Admin\Classes
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that passes (or fails) when the site is on the eCommerce
* plan.
*/
class IsEcommerceRuleProcessor implements RuleProcessorInterface {
/**
* Passes (or fails) based on whether the site is on the eCommerce plan or
* not.
*
* @param object $rule The rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
if ( ! function_exists( 'wc_calypso_bridge_is_ecommerce_plan' ) ) {
return false === $rule->value;
}
return (bool) wc_calypso_bridge_is_ecommerce_plan() === $rule->value;
}
/**
* Validate the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->value ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/NotRuleProcessor.php 0000644 00000002476 15153704477 0016610 0 ustar 00 <?php
/**
* Rule processor that negates the rules in the rule's operand.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that negates the rules in the rule's operand.
*/
class NotRuleProcessor implements RuleProcessorInterface {
/**
* The rule evaluator to use.
*
* @var RuleEvaluator
*/
protected $rule_evaluator;
/**
* Constructor.
*
* @param RuleEvaluator $rule_evaluator The rule evaluator to use.
*/
public function __construct( $rule_evaluator = null ) {
$this->rule_evaluator = null === $rule_evaluator
? new RuleEvaluator()
: $rule_evaluator;
}
/**
* Evaluates the rules in the operand and negates the result.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$evaluated_operand = $this->rule_evaluator->evaluate(
$rule->operand,
$stored_state
);
return ! $evaluated_operand;
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->operand ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/NoteStatusRuleProcessor.php 0000644 00000002452 15153704477 0020153 0 ustar 00 <?php
/**
* Rule processor that compares against the status of another note. For
* example, this could be used to conditionally create a note only if another
* note has not been actioned.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Rule processor that compares against the status of another note.
*/
class NoteStatusRuleProcessor implements RuleProcessorInterface {
/**
* Compare against the status of another note.
*
* @param object $rule The rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$status = Notes::get_note_status( $rule->note_name );
if ( ! $status ) {
return false;
}
return ComparisonOperation::compare(
$status,
$rule->status,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->note_name ) ) {
return false;
}
if ( ! isset( $rule->status ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/OnboardingProfileRuleProcessor.php 0000644 00000002600 15153704477 0021440 0 ustar 00 <?php
/**
* Rule processor that performs a comparison operation against a value in the
* onboarding profile.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs a comparison operation against a value in the
* onboarding profile.
*/
class OnboardingProfileRuleProcessor implements RuleProcessorInterface {
/**
* Performs a comparison operation against a value in the onboarding
* profile.
*
* @param object $rule The rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile' );
if ( empty( $onboarding_profile ) ) {
return false;
}
if ( ! isset( $onboarding_profile[ $rule->index ] ) ) {
return false;
}
return ComparisonOperation::compare(
$onboarding_profile[ $rule->index ],
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->index ) ) {
return false;
}
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/OptionRuleProcessor.php 0000644 00000005523 15153704477 0017314 0 ustar 00 <?php
/**
* Rule processor that performs a comparison operation against an option value.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs a comparison operation against an option value.
*/
class OptionRuleProcessor implements RuleProcessorInterface {
/**
* Performs a comparison operation against the option value.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$is_contains = $rule->operation && strpos( $rule->operation, 'contains' ) !== false;
$default_value = $is_contains ? array() : false;
$default = isset( $rule->default ) ? $rule->default : $default_value;
$option_value = $this->get_option_value( $rule, $default, $is_contains );
if ( isset( $rule->transformers ) && is_array( $rule->transformers ) ) {
$option_value = TransformerService::apply( $option_value, $rule->transformers, $default );
}
return ComparisonOperation::compare(
$option_value,
$rule->value,
$rule->operation
);
}
/**
* Retrieves the option value and handles logging if necessary.
*
* @param object $rule The specific rule being processed.
* @param mixed $default The default value.
* @param bool $is_contains Indicates whether the operation is "contains".
*
* @return mixed The option value.
*/
private function get_option_value( $rule, $default, $is_contains ) {
$option_value = get_option( $rule->option_name, $default );
$is_contains_valid = $is_contains && ( is_array( $option_value ) || ( is_string( $option_value ) && is_string( $rule->value ) ) );
if ( $is_contains && ! $is_contains_valid ) {
$logger = wc_get_logger();
$logger->warning(
sprintf(
'ComparisonOperation "%s" option value "%s" is not an array, defaulting to empty array.',
$rule->operation,
$rule->option_name
),
array(
'option_value' => $option_value,
'rule' => $rule,
)
);
$option_value = array();
}
return $option_value;
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->option_name ) ) {
return false;
}
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
if ( isset( $rule->transformers ) && is_array( $rule->transformers ) ) {
foreach ( $rule->transformers as $transform_args ) {
$transformer = TransformerService::create_transformer( $transform_args->use );
if ( ! $transformer->validate( $transform_args->arguments ) ) {
return false;
}
}
}
return true;
}
}
Admin/RemoteInboxNotifications/OrRuleProcessor.php 0000644 00000002750 15153704477 0016423 0 ustar 00 <?php
/**
* Rule processor that performs an OR operation on the rule's left and right
* operands.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs an OR operation on the rule's left and right
* operands.
*/
class OrRuleProcessor implements RuleProcessorInterface {
/**
* Rule evaluator to use.
*
* @var RuleEvaluator
*/
private $rule_evaluator;
/**
* Constructor.
*
* @param RuleEvaluator $rule_evaluator The rule evaluator to use.
*/
public function __construct( $rule_evaluator = null ) {
$this->rule_evaluator = null === $rule_evaluator
? new RuleEvaluator()
: $rule_evaluator;
}
/**
* Performs an OR operation on the rule's left and right operands.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
foreach ( $rule->operands as $operand ) {
$evaluated_operand = $this->rule_evaluator->evaluate(
$operand,
$stored_state
);
if ( $evaluated_operand ) {
return true;
}
}
return false;
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->operands ) || ! is_array( $rule->operands ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/OrderCountRuleProcessor.php 0000644 00000002474 15153704477 0020132 0 ustar 00 <?php
/**
* Rule processor for publishing based on the number of orders.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor for publishing based on the number of orders.
*/
class OrderCountRuleProcessor implements RuleProcessorInterface {
/**
* The orders provider.
*
* @var OrdersProvider
*/
protected $orders_provider;
/**
* Constructor.
*
* @param object $orders_provider The orders provider.
*/
public function __construct( $orders_provider = null ) {
$this->orders_provider = null === $orders_provider
? new OrdersProvider()
: $orders_provider;
}
/**
* Process the rule.
*
* @param object $rule The rule to process.
* @param object $stored_state Stored state.
*
* @return bool Whether the rule passes or not.
*/
public function process( $rule, $stored_state ) {
$count = $this->orders_provider->get_order_count();
return ComparisonOperation::compare(
$count,
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/OrdersProvider.php 0000644 00000001277 15153704477 0016267 0 ustar 00 <?php
/**
* Provider for order-related queries and operations.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Provider for order-related queries and operations.
*/
class OrdersProvider {
/**
* Allowed order statuses for calculating milestones.
*
* @var array
*/
protected $allowed_statuses = array(
'pending',
'processing',
'completed',
);
/**
* Returns the number of orders.
*
* @return integer The number of orders.
*/
public function get_order_count() {
$status_counts = array_map( 'wc_orders_count', $this->allowed_statuses );
$orders_count = array_sum( $status_counts );
return $orders_count;
}
}
Admin/RemoteInboxNotifications/PassRuleProcessor.php 0000644 00000001407 15153704477 0016747 0 ustar 00 <?php
/**
* Rule processor that passes. This is required because an empty set of rules
* (or predicate) evaluates to false.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that passes.
*/
class PassRuleProcessor implements RuleProcessorInterface {
/**
* Passes the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Always true.
*/
public function process( $rule, $stored_state ) {
return true;
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
return true;
}
}
Admin/RemoteInboxNotifications/PluginVersionRuleProcessor.php 0000644 00000003554 15153704477 0020652 0 ustar 00 <?php
/**
* Rule processor for sending when the provided plugin is activated and
* matches the specified version.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider;
/**
* Rule processor for sending when the provided plugin is activated and
* matches the specified version.
*/
class PluginVersionRuleProcessor implements RuleProcessorInterface {
/**
* Plugins provider instance.
*
* @var PluginsProviderInterface
*/
private $plugins_provider;
/**
* Constructor.
*
* @param PluginsProviderInterface $plugins_provider The plugins provider.
*/
public function __construct( $plugins_provider = null ) {
$this->plugins_provider = null === $plugins_provider
? new PluginsProvider()
: $plugins_provider;
}
/**
* Process the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Whether the rule passes or not.
*/
public function process( $rule, $stored_state ) {
$active_plugin_slugs = $this->plugins_provider->get_active_plugin_slugs();
if ( ! in_array( $rule->plugin, $active_plugin_slugs, true ) ) {
return false;
}
$plugin_data = $this->plugins_provider->get_plugin_data( $rule->plugin );
if ( ! $plugin_data ) {
return false;
}
$plugin_version = $plugin_data['Version'];
return version_compare( $plugin_version, $rule->version, $rule->operator );
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->plugin ) ) {
return false;
}
if ( ! isset( $rule->version ) ) {
return false;
}
if ( ! isset( $rule->operator ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/PluginsActivatedRuleProcessor.php 0000644 00000003131 15153704477 0021303 0 ustar 00 <?php
/**
* Rule processor for sending when the provided plugins are activated.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider;
/**
* Rule processor for sending when the provided plugins are activated.
*/
class PluginsActivatedRuleProcessor implements RuleProcessorInterface {
/**
* The plugins provider.
*
* @var PluginsProviderInterface
*/
protected $plugins_provider;
/**
* Constructor.
*
* @param PluginsProviderInterface $plugins_provider The plugins provider.
*/
public function __construct( $plugins_provider = null ) {
$this->plugins_provider = null === $plugins_provider
? new PluginsProvider()
: $plugins_provider;
}
/**
* Process the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Whether the rule passes or not.
*/
public function process( $rule, $stored_state ) {
if ( 0 === count( $rule->plugins ) ) {
return false;
}
$active_plugin_slugs = $this->plugins_provider->get_active_plugin_slugs();
foreach ( $rule->plugins as $plugin_slug ) {
if ( ! in_array( $plugin_slug, $active_plugin_slugs, true ) ) {
return false;
}
}
return true;
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->plugins ) || ! is_array( $rule->plugins ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/ProductCountRuleProcessor.php 0000644 00000003104 15153704477 0020466 0 ustar 00 <?php
/**
* Rule processor that performs a comparison operation against the number of
* products.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs a comparison operation against the number of
* products.
*/
class ProductCountRuleProcessor implements RuleProcessorInterface {
/**
* The product query.
*
* @var WC_Product_Query
*/
protected $product_query;
/**
* Constructor.
*
* @param object $product_query The product query.
*/
public function __construct( $product_query = null ) {
$this->product_query = null === $product_query
? new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
)
: $product_query;
}
/**
* Performs a comparison operation against the number of products.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$products = $this->product_query->get_products();
return ComparisonOperation::compare(
$products->total,
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/PublishAfterTimeRuleProcessor.php 0000644 00000002566 15153704477 0021257 0 ustar 00 <?php
/**
* Rule processor for sending after a specified date/time.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\DateTimeProvider\CurrentDateTimeProvider;
/**
* Rule processor for sending after a specified date/time.
*/
class PublishAfterTimeRuleProcessor implements RuleProcessorInterface {
/**
* The DateTime provider.
*
* @var DateTimeProviderInterface
*/
protected $date_time_provider;
/**
* Constructor.
*
* @param DateTimeProviderInterface $date_time_provider The DateTime provider.
*/
public function __construct( $date_time_provider = null ) {
$this->date_time_provider = null === $date_time_provider
? new CurrentDateTimeProvider()
: $date_time_provider;
}
/**
* Process the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Whether the rule passes or not.
*/
public function process( $rule, $stored_state ) {
return $this->date_time_provider->get_now() >= new \DateTime( $rule->publish_after );
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->publish_after ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/PublishBeforeTimeRuleProcessor.php 0000644 00000002573 15153704477 0021416 0 ustar 00 <?php
/**
* Rule processor for sending before a specified date/time.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\DateTimeProvider\CurrentDateTimeProvider;
/**
* Rule processor for sending before a specified date/time.
*/
class PublishBeforeTimeRuleProcessor implements RuleProcessorInterface {
/**
* The DateTime provider.
*
* @var DateTimeProviderInterface
*/
protected $date_time_provider;
/**
* Constructor.
*
* @param DateTimeProviderInterface $date_time_provider The DateTime provider.
*/
public function __construct( $date_time_provider = null ) {
$this->date_time_provider = null === $date_time_provider
? new CurrentDateTimeProvider()
: $date_time_provider;
}
/**
* Process the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Whether the rule passes or not.
*/
public function process( $rule, $stored_state ) {
return $this->date_time_provider->get_now() <= new \DateTime( $rule->publish_before );
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->publish_before ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php 0000644 00000014770 15153704477 0021433 0 ustar 00 <?php
/**
* Handles running specs
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Notes\Note;
/**
* Remote Inbox Notifications engine.
* This goes through the specs and runs (creates admin notes) for those
* specs that are able to be triggered.
*/
class RemoteInboxNotificationsEngine {
const STORED_STATE_OPTION_NAME = 'wc_remote_inbox_notifications_stored_state';
const WCA_UPDATED_OPTION_NAME = 'wc_remote_inbox_notifications_wca_updated';
/**
* Initialize the engine.
*/
public static function init() {
// Init things that need to happen before admin_init.
add_action( 'init', array( __CLASS__, 'on_init' ), 0, 0 );
// Continue init via admin_init.
add_action( 'admin_init', array( __CLASS__, 'on_admin_init' ) );
// Trigger when the profile data option is updated (during onboarding).
add_action(
'update_option_' . OnboardingProfile::DATA_OPTION,
array( __CLASS__, 'update_profile_option' ),
10,
2
);
// Hook into WCA updated. This is hooked up here rather than in
// on_admin_init because that runs too late to hook into the action.
add_action( 'woocommerce_run_on_woocommerce_admin_updated', array( __CLASS__, 'run_on_woocommerce_admin_updated' ) );
add_action(
'woocommerce_updated',
function() {
$next_hook = WC()->queue()->get_next(
'woocommerce_run_on_woocommerce_admin_updated',
array(),
'woocommerce-remote-inbox-engine'
);
if ( null === $next_hook ) {
WC()->queue()->schedule_single(
time(),
'woocommerce_run_on_woocommerce_admin_updated',
array(),
'woocommerce-remote-inbox-engine'
);
}
}
);
add_filter( 'woocommerce_get_note_from_db', array( __CLASS__, 'get_note_from_db' ), 10, 1 );
}
/**
* This is triggered when the profile option is updated and if the
* profiler is being completed, triggers a run of the engine.
*
* @param mixed $old_value Old value.
* @param mixed $new_value New value.
*/
public static function update_profile_option( $old_value, $new_value ) {
// Return early if we're not completing the profiler.
if (
( isset( $old_value['completed'] ) && $old_value['completed'] ) ||
! isset( $new_value['completed'] ) ||
! $new_value['completed']
) {
return;
}
self::run();
}
/**
* Init is continued via admin_init so that WC is loaded when the product
* query is used, otherwise the query generates a "0 = 1" in the WHERE
* condition and thus doesn't return any results.
*/
public static function on_admin_init() {
add_action( 'activated_plugin', array( __CLASS__, 'run' ) );
add_action( 'deactivated_plugin', array( __CLASS__, 'run_on_deactivated_plugin' ), 10, 1 );
StoredStateSetupForProducts::admin_init();
// Pre-fetch stored state so it has the correct initial values.
self::get_stored_state();
}
/**
* An init hook is used here so that StoredStateSetupForProducts can set
* up a hook that gets triggered by action-scheduler - this is needed
* because the admin_init hook doesn't get triggered by WP Cron.
*/
public static function on_init() {
StoredStateSetupForProducts::init();
}
/**
* Go through the specs and run them.
*/
public static function run() {
$specs = DataSourcePoller::get_instance()->get_specs_from_data_sources();
if ( $specs === false || count( $specs ) === 0 ) {
return;
}
$stored_state = self::get_stored_state();
foreach ( $specs as $spec ) {
SpecRunner::run_spec( $spec, $stored_state );
}
}
/**
* Set an option indicating that WooCommerce Admin has just been updated,
* run the specs, then clear that option. This lets the
* WooCommerceAdminUpdatedRuleProcessor trigger on WCA update.
*/
public static function run_on_woocommerce_admin_updated() {
update_option( self::WCA_UPDATED_OPTION_NAME, true, false );
self::run();
update_option( self::WCA_UPDATED_OPTION_NAME, false, false );
}
/**
* Gets the stored state option, and does the initial set up if it doesn't
* already exist.
*
* @return object The stored state option.
*/
public static function get_stored_state() {
$stored_state = get_option( self::STORED_STATE_OPTION_NAME );
if ( $stored_state === false ) {
$stored_state = new \stdClass();
$stored_state = StoredStateSetupForProducts::init_stored_state(
$stored_state
);
add_option(
self::STORED_STATE_OPTION_NAME,
$stored_state,
'',
false
);
}
return $stored_state;
}
/**
* The deactivated_plugin hook happens before the option is updated
* (https://github.com/WordPress/WordPress/blob/master/wp-admin/includes/plugin.php#L826)
* so this captures the deactivated plugin path and pushes it into the
* PluginsProvider.
*
* @param string $plugin Path to the plugin file relative to the plugins directory.
*/
public static function run_on_deactivated_plugin( $plugin ) {
PluginsProvider::set_deactivated_plugin( $plugin );
self::run();
}
/**
* Update the stored state option.
*
* @param object $stored_state The stored state.
*/
public static function update_stored_state( $stored_state ) {
update_option( self::STORED_STATE_OPTION_NAME, $stored_state, false );
}
/**
* Get the note. This is used to display localized note.
*
* @param Note $note_from_db The note object created from db.
* @return Note The note.
*/
public static function get_note_from_db( $note_from_db ) {
if ( ! $note_from_db instanceof Note || get_user_locale() === $note_from_db->get_locale() ) {
return $note_from_db;
}
$specs = DataSourcePoller::get_instance()->get_specs_from_data_sources();
foreach ( $specs as $spec ) {
if ( $spec->slug !== $note_from_db->get_name() ) {
continue;
}
$locale = SpecRunner::get_locale( $spec->locales, true );
if ( $locale === null ) {
// No locale found, so don't update the note.
break;
}
$localized_actions = SpecRunner::get_actions( $spec );
// Manually copy the action id from the db to the localized action, since they were not being provided.
foreach ( $localized_actions as $localized_action ) {
$action = $note_from_db->get_action( $localized_action->name );
if ( $action ) {
$localized_action->id = $action->id;
}
}
$note_from_db->set_title( $locale->title );
$note_from_db->set_content( $locale->content );
$note_from_db->set_actions( $localized_actions );
}
return $note_from_db;
}
}
Admin/RemoteInboxNotifications/RuleEvaluator.php 0000644 00000004474 15153704477 0016112 0 ustar 00 <?php
/**
* Evaluate the given rules as an AND operation - return false early if a
* rule evaluates to false.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Evaluate the given rules as an AND operation - return false early if a
* rule evaluates to false.
*/
class RuleEvaluator {
/**
* GetRuleProcessor to use.
*
* @var GetRuleProcessor
*/
private $get_rule_processor;
/**
* Constructor.
*
* @param GetRuleProcessor $get_rule_processor The GetRuleProcessor to use.
*/
public function __construct( $get_rule_processor = null ) {
$this->get_rule_processor = null === $get_rule_processor
? new GetRuleProcessor()
: $get_rule_processor;
}
/**
* Evaluate the given rules as an AND operation - return false early if a
* rule evaluates to false.
*
* @param array|object $rules The rule or rules being processed.
* @param object|null $stored_state Stored state.
* @param array $logger_args Arguments for the event logger. `slug` is required.
*
* @throws \InvalidArgumentException Thrown when $logger_args is missing slug.
*
* @return bool The result of the operation.
*/
public function evaluate( $rules, $stored_state = null, $logger_args = array() ) {
if ( is_bool( $rules ) ) {
return $rules;
}
if ( ! is_array( $rules ) ) {
$rules = array( $rules );
}
if ( 0 === count( $rules ) ) {
return false;
}
$evaluation_logger = null;
if ( count( $logger_args ) ) {
if ( ! array_key_exists( 'slug', $logger_args ) ) {
throw new \InvalidArgumentException( 'Missing required field: slug in $logger_args.' );
}
array_key_exists( 'source', $logger_args ) ? $source = $logger_args['source'] : $source = null;
$evaluation_logger = new EvaluationLogger( $logger_args['slug'], $source );
}
foreach ( $rules as $rule ) {
if ( ! is_object( $rule ) ) {
return false;
}
$processor = $this->get_rule_processor->get_processor( $rule->type );
$processor_result = $processor->process( $rule, $stored_state );
$evaluation_logger && $evaluation_logger->add_result( $rule->type, $processor_result );
if ( ! $processor_result ) {
$evaluation_logger && $evaluation_logger->log();
return false;
}
}
$evaluation_logger && $evaluation_logger->log();
return true;
}
}
Admin/RemoteInboxNotifications/RuleProcessorInterface.php 0000644 00000001221 15153704477 0017733 0 ustar 00 <?php
/**
* Interface for a rule processor.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor interface
*/
interface RuleProcessorInterface {
/**
* Processes a rule, returning the boolean result of the processing.
*
* @param object $rule The rule to process.
* @param object $stored_state Stored state.
*
* @return bool The result of the processing.
*/
public function process( $rule, $stored_state );
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule );
}
Admin/RemoteInboxNotifications/SpecRunner.php 0000644 00000011143 15153704477 0015373 0 ustar 00 <?php
/**
* Runs a single spec.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Runs a single spec.
*/
class SpecRunner {
/**
* Run the spec.
*
* @param object $spec The spec to run.
* @param object $stored_state Stored state.
*/
public static function run_spec( $spec, $stored_state ) {
$data_store = Notes::load_data_store();
// Create or update the note.
$existing_note_ids = $data_store->get_notes_with_name( $spec->slug );
if ( count( $existing_note_ids ) === 0 ) {
$note = new Note();
$note->set_status( Note::E_WC_ADMIN_NOTE_PENDING );
} else {
$note = Notes::get_note( $existing_note_ids[0] );
if ( $note === false ) {
return;
}
}
// Evaluate the spec and get the new note status.
$previous_status = $note->get_status();
$status = EvaluateAndGetStatus::evaluate(
$spec,
$previous_status,
$stored_state,
new RuleEvaluator()
);
// If the status is changing, update the created date to now.
if ( $previous_status !== $status ) {
$note->set_date_created( time() );
}
// Get the matching locale or fall back to en-US.
$locale = self::get_locale( $spec->locales );
if ( $locale === null ) {
return;
}
// Set up the note.
$note->set_title( $locale->title );
$note->set_content( $locale->content );
$note->set_content_data( isset( $spec->content_data ) ? $spec->content_data : (object) array() );
$note->set_status( $status );
$note->set_type( $spec->type );
$note->set_name( $spec->slug );
if ( isset( $spec->source ) ) {
$note->set_source( $spec->source );
}
// Recreate actions.
$note->set_actions( self::get_actions( $spec ) );
$note->save();
}
/**
* Get the URL for an action.
*
* @param object $action The action.
*
* @return string The URL for the action.
*/
private static function get_url( $action ) {
if ( ! isset( $action->url ) ) {
return '';
}
if ( isset( $action->url_is_admin_query ) && $action->url_is_admin_query ) {
if ( strpos( $action->url, '&path' ) === 0 ) {
return wc_admin_url( $action->url );
}
return admin_url( $action->url );
}
return $action->url;
}
/**
* Get the locale for the WordPress locale, or fall back to the en_US
* locale.
*
* @param Array $locales The locales to search through.
*
* @returns object The locale that was found, or null if no matching locale was found.
*/
public static function get_locale( $locales ) {
$wp_locale = get_user_locale();
$matching_wp_locales = array_values(
array_filter(
$locales,
function( $l ) use ( $wp_locale ) {
return $wp_locale === $l->locale;
}
)
);
if ( count( $matching_wp_locales ) !== 0 ) {
return $matching_wp_locales[0];
}
// Fall back to en_US locale.
$en_us_locales = array_values(
array_filter(
$locales,
function( $l ) {
return $l->locale === 'en_US';
}
)
);
if ( count( $en_us_locales ) !== 0 ) {
return $en_us_locales[0];
}
return null;
}
/**
* Get the action locale that matches the note locale, or fall back to the
* en_US locale.
*
* @param Array $action_locales The locales from the spec's action.
*
* @return object The matching locale, or the en_US fallback locale, or null if neither was found.
*/
public static function get_action_locale( $action_locales ) {
$wp_locale = get_user_locale();
$matching_wp_locales = array_values(
array_filter(
$action_locales,
function ( $l ) use ( $wp_locale ) {
return $wp_locale === $l->locale;
}
)
);
if ( count( $matching_wp_locales ) !== 0 ) {
return $matching_wp_locales[0];
}
// Fall back to en_US locale.
$en_us_locales = array_values(
array_filter(
$action_locales,
function( $l ) {
return $l->locale === 'en_US';
}
)
);
if ( count( $en_us_locales ) !== 0 ) {
return $en_us_locales[0];
}
return null;
}
/**
* Get the actions for a note.
*
* @param object $spec The spec.
*
* @return array The actions.
*/
public static function get_actions( $spec ) {
$note = new Note();
$actions = isset( $spec->actions ) ? $spec->actions : array();
foreach ( $actions as $action ) {
$action_locale = self::get_action_locale( $action->locales );
$url = self::get_url( $action );
$note->add_action(
$action->name,
( $action_locale === null || ! isset( $action_locale->label ) )
? ''
: $action_locale->label,
$url,
$action->status
);
}
return $note->get_actions();
}
}
Admin/RemoteInboxNotifications/StoredStateRuleProcessor.php 0000644 00000002346 15153704477 0020305 0 ustar 00 <?php
/**
* Rule processor that performs a comparison operation against a value in the
* stored state object.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor that performs a comparison operation against a value in the
* stored state object.
*/
class StoredStateRuleProcessor implements RuleProcessorInterface {
/**
* Performs a comparison operation against a value in the stored state object.
*
* @param object $rule The rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
if ( ! isset( $stored_state->{$rule->index} ) ) {
return false;
}
return ComparisonOperation::compare(
$stored_state->{$rule->index},
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->index ) ) {
return false;
}
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/StoredStateSetupForProducts.php 0000644 00000007200 15153704477 0020763 0 ustar 00 <?php
/**
* Handles stored state setup for products.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\SpecRunner;
/**
* Handles stored state setup for products.
*/
class StoredStateSetupForProducts {
const ASYNC_RUN_REMOTE_NOTIFICATIONS_ACTION_NAME =
'woocommerce_admin/stored_state_setup_for_products/async/run_remote_notifications';
/**
* Initialize the class via the admin_init hook.
*/
public static function admin_init() {
add_action( 'product_page_product_importer', array( __CLASS__, 'run_on_product_importer' ) );
add_action( 'transition_post_status', array( __CLASS__, 'run_on_transition_post_status' ), 10, 3 );
}
/**
* Initialize the class via the init hook.
*/
public static function init() {
add_action( self::ASYNC_RUN_REMOTE_NOTIFICATIONS_ACTION_NAME, array( __CLASS__, 'run_remote_notifications' ) );
}
/**
* Run the remote notifications engine. This is triggered by
* action-scheduler after a product is added. It also cleans up from
* setting the product count increment.
*/
public static function run_remote_notifications() {
RemoteInboxNotificationsEngine::run();
}
/**
* Set initial stored state values.
*
* @param object $stored_state The stored state.
*
* @return object The stored state.
*/
public static function init_stored_state( $stored_state ) {
$stored_state->there_were_no_products = ! self::are_there_products();
$stored_state->there_are_now_products = ! $stored_state->there_were_no_products;
return $stored_state;
}
/**
* Are there products query.
*
* @return bool
*/
private static function are_there_products() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
$count = $products->total;
return $count > 0;
}
/**
* Runs on product importer steps.
*/
public static function run_on_product_importer() {
// We're only interested in when the importer completes.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_REQUEST['step'] ) ) {
return;
}
if ( 'done' !== $_REQUEST['step'] ) {
return;
}
// phpcs:enable
self::update_stored_state_and_possibly_run_remote_notifications();
}
/**
* Runs when a post status transitions, but we're only interested if it is
* a product being published.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
* @param Post $post The post.
*/
public static function run_on_transition_post_status( $new_status, $old_status, $post ) {
if (
'product' !== $post->post_type ||
'publish' !== $new_status
) {
return;
}
self::update_stored_state_and_possibly_run_remote_notifications();
}
/**
* Enqueues an async action (using action-scheduler) to run remote
* notifications.
*/
private static function update_stored_state_and_possibly_run_remote_notifications() {
$stored_state = RemoteInboxNotificationsEngine::get_stored_state();
// If the stored_state is the same, we don't need to run remote notifications to avoid unnecessary action scheduling.
if ( true === $stored_state->there_are_now_products ) {
return;
}
$stored_state->there_are_now_products = true;
RemoteInboxNotificationsEngine::update_stored_state( $stored_state );
// Run self::run_remote_notifications asynchronously.
as_enqueue_async_action( self::ASYNC_RUN_REMOTE_NOTIFICATIONS_ACTION_NAME );
}
}
Admin/RemoteInboxNotifications/TotalPaymentsVolumeProcessor.php 0000644 00000003410 15153704477 0021201 0 ustar 00 <?php
/**
* Rule processor that passes when a store's payments volume exceeds a provided amount.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
/**
* Rule processor that passes when a store's payments volume exceeds a provided amount.
*/
class TotalPaymentsVolumeProcessor implements RuleProcessorInterface {
/**
* Compare against the store's total payments volume.
*
* @param object $rule The rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$dates = TimeInterval::get_timeframe_dates( $rule->timeframe );
$reports_revenue = new RevenueQuery(
array(
'before' => $dates['end'],
'after' => $dates['start'],
'interval' => 'year',
'fields' => array( 'total_sales' ),
)
);
$report_data = $reports_revenue->get_data();
$value = $report_data->totals->total_sales;
return ComparisonOperation::compare(
$value,
$rule->value,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
$allowed_timeframes = array(
'last_week',
'last_month',
'last_quarter',
'last_6_months',
'last_year',
);
if ( ! isset( $rule->timeframe ) || ! in_array( $rule->timeframe, $allowed_timeframes, true ) ) {
return false;
}
if ( ! isset( $rule->value ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/TransformerInterface.php 0000644 00000001424 15153704477 0017433 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
use stdClass;
/**
* An interface to define a transformer.
*
* Interface TransformerInterface
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications
*/
interface TransformerInterface {
/**
* Transform given value to a different value.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments arguments.
* @param string|null $default default value.
*
* @return mixed|null
*/
public function transform( $value, stdClass $arguments = null, $default = null);
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null );
}
Admin/RemoteInboxNotifications/TransformerService.php 0000644 00000003756 15153704477 0017145 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
use InvalidArgumentException;
use stdClass;
/**
* A simple service class for the Transformer classes.
*
* Class TransformerService
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications
*/
class TransformerService {
/**
* Create a transformer object by name.
*
* @param string $name name of the transformer.
*
* @return TransformerInterface|null
*/
public static function create_transformer( $name ) {
$camel_cased = str_replace( ' ', '', ucwords( str_replace( '_', ' ', $name ) ) );
$classname = __NAMESPACE__ . '\\Transformers\\' . $camel_cased;
if ( ! class_exists( $classname ) ) {
return null;
}
return new $classname();
}
/**
* Apply transformers to the given value.
*
* @param mixed $target_value a value to transform.
* @param array $transformer_configs transform configuration.
* @param string $default default value.
*
* @throws InvalidArgumentException Throws when one of the requried arguments is missing.
* @return mixed|null
*/
public static function apply( $target_value, array $transformer_configs, $default ) {
foreach ( $transformer_configs as $transformer_config ) {
if ( ! isset( $transformer_config->use ) ) {
throw new InvalidArgumentException( 'Missing required config value: use' );
}
if ( ! isset( $transformer_config->arguments ) ) {
$transformer_config->arguments = null;
}
$transformer = self::create_transformer( $transformer_config->use );
if ( null === $transformer ) {
throw new InvalidArgumentException( "Unable to find a transformer by name: {$transformer_config->use}" );
}
$transformed_value = $transformer->transform( $target_value, $transformer_config->arguments, $default );
// if the transformer returns null, then return the previously transformed value.
if ( null === $transformed_value ) {
return $target_value;
}
$target_value = $transformed_value;
}
return $target_value;
}
}
Admin/RemoteInboxNotifications/Transformers/ArrayColumn.php 0000644 00000002161 15153704477 0020230 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use InvalidArgumentException;
use stdClass;
/**
* Search array value by one of its key.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class ArrayColumn implements TransformerInterface {
/**
* Search array value by one of its key.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments required arguments 'key'.
* @param string|null $default default value.
*
* @throws InvalidArgumentException Throws when the required argument 'key' is missing.
*
* @return mixed
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
return array_column( $value, $arguments->key );
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
if ( ! isset( $arguments->key ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/ArrayFlatten.php 0000644 00000002000 15153704477 0020360 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use stdClass;
/**
* Flatten nested array.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class ArrayFlatten implements TransformerInterface {
/**
* Search a given value in the array.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments arguments.
* @param string|null $default default value.
*
* @return mixed|null
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
$return = array();
array_walk_recursive(
$value,
function( $item ) use ( &$return ) {
$return[] = $item;
}
);
return $return;
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/ArrayKeys.php 0000644 00000001626 15153704477 0017713 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use stdClass;
/**
* Search array value by one of its key.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class ArrayKeys implements TransformerInterface {
/**
* Search array value by one of its key.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments arguments.
* @param string|null $default default value.
*
* @return mixed
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
return array_keys( $value );
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/ArraySearch.php 0000644 00000002306 15153704477 0020201 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use InvalidArgumentException;
use stdClass;
/**
* Searches a given a given value in the array.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class ArraySearch implements TransformerInterface {
/**
* Search a given value in the array.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments required argument 'value'.
* @param string|null $default default value.
*
* @throws InvalidArgumentException Throws when the required 'value' is missing.
*
* @return mixed|null
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
$key = array_search( $arguments->value, $value, true );
if ( false !== $key ) {
return $value[ $key ];
}
return null;
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
if ( ! isset( $arguments->value ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/ArrayValues.php 0000644 00000001632 15153704477 0020234 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use stdClass;
/**
* Search array value by one of its key.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class ArrayValues implements TransformerInterface {
/**
* Search array value by one of its key.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments arguments.
* @param string|null $default default value.
*
* @return mixed
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
return array_values( $value );
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/Count.php 0000644 00000001562 15153704477 0017070 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use stdClass;
/**
* Count elements in Array.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class Count implements TransformerInterface {
/**
* Count elements in Array.
*
* @param array $value an array to count.
* @param stdClass|null $arguments arguments.
* @param string|null $default default value.
*
* @return number
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
return count( $value );
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/DotNotation.php 0000644 00000003545 15153704477 0020245 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use InvalidArgumentException;
use stdClass;
/**
* Find an array value by dot notation.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class DotNotation implements TransformerInterface {
/**
* Find given path from the given value.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments required argument 'path'.
* @param string|null $default default value.
*
* @throws InvalidArgumentException Throws when the required 'path' is missing.
*
* @return mixed
*/
public function transform( $value, stdclass $arguments = null, $default = null ) {
if ( is_object( $value ) ) {
// if the value is an object, convert it to an array.
$value = json_decode( wp_json_encode( $value ), true );
}
return $this->get( $value, $arguments->path, $default );
}
/**
* Find the given $path in $array by dot notation.
*
* @param array $array an array to search in.
* @param string $path a path in the given array.
* @param null $default default value to return if $path was not found.
*
* @return mixed|null
*/
public function get( $array, $path, $default = null ) {
if ( isset( $array[ $path ] ) ) {
return $array[ $path ];
}
foreach ( explode( '.', $path ) as $segment ) {
if ( ! is_array( $array ) || ! array_key_exists( $segment, $array ) ) {
return $default;
}
$array = $array[ $segment ];
}
return $array;
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
if ( ! isset( $arguments->path ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/Transformers/PrepareUrl.php 0000644 00000002057 15153704477 0020061 0 ustar 00 <?php
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\TransformerInterface;
use stdClass;
/**
* Prepare site URL for comparison.
*
* @package Automattic\WooCommerce\Admin\RemoteInboxNotifications\Transformers
*/
class PrepareUrl implements TransformerInterface {
/**
* Prepares the site URL by removing the protocol and trailing slash.
*
* @param mixed $value a value to transform.
* @param stdClass|null $arguments arguments.
* @param string|null $default default value.
*
* @return mixed|null
*/
public function transform( $value, stdClass $arguments = null, $default = null ) {
$url_parts = wp_parse_url( rtrim( $value, '/' ) );
return isset( $url_parts['path'] ) ? $url_parts['host'] . $url_parts['path'] : $url_parts['host'];
}
/**
* Validate Transformer arguments.
*
* @param stdClass|null $arguments arguments to validate.
*
* @return mixed
*/
public function validate( stdClass $arguments = null ) {
return true;
}
}
Admin/RemoteInboxNotifications/WCAdminActiveForProvider.php 0000644 00000000767 15153704477 0020121 0 ustar 00 <?php
/**
* WCAdmin active for provider.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
use Automattic\WooCommerce\Admin\WCAdminHelper;
defined( 'ABSPATH' ) || exit;
/**
* WCAdminActiveForProvider class
*/
class WCAdminActiveForProvider {
/**
* Get the number of seconds that the store has been active.
*
* @return number Number of seconds.
*/
public function get_wcadmin_active_for_in_seconds() {
return WCAdminHelper::get_wcadmin_active_for_in_seconds();
}
}
Admin/RemoteInboxNotifications/WCAdminActiveForRuleProcessor.php 0000644 00000003374 15153704477 0021133 0 ustar 00 <?php
/**
* Rule processor for publishing if wc-admin has been active for at least the
* given number of seconds.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor for publishing if wc-admin has been active for at least the
* given number of seconds.
*/
class WCAdminActiveForRuleProcessor implements RuleProcessorInterface {
/**
* Provides the amount of time wcadmin has been active for.
*
* @var WCAdminActiveForProvider
*/
protected $wcadmin_active_for_provider;
/**
* Constructor
*
* @param object $wcadmin_active_for_provider Provides the amount of time wcadmin has been active for.
*/
public function __construct( $wcadmin_active_for_provider = null ) {
$this->wcadmin_active_for_provider = null === $wcadmin_active_for_provider
? new WCAdminActiveForProvider()
: $wcadmin_active_for_provider;
}
/**
* Performs a comparison operation against the amount of time wc-admin has
* been active for in days.
*
* @param object $rule The rule being processed.
* @param object $stored_state Stored state.
*
* @return bool The result of the operation.
*/
public function process( $rule, $stored_state ) {
$active_for_seconds = $this->wcadmin_active_for_provider->get_wcadmin_active_for_in_seconds();
$rule_seconds = $rule->days * DAY_IN_SECONDS;
return ComparisonOperation::compare(
$active_for_seconds,
$rule_seconds,
$rule->operation
);
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
if ( ! isset( $rule->days ) ) {
return false;
}
if ( ! isset( $rule->operation ) ) {
return false;
}
return true;
}
}
Admin/RemoteInboxNotifications/WooCommerceAdminUpdatedRuleProcessor.php 0000644 00000001561 15153704477 0022541 0 ustar 00 <?php
/**
* Rule processor for sending when WooCommerce Admin has been updated.
*/
namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
/**
* Rule processor for sending when WooCommerce Admin has been updated.
*/
class WooCommerceAdminUpdatedRuleProcessor implements RuleProcessorInterface {
/**
* Process the rule.
*
* @param object $rule The specific rule being processed by this rule processor.
* @param object $stored_state Stored state.
*
* @return bool Whether the rule passes or not.
*/
public function process( $rule, $stored_state ) {
return get_option( RemoteInboxNotificationsEngine::WCA_UPDATED_OPTION_NAME, false );
}
/**
* Validates the rule.
*
* @param object $rule The rule to validate.
*
* @return bool Pass/fail.
*/
public function validate( $rule ) {
return true;
}
}
Admin/ReportCSVEmail.php 0000644 00000007500 15153704477 0011123 0 ustar 00 <?php
/**
* Handles emailing users CSV Export download links.
*/
namespace Automattic\WooCommerce\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Include dependencies.
*/
if ( ! class_exists( 'WC_Email', false ) ) {
include_once WC_ABSPATH . 'includes/emails/class-wc-email.php';
}
/**
* ReportCSVEmail Class.
*/
class ReportCSVEmail extends \WC_Email {
/**
* Report labels.
*
* @var array
*/
protected $report_labels;
/**
* Report type (e.g. 'customers').
*
* @var string
*/
protected $report_type;
/**
* Download URL.
*
* @var string
*/
protected $download_url;
/**
* Constructor.
*/
public function __construct() {
$this->id = 'admin_report_export_download';
$this->template_base = WC()->plugin_path() . '/includes/react-admin/emails/';
$this->template_html = 'html-admin-report-export-download.php';
$this->template_plain = 'plain-admin-report-export-download.php';
$this->report_labels = array(
'categories' => __( 'Categories', 'woocommerce' ),
'coupons' => __( 'Coupons', 'woocommerce' ),
'customers' => __( 'Customers', 'woocommerce' ),
'downloads' => __( 'Downloads', 'woocommerce' ),
'orders' => __( 'Orders', 'woocommerce' ),
'products' => __( 'Products', 'woocommerce' ),
'revenue' => __( 'Revenue', 'woocommerce' ),
'stock' => __( 'Stock', 'woocommerce' ),
'taxes' => __( 'Taxes', 'woocommerce' ),
'variations' => __( 'Variations', 'woocommerce' ),
);
// Call parent constructor.
parent::__construct();
}
/**
* This email has no user-facing settings.
*/
public function init_form_fields() {}
/**
* This email has no user-facing settings.
*/
public function init_settings() {}
/**
* Return email type.
*
* @return string
*/
public function get_email_type() {
return class_exists( 'DOMDocument' ) ? 'html' : 'plain';
}
/**
* Get email heading.
*
* @return string
*/
public function get_default_heading() {
return __( 'Your Report Download', 'woocommerce' );
}
/**
* Get email subject.
*
* @return string
*/
public function get_default_subject() {
return __( '[{site_title}]: Your {report_name} Report download is ready', 'woocommerce' );
}
/**
* Get content html.
*
* @return string
*/
public function get_content_html() {
return wc_get_template_html(
$this->template_html,
array(
'report_name' => $this->report_type,
'download_url' => $this->download_url,
'email_heading' => $this->get_heading(),
'sent_to_admin' => true,
'plain_text' => false,
'email' => $this,
),
'',
$this->template_base
);
}
/**
* Get content plain.
*
* @return string
*/
public function get_content_plain() {
return wc_get_template_html(
$this->template_plain,
array(
'report_name' => $this->report_type,
'download_url' => $this->download_url,
'email_heading' => $this->get_heading(),
'sent_to_admin' => true,
'plain_text' => true,
'email' => $this,
),
'',
$this->template_base
);
}
/**
* Trigger the sending of this email.
*
* @param int $user_id User ID to email.
* @param string $report_type The type of report export being emailed.
* @param string $download_url The URL for downloading the report.
*/
public function trigger( $user_id, $report_type, $download_url ) {
$user = new \WP_User( $user_id );
$this->recipient = $user->user_email;
$this->download_url = $download_url;
if ( isset( $this->report_labels[ $report_type ] ) ) {
$this->report_type = $this->report_labels[ $report_type ];
$this->placeholders['{report_name}'] = $this->report_type;
}
$this->send(
$this->get_recipient(),
$this->get_subject(),
$this->get_content(),
$this->get_headers(),
$this->get_attachments()
);
}
}
Admin/ReportCSVExporter.php 0000644 00000022772 15153704477 0011714 0 ustar 00 <?php
/**
* Handles reports CSV export batches.
*/
namespace Automattic\WooCommerce\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
/**
* Include dependencies.
*/
if ( ! class_exists( 'WC_CSV_Batch_Exporter', false ) ) {
include_once WC_ABSPATH . 'includes/export/abstract-wc-csv-batch-exporter.php';
}
/**
* ReportCSVExporter Class.
*/
class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
/**
* Type of report being exported.
*
* @var string
*/
protected $report_type;
/**
* Parameters for the report query.
*
* @var array
*/
protected $report_args;
/**
* REST controller for the report.
*
* @var WC_REST_Reports_Controller
*/
protected $controller;
/**
* Constructor.
*
* @param string $type Report type. E.g. 'customers'.
* @param array $args Report parameters.
*/
public function __construct( $type = false, $args = array() ) {
parent::__construct();
self::maybe_create_directory();
if ( ! empty( $type ) ) {
$this->set_report_type( $type );
$this->set_column_names( $this->get_report_columns() );
}
if ( ! empty( $args ) ) {
$this->set_report_args( $args );
}
}
/**
* Create the directory for reports if it does not yet exist.
*/
public static function maybe_create_directory() {
$reports_dir = self::get_reports_directory();
$files = array(
array(
'base' => $reports_dir,
'file' => '.htaccess',
'content' => 'DirectoryIndex index.php index.html' . PHP_EOL . 'deny from all',
),
array(
'base' => $reports_dir,
'file' => 'index.html',
'content' => '',
),
);
foreach ( $files as $file ) {
if ( ! file_exists( trailingslashit( $file['base'] ) ) ) {
wp_mkdir_p( $file['base'] );
}
if ( ! file_exists( trailingslashit( $file['base'] ) . $file['file'] ) ) {
$file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_fopen
if ( $file_handle ) {
fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite
fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose
}
}
}
}
/**
* Get report uploads directory.
*
* @return string
*/
public static function get_reports_directory() {
$upload_dir = wp_upload_dir();
return trailingslashit( $upload_dir['basedir'] ) . 'woocommerce_uploads/reports/';
}
/**
* Get file path to export to.
*
* @return string
*/
protected function get_file_path() {
return self::get_reports_directory() . $this->get_filename();
}
/**
* Setter for report type.
*
* @param string $type The report type. E.g. customers.
*/
public function set_report_type( $type ) {
$this->report_type = $type;
$this->export_type = "admin_{$type}_report";
$this->filename = "wc-{$type}-report-export";
$this->controller = $this->map_report_controller();
}
/**
* Setter for report args.
*
* @param array $args The report args.
*/
public function set_report_args( $args ) {
// Use our own internal limit and include all extended info.
$report_args = array_merge(
$args,
array(
'per_page' => $this->get_limit(),
'extended_info' => true,
)
);
// Should this happen externally?
if ( isset( $report_args['page'] ) ) {
$this->set_page( $report_args['page'] );
}
$this->report_args = $report_args;
}
/**
* Get a REST controller instance for the report type.
*
* @return bool|WC_REST_Reports_Controller Report controller instance or boolean false on error.
*/
protected function map_report_controller() {
// @todo - Add filter to this list.
$controller_map = array(
'products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Controller',
'variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller',
'orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller',
'categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\Controller',
'taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller',
'coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Controller',
'stock' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Controller',
'downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Controller',
'customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Controller',
'revenue' => 'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller',
);
if ( isset( $controller_map[ $this->report_type ] ) ) {
// Load the controllers if accessing outside the REST API.
return new $controller_map[ $this->report_type ]();
}
// Should this do something else?
return false;
}
/**
* Get the report columns from the controller.
*
* @return array Array of report column names.
*/
protected function get_report_columns() {
// Default to the report's defined export columns.
if ( $this->controller instanceof ExportableInterface ) {
return $this->controller->get_export_columns();
}
// Fallback to generating columns from the report schema.
$report_columns = array();
$report_schema = $this->controller->get_item_schema();
if ( isset( $report_schema['properties'] ) ) {
foreach ( $report_schema['properties'] as $column_name => $column_info ) {
// Expand extended info columns into export.
if ( 'extended_info' === $column_name ) {
// Remove columns with questionable CSV values, like markup.
$extended_info = array_diff( array_keys( $column_info ), array( 'image' ) );
$report_columns = array_merge( $report_columns, $extended_info );
} else {
$report_columns[] = $column_name;
}
}
}
return $report_columns;
}
/**
* Get total % complete.
*
* Forces an int from parent::get_percent_complete(), which can return a float.
*
* @return int Percent complete.
*/
public function get_percent_complete() {
return intval( parent::get_percent_complete() );
}
/**
* Get total number of rows in export.
*
* @return int Number of rows to export.
*/
public function get_total_rows() {
return $this->total_rows;
}
/**
* Prepare data for export.
*/
public function prepare_data_to_export() {
$request = new \WP_REST_Request( 'GET', "/wc-analytics/reports/{$this->report_type}" );
$params = $this->controller->get_collection_params();
$defaults = array();
foreach ( $params as $arg => $options ) {
if ( isset( $options['default'] ) ) {
$defaults[ $arg ] = $options['default'];
}
}
$request->set_attributes( array( 'args' => $params ) );
$request->set_default_params( $defaults );
$request->set_query_params( $this->report_args );
$request->sanitize_params();
// Does the controller have an export-specific item retrieval method?
// @todo - Potentially revisit. This is only for /revenue/stats/.
if ( is_callable( array( $this->controller, 'get_export_items' ) ) ) {
$response = $this->controller->get_export_items( $request );
} else {
$response = $this->controller->get_items( $request );
}
// Use WP_REST_Server::response_to_data() to embed links in data.
add_filter( 'woocommerce_rest_check_permissions', '__return_true' );
$rest_server = rest_get_server();
$report_data = $rest_server->response_to_data( $response, true );
remove_filter( 'woocommerce_rest_check_permissions', '__return_true' );
$report_meta = $response->get_headers();
$this->total_rows = $report_meta['X-WP-Total'];
$this->row_data = array_map( array( $this, 'generate_row_data' ), $report_data );
}
/**
* Generate row data from a raw report item.
*
* @param object $item Report item data.
* @return array CSV row data.
*/
protected function get_raw_row_data( $item ) {
$columns = $this->get_column_names();
$row = array();
// Expand extended info.
if ( isset( $item['extended_info'] ) ) {
// Pull extended info property from report item object.
$extended_info = (array) $item['extended_info'];
unset( $item['extended_info'] );
// Merge extended info columns into report item object.
$item = array_merge( $item, $extended_info );
}
foreach ( $columns as $column_id => $column_name ) {
$value = isset( $item[ $column_name ] ) ? $item[ $column_name ] : null;
if ( has_filter( "woocommerce_export_{$this->export_type}_column_{$column_name}" ) ) {
// Filter for 3rd parties.
$value = apply_filters( "woocommerce_export_{$this->export_type}_column_{$column_name}", '', $item );
} elseif ( is_callable( array( $this, "get_column_value_{$column_name}" ) ) ) {
// Handle special columns which don't map 1:1 to item data.
$value = $this->{"get_column_value_{$column_name}"}( $item, $this->export_type );
} elseif ( ! is_scalar( $value ) ) {
// Ensure that the value is somewhat readable in CSV.
$value = wp_json_encode( $value );
}
$row[ $column_id ] = $value;
}
return $row;
}
/**
* Get the export row for a given report item.
*
* @param object $item Report item data.
* @return array CSV row data.
*/
protected function generate_row_data( $item ) {
// Default to the report's export method.
if ( $this->controller instanceof ExportableInterface ) {
$row = $this->controller->prepare_item_for_export( $item );
} else {
// Fallback to raw report data.
$row = $this->get_raw_row_data( $item );
}
return apply_filters( "woocommerce_export_{$this->export_type}_row_data", $row, $item );
}
}
Admin/ReportExporter.php 0000644 00000014437 15153704477 0011337 0 ustar 00 <?php
/**
* Handles reports CSV export.
*/
namespace Automattic\WooCommerce\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits;
/**
* ReportExporter Class.
*/
class ReportExporter {
/**
* Slug to identify the scheduler.
*
* @var string
*/
public static $name = 'report_exporter';
/**
* Scheduler traits.
*/
use SchedulerTraits {
init as scheduler_init;
}
/**
* Export status option name.
*/
const EXPORT_STATUS_OPTION = 'woocommerce_admin_report_export_status';
/**
* Export file download action.
*/
const DOWNLOAD_EXPORT_ACTION = 'woocommerce_admin_download_report_csv';
/**
* Get all available scheduling actions.
* Used to determine action hook names and clear events.
*
* @return array
*/
public static function get_scheduler_actions() {
return array(
'export_report' => 'woocommerce_admin_report_export',
'email_report_download_link' => 'woocommerce_admin_email_report_download_link',
);
}
/**
* Add action dependencies.
*
* @return array
*/
public static function get_dependencies() {
return array(
'email_report_download_link' => self::get_action( 'export_report' ),
);
}
/**
* Hook in action methods.
*/
public static function init() {
// Initialize scheduled action handlers.
self::scheduler_init();
// Report download handler.
add_action( 'admin_init', array( __CLASS__, 'download_export_file' ) );
}
/**
* Queue up actions for a full report export.
*
* @param string $export_id Unique ID for report (timestamp expected).
* @param string $report_type Report type. E.g. 'customers'.
* @param array $report_args Report parameters, passed to data query.
* @param bool $send_email Optional. Send an email when the export is complete.
* @return int Number of items to export.
*/
public static function queue_report_export( $export_id, $report_type, $report_args = array(), $send_email = false ) {
$exporter = new ReportCSVExporter( $report_type, $report_args );
$exporter->prepare_data_to_export();
$total_rows = $exporter->get_total_rows();
$batch_size = $exporter->get_limit();
$num_batches = (int) ceil( $total_rows / $batch_size );
// Create batches, like initial import.
$report_batch_args = array( $export_id, $report_type, $report_args );
if ( 0 < $num_batches ) {
self::queue_batches( 1, $num_batches, 'export_report', $report_batch_args );
if ( $send_email ) {
$email_action_args = array( get_current_user_id(), $export_id, $report_type );
self::schedule_action( 'email_report_download_link', $email_action_args );
}
}
return $total_rows;
}
/**
* Process a report export action.
*
* @param int $page_number Page number for this action.
* @param string $export_id Unique ID for report (timestamp expected).
* @param string $report_type Report type. E.g. 'customers'.
* @param array $report_args Report parameters, passed to data query.
* @return void
*/
public static function export_report( $page_number, $export_id, $report_type, $report_args ) {
$report_args['page'] = $page_number;
$exporter = new ReportCSVExporter( $report_type, $report_args );
$exporter->set_filename( "wc-{$report_type}-report-export-{$export_id}" );
$exporter->generate_file();
self::update_export_percentage_complete( $report_type, $export_id, $exporter->get_percent_complete() );
}
/**
* Generate a key to reference an export status.
*
* @param string $report_type Report type. E.g. 'customers'.
* @param string $export_id Unique ID for report (timestamp expected).
* @return string Status key.
*/
protected static function get_status_key( $report_type, $export_id ) {
return $report_type . ':' . $export_id;
}
/**
* Update the completion percentage of a report export.
*
* @param string $report_type Report type. E.g. 'customers'.
* @param string $export_id Unique ID for report (timestamp expected).
* @param int $percentage Completion percentage.
* @return void
*/
public static function update_export_percentage_complete( $report_type, $export_id, $percentage ) {
$exports_status = get_option( self::EXPORT_STATUS_OPTION, array() );
$status_key = self::get_status_key( $report_type, $export_id );
$exports_status[ $status_key ] = $percentage;
update_option( self::EXPORT_STATUS_OPTION, $exports_status );
}
/**
* Get the completion percentage of a report export.
*
* @param string $report_type Report type. E.g. 'customers'.
* @param string $export_id Unique ID for report (timestamp expected).
* @return bool|int Completion percentage, or false if export not found.
*/
public static function get_export_percentage_complete( $report_type, $export_id ) {
$exports_status = get_option( self::EXPORT_STATUS_OPTION, array() );
$status_key = self::get_status_key( $report_type, $export_id );
if ( isset( $exports_status[ $status_key ] ) ) {
return $exports_status[ $status_key ];
}
return false;
}
/**
* Serve the export file.
*/
public static function download_export_file() {
// @todo - add nonce? (nonces are good for 24 hours)
if (
isset( $_GET['action'] ) &&
! empty( $_GET['filename'] ) &&
self::DOWNLOAD_EXPORT_ACTION === wp_unslash( $_GET['action'] ) && // WPCS: input var ok, sanitization ok.
current_user_can( 'view_woocommerce_reports' )
) {
$exporter = new ReportCSVExporter();
$exporter->set_filename( wp_unslash( $_GET['filename'] ) ); // WPCS: input var ok, sanitization ok.
$exporter->export();
}
}
/**
* Process a report export email action.
*
* @param int $user_id User ID that requested the email.
* @param string $export_id Unique ID for report (timestamp expected).
* @param string $report_type Report type. E.g. 'customers'.
* @return void
*/
public static function email_report_download_link( $user_id, $export_id, $report_type ) {
$percent_complete = self::get_export_percentage_complete( $report_type, $export_id );
if ( 100 === $percent_complete ) {
$query_args = array(
'action' => self::DOWNLOAD_EXPORT_ACTION,
'filename' => "wc-{$report_type}-report-export-{$export_id}",
);
$download_url = add_query_arg( $query_args, admin_url() );
\WC_Emails::instance();
$email = new ReportCSVEmail();
$email->trigger( $user_id, $report_type, $download_url );
}
}
}
Admin/ReportsSync.php 0000644 00000013704 15153704477 0010622 0 ustar 00 <?php
/**
* Report table sync related functions and actions.
*/
namespace Automattic\WooCommerce\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler;
use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
use Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler;
/**
* ReportsSync Class.
*/
class ReportsSync {
/**
* Hook in sync methods.
*/
public static function init() {
// Initialize scheduler hooks.
foreach ( self::get_schedulers() as $scheduler ) {
$scheduler::init();
}
add_action( 'woocommerce_update_product', array( __CLASS__, 'clear_stock_count_cache' ) );
add_action( 'woocommerce_new_product', array( __CLASS__, 'clear_stock_count_cache' ) );
add_action( 'update_option_woocommerce_notify_low_stock_amount', array( __CLASS__, 'clear_stock_count_cache' ) );
add_action( 'update_option_woocommerce_notify_no_stock_amount', array( __CLASS__, 'clear_stock_count_cache' ) );
}
/**
* Get classes for syncing data.
*
* @return array
* @throws \Exception Throws exception when invalid data is found.
*/
public static function get_schedulers() {
$schedulers = apply_filters(
'woocommerce_analytics_report_schedulers',
array(
new CustomersScheduler(),
new OrdersScheduler(),
)
);
foreach ( $schedulers as $scheduler ) {
if ( ! is_subclass_of( $scheduler, 'Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler' ) ) {
throw new \Exception( __( 'Report sync schedulers should be derived from the Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler class.', 'woocommerce' ) );
}
}
return $schedulers;
}
/**
* Returns true if an import is in progress.
*
* @return bool
*/
public static function is_importing() {
foreach ( self::get_schedulers() as $scheduler ) {
if ( $scheduler::is_importing() ) {
return true;
}
}
return false;
}
/**
* Regenerate data for reports.
*
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip existing records.
* @return string
*/
public static function regenerate_report_data( $days, $skip_existing ) {
if ( self::is_importing() ) {
return new \WP_Error( 'wc_admin_import_in_progress', __( 'An import is already in progress. Please allow the previous import to complete before beginning a new one.', 'woocommerce' ) );
}
self::reset_import_stats( $days, $skip_existing );
foreach ( self::get_schedulers() as $scheduler ) {
$scheduler::schedule_action( 'import_batch_init', array( $days, $skip_existing ) );
}
/**
* Fires when report data regeneration begins.
*
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip existing records.
*/
do_action( 'woocommerce_analytics_regenerate_init', $days, $skip_existing );
return __( 'Report table data is being rebuilt. Please allow some time for data to fully populate.', 'woocommerce' );
}
/**
* Update the import stat totals and counts.
*
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip existing records.
*/
public static function reset_import_stats( $days, $skip_existing ) {
$import_stats = get_option( ImportScheduler::IMPORT_STATS_OPTION, array() );
$totals = self::get_import_totals( $days, $skip_existing );
foreach ( self::get_schedulers() as $scheduler ) {
$import_stats[ $scheduler::$name ]['imported'] = 0;
$import_stats[ $scheduler::$name ]['total'] = $totals[ $scheduler::$name ];
}
// Update imported from date if older than previous.
$previous_import_date = isset( $import_stats['imported_from'] ) ? $import_stats['imported_from'] : null;
$current_import_date = $days ? gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) ) : -1;
if ( ! $previous_import_date || -1 === $current_import_date || new \DateTime( $previous_import_date ) > new \DateTime( $current_import_date ) ) {
$import_stats['imported_from'] = $current_import_date;
}
update_option( ImportScheduler::IMPORT_STATS_OPTION, $import_stats );
}
/**
* Get stats for current import.
*
* @return array
*/
public static function get_import_stats() {
$import_stats = get_option( ImportScheduler::IMPORT_STATS_OPTION, array() );
$import_stats['is_importing'] = self::is_importing();
return $import_stats;
}
/**
* Get the import totals for all syncs.
*
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip existing records.
* @return array
*/
public static function get_import_totals( $days, $skip_existing ) {
$totals = array();
foreach ( self::get_schedulers() as $scheduler ) {
$items = $scheduler::get_items( 1, 1, $days, $skip_existing );
$totals[ $scheduler::$name ] = $items->total;
}
return $totals;
}
/**
* Clears all queued actions.
*/
public static function clear_queued_actions() {
foreach ( self::get_schedulers() as $scheduler ) {
$scheduler::clear_queued_actions();
}
}
/**
* Delete all data for reports.
*
* @return string
*/
public static function delete_report_data() {
// Cancel all pending import jobs.
self::clear_queued_actions();
foreach ( self::get_schedulers() as $scheduler ) {
$scheduler::schedule_action( 'delete_batch_init', array() );
}
// Delete import options.
delete_option( ImportScheduler::IMPORT_STATS_OPTION );
return __( 'Report table data is being deleted.', 'woocommerce' );
}
/**
* Clear the count cache when products are added or updated, or when
* the no/low stock options are changed.
*
* @param int $id Post/product ID.
*/
public static function clear_stock_count_cache( $id ) {
delete_transient( 'wc_admin_stock_count_lowstock' );
delete_transient( 'wc_admin_product_count' );
$status_options = wc_get_product_stock_status_options();
foreach ( $status_options as $status => $label ) {
delete_transient( 'wc_admin_stock_count_' . $status );
}
}
}
Admin/Schedulers/SchedulerTraits.php 0000644 00000023360 15153704477 0013534 0 ustar 00 <?php
/**
* Traits for scheduling actions and dependencies.
*/
namespace Automattic\WooCommerce\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
/**
* SchedulerTraits class.
*/
trait SchedulerTraits {
/**
* Action scheduler group.
*
* @var string|null
*/
public static $group = 'wc-admin-data';
/**
* Queue instance.
*
* @var WC_Queue_Interface
*/
protected static $queue = null;
/**
* Add all actions as hooks.
*/
public static function init() {
foreach ( self::get_actions() as $action_name => $action_hook ) {
$method = new \ReflectionMethod( static::class, $action_name );
add_action( $action_hook, array( static::class, 'do_action_or_reschedule' ), 10, $method->getNumberOfParameters() );
}
}
/**
* Get queue instance.
*
* @return WC_Queue_Interface
*/
public static function queue() {
if ( is_null( self::$queue ) ) {
self::$queue = WC()->queue();
}
return self::$queue;
}
/**
* Set queue instance.
*
* @param WC_Queue_Interface $queue Queue instance.
*/
public static function set_queue( $queue ) {
self::$queue = $queue;
}
/**
* Gets the default scheduler actions for batching and scheduling actions.
*/
public static function get_default_scheduler_actions() {
return array(
'schedule_action' => 'wc-admin_schedule_action_' . static::$name,
'queue_batches' => 'wc-admin_queue_batches_' . static::$name,
);
}
/**
* Gets the actions for this specific scheduler.
*
* @return array
*/
public static function get_scheduler_actions() {
return array();
}
/**
* Get all available scheduling actions.
* Used to determine action hook names and clear events.
*/
public static function get_actions() {
return array_merge(
static::get_default_scheduler_actions(),
static::get_scheduler_actions()
);
}
/**
* Get an action tag name from the action name.
*
* @param string $action_name The action name.
* @return string|null
*/
public static function get_action( $action_name ) {
$actions = static::get_actions();
return isset( $actions[ $action_name ] ) ? $actions[ $action_name ] : null;
}
/**
* Returns an array of actions and dependencies as key => value pairs.
*
* @return array
*/
public static function get_dependencies() {
return array();
}
/**
* Get dependencies associated with an action.
*
* @param string $action_name The action slug.
* @return string|null
*/
public static function get_dependency( $action_name ) {
$dependencies = static::get_dependencies();
return isset( $dependencies[ $action_name ] ) ? $dependencies[ $action_name ] : null;
}
/**
* Batch action size.
*/
public static function get_batch_sizes() {
return array(
'queue_batches' => 100,
);
}
/**
* Returns the batch size for an action.
*
* @param string $action Single batch action name.
* @return int Batch size.
*/
public static function get_batch_size( $action ) {
$batch_sizes = static::get_batch_sizes();
$batch_size = isset( $batch_sizes[ $action ] ) ? $batch_sizes[ $action ] : 25;
/**
* Filter the batch size for regenerating a report table.
*
* @param int $batch_size Batch size.
* @param string $action Batch action name.
*/
return apply_filters( 'woocommerce_analytics_regenerate_batch_size', $batch_size, static::$name, $action );
}
/**
* Flatten multidimensional arrays to store for scheduling.
*
* @param array $args Argument array.
* @return string
*/
public static function flatten_args( $args ) {
$flattened = array();
foreach ( $args as $arg ) {
if ( is_array( $arg ) ) {
$flattened[] = self::flatten_args( $arg );
} else {
$flattened[] = $arg;
}
}
$string = '[' . implode( ',', $flattened ) . ']';
return $string;
}
/**
* Check if existing jobs exist for an action and arguments.
*
* @param string $action_name Action name.
* @param array $args Array of arguments to pass to action.
* @return bool
*/
public static function has_existing_jobs( $action_name, $args ) {
$existing_jobs = self::queue()->search(
array(
'status' => 'pending',
'per_page' => 1,
'claimed' => false,
'hook' => static::get_action( $action_name ),
'search' => self::flatten_args( $args ),
'group' => self::$group,
)
);
if ( $existing_jobs ) {
$existing_job = current( $existing_jobs );
// Bail out if there's a pending single action, or a pending scheduled actions.
if (
( static::get_action( $action_name ) === $existing_job->get_hook() ) ||
(
static::get_action( 'schedule_action' ) === $existing_job->get_hook() &&
in_array( self::get_action( $action_name ), $existing_job->get_args(), true )
)
) {
return true;
}
}
return false;
}
/**
* Get the next blocking job for an action.
*
* @param string $action_name Action name.
* @return false|ActionScheduler_Action
*/
public static function get_next_blocking_job( $action_name ) {
$dependency = self::get_dependency( $action_name );
if ( ! $dependency ) {
return false;
}
$blocking_jobs = self::queue()->search(
array(
'status' => 'pending',
'orderby' => 'date',
'order' => 'DESC',
'per_page' => 1,
'search' => $dependency, // search is used instead of hook to find queued batch creation.
'group' => static::$group,
)
);
$next_job_schedule = null;
if ( is_array( $blocking_jobs ) ) {
foreach ( $blocking_jobs as $blocking_job ) {
$next_job_schedule = self::get_next_action_time( $blocking_job );
// Ensure that the next schedule is a DateTime (it can be null).
if ( is_a( $next_job_schedule, 'DateTime' ) ) {
return $blocking_job;
}
}
}
return false;
}
/**
* Check for blocking jobs and reschedule if any exist.
*/
public static function do_action_or_reschedule() {
$action_hook = current_action();
$action_name = array_search( $action_hook, static::get_actions(), true );
$args = func_get_args();
// Check if any blocking jobs exist and schedule after they've completed
// or schedule to run now if no blocking jobs exist.
$blocking_job = static::get_next_blocking_job( $action_name );
if ( $blocking_job ) {
$after = new \DateTime();
self::queue()->schedule_single(
self::get_next_action_time( $blocking_job )->getTimestamp() + 5,
$action_hook,
$args,
static::$group
);
} else {
call_user_func_array( array( static::class, $action_name ), $args );
}
}
/**
* Get the DateTime for the next scheduled time an action should run.
* This function allows backwards compatibility with Action Scheduler < v3.0.
*
* @param \ActionScheduler_Action $action Action.
* @return DateTime|null
*/
public static function get_next_action_time( $action ) {
if ( method_exists( $action->get_schedule(), 'get_next' ) ) {
$after = new \DateTime();
$next_job_schedule = $action->get_schedule()->get_next( $after );
} else {
$next_job_schedule = $action->get_schedule()->next();
}
return $next_job_schedule;
}
/**
* Schedule an action to run and check for dependencies.
*
* @param string $action_name Action name.
* @param array $args Array of arguments to pass to action.
*/
public static function schedule_action( $action_name, $args = array() ) {
// Check for existing jobs and bail if they already exist.
if ( static::has_existing_jobs( $action_name, $args ) ) {
return;
}
$action_hook = static::get_action( $action_name );
if ( ! $action_hook ) {
return;
}
if (
// Skip scheduling if Action Scheduler tables have not been initialized.
! get_option( 'schema-ActionScheduler_StoreSchema' ) ||
apply_filters( 'woocommerce_analytics_disable_action_scheduling', false )
) {
call_user_func_array( array( static::class, $action_name ), $args );
return;
}
self::queue()->schedule_single( time() + 5, $action_hook, $args, static::$group );
}
/**
* Queue a large number of batch jobs, respecting the batch size limit.
* Reduces a range of batches down to "single batch" jobs.
*
* @param int $range_start Starting batch number.
* @param int $range_end Ending batch number.
* @param string $single_batch_action Action to schedule for a single batch.
* @param array $action_args Action arguments.
* @return void
*/
public static function queue_batches( $range_start, $range_end, $single_batch_action, $action_args = array() ) {
$batch_size = static::get_batch_size( 'queue_batches' );
$range_size = 1 + ( $range_end - $range_start );
$action_timestamp = time() + 5;
if ( $range_size > $batch_size ) {
// If the current batch range is larger than a single batch,
// split the range into $queue_batch_size chunks.
$chunk_size = (int) ceil( $range_size / $batch_size );
for ( $i = 0; $i < $batch_size; $i++ ) {
$batch_start = (int) ( $range_start + ( $i * $chunk_size ) );
$batch_end = (int) min( $range_end, $range_start + ( $chunk_size * ( $i + 1 ) ) - 1 );
if ( $batch_start > $range_end ) {
return;
}
self::schedule_action(
'queue_batches',
array( $batch_start, $batch_end, $single_batch_action, $action_args )
);
}
} else {
// Otherwise, queue the single batches.
for ( $i = $range_start; $i <= $range_end; $i++ ) {
$batch_action_args = array_merge( array( $i ), $action_args );
self::schedule_action( $single_batch_action, $batch_action_args );
}
}
}
/**
* Clears all queued actions.
*/
public static function clear_queued_actions() {
if ( version_compare( \ActionScheduler_Versions::instance()->latest_version(), '3.0', '>=' ) ) {
\ActionScheduler::store()->cancel_actions_by_group( static::$group );
} else {
$actions = static::get_actions();
foreach ( $actions as $action ) {
self::queue()->cancel_all( $action, null, static::$group );
}
}
}
}
Admin/WCAdminHelper.php 0000644 00000005316 15153704477 0010751 0 ustar 00 <?php
/**
* WCAdminHelper
*
* Helper class for generic WCAdmin functions.
*/
namespace Automattic\WooCommerce\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Class WCAdminHelper
*/
class WCAdminHelper {
/**
* WC Admin timestamp option name.
*/
const WC_ADMIN_TIMESTAMP_OPTION = 'woocommerce_admin_install_timestamp';
const WC_ADMIN_STORE_AGE_RANGES = array(
'week-1' => array(
'start' => 0,
'end' => WEEK_IN_SECONDS,
),
'week-1-4' => array(
'start' => WEEK_IN_SECONDS,
'end' => WEEK_IN_SECONDS * 4,
),
'month-1-3' => array(
'start' => MONTH_IN_SECONDS,
'end' => MONTH_IN_SECONDS * 3,
),
'month-3-6' => array(
'start' => MONTH_IN_SECONDS * 3,
'end' => MONTH_IN_SECONDS * 6,
),
'month-6+' => array(
'start' => MONTH_IN_SECONDS * 6,
),
);
/**
* Get the number of seconds that the store has been active.
*
* @return number Number of seconds.
*/
public static function get_wcadmin_active_for_in_seconds() {
$install_timestamp = get_option( self::WC_ADMIN_TIMESTAMP_OPTION );
if ( ! is_numeric( $install_timestamp ) ) {
$install_timestamp = time();
update_option( self::WC_ADMIN_TIMESTAMP_OPTION, $install_timestamp );
}
return time() - $install_timestamp;
}
/**
* Test how long WooCommerce Admin has been active.
*
* @param int $seconds Time in seconds to check.
* @return bool Whether or not WooCommerce admin has been active for $seconds.
*/
public static function is_wc_admin_active_for( $seconds ) {
$wc_admin_active_for = self::get_wcadmin_active_for_in_seconds();
return ( $wc_admin_active_for >= $seconds );
}
/**
* Test if WooCommerce Admin has been active within a pre-defined range.
*
* @param string $range range available in WC_ADMIN_STORE_AGE_RANGES.
* @param int $custom_start custom start in range.
* @throws \InvalidArgumentException Throws exception when invalid $range is passed in.
* @return bool Whether or not WooCommerce admin has been active within the range.
*/
public static function is_wc_admin_active_in_date_range( $range, $custom_start = null ) {
if ( ! array_key_exists( $range, self::WC_ADMIN_STORE_AGE_RANGES ) ) {
throw new \InvalidArgumentException(
sprintf(
'"%s" range is not supported, use one of: %s',
$range,
implode( ', ', array_keys( self::WC_ADMIN_STORE_AGE_RANGES ) )
)
);
}
$wc_admin_active_for = self::get_wcadmin_active_for_in_seconds();
$range_data = self::WC_ADMIN_STORE_AGE_RANGES[ $range ];
$start = null !== $custom_start ? $custom_start : $range_data['start'];
if ( $range_data && $wc_admin_active_for >= $start ) {
return isset( $range_data['end'] ) ? $wc_admin_active_for < $range_data['end'] : true;
}
return false;
}
}
Autoloader.php 0000644 00000004006 15153704477 0007371 0 ustar 00 <?php
/**
* Includes the composer Autoloader used for packages and classes in the src/ directory.
*/
namespace Automattic\WooCommerce\GoogleListingsAndAds;
defined( 'ABSPATH' ) || exit;
/**
* Autoloader class.
*
* @since 3.7.0
*/
class Autoloader {
/**
* Static-only class.
*/
private function __construct() {}
/**
* Require the autoloader and return the result.
*
* If the autoloader is not present, let's log the failure and display a nice admin notice.
*
* @return boolean
*/
public static function init() {
$autoloader = dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! is_readable( $autoloader ) ) {
self::missing_autoloader();
return false;
}
$autoloader_result = require $autoloader;
if ( ! $autoloader_result ) {
return false;
}
return $autoloader_result;
}
/**
* If the autoloader is missing, add an admin notice.
*/
protected static function missing_autoloader() {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions
esc_html__( 'Your installation of Google for WooCommerce is incomplete. If you installed from GitHub, please refer to this document to set up your development environment: https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment', 'google-listings-and-ads' )
);
}
add_action(
'admin_notices',
function () {
?>
<div class="notice notice-error">
<p>
<?php
printf(
/* translators: 1: is a link to a support document. 2: closing link */
esc_html__( 'Your installation of Google for WooCommerce is incomplete. If you installed from GitHub, %1$splease refer to this document%2$s to set up your development environment.', 'google-listings-and-ads' ),
'<a href="' . esc_url( 'https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment' ) . '" target="_blank" rel="noopener noreferrer">',
'</a>'
);
?>
</p>
</div>
<?php
}
);
}
}
Caches/OrderCache.php 0000644 00000002111 15153704477 0010452 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caches;
use Automattic\WooCommerce\Caching\CacheException;
use Automattic\WooCommerce\Caching\ObjectCache;
/**
* A class to cache order objects.
*/
class OrderCache extends ObjectCache {
/**
* Get the identifier for the type of the cached objects.
*
* @return string
*/
public function get_object_type(): string {
return 'orders';
}
/**
* Get the id of an object to be cached.
*
* @param array|object $object The object to be cached.
* @return int|string|null The id of the object, or null if it can't be determined.
*/
protected function get_object_id( $object ) {
return $object->get_id();
}
/**
* Validate an object before caching it.
*
* @param array|object $object The object to validate.
* @return string[]|null An array of error messages, or null if the object is valid.
*/
protected function validate( $object ): ?array {
if ( ! $object instanceof \WC_Abstract_Order ) {
return array( 'The supplied order is not an instance of WC_Abstract_Order, ' . gettype( $object ) );
}
return null;
}
}
Caches/OrderCacheController.php 0000644 00000004405 15153704477 0012526 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caches;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* A class to control the usage of the orders cache.
*/
class OrderCacheController {
use AccessiblePrivateMethods;
/**
* The orders cache to use.
*
* @var OrderCache
*/
private $order_cache;
/**
* The orders cache to use.
*
* @var FeaturesController
*/
private $features_controller;
/**
* The backup value of the cache usage enable status, stored while the cache is temporarily disabled.
*
* @var null|bool
*/
private $orders_cache_usage_backup = null;
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param OrderCache $order_cache The order cache engine to use.
*/
final public function init( OrderCache $order_cache ) {
$this->order_cache = $order_cache;
}
/**
* Whether order cache usage is enabled. Currently, linked to custom orders' table usage.
*
* @return bool True if the order cache is enabled.
*/
public function orders_cache_usage_is_enabled(): bool {
return OrderUtil::custom_orders_table_usage_is_enabled();
}
/**
* Temporarily disable the order cache if it's enabled.
*
* This is a purely in-memory operation: a variable is created with the value
* of the current enable status for the feature, and this variable
* is checked by orders_cache_usage_is_enabled. In the next request the
* feature will be again enabled or not depending on how the feature is set.
*/
public function temporarily_disable_orders_cache_usage(): void {
if ( $this->orders_cache_usage_is_temporarly_disabled() ) {
return;
}
$this->orders_cache_usage_backup = $this->orders_cache_usage_is_enabled();
}
/**
* Check if the order cache has been temporarily disabled.
*
* @return bool True if the order cache is currently temporarily disabled.
*/
public function orders_cache_usage_is_temporarly_disabled(): bool {
return null !== $this->orders_cache_usage_backup;
}
/**
* Restore the order cache usage that had been temporarily disabled.
*/
public function maybe_restore_orders_cache_usage(): void {
$this->orders_cache_usage_backup = null;
}
}
Caching/CacheEngine.php 0000644 00000004225 15153704477 0010762 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caching;
/**
* Interface for cache engines used by objects inheriting from ObjectCache.
* Here "object" means either an array or an actual PHP object.
*/
interface CacheEngine {
/**
* Retrieves an object cached under a given key.
*
* @param string $key They key under which the object to retrieve is cached.
* @param string $group The group under which the object is cached.
*
* @return array|object|null The cached object, or null if there's no object cached under the passed key.
*/
public function get_cached_object( string $key, string $group = '' );
/**
* Caches an object under a given key, and with a given expiration.
*
* @param string $key The key under which the object will be cached.
* @param array|object $object The object to cache.
* @param int $expiration Expiration for the cached object, in seconds.
* @param string $group The group under which the object will be cached.
*
* @return bool True if the object is cached successfully, false otherwise.
*/
public function cache_object( string $key, $object, int $expiration, string $group = '' ): bool;
/**
* Removes a cached object from the cache.
*
* @param string $key They key under which the object is cached.
* @param string $group The group under which the object is cached.
*
* @return bool True if the object is removed from the cache successfully, false otherwise (because the object wasn't cached or for other reason).
*/
public function delete_cached_object( string $key, string $group = '' ): bool;
/**
* Checks if an object is cached under a given key.
*
* @param string $key The key to verify.
* @param string $group The group under which the object is cached.
*
* @return bool True if there's an object cached under the given key, false otherwise.
*/
public function is_cached( string $key, string $group = '' ): bool;
/**
* Deletes all cached objects under a given group.
*
* @param string $group The group to delete.
*
* @return bool True if the group is deleted successfully, false otherwise.
*/
public function delete_cache_group( string $group = '' ): bool;
}
Caching/CacheException.php 0000644 00000004320 15153704477 0011507 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caching;
/**
* Exception thrown by classes derived from ObjectCache.
*/
class CacheException extends \Exception {
/**
* Error messages.
*
* @var array
*/
private $errors;
/**
* The object that threw the exception.
*
* @var ObjectCache
*/
private $thrower;
/**
* The id of the cached object, if available.
*
* @var int|string|null
*/
private $cached_id;
/**
* Creates a new instance of the class.
*
* @param string $message The exception message.
* @param ObjectCache $thrower The object that is throwing the exception.
* @param int|string|null $cached_id The involved cached object id, if available.
* @param array|null $errors An array of error messages, if available.
* @param mixed $code An error code, if available.
* @param \Throwable|null $previous The previous exception, if available.
*/
public function __construct( string $message, ObjectCache $thrower, $cached_id = null, ?array $errors = null, $code = 0, \Throwable $previous = null ) {
$this->errors = $errors ?? array();
$this->thrower = $thrower;
$this->cached_id = $cached_id;
parent::__construct( $message, $code, $previous );
}
/**
* Get a string representation of the exception object.
*
* @return string String representation of the exception object.
*/
public function __toString(): string {
$cached_id_part = $this->cached_id ? ", id: {$this->cached_id}" : '';
return "CacheException: [{$this->thrower->get_object_type()}{$cached_id_part}]: {$this->message}";
}
/**
* Gets the array of error messages passed to the exception constructor.
*
* @return array Error messages passed to the exception constructor.
*/
public function get_errors(): array {
return $this->errors;
}
/**
* Gets the object that threw the exception as passed to the exception constructor.
*
* @return object The object that threw the exception.
*/
public function get_thrower(): object {
return $this->thrower;
}
/**
* Gets the id of the cached object as passed to the exception constructor.
*
* @return int|string|null The id of the cached object.
*/
public function get_cached_id() {
return $this->cached_id;
}
}
Caching/CacheNameSpaceTrait.php 0000644 00000004100 15153704477 0012405 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caching;
/**
* Implements namespacing algorithm to simulate grouping and namespacing for wp_cache, memcache and other caching engines that don't support grouping natively.
*
* See the algorithm details here: https://github.com/memcached/memcached/wiki/ProgrammingTricks#namespacing.
*
* To use the namespacing algorithm in the CacheEngine class:
* 1. Use a group string to identify all objects of a type.
* 2. Before setting cache, prefix the cache key by using the `get_cache_prefix`.
* 3. Use `invalidate_cache_group` function to invalidate all caches in entire group at once.
*/
trait CacheNameSpaceTrait {
/**
* Get prefix for use with wp_cache_set. Allows all cache in a group to be invalidated at once.
*
* @param string $group Group of cache to get.
* @return string Prefix.
*/
public static function get_cache_prefix( $group ) {
// Get cache key - uses cache key wc_orders_cache_prefix to invalidate when needed.
$prefix = wp_cache_get( 'wc_' . $group . '_cache_prefix', $group );
if ( false === $prefix ) {
$prefix = microtime();
wp_cache_set( 'wc_' . $group . '_cache_prefix', $prefix, $group );
}
return 'wc_cache_' . $prefix . '_';
}
/**
* Increment group cache prefix (invalidates cache).
*
* @param string $group Group of cache to clear.
*/
public static function incr_cache_prefix( $group ) {
wc_deprecated_function( 'WC_Cache_Helper::incr_cache_prefix', '3.9.0', 'WC_Cache_Helper::invalidate_cache_group' );
self::invalidate_cache_group( $group );
}
/**
* Invalidate cache group.
*
* @param string $group Group of cache to clear.
* @since 3.9.0
*/
public static function invalidate_cache_group( $group ) {
return wp_cache_set( 'wc_' . $group . '_cache_prefix', microtime(), $group );
}
/**
* Helper method to get prefixed key.
*
* @param string $key Key to prefix.
* @param string $group Group of cache to get.
*
* @return string Prefixed key.
*/
public static function get_prefixed_key( $key, $group ) {
return self::get_cache_prefix( $group ) . $key;
}
}
Caching/ObjectCache.php 0000644 00000026064 15153704477 0010770 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caching;
/**
* Base class for caching objects (or associative arrays) that have a unique identifier.
* At the very least, derived classes need to implement the 'get_object_type' method,
* but usually it will be convenient to override some of the other protected members.
*
* The actual caching is delegated to an instance of CacheEngine. By default WpCacheEngine is used,
* but a different engine can be used by either overriding the get_cache_engine_instance method
* or capturing the wc_object_cache_get_engine filter.
*
* Objects are identified by ids that are either integers or strings. The actual cache keys passed
* to the cache engine will be prefixed with the object type and a random string. The 'flush' operation
* just forces the generation a new prefix and lets the old cached objects expire.
*/
abstract class ObjectCache {
/**
* Expiration value to be passed to 'set' to use the value of $default_expiration.
*/
public const DEFAULT_EXPIRATION = -1;
/**
* Maximum expiration time value, in seconds, that can be passed to 'set'.
*/
public const MAX_EXPIRATION = MONTH_IN_SECONDS;
/**
* This needs to be set in each derived class.
*
* @var string
*/
private $object_type;
/**
* Default value for the duration of the objects in the cache, in seconds
* (may not be used depending on the cache engine used WordPress cache implementation).
*
* @var int
*/
protected $default_expiration = HOUR_IN_SECONDS;
/**
* Temporarily used when retrieving data in 'get'.
*
* @var array
*/
private $last_cached_data;
/**
* The cache engine to use.
*
* @var ?CacheEngine
*/
private $cache_engine = null;
/**
* Gets an identifier for the types of objects cached by this class.
* This identifier will be used to compose the keys passed to the cache engine,
* to the name of the option that stores the cache prefix, and the names of the hooks used.
* It must be unique for each class inheriting from ObjectCache.
*
* @return string
*/
abstract public function get_object_type(): string;
/**
* Creates a new instance of the class.
*
* @throws CacheException If get_object_type returns null or an empty string.
*/
public function __construct() {
$this->object_type = $this->get_object_type();
if ( empty( $this->object_type ) ) {
throw new CacheException( 'Class ' . get_class( $this ) . ' returns an empty value for get_object_type', $this );
}
}
/**
* Get the default expiration time for cached objects, in seconds.
*
* @return int
*/
public function get_default_expiration_value(): int {
return $this->default_expiration;
}
/**
* Get the cache engine to use and cache it internally.
*
* @return CacheEngine
*/
private function get_cache_engine(): CacheEngine {
if ( null === $this->cache_engine ) {
$engine = $this->get_cache_engine_instance();
/**
* Filters the underlying cache engine to be used by an instance of ObjectCache.
*
* @since 7.4.0
*
* @param CacheEngine $engine The cache engine to be used by default.
* @param ObjectCache $cache_instance The instance of ObjectCache that will use the cache engine.
* @returns CacheEngine The actual cache engine that will be used.
*/
$this->cache_engine = apply_filters( 'wc_object_cache_get_engine', $engine, $this );
}
return $this->cache_engine;
}
/**
* Add an object to the cache, or update an already cached object.
*
* @param object|array $object The object to be cached.
* @param int|string|null $id Id of the object to be cached, if null, get_object_id will be used to get it.
* @param int $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value.
* @return bool True on success, false on error.
* @throws CacheException Invalid parameter, or null id was passed and get_object_id returns null too.
*/
public function set( $object, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool {
if ( null === $object ) {
throw new CacheException( "Can't cache a null value", $this, $id );
}
if ( ! is_array( $object ) && ! is_object( $object ) ) {
throw new CacheException( "Can't cache a non-object, non-array value", $this, $id );
}
if ( ! is_string( $id ) && ! is_int( $id ) && ! is_null( $id ) ) {
throw new CacheException( "Object id must be an int, a string, or null for 'set'", $this, $id );
}
$this->verify_expiration_value( $expiration );
$errors = $this->validate( $object );
if ( ! is_null( $errors ) ) {
try {
$id = $this->get_id_from_object_if_null( $object, $id );
} catch ( \Throwable $ex ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Nothing else to do, we won't be able to add any significant object id to the CacheException and that's it.
}
if ( count( $errors ) === 1 ) {
throw new CacheException( 'Object validation/serialization failed: ' . $errors[0], $this, $id, $errors );
} elseif ( ! empty( $errors ) ) {
throw new CacheException( 'Object validation/serialization failed', $this, $id, $errors );
}
}
$id = $this->get_id_from_object_if_null( $object, $id );
$this->last_cached_data = $object;
return $this->get_cache_engine()->cache_object(
$id,
$object,
self::DEFAULT_EXPIRATION === $expiration ? $this->default_expiration : $expiration,
$this->get_object_type()
);
}
/**
* Update an object in the cache, but only if an object is already cached with the same id.
*
* @param object|array $object The new object that will replace the already cached one.
* @param int|string|null $id Id of the object to be cached, if null, get_object_id will be used to get it.
* @param int $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value.
* @return bool True on success, false on error or if no object wiith the supplied id was cached.
* @throws CacheException Invalid parameter, or null id was passed and get_object_id returns null too.
*/
public function update_if_cached( $object, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool {
$id = $this->get_id_from_object_if_null( $object, $id );
if ( ! $this->is_cached( $id ) ) {
return false;
}
return $this->set( $object, $id, $expiration );
}
/**
* Get the id from an object if the id itself is null.
*
* @param object|array $object The object to get the id from.
* @param int|string|null $id An object id or null.
*
* @return int|string|null Passed $id if it wasn't null, otherwise id obtained from $object using get_object_id.
*
* @throws CacheException Passed $id is null and get_object_id returned null too.
*/
private function get_id_from_object_if_null( $object, $id ) {
if ( null === $id ) {
$id = $this->get_object_id( $object );
if ( null === $id ) {
throw new CacheException( "Null id supplied and the cache class doesn't implement get_object_id", $this );
}
}
return $id;
}
/**
* Check if the given expiration time value is valid, throw an exception if not.
*
* @param int $expiration Expiration time to check.
* @return void
* @throws CacheException Expiration time is negative or higher than MAX_EXPIRATION.
*/
private function verify_expiration_value( int $expiration ): void {
if ( self::DEFAULT_EXPIRATION !== $expiration && ( ( $expiration < 1 ) || ( $expiration > self::MAX_EXPIRATION ) ) ) {
throw new CacheException( 'Invalid expiration value, must be ObjectCache::DEFAULT_EXPIRATION or a value between 1 and ObjectCache::MAX_EXPIRATION', $this );
}
}
/**
* Retrieve a cached object, and if no object is cached with the given id,
* try to get one via get_from_datastore method or by supplying a callback and then cache it.
*
* If you want to provide a callable but still use the default expiration value,
* pass "ObjectCache::DEFAULT_EXPIRATION" as the second parameter.
*
* @param int|string $id The id of the object to retrieve.
* @param int $expiration Expiration of the cached data in seconds from the current time, used if an object is retrieved from datastore and cached.
* @param callable|null $get_from_datastore_callback Optional callback to get the object if it's not cached, it must return an object/array or null.
* @return object|array|null Cached object, or null if it's not cached and can't be retrieved from datastore or via callback.
* @throws CacheException Invalid id parameter.
*/
public function get( $id, int $expiration = self::DEFAULT_EXPIRATION, callable $get_from_datastore_callback = null ) {
if ( ! is_string( $id ) && ! is_int( $id ) ) {
throw new CacheException( "Object id must be an int or a string for 'get'", $this );
}
$this->verify_expiration_value( $expiration );
$data = $this->get_cache_engine()->get_cached_object( $id, $this->get_object_type() );
if ( null === $data ) {
$object = null;
if ( $get_from_datastore_callback ) {
$object = $get_from_datastore_callback( $id );
}
if ( null === $object ) {
return null;
}
$this->set( $object, $id, $expiration );
$data = $this->last_cached_data;
}
return $data;
}
/**
* Remove an object from the cache.
*
* @param int|string $id The id of the object to remove.
* @return bool True if the object is removed from the cache successfully, false otherwise (because the object wasn't cached or for other reason).
*/
public function remove( $id ): bool {
return $this->get_cache_engine()->delete_cached_object( $id, $this->get_object_type() );
}
/**
* Remove all the objects from the cache.
*
* @return bool True on success, false on error.
*/
public function flush(): bool {
return $this->get_cache_engine()->delete_cache_group( $this->get_object_type() );
}
/**
* Is a given object cached?
*
* @param int|string $id The id of the object to check.
* @return bool True if there's a cached object with the specified id.
*/
public function is_cached( $id ): bool {
return $this->get_cache_engine()->is_cached( $id, $this->get_object_type() );
}
/**
* Get the id of an object. This is used by 'set' when a null id is passed.
* If the object id can't be determined the method must return null.
*
* @param array|object $object The object to get the id for.
* @return int|string|null
*/
abstract protected function get_object_id( $object );
/**
* Validate an object before it's cached.
*
* @param array|object $object Object to validate.
* @return array|null An array with validation error messages, null or an empty array if there are no errors.
*/
abstract protected function validate( $object ): ?array;
/**
* Get the instance of the cache engine to use.
*
* @return CacheEngine
*/
protected function get_cache_engine_instance(): CacheEngine {
return wc_get_container()->get( WPCacheEngine::class );
}
/**
* Get a random string to be used to compose the cache key prefix.
* It should return a different string each time.
*
* @return string
*/
protected function get_random_string(): string {
return dechex( microtime( true ) * 1000 ) . bin2hex( random_bytes( 8 ) );
}
}
Caching/WPCacheEngine.php 0000644 00000005343 15153704477 0011233 0 ustar 00 <?php
namespace Automattic\WooCommerce\Caching;
/**
* Implementation of CacheEngine that uses the built-in WordPress cache.
*/
class WPCacheEngine implements CacheEngine {
use CacheNameSpaceTrait;
/**
* Retrieves an object cached under a given key.
*
* @param string $key They key under which the object to retrieve is cached.
* @param string $group The group under which the object is cached.
*
* @return array|object|null The cached object, or null if there's no object cached under the passed key.
*/
public function get_cached_object( string $key, string $group = '' ) {
$prefixed_key = self::get_prefixed_key( $key, $group );
$value = wp_cache_get( $prefixed_key, $group );
return false === $value ? null : $value;
}
/**
* Caches an object under a given key, and with a given expiration.
*
* @param string $key The key under which the object will be cached.
* @param array|object $object The object to cache.
* @param int $expiration Expiration for the cached object, in seconds.
* @param string $group The group under which the object will be cached.
*
* @return bool True if the object is cached successfully, false otherwise.
*/
public function cache_object( string $key, $object, int $expiration, string $group = '' ): bool {
$prefixed_key = self::get_prefixed_key( $key, $group );
return false !== wp_cache_set( $prefixed_key, $object, $group, $expiration );
}
/**
* Removes a cached object from the cache.
*
* @param string $key They key under which the object is cached.
* @param string $group The group under which the object is cached.
*
* @return bool True if the object is removed from the cache successfully, false otherwise (because the object wasn't cached or for other reason).
*/
public function delete_cached_object( string $key, string $group = '' ): bool {
$prefixed_key = self::get_prefixed_key( $key, $group );
return false !== wp_cache_delete( $prefixed_key, $group );
}
/**
* Checks if an object is cached under a given key.
*
* @param string $key The key to verify.
* @param string $group The group under which the object is cached.
*
* @return bool True if there's an object cached under the given key, false otherwise.
*/
public function is_cached( string $key, string $group = '' ): bool {
$prefixed_key = self::get_prefixed_key( $key, $group );
return false !== wp_cache_get( $prefixed_key, $group );
}
/**
* Deletes all cached objects under a given group.
*
* @param string $group The group to delete.
*
* @return bool True if the group is deleted successfully, false otherwise.
*/
public function delete_cache_group( string $group = '' ): bool {
return false !== self::invalidate_cache_group( $group );
}
}
Checkout/Helpers/ReserveStock.php 0000644 00000020551 15153704477 0013063 0 ustar 00 <?php
/**
* Handle product stock reservation during checkout.
*/
namespace Automattic\WooCommerce\Checkout\Helpers;
use Automattic\WooCommerce\Utilities\OrderUtil;
defined( 'ABSPATH' ) || exit;
/**
* Stock Reservation class.
*/
final class ReserveStock {
/**
* Is stock reservation enabled?
*
* @var boolean
*/
private $enabled = true;
/**
* Constructor
*/
public function __construct() {
// Table needed for this feature are added in 4.3.
$this->enabled = get_option( 'woocommerce_schema_version', 0 ) >= 430;
}
/**
* Is stock reservation enabled?
*
* @return boolean
*/
protected function is_enabled() {
return $this->enabled;
}
/**
* Query for any existing holds on stock for this item.
*
* @param \WC_Product $product Product to get reserved stock for.
* @param integer $exclude_order_id Optional order to exclude from the results.
*
* @return integer Amount of stock already reserved.
*/
public function get_reserved_stock( $product, $exclude_order_id = 0 ) {
global $wpdb;
if ( ! $this->is_enabled() ) {
return 0;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
return (int) $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) );
}
/**
* Put a temporary hold on stock for an order if enough is available.
*
* @throws ReserveStockException If stock cannot be reserved.
*
* @param \WC_Order $order Order object.
* @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
*/
public function reserve_stock_for_order( $order, $minutes = 0 ) {
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
if ( ! $minutes || ! $this->is_enabled() ) {
return;
}
$held_stock_notes = array();
try {
$items = array_filter(
$order->get_items(),
function( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
}
);
$rows = array();
foreach ( $items as $item ) {
$product = $item->get_product();
if ( ! $product->is_in_stock() ) {
throw new ReserveStockException(
'woocommerce_product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '"%s" is out of stock and cannot be purchased.', 'woocommerce' ),
$product->get_name()
),
403
);
}
// If stock management is off, no need to reserve any stock here.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
}
$managed_by_id = $product->get_stock_managed_by_id();
/**
* Filter order item quantity.
*
* @param int|float $quantity Quantity.
* @param WC_Order $order Order data.
* @param WC_Order_Item_Product $item Order item data.
*/
$item_quantity = apply_filters( 'woocommerce_order_item_quantity', $item->get_quantity(), $order, $item );
$rows[ $managed_by_id ] = isset( $rows[ $managed_by_id ] ) ? $rows[ $managed_by_id ] + $item_quantity : $item_quantity;
if ( count( $held_stock_notes ) < 5 ) {
// translators: %1$s is a product's formatted name, %2$d: is the quantity of said product to which the stock hold applied.
$held_stock_notes[] = sprintf( _x( '- %1$s × %2$d', 'held stock note', 'woocommerce' ), $product->get_formatted_name(), $rows[ $managed_by_id ] );
}
}
if ( ! empty( $rows ) ) {
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
}
} catch ( ReserveStockException $e ) {
$this->release_stock_for_order( $order );
throw $e;
}
// Add order note after successfully holding the stock.
if ( ! empty( $held_stock_notes ) ) {
$remaining_count = count( $rows ) - count( $held_stock_notes );
if ( $remaining_count > 0 ) {
$held_stock_notes[] = sprintf(
// translators: %d is the remaining order items count.
_nx( '- ...and %d more item.', '- ... and %d more items.', $remaining_count, 'held stock note', 'woocommerce' ),
$remaining_count
);
}
$order->add_order_note(
sprintf(
// translators: %1$s is a time in minutes, %2$s is a list of products and quantities.
_x( 'Stock hold of %1$s minutes applied to: %2$s', 'held stock note', 'woocommerce' ),
$minutes,
'<br>' . implode( '<br>', $held_stock_notes )
)
);
}
}
/**
* Release a temporary hold on stock for an order.
*
* @param \WC_Order $order Order object.
*/
public function release_stock_for_order( $order ) {
global $wpdb;
if ( ! $this->is_enabled() ) {
return;
}
$wpdb->delete(
$wpdb->wc_reserved_stock,
array(
'order_id' => $order->get_id(),
)
);
}
/**
* Reserve stock for a product by inserting rows into the DB.
*
* @throws ReserveStockException If a row cannot be inserted.
*
* @param int $product_id Product ID which is having stock reserved.
* @param int $stock_quantity Stock amount to reserve.
* @param \WC_Order $order Order object which contains the product.
* @param int $minutes How long to reserve stock in minutes.
*/
private function reserve_stock_for_product( $product_id, $stock_quantity, $order, $minutes ) {
global $wpdb;
$product_data_store = \WC_Data_Store::load( 'product' );
$query_for_stock = $product_data_store->get_query_for_stock( $product_id );
$query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->query(
$wpdb->prepare(
"
INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL
WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` )
",
$order->get_id(),
$product_id,
$stock_quantity,
$minutes,
$stock_quantity
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
if ( ! $result ) {
$product = wc_get_product( $product_id );
throw new ReserveStockException(
'woocommerce_product_not_enough_stock',
sprintf(
/* translators: %s: product name */
__( 'Not enough units of %s are available in stock to fulfil this order.', 'woocommerce' ),
$product ? $product->get_name() : '#' . $product_id
),
403
);
}
}
/**
* Returns query statement for getting reserved stock of a product.
*
* @param int $product_id Product ID.
* @param integer $exclude_order_id Optional order to exclude from the results.
* @return string|void Query statement.
*/
private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
global $wpdb;
$join = "$wpdb->posts posts ON stock_table.`order_id` = posts.ID";
$where_status = "posts.post_status IN ( 'wc-checkout-draft', 'wc-pending' )";
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
$join = "{$wpdb->prefix}wc_orders orders ON stock_table.`order_id` = orders.id";
$where_status = "orders.status IN ( 'wc-checkout-draft', 'wc-pending' )";
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$query = $wpdb->prepare(
"
SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table
LEFT JOIN $join
WHERE $where_status
AND stock_table.`expires` > NOW()
AND stock_table.`product_id` = %d
AND stock_table.`order_id` != %d
",
$product_id,
$exclude_order_id
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
/**
* Filter: woocommerce_query_for_reserved_stock
* Allows to filter the query for getting reserved stock of a product.
*
* @since 4.5.0
* @param string $query The query for getting reserved stock of a product.
* @param int $product_id Product ID.
* @param int $exclude_order_id Order to exclude from the results.
*/
return apply_filters( 'woocommerce_query_for_reserved_stock', $query, $product_id, $exclude_order_id );
}
}
Checkout/Helpers/ReserveStockException.php 0000644 00000002311 15153704477 0014734 0 ustar 00 <?php
/**
* Exceptions for stock reservation.
*/
namespace Automattic\WooCommerce\Checkout\Helpers;
defined( 'ABSPATH' ) || exit;
/**
* ReserveStockException class.
*/
class ReserveStockException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
protected $error_code;
/**
* Error extra data.
*
* @var array
*/
protected $error_data;
/**
* Setup exception.
*
* @param string $code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param string $message User-friendly translated error message, e.g. 'Product ID is invalid'.
* @param int $http_status_code Proper HTTP status code to respond with, e.g. 400.
* @param array $data Extra error data.
*/
public function __construct( $code, $message, $http_status_code = 400, $data = array() ) {
$this->error_code = $code;
$this->error_data = $data;
parent::__construct( $message, $http_status_code );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns error data.
*
* @return array
*/
public function getErrorData() {
return $this->error_data;
}
}
Container.php 0000644 00000011222 15153704477 0007212 0 ustar 00 <?php
/**
* Container class file.
*/
namespace Automattic\WooCommerce\GoogleListingsAndAds;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\AdminServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\CoreServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\DBServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\GoogleServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\IntegrationServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\JobServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\ProxyServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\RESTServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\ThirdPartyServiceProvider;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Container as LeagueContainer;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerExceptionInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\NotFoundExceptionInterface;
/**
* PSR11 compliant dependency injection container for Google for WooCommerce.
*
* Classes in the `src` directory should specify dependencies from that directory via constructor arguments
* with type hints. If an instance of the container itself is needed, the type hint to use is
* Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface.
*
* Classes in the `src` directory should interact with anything outside (especially WordPress functions) by using
* the classes in the `Proxies` directory. The exception is idempotent functions (e.g. `wp_parse_url`), which
* can be used directly.
*
* Class registration should be done via service providers that inherit from
* \Automattic\WooCommerce\Internal\DependencyManagement and those should go in the
* `src/Internal/DependencyManagement/ServiceProviders` folder unless there's a good reason to put them elsewhere.
* All the service provider class names must be in the `$service_providers` array.
*/
final class Container implements ContainerInterface {
/**
* The list of service provider classes to register.
*
* @var string[]
*/
private $service_providers = [
ProxyServiceProvider::class,
CoreServiceProvider::class,
RESTServiceProvider::class,
ThirdPartyServiceProvider::class,
GoogleServiceProvider::class,
JobServiceProvider::class,
IntegrationServiceProvider::class,
DBServiceProvider::class,
AdminServiceProvider::class,
];
/**
* The underlying container.
*
* @var LeagueContainer
*/
private $container;
/**
* Container constructor.
*
* @param LeagueContainer|null $container
*/
public function __construct( ?LeagueContainer $container = null ) {
$this->container = $container ?? new LeagueContainer();
$this->container->addShared( ContainerInterface::class, $this );
$this->container->inflector( ContainerAwareInterface::class )
->invokeMethod( 'set_container', [ ContainerInterface::class ] );
foreach ( $this->service_providers as $service_provider_class ) {
$service_provider = new $service_provider_class();
$implements = class_implements( $service_provider );
if ( array_key_exists( Conditional::class, $implements ) && ! $service_provider->is_needed() ) {
continue;
}
$this->container->addServiceProvider( $service_provider );
}
}
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
*
* @throws NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws ContainerExceptionInterface Error while retrieving the entry.
*
* @return mixed Entry.
*/
public function get( $id ) {
return $this->container->get( $id );
}
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* `has($id)` returning true does not mean that `get($id)` will not throw an exception.
* It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
*
* @param string $id Identifier of the entry to look for.
*
* @return bool
*/
public function has( $id ) {
return $this->container->has( $id );
}
}
Database/Migrations/CustomOrderTable/CLIRunner.php 0000644 00000070472 15153704477 0016143 0 ustar 00 <?php
namespace Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use WP_CLI;
/**
* CLI tool for migrating order data to/from custom table.
*
* Credits https://github.com/liquidweb/woocommerce-custom-orders-table/blob/develop/includes/class-woocommerce-custom-orders-table-cli.php.
*
* Class CLIRunner
*/
class CLIRunner {
/**
* CustomOrdersTableController instance.
*
* @var CustomOrdersTableController
*/
private $controller;
/**
* DataSynchronizer instance.
*
* @var DataSynchronizer;
*/
private $synchronizer;
/**
* PostsToOrdersMigrationController instance.
*
* @var PostsToOrdersMigrationController
*/
private $post_to_cot_migrator;
/**
* Init method, invoked by DI container.
*
* @param CustomOrdersTableController $controller Instance.
* @param DataSynchronizer $synchronizer Instance.
* @param PostsToOrdersMigrationController $posts_to_orders_migration_controller Instance.
*
* @internal
*/
final public function init( CustomOrdersTableController $controller, DataSynchronizer $synchronizer, PostsToOrdersMigrationController $posts_to_orders_migration_controller ) {
$this->controller = $controller;
$this->synchronizer = $synchronizer;
$this->post_to_cot_migrator = $posts_to_orders_migration_controller;
}
/**
* Registers commands for CLI.
*/
public function register_commands() {
WP_CLI::add_command( 'wc cot count_unmigrated', array( $this, 'count_unmigrated' ) );
WP_CLI::add_command( 'wc cot migrate', array( $this, 'migrate' ) );
WP_CLI::add_command( 'wc cot sync', array( $this, 'sync' ) );
WP_CLI::add_command( 'wc cot verify_cot_data', array( $this, 'verify_cot_data' ) );
WP_CLI::add_command( 'wc cot enable', array( $this, 'enable' ) );
WP_CLI::add_command( 'wc cot disable', array( $this, 'disable' ) );
}
/**
* Check if the COT feature is enabled.
*
* @param bool $log Optionally log a error message.
*
* @return bool Whether the COT feature is enabled.
*/
private function is_enabled( $log = true ) : bool {
if ( ! $this->controller->custom_orders_table_usage_is_enabled() ) {
if ( $log ) {
WP_CLI::log(
sprintf(
// translators: %s - link to testing instructions webpage.
__( 'Custom order table usage is not enabled. If you are testing, you can enable it by following the testing instructions in %s', 'woocommerce' ),
'https://github.com/woocommerce/woocommerce/wiki/High-Performance-Order-Storage-Upgrade-Recipe-Book'
)
);
}
}
return $this->controller->custom_orders_table_usage_is_enabled();
}
/**
* Count how many orders have yet to be migrated into the custom orders table.
*
* ## EXAMPLES
*
* wp wc cot count_unmigrated
*
* @param array $args Positional arguments passed to the command.
*
* @param array $assoc_args Associative arguments (options) passed to the command.
*
* @return int The number of orders to be migrated.*
*/
public function count_unmigrated( $args = array(), $assoc_args = array() ) : int {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$order_count = $this->synchronizer->get_current_orders_pending_sync_count();
$assoc_args = wp_parse_args(
$assoc_args,
array(
'log' => true,
)
);
if ( isset( $assoc_args['log'] ) && $assoc_args['log'] ) {
WP_CLI::log(
sprintf(
/* Translators: %1$d is the number of orders to be synced. */
_n(
'There is %1$d order to be synced.',
'There are %1$d orders to be synced.',
$order_count,
'woocommerce'
),
$order_count
)
);
}
return (int) $order_count;
}
/**
* Sync order data between the custom order tables and the core WordPress post tables.
*
* ## OPTIONS
*
* [--batch-size=<batch-size>]
* : The number of orders to process in each batch.
* ---
* default: 500
* ---
*
* ## EXAMPLES
*
* wp wc cot sync --batch-size=500
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
*/
public function sync( $args = array(), $assoc_args = array() ) {
if ( ! $this->synchronizer->check_orders_table_exists() ) {
WP_CLI::warning( __( 'Custom order tables does not exist, creating...', 'woocommerce' ) );
$this->synchronizer->create_database_tables();
if ( $this->synchronizer->check_orders_table_exists() ) {
WP_CLI::success( __( 'Custom order tables were created successfully.', 'woocommerce' ) );
} else {
WP_CLI::error( __( 'Custom order tables could not be created.', 'woocommerce' ) );
}
}
$order_count = $this->count_unmigrated();
// Abort if there are no orders to migrate.
if ( ! $order_count ) {
return WP_CLI::warning( __( 'There are no orders to sync, aborting.', 'woocommerce' ) );
}
$assoc_args = wp_parse_args(
$assoc_args,
array(
'batch-size' => 500,
)
);
$batch_size = ( (int) $assoc_args['batch-size'] ) === 0 ? 500 : (int) $assoc_args['batch-size'];
$progress = WP_CLI\Utils\make_progress_bar( 'Order Data Sync', $order_count / $batch_size );
$processed = 0;
$batch_count = 1;
$total_time = 0;
$orders_remaining = true;
while ( $order_count > 0 || $orders_remaining ) {
$remaining_count = $order_count;
WP_CLI::debug(
sprintf(
/* Translators: %1$d is the batch number and %2$d is the batch size. */
__( 'Beginning batch #%1$d (%2$d orders/batch).', 'woocommerce' ),
$batch_count,
$batch_size
)
);
$batch_start_time = microtime( true );
$order_ids = $this->synchronizer->get_next_batch_to_process( $batch_size );
if ( count( $order_ids ) ) {
$this->synchronizer->process_batch( $order_ids );
}
$processed += count( $order_ids );
$batch_total_time = microtime( true ) - $batch_start_time;
WP_CLI::debug(
sprintf(
// Translators: %1$d is the batch number, %2$d is the number of processed orders and %3$d is the execution time in seconds.
__( 'Batch %1$d (%2$d orders) completed in %3$d seconds', 'woocommerce' ),
$batch_count,
count( $order_ids ),
$batch_total_time
)
);
$batch_count ++;
$total_time += $batch_total_time;
$progress->tick();
$orders_remaining = count( $this->synchronizer->get_next_batch_to_process( 1 ) ) > 0;
$order_count = $remaining_count - $batch_size;
}
$progress->finish();
// Issue a warning if no orders were migrated.
if ( ! $processed ) {
return WP_CLI::warning( __( 'No orders were synced.', 'woocommerce' ) );
}
WP_CLI::log( __( 'Sync completed.', 'woocommerce' ) );
return WP_CLI::success(
sprintf(
/* Translators: %1$d is the number of migrated orders and %2$d is the execution time in seconds. */
_n(
'%1$d order was synced in %2$d seconds.',
'%1$d orders were synced in %2$d seconds.',
$processed,
'woocommerce'
),
$processed,
$total_time
)
);
}
/**
* [Deprecated] Use `wp wc cot sync` instead.
* Copy order data into the postmeta table.
*
* Note that this could dramatically increase the size of your postmeta table, but is recommended
* if you wish to stop using the custom orders table plugin.
*
* ## OPTIONS
*
* [--batch-size=<batch-size>]
* : The number of orders to process in each batch. Passing a value of 0 will disable batching.
* ---
* default: 500
* ---
*
* ## EXAMPLES
*
* # Copy all order data into the post meta table, 500 posts at a time.
* wp wc cot backfill --batch-size=500
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
*/
public function migrate( $args = array(), $assoc_args = array() ) {
WP_CLI::log( __( 'Migrate command is deprecated. Please use `sync` instead.', 'woocommerce' ) );
}
/**
* Verify migrated order data with original posts data.
*
* ## OPTIONS
*
* [--batch-size=<batch-size>]
* : The number of orders to verify in each batch.
* ---
* default: 500
* ---
*
* [--start-from=<order_id>]
* : Order ID to start from.
* ---
* default: 0
* ---
*
* [--end-at=<order_id>]
* : Order ID to end at.
* ---
* default: -1
* ---
*
* [--verbose]
* : Whether to output errors as they happen in batch, or output them all together at the end.
* ---
* default: false
* ---
*
* [--order-types]
* : Comma seperated list of order types that needs to be verified. For example, --order-types=shop_order,shop_order_refund
* ---
* default: Output of function `wc_get_order_types( 'cot-migration' )`
*
* [--re-migrate]
* : Attempt to re-migrate orders that failed verification. You should only use this option when you have never run the site with HPOS as authoritative source of order data yet, or you have manually checked the reported errors, otherwise, you risk stale data overwriting the more recent data.
* This option can only be enabled when --verbose flag is also set.
* default: false
*
* ## EXAMPLES
*
* # Verify migrated order data, 500 orders at a time.
* wp wc cot verify_cot_data --batch-size=500 --start-from=0 --end-at=10000
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
*/
public function verify_cot_data( $args = array(), $assoc_args = array() ) {
global $wpdb;
if ( ! $this->synchronizer->check_orders_table_exists() ) {
WP_CLI::error( __( 'Orders table does not exist.', 'woocommerce' ) );
return;
}
$assoc_args = wp_parse_args(
$assoc_args,
array(
'batch-size' => 500,
'start-from' => 0,
'end-at' => - 1,
'verbose' => false,
'order-types' => '',
're-migrate' => false,
)
);
$batch_count = 1;
$total_time = 0;
$failed_ids = array();
$processed = 0;
$order_id_start = (int) $assoc_args['start-from'];
$order_id_end = (int) $assoc_args['end-at'];
$order_id_end = -1 === $order_id_end ? PHP_INT_MAX : $order_id_end;
$batch_size = ( (int) $assoc_args['batch-size'] ) === 0 ? 500 : (int) $assoc_args['batch-size'];
$verbose = (bool) $assoc_args['verbose'];
$order_types = wc_get_order_types( 'cot-migration' );
$remigrate = (bool) $assoc_args['re-migrate'];
if ( ! empty( $assoc_args['order-types'] ) ) {
$passed_order_types = array_map( 'trim', explode( ',', $assoc_args['order-types'] ) );
$order_types = array_intersect( $order_types, $passed_order_types );
}
if ( 0 === count( $order_types ) ) {
return WP_CLI::error(
sprintf(
/* Translators: %s is the comma seperated list of order types. */
__( 'Passed order type does not match any registered order types. Following order types are registered: %s', 'woocommerce' ),
implode( ',', wc_get_order_types( 'cot-migration' ) )
)
);
}
$order_types_pl = implode( ',', array_fill( 0, count( $order_types ), '%s' ) );
$order_count = $this->get_verify_order_count( $order_id_start, $order_id_end, $order_types, false );
$progress = WP_CLI\Utils\make_progress_bar( 'Order Data Verification', $order_count / $batch_size );
if ( ! $order_count ) {
return WP_CLI::warning( __( 'There are no orders to verify, aborting.', 'woocommerce' ) );
}
while ( $order_count > 0 ) {
WP_CLI::debug(
sprintf(
/* Translators: %1$d is the batch number, %2$d is the batch size. */
__( 'Beginning verification for batch #%1$d (%2$d orders/batch).', 'woocommerce' ),
$batch_count,
$batch_size
)
);
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inputs are prepared.
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE post_type in ( $order_types_pl ) AND ID >= %d AND ID <= %d ORDER BY ID ASC LIMIT %d",
array_merge(
$order_types,
array(
$order_id_start,
$order_id_end,
$batch_size,
)
)
)
);
// phpcs:enable
$batch_start_time = microtime( true );
$failed_ids_in_current_batch = $this->post_to_cot_migrator->verify_migrated_orders( $order_ids );
$failed_ids_in_current_batch = $this->verify_meta_data( $order_ids, $failed_ids_in_current_batch );
$failed_ids = $verbose ? array() : $failed_ids + $failed_ids_in_current_batch;
$processed += count( $order_ids );
$batch_total_time = microtime( true ) - $batch_start_time;
$batch_count ++;
$total_time += $batch_total_time;
if ( $verbose && count( $failed_ids_in_current_batch ) > 0 ) {
$errors = wp_json_encode( $failed_ids_in_current_batch, JSON_PRETTY_PRINT );
WP_CLI::warning(
sprintf(
/* Translators: %1$d is number of errors and %2$s is the formatted array of order IDs. */
_n(
'%1$d error found: %2$s. Please review the error above.',
'%1$d errors found: %2$s. Please review the errors above.',
count( $failed_ids_in_current_batch ),
'woocommerce'
),
count( $failed_ids_in_current_batch ),
$errors
)
);
if ( $remigrate ) {
WP_CLI::warning(
sprintf(
__( 'Attempting to remigrate...', 'woocommerce' )
)
);
$failed_ids = array_keys( $failed_ids_in_current_batch );
$this->synchronizer->process_batch( $failed_ids );
$errors_in_remigrate_batch = $this->post_to_cot_migrator->verify_migrated_orders( $failed_ids );
$errors_in_remigrate_batch = $this->verify_meta_data( $failed_ids, $errors_in_remigrate_batch );
if ( count( $errors_in_remigrate_batch ) > 0 ) {
$formatted_errors = wp_json_encode( $errors_in_remigrate_batch, JSON_PRETTY_PRINT );
WP_CLI::warning(
sprintf(
/* Translators: %1$d is number of errors and %2$s is the formatted array of order IDs. */
_n(
'%1$d error found: %2$s when re-migrating order. Please review the error above.',
'%1$d errors found: %2$s when re-migrating orders. Please review the errors above.',
count( $errors_in_remigrate_batch ),
'woocommerce'
),
count( $errors_in_remigrate_batch ),
$formatted_errors
)
);
} else {
WP_CLI::warning( 'Re-migration successful.', 'woocommerce' );
}
}
}
$progress->tick();
WP_CLI::debug(
sprintf(
/* Translators: %1$d is the batch number, %2$d is time taken to process batch. */
__( 'Batch %1$d (%2$d orders) completed in %3$d seconds.', 'woocommerce' ),
$batch_count,
count( $order_ids ),
$batch_total_time
)
);
$order_id_start = max( $order_ids ) + 1;
$remaining_count = $this->get_verify_order_count( $order_id_start, $order_id_end, $order_types, false );
if ( $remaining_count === $order_count ) {
return WP_CLI::error( __( 'Infinite loop detected, aborting. No errors found.', 'woocommerce' ) );
}
$order_count = $remaining_count;
}
$progress->finish();
WP_CLI::log( __( 'Verification completed.', 'woocommerce' ) );
if ( $verbose ) {
return;
}
if ( 0 === count( $failed_ids ) ) {
return WP_CLI::success(
sprintf(
/* Translators: %1$d is the number of migrated orders and %2$d is time taken. */
_n(
'%1$d order was verified in %2$d seconds.',
'%1$d orders were verified in %2$d seconds.',
$processed,
'woocommerce'
),
$processed,
$total_time
)
);
} else {
$errors = wp_json_encode( $failed_ids, JSON_PRETTY_PRINT );
return WP_CLI::error(
sprintf(
'%1$s %2$s',
sprintf(
/* Translators: %1$d is the number of migrated orders and %2$d is the execution time in seconds. */
_n(
'%1$d order was verified in %2$d seconds.',
'%1$d orders were verified in %2$d seconds.',
$processed,
'woocommerce'
),
$processed,
$total_time
),
sprintf(
/* Translators: %1$d is number of errors and %2$s is the formatted array of order IDs. */
_n(
'%1$d error found: %2$s. Please review the error above.',
'%1$d errors found: %2$s. Please review the errors above.',
count( $failed_ids ),
'woocommerce'
),
count( $failed_ids ),
$errors
)
)
);
}
}
/**
* Helper method to get count for orders needing verification.
*
* @param int $order_id_start Order ID to start from.
* @param int $order_id_end Order ID to end at.
* @param array $order_types List of order types to verify.
* @param bool $log Whether to also log an error message.
*
* @return int Order count.
*/
private function get_verify_order_count( int $order_id_start, int $order_id_end, array $order_types, bool $log = true ) : int {
global $wpdb;
$order_types_placeholder = implode( ',', array_fill( 0, count( $order_types ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inputs are prepared.
$order_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts WHERE post_type in ($order_types_placeholder) AND ID >= %d AND ID <= %d",
array_merge(
$order_types,
array(
$order_id_start,
$order_id_end,
)
)
)
);
// phpcs:enable
if ( $log ) {
WP_CLI::log(
sprintf(
/* Translators: %1$d is the number of orders to be verified. */
_n(
'There is %1$d order to be verified.',
'There are %1$d orders to be verified.',
$order_count,
'woocommerce'
),
$order_count
)
);
}
return $order_count;
}
/**
* Verify meta data as part of verifying the order object.
*
* @param array $order_ids Order IDs.
* @param array $failed_ids Array for storing failed IDs.
*
* @return array Failed IDs with meta details.
*/
private function verify_meta_data( array $order_ids, array $failed_ids ) : array {
global $wpdb;
if ( ! count( $order_ids ) ) {
return array();
}
$excluded_columns = $this->post_to_cot_migrator->get_migrated_meta_keys();
$excluded_columns_placeholder = implode( ', ', array_fill( 0, count( $excluded_columns ), '%s' ) );
$order_ids_placeholder = implode( ', ', array_fill( 0, count( $order_ids ), '%d' ) );
$meta_table = OrdersTableDataStore::get_meta_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table names are hardcoded, orders_ids and excluded_columns are prepared.
$query = $wpdb->prepare(
"
SELECT {$wpdb->postmeta}.post_id as entity_id, {$wpdb->postmeta}.meta_key, {$wpdb->postmeta}.meta_value
FROM $wpdb->postmeta
WHERE
{$wpdb->postmeta}.post_id in ( $order_ids_placeholder ) AND
{$wpdb->postmeta}.meta_key not in ( $excluded_columns_placeholder )
ORDER BY {$wpdb->postmeta}.post_id ASC, {$wpdb->postmeta}.meta_key ASC;
",
array_merge(
$order_ids,
$excluded_columns
)
);
$source_data = $wpdb->get_results( $query, ARRAY_A );
// phpcs:enable
$normalized_source_data = $this->normalize_raw_meta_data( $source_data );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table names are hardcoded, orders_ids and excluded_columns are prepared.
$migrated_query = $wpdb->prepare(
"
SELECT $meta_table.order_id as entity_id, $meta_table.meta_key, $meta_table.meta_value
FROM $meta_table
WHERE
$meta_table.order_id in ( $order_ids_placeholder )
ORDER BY $meta_table.order_id ASC, $meta_table.meta_key ASC;
",
$order_ids
);
$migrated_data = $wpdb->get_results( $migrated_query, ARRAY_A );
// phpcs:enable
$normalized_migrated_meta_data = $this->normalize_raw_meta_data( $migrated_data );
foreach ( $normalized_source_data as $order_id => $meta ) {
foreach ( $meta as $meta_key => $values ) {
$migrated_meta_values = isset( $normalized_migrated_meta_data[ $order_id ][ $meta_key ] ) ? $normalized_migrated_meta_data[ $order_id ][ $meta_key ] : array();
$diff = array_diff( $values, $migrated_meta_values );
if ( count( $diff ) ) {
if ( ! isset( $failed_ids[ $order_id ] ) ) {
$failed_ids[ $order_id ] = array();
}
$failed_ids[ $order_id ][] = array(
'order_id' => $order_id,
'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a meta query.
'orig_meta_values' => $values,
'new_meta_values' => $migrated_meta_values,
);
}
}
}
return $failed_ids;
}
/**
* Helper method to normalize response from meta queries into order_id > meta_key > meta_values.
*
* @param array $data Data fetched from meta queries.
*
* @return array Normalized data.
*/
private function normalize_raw_meta_data( array $data ) : array {
$clubbed_data = array();
foreach ( $data as $row ) {
if ( ! isset( $clubbed_data[ $row['entity_id'] ] ) ) {
$clubbed_data[ $row['entity_id'] ] = array();
}
if ( ! isset( $clubbed_data[ $row['entity_id'] ][ $row['meta_key'] ] ) ) {
$clubbed_data[ $row['entity_id'] ][ $row['meta_key'] ] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a meta query.
}
$clubbed_data[ $row['entity_id'] ][ $row['meta_key'] ][] = $row['meta_value'];
}
return $clubbed_data;
}
/**
* Set custom order tables (HPOS) to authoritative if: 1). HPOS and posts tables are in sync, or, 2). This is a new shop (in this case also create tables). Additionally, all installed WC plugins should be compatible.
*
* ## OPTIONS
*
* [--for-new-shop]
* : Enable only if this is a new shop, irrespective of whether tables are in sync.
* ---
* default: false
* ---
*
* [--with-sync]
* : Also enables sync (if it's currently not enabled).
* ---
* default: false
* ---
*
* ### EXAMPLES
*
* # Enable HPOS on new shops.
* wp wc cot enable --for-new-shop
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
*
* @return void
*/
public function enable( array $args = array(), array $assoc_args = array() ) {
$assoc_args = wp_parse_args(
$assoc_args,
array(
'for-new-shop' => false,
'with-sync' => false,
)
);
$enable_hpos = true;
WP_CLI::log( __( 'Running pre-enable checks...', 'woocommerce' ) );
$is_new_shop = \WC_Install::is_new_install();
if ( $assoc_args['for-new-shop'] && ! $is_new_shop ) {
WP_CLI::error( __( '[Failed] This is not a new shop, but --for-new-shop flag was passed.', 'woocommerce' ) );
}
/** Feature controller instance @var FeaturesController $feature_controller */
$feature_controller = wc_get_container()->get( FeaturesController::class );
$plugin_info = $feature_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
if ( count( array_merge( $plugin_info['uncertain'], $plugin_info['incompatible'] ) ) > 0 ) {
WP_CLI::warning( __( '[Failed] Some installed plugins are incompatible. Please review the plugins by going to WooCommerce > Settings > Advanced > Features and see the "Order data storage" section.', 'woocommerce' ) );
$enable_hpos = false;
}
/** DataSynchronizer instance @var DataSynchronizer $data_synchronizer */
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
$pending_orders = $data_synchronizer->get_total_pending_count();
$table_exists = $data_synchronizer->check_orders_table_exists();
if ( ! $table_exists ) {
WP_CLI::warning( __( 'Orders table does not exist. Creating...', 'woocommerce' ) );
if ( $is_new_shop || 0 === $pending_orders ) {
$data_synchronizer->create_database_tables();
if ( $data_synchronizer->check_orders_table_exists() ) {
WP_CLI::log( __( 'Orders table created.', 'woocommerce' ) );
$table_exists = true;
} else {
WP_CLI::warning( __( '[Failed] Orders table could not be created.', 'woocommerce' ) );
$enable_hpos = false;
}
} else {
WP_CLI::warning( __( '[Failed] The orders table does not exist and this is not a new shop. Please create the table by going to WooCommerce > Settings > Advanced > Features and enabling sync.', 'woocommerce' ) );
$enable_hpos = false;
}
}
if ( $pending_orders > 0 ) {
WP_CLI::warning(
sprintf(
// translators: %s is the command to run (wp wc cot sync).
__( '[Failed] There are orders pending sync. Please run `%s` to sync pending orders.', 'woocommerce' ),
'wp wc cot sync',
)
);
$enable_hpos = false;
}
if ( $assoc_args['with-sync'] && $table_exists ) {
if ( $data_synchronizer->data_sync_is_enabled() ) {
WP_CLI::warning( __( 'Sync is already enabled.', 'woocommerce' ) );
} else {
$feature_controller->change_feature_enable( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, true );
WP_CLI::success( __( 'Sync enabled.', 'woocommerce' ) );
}
}
if ( ! $enable_hpos ) {
WP_CLI::error( __( 'HPOS pre-checks failed, please see the errors above', 'woocommerce' ) );
return;
}
/** CustomOrdersTableController instance @var CustomOrdersTableController $cot_status */
$cot_status = wc_get_container()->get( CustomOrdersTableController::class );
if ( $cot_status->custom_orders_table_usage_is_enabled() ) {
WP_CLI::warning( __( 'HPOS is already enabled.', 'woocommerce' ) );
} else {
$feature_controller->change_feature_enable( 'custom_order_tables', true );
if ( $cot_status->custom_orders_table_usage_is_enabled() ) {
WP_CLI::success( __( 'HPOS enabled.', 'woocommerce' ) );
} else {
WP_CLI::error( __( 'HPOS could not be enabled.', 'woocommerce' ) );
}
}
}
/**
* Disables custom order tables (HPOS) and posts to authoritative if HPOS and post tables are in sync.
*
* ## OPTIONS
*
* [--with-sync]
* : Also disables sync (if it's currently enabled).
* ---
* default: false
* ---
*
* ### EXAMPLES
*
* # Disable HPOS.
* wp wc cot disable
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
*/
public function disable( $args, $assoc_args ) {
$assoc_args = wp_parse_args(
$assoc_args,
array(
'with-sync' => false,
)
);
WP_CLI::log( __( 'Running pre-disable checks...', 'woocommerce' ) );
/** DataSynchronizer instance @var DataSynchronizer $data_synchronizer */
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
$pending_orders = $data_synchronizer->get_total_pending_count();
if ( $pending_orders > 0 ) {
return WP_CLI::error(
sprintf(
// translators: %s is the command to run (wp wc cot sync).
__( '[Failed] There are orders pending sync. Please run `%s` to sync pending orders.', 'woocommerce' ),
'wp wc cot sync',
)
);
}
/** FeaturesController instance @var FeaturesController $feature_controller */
$feature_controller = wc_get_container()->get( FeaturesController::class );
/** CustomOrdersTableController instance @var CustomOrdersTableController $cot_status */
$cot_status = wc_get_container()->get( CustomOrdersTableController::class );
if ( ! $cot_status->custom_orders_table_usage_is_enabled() ) {
WP_CLI::warning( __( 'HPOS is already disabled.', 'woocommerce' ) );
} else {
$feature_controller->change_feature_enable( 'custom_order_tables', false );
if ( $cot_status->custom_orders_table_usage_is_enabled() ) {
return WP_CLI::warning( __( 'HPOS could not be disabled.', 'woocommerce' ) );
} else {
WP_CLI::success( __( 'HPOS disabled.', 'woocommerce' ) );
}
}
if ( $assoc_args['with-sync'] ) {
if ( ! $data_synchronizer->data_sync_is_enabled() ) {
return WP_CLI::warning( __( 'Sync is already disabled.', 'woocommerce' ) );
}
$feature_controller->change_feature_enable( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, false );
if ( $data_synchronizer->data_sync_is_enabled() ) {
return WP_CLI::warning( __( 'Sync could not be disabled.', 'woocommerce' ) );
} else {
WP_CLI::success( __( 'Sync disabled.', 'woocommerce' ) );
}
}
}
}
Database/Migrations/CustomOrderTable/PostMetaToOrderMetaMigrator.php 0000644 00000003604 15153704477 0021702 0 ustar 00 <?php
/**
* Migration class for migrating from WPPostMeta to OrderMeta table.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MetaToMetaTableMigrator;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* Helper class to migrate records from the WordPress post meta table
* to the custom orders meta table.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class PostMetaToOrderMetaMigrator extends MetaToMetaTableMigrator {
/**
* List of meta keys to exclude from migration.
*
* @var array
*/
private $excluded_columns;
/**
* PostMetaToOrderMetaMigrator constructor.
*
* @param array $excluded_columns List of meta keys to exclude from migration.
*/
public function __construct( $excluded_columns ) {
$this->excluded_columns = $excluded_columns;
parent::__construct();
}
/**
* Generate config for meta data migration.
*
* @return array Meta data migration config.
*/
protected function get_meta_config(): array {
global $wpdb;
return array(
'source' => array(
'meta' => array(
'table_name' => $wpdb->postmeta,
'entity_id_column' => 'post_id',
'meta_id_column' => 'meta_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
),
'entity' => array(
'table_name' => $wpdb->posts,
'source_id_column' => 'ID',
'id_column' => 'ID',
),
'excluded_keys' => $this->excluded_columns,
),
'destination' => array(
'meta' => array(
'table_name' => OrdersTableDataStore::get_meta_table_name(),
'entity_id_column' => 'order_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_type' => 'int',
'meta_id_column' => 'id',
),
),
);
}
}
Database/Migrations/CustomOrderTable/PostToOrderAddressTableMigrator.php 0000644 00000010634 15153704477 0022543 0 ustar 00 <?php
/**
* Class for WPPost to wc_order_address table migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MetaToCustomTableMigrator;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* Helper class to migrate records from the WordPress post table
* to the custom order addresses table.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class PostToOrderAddressTableMigrator extends MetaToCustomTableMigrator {
/**
* Type of addresses being migrated; 'billing' or 'shipping'.
*
* @var $type
*/
protected $type;
/**
* PostToOrderAddressTableMigrator constructor.
*
* @param string $type Type of address being migrated; 'billing' or 'shipping'.
*/
public function __construct( $type ) {
$this->type = $type;
parent::__construct();
}
/**
* Get schema config for wp_posts and wc_order_address table.
*
* @return array Config.
*/
protected function get_schema_config(): array {
global $wpdb;
return array(
'source' => array(
'entity' => array(
'table_name' => $wpdb->posts,
'meta_rel_column' => 'ID',
'destination_rel_column' => 'ID',
'primary_key' => 'ID',
),
'meta' => array(
'table_name' => $wpdb->postmeta,
'meta_id_column' => 'meta_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_column' => 'post_id',
),
),
'destination' => array(
'table_name' => OrdersTableDataStore::get_addresses_table_name(),
'source_rel_column' => 'order_id',
'primary_key' => 'id',
'primary_key_type' => 'int',
),
);
}
/**
* Get columns config.
*
* @return \string[][] Config.
*/
protected function get_core_column_mapping(): array {
$type = $this->type;
return array(
'ID' => array(
'type' => 'int',
'destination' => 'order_id',
),
'type' => array(
'type' => 'string',
'destination' => 'address_type',
'select_clause' => "'$type'",
),
);
}
/**
* Get meta data config.
*
* @return \string[][] Config.
*/
public function get_meta_column_config(): array {
$type = $this->type;
return array(
"_{$type}_first_name" => array(
'type' => 'string',
'destination' => 'first_name',
),
"_{$type}_last_name" => array(
'type' => 'string',
'destination' => 'last_name',
),
"_{$type}_company" => array(
'type' => 'string',
'destination' => 'company',
),
"_{$type}_address_1" => array(
'type' => 'string',
'destination' => 'address_1',
),
"_{$type}_address_2" => array(
'type' => 'string',
'destination' => 'address_2',
),
"_{$type}_city" => array(
'type' => 'string',
'destination' => 'city',
),
"_{$type}_state" => array(
'type' => 'string',
'destination' => 'state',
),
"_{$type}_postcode" => array(
'type' => 'string',
'destination' => 'postcode',
),
"_{$type}_country" => array(
'type' => 'string',
'destination' => 'country',
),
"_{$type}_email" => array(
'type' => 'string',
'destination' => 'email',
),
"_{$type}_phone" => array(
'type' => 'string',
'destination' => 'phone',
),
);
}
/**
* Additional WHERE clause to only fetch the addresses of the current type.
*
* @param array $entity_ids The ids of the entities being inserted or updated.
* @return string The additional string for the WHERE clause.
*/
protected function get_additional_where_clause_for_get_data_to_insert_or_update( array $entity_ids ): string {
return "AND destination.`address_type` = '{$this->type}'";
}
/**
* Helper function to generate where clause for fetching data for verification.
*
* @param array $source_ids Array of IDs from source table.
*
* @return string WHERE clause.
*/
protected function get_where_clause_for_verification( $source_ids ) {
global $wpdb;
$query = parent::get_where_clause_for_verification( $source_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $query should already be prepared, $schema_config is hardcoded.
return $wpdb->prepare( "$query AND {$this->schema_config['destination']['table_name']}.address_type = %s", $this->type );
}
}
Database/Migrations/CustomOrderTable/PostToOrderOpTableMigrator.php 0000644 00000007365 15153704477 0021543 0 ustar 00 <?php
/**
* Class for WPPost to wc_order_operational_details migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MetaToCustomTableMigrator;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* Helper class to migrate records from the WordPress post table
* to the custom order operations table.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class PostToOrderOpTableMigrator extends MetaToCustomTableMigrator {
/**
* Get schema config for wp_posts and wc_order_operational_detail table.
*
* @return array Config.
*/
protected function get_schema_config(): array {
global $wpdb;
return array(
'source' => array(
'entity' => array(
'table_name' => $wpdb->posts,
'meta_rel_column' => 'ID',
'destination_rel_column' => 'ID',
'primary_key' => 'ID',
),
'meta' => array(
'table_name' => $wpdb->postmeta,
'meta_id_column' => 'meta_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_column' => 'post_id',
),
),
'destination' => array(
'table_name' => OrdersTableDataStore::get_operational_data_table_name(),
'source_rel_column' => 'order_id',
'primary_key' => 'id',
'primary_key_type' => 'int',
),
);
}
/**
* Get columns config.
*
* @return \string[][] Config.
*/
protected function get_core_column_mapping(): array {
return array(
'ID' => array(
'type' => 'int',
'destination' => 'order_id',
),
);
}
/**
* Get meta data config.
*
* @return \string[][] Config.
*/
public function get_meta_column_config(): array {
return array(
'_created_via' => array(
'type' => 'string',
'destination' => 'created_via',
),
'_order_version' => array(
'type' => 'string',
'destination' => 'woocommerce_version',
),
'_prices_include_tax' => array(
'type' => 'bool',
'destination' => 'prices_include_tax',
),
'_recorded_coupon_usage_counts' => array(
'type' => 'bool',
'destination' => 'coupon_usages_are_counted',
),
'_download_permissions_granted' => array(
'type' => 'bool',
'destination' => 'download_permission_granted',
),
'_cart_hash' => array(
'type' => 'string',
'destination' => 'cart_hash',
),
'_new_order_email_sent' => array(
'type' => 'bool',
'destination' => 'new_order_email_sent',
),
'_order_key' => array(
'type' => 'string',
'destination' => 'order_key',
),
'_order_stock_reduced' => array(
'type' => 'bool',
'destination' => 'order_stock_reduced',
),
'_date_paid' => array(
'type' => 'date_epoch',
'destination' => 'date_paid_gmt',
),
'_date_completed' => array(
'type' => 'date_epoch',
'destination' => 'date_completed_gmt',
),
'_order_shipping_tax' => array(
'type' => 'decimal',
'destination' => 'shipping_tax_amount',
),
'_order_shipping' => array(
'type' => 'decimal',
'destination' => 'shipping_total_amount',
),
'_cart_discount_tax' => array(
'type' => 'decimal',
'destination' => 'discount_tax_amount',
),
'_cart_discount' => array(
'type' => 'decimal',
'destination' => 'discount_total_amount',
),
'_recorded_sales' => array(
'type' => 'bool',
'destination' => 'recorded_sales',
),
);
}
}
Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php 0000644 00000007152 15153704477 0021236 0 ustar 00 <?php
/**
* Class for WPPost To order table migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MetaToCustomTableMigrator;
/**
* Helper class to migrate records from the WordPress post table
* to the custom order table (and only that table - PostsToOrdersMigrationController
* is used for fully migrating orders).
*/
class PostToOrderTableMigrator extends MetaToCustomTableMigrator {
/**
* Get schema config for wp_posts and wc_order table.
*
* @return array Config.
*/
protected function get_schema_config(): array {
global $wpdb;
$table_names = array(
'orders' => $wpdb->prefix . 'wc_orders',
'addresses' => $wpdb->prefix . 'wc_order_addresses',
'op_data' => $wpdb->prefix . 'wc_order_operational_data',
'meta' => $wpdb->prefix . 'wc_orders_meta',
);
return array(
'source' => array(
'entity' => array(
'table_name' => $wpdb->posts,
'meta_rel_column' => 'ID',
'destination_rel_column' => 'ID',
'primary_key' => 'ID',
),
'meta' => array(
'table_name' => $wpdb->postmeta,
'meta_id_column' => 'meta_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_column' => 'post_id',
),
),
'destination' => array(
'table_name' => $table_names['orders'],
'source_rel_column' => 'id',
'primary_key' => 'id',
'primary_key_type' => 'int',
),
);
}
/**
* Get columns config.
*
* @return \string[][] Config.
*/
protected function get_core_column_mapping(): array {
return array(
'ID' => array(
'type' => 'int',
'destination' => 'id',
),
'post_status' => array(
'type' => 'string',
'destination' => 'status',
),
'post_date_gmt' => array(
'type' => 'date',
'destination' => 'date_created_gmt',
),
'post_modified_gmt' => array(
'type' => 'date',
'destination' => 'date_updated_gmt',
),
'post_parent' => array(
'type' => 'int',
'destination' => 'parent_order_id',
),
'post_type' => array(
'type' => 'string',
'destination' => 'type',
),
'post_excerpt' => array(
'type' => 'string',
'destination' => 'customer_note',
),
);
}
/**
* Get meta data config.
*
* @return \string[][] Config.
*/
public function get_meta_column_config(): array {
return array(
'_order_currency' => array(
'type' => 'string',
'destination' => 'currency',
),
'_order_tax' => array(
'type' => 'decimal',
'destination' => 'tax_amount',
),
'_order_total' => array(
'type' => 'decimal',
'destination' => 'total_amount',
),
'_customer_user' => array(
'type' => 'int',
'destination' => 'customer_id',
),
'_billing_email' => array(
'type' => 'string',
'destination' => 'billing_email',
),
'_payment_method' => array(
'type' => 'string',
'destination' => 'payment_method',
),
'_payment_method_title' => array(
'type' => 'string',
'destination' => 'payment_method_title',
),
'_customer_ip_address' => array(
'type' => 'string',
'destination' => 'ip_address',
),
'_customer_user_agent' => array(
'type' => 'string',
'destination' => 'user_agent',
),
'_transaction_id' => array(
'type' => 'string',
'destination' => 'transaction_id',
),
);
}
}
Database/Migrations/CustomOrderTable/PostsToOrdersMigrationController.php 0000644 00000020734 15153704477 0023046 0 ustar 00 <?php
/**
* Class for implementing migration from wp_posts and wp_postmeta to custom order tables.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Utilities\ArrayUtil;
/**
* This is the main class used to perform the complete migration of orders
* from the posts table to the custom orders table.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class PostsToOrdersMigrationController {
/**
* Error logger for migration errors.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* Array of objects used to perform the migration.
*
* @var TableMigrator[]
*/
private $all_migrators;
/**
* The source name to use for logs.
*/
public const LOGS_SOURCE_NAME = 'posts-to-orders-migration';
/**
* PostsToOrdersMigrationController constructor.
*/
public function __construct() {
$this->all_migrators = array();
$this->all_migrators['order'] = new PostToOrderTableMigrator();
$this->all_migrators['order_address_billing'] = new PostToOrderAddressTableMigrator( 'billing' );
$this->all_migrators['order_address_shipping'] = new PostToOrderAddressTableMigrator( 'shipping' );
$this->all_migrators['order_operational_data'] = new PostToOrderOpTableMigrator();
$this->all_migrators['order_meta'] = new PostMetaToOrderMetaMigrator( $this->get_migrated_meta_keys() );
$this->error_logger = wc_get_logger();
}
/**
* Helper method to get migrated keys for all the tables in this controller.
*
* @return string[] Array of meta keys.
*/
public function get_migrated_meta_keys() {
$migrated_meta_keys = array();
foreach ( $this->all_migrators as $name => $migrator ) {
if ( method_exists( $migrator, 'get_meta_column_config' ) ) {
$migrated_meta_keys = array_merge( $migrated_meta_keys, $migrator->get_meta_column_config() );
}
}
return array_keys( $migrated_meta_keys );
}
/**
* Migrates a set of orders from the posts table to the custom orders tables.
*
* @param array $order_post_ids List of post IDs of the orders to migrate.
*/
public function migrate_orders( array $order_post_ids ): void {
$this->error_logger = WC()->call_function( 'wc_get_logger' );
$data = array();
try {
foreach ( $this->all_migrators as $name => $migrator ) {
$data[ $name ] = $migrator->fetch_sanitized_migration_data( $order_post_ids );
if ( ! empty( $data[ $name ]['errors'] ) ) {
$this->handle_migration_error( $order_post_ids, $data[ $name ]['errors'], null, null, $name );
return;
}
}
} catch ( \Exception $e ) {
$this->handle_migration_error( $order_post_ids, $data, $e, null, 'Fetching data' );
return;
}
$using_transactions = $this->maybe_start_transaction();
foreach ( $this->all_migrators as $name => $migrator ) {
$results = $migrator->process_migration_data( $data[ $name ] );
$errors = array_unique( $results['errors'] );
$exception = $results['exception'];
if ( null === $exception && empty( $errors ) ) {
continue;
}
$this->handle_migration_error( $order_post_ids, $errors, $exception, $using_transactions, $name );
return;
}
if ( $using_transactions ) {
$this->commit_transaction();
}
}
/**
* Log migration errors if any.
*
* @param array $order_post_ids List of post IDs of the orders to migrate.
* @param array $errors List of errors to log.
* @param \Exception|null $exception Exception to log.
* @param bool|null $using_transactions Whether transactions were used.
* @param string $name Name of the migrator.
*/
private function handle_migration_error( array $order_post_ids, array $errors, ?\Exception $exception, ?bool $using_transactions, string $name ) {
$batch = ArrayUtil::to_ranges_string( $order_post_ids );
if ( null !== $exception ) {
$exception_class = get_class( $exception );
$this->error_logger->error(
"$name: when processing ids $batch: ($exception_class) {$exception->getMessage()}, {$exception->getTraceAsString()}",
array(
'source' => self::LOGS_SOURCE_NAME,
'ids' => $order_post_ids,
'exception' => $exception,
)
);
}
foreach ( $errors as $error ) {
$this->error_logger->error(
"$name: when processing ids $batch: $error",
array(
'source' => self::LOGS_SOURCE_NAME,
'ids' => $order_post_ids,
'error' => $error,
)
);
}
if ( $using_transactions ) {
$this->rollback_transaction();
}
}
/**
* Start a database transaction if the configuration mandates so.
*
* @return bool|null True if transaction started, false if transactions won't be used, null if transaction failed to start.
*
* @throws \Exception If the transaction isolation level is invalid.
*/
private function maybe_start_transaction(): ?bool {
$use_transactions = get_option( CustomOrdersTableController::USE_DB_TRANSACTIONS_OPTION, 'yes' );
if ( 'yes' !== $use_transactions ) {
return null;
}
$transaction_isolation_level = get_option( CustomOrdersTableController::DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION, CustomOrdersTableController::DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL );
$valid_transaction_isolation_levels = array( 'READ UNCOMMITTED', 'READ COMMITTED', 'REPEATABLE READ', 'SERIALIZABLE' );
if ( ! in_array( $transaction_isolation_level, $valid_transaction_isolation_levels, true ) ) {
throw new \Exception( "Invalid database transaction isolation level name $transaction_isolation_level" );
}
$set_transaction_isolation_level_command = "SET TRANSACTION ISOLATION LEVEL $transaction_isolation_level";
// We suppress errors in transaction isolation level setting because it's not supported by all DB engines, additionally, this might be executing in context of another transaction with a different isolation level.
if ( ! $this->db_query( $set_transaction_isolation_level_command, true ) ) {
return null;
}
return $this->db_query( 'START TRANSACTION' ) ? true : null;
}
/**
* Commit the current database transaction.
*
* @return bool True on success, false on error.
*/
private function commit_transaction(): bool {
return $this->db_query( 'COMMIT' );
}
/**
* Rollback the current database transaction.
*
* @return bool True on success, false on error.
*/
private function rollback_transaction(): bool {
return $this->db_query( 'ROLLBACK' );
}
/**
* Execute a database query and log any errors.
*
* @param string $query The SQL query to execute.
* @param bool $supress_errors Whether to suppress errors.
*
* @return bool True if the query succeeded, false if there were errors.
*/
private function db_query( string $query, bool $supress_errors = false ): bool {
$wpdb = WC()->get_global( 'wpdb' );
try {
if ( $supress_errors ) {
$suppress = $wpdb->suppress_errors( true );
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $query );
if ( $supress_errors ) {
$wpdb->suppress_errors( $suppress );
}
} catch ( \Exception $exception ) {
$exception_class = get_class( $exception );
$this->error_logger->error(
"PostsToOrdersMigrationController: when executing $query: ($exception_class) {$exception->getMessage()}, {$exception->getTraceAsString()}",
array(
'source' => self::LOGS_SOURCE_NAME,
'exception' => $exception,
)
);
return false;
}
$error = $wpdb->last_error;
if ( '' !== $error ) {
$this->error_logger->error(
"PostsToOrdersMigrationController: when executing $query: $error",
array(
'source' => self::LOGS_SOURCE_NAME,
'error' => $error,
)
);
return false;
}
return true;
}
/**
* Verify whether the given order IDs were migrated properly or not.
*
* @param array $order_post_ids Order IDs.
*
* @return array Array of failed IDs along with columns.
*/
public function verify_migrated_orders( array $order_post_ids ): array {
$errors = array();
foreach ( $this->all_migrators as $migrator ) {
if ( method_exists( $migrator, 'verify_migrated_data' ) ) {
$errors = $errors + $migrator->verify_migrated_data( $order_post_ids );
}
}
return $errors;
}
/**
* Migrates an order from the posts table to the custom orders tables.
*
* @param int $order_post_id Post ID of the order to migrate.
*/
public function migrate_order( int $order_post_id ): void {
$this->migrate_orders( array( $order_post_id ) );
}
}
Database/Migrations/MetaToCustomTableMigrator.php 0000644 00000102072 15153704477 0016155 0 ustar 00 <?php
/**
* Generic migration class to move any entity, entity_meta table combination to custom table.
*/
namespace Automattic\WooCommerce\Database\Migrations;
/**
* Base class for implementing migrations from the standard WordPress meta table
* to custom structured tables.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
abstract class MetaToCustomTableMigrator extends TableMigrator {
/**
* Config for tables being migrated and migrated from. See __construct() for detailed config.
*
* @var array
*/
protected $schema_config;
/**
* Meta config, see __construct for detailed config.
*
* @var array
*/
protected $meta_column_mapping;
/**
* Column mapping from source table to destination custom table. See __construct for detailed config.
*
* @var array
*/
protected $core_column_mapping;
/**
* MetaToCustomTableMigrator constructor.
*/
public function __construct() {
$this->schema_config = MigrationHelper::escape_schema_for_backtick( $this->get_schema_config() );
$this->meta_column_mapping = $this->get_meta_column_config();
$this->core_column_mapping = $this->get_core_column_mapping();
}
/**
* Specify schema config the source and destination table.
*
* @return array Schema, must of the form:
* array(
'source' => array(
'entity' => array(
'table_name' => $source_table_name,
'meta_rel_column' => $column_meta, Name of column in source table which is referenced by meta table.
'destination_rel_column' => $column_dest, Name of column in source table which is refenced by destination table,
'primary_key' => $primary_key, Primary key of the source table
),
'meta' => array(
'table' => $meta_table_name,
'meta_key_column' => $meta_key_column_name,
'meta_value_column' => $meta_value_column_name,
'entity_id_column' => $entity_id_column, Name of the column having entity IDs.
),
),
'destination' => array(
'table_name' => $table_name, Name of destination table,
'source_rel_column' => $column_source_id, Name of the column in destination table which is referenced by source table.
'primary_key' => $table_primary_key,
'primary_key_type' => $type bool|int|string|decimal
)
*/
abstract protected function get_schema_config(): array;
/**
* Specify column config from the source table.
*
* @return array Config, must be of the form:
* array(
* '$source_column_name_1' => array( // $source_column_name_1 is column name in source table, or a select statement.
* 'type' => 'type of value, could be string/int/date/float.',
* 'destination' => 'name of the column in column name where this data should be inserted in.',
* ),
* '$source_column_name_2' => array(
* ......
* ),
* ....
* ).
*/
abstract protected function get_core_column_mapping(): array;
/**
* Specify meta keys config from source meta table.
*
* @return array Config, must be of the form.
* array(
* '$meta_key_1' => array( // $meta_key_1 is the name of meta_key in source meta table.
* 'type' => 'type of value, could be string/int/date/float',
* 'destination' => 'name of the column in column name where this data should be inserted in.',
* ),
* '$meta_key_2' => array(
* ......
* ),
* ....
* ).
*/
abstract protected function get_meta_column_config(): array;
/**
* Generate SQL for data insertion.
*
* @param array $batch Data to generate queries for. Will be 'data' array returned by `$this->fetch_data_for_migration_for_ids()` method.
*
* @return string Generated queries for insertion for this batch, would be of the form:
* INSERT IGNORE INTO $table_name ($columns) values
* ($value for row 1)
* ($value for row 2)
* ...
*/
private function generate_insert_sql_for_batch( array $batch ): string {
$table = $this->schema_config['destination']['table_name'];
list( $value_sql, $column_sql ) = $this->generate_column_clauses( array_merge( $this->core_column_mapping, $this->meta_column_mapping ), $batch );
return "INSERT INTO $table (`$column_sql`) VALUES $value_sql;"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, -- $insert_query is hardcoded, $value_sql is already escaped.
}
/**
* Generate SQL for data updating.
*
* @param array $batch Data to generate queries for. Will be `data` array returned by fetch_data_for_migration_for_ids() method.
*
* @param array $entity_row_mapping Maps rows to update data with their original IDs. Will be returned by `generate_update_sql_for_batch`.
*
* @return string Generated queries for batch update. Would be of the form:
* INSERT INTO $table ( $columns ) VALUES
* ($value for row 1)
* ($valye for row 2)
* ...
* ON DUPLICATE KEY UPDATE
* $column1 = VALUES($column1)
* $column2 = VALUES($column2)
* ...
*/
private function generate_update_sql_for_batch( array $batch, array $entity_row_mapping ): string {
$table = $this->schema_config['destination']['table_name'];
$destination_primary_id_schema = $this->get_destination_table_primary_id_schema();
foreach ( $batch as $entity_id => $row ) {
$batch[ $entity_id ][ $destination_primary_id_schema['destination_primary_key']['destination'] ] = $entity_row_mapping[ $entity_id ]->destination_id;
}
list( $value_sql, $column_sql, $columns ) = $this->generate_column_clauses(
array_merge( $destination_primary_id_schema, $this->core_column_mapping, $this->meta_column_mapping ),
$batch
);
$duplicate_update_key_statement = MigrationHelper::generate_on_duplicate_statement_clause( $columns );
return "INSERT INTO $table (`$column_sql`) VALUES $value_sql $duplicate_update_key_statement;";
}
/**
* Generate schema for primary ID column of destination table.
*
* @return array[] Schema for primary ID column.
*/
private function get_destination_table_primary_id_schema(): array {
return array(
'destination_primary_key' => array(
'destination' => $this->schema_config['destination']['primary_key'],
'type' => $this->schema_config['destination']['primary_key_type'],
),
);
}
/**
* Generate values and columns clauses to be used in INSERT and INSERT..ON DUPLICATE KEY UPDATE statements.
*
* @param array $columns_schema Columns config for destination table.
* @param array $batch Actual data to migrate as returned by `data` in `fetch_data_for_migration_for_ids` method.
*
* @return array SQL clause for values, columns placeholders, and columns.
*/
private function generate_column_clauses( array $columns_schema, array $batch ): array {
global $wpdb;
$columns = array();
$placeholders = array();
foreach ( $columns_schema as $prev_column => $schema ) {
if ( in_array( $schema['destination'], $columns, true ) ) {
continue;
}
$columns[] = $schema['destination'];
$placeholders[] = MigrationHelper::get_wpdb_placeholder_for_type( $schema['type'] );
}
$values = array();
foreach ( array_values( $batch ) as $row ) {
$row_values = array();
foreach ( $columns as $index => $column ) {
if ( ! isset( $row[ $column ] ) || is_null( $row[ $column ] ) ) {
$row_values[] = 'NULL';
} else {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a placeholder.
$row_values[] = $wpdb->prepare( $placeholders[ $index ], $row[ $column ] );
}
}
$value_string = '(' . implode( ',', $row_values ) . ')';
$values[] = $value_string;
}
$value_sql = implode( ',', $values );
$column_sql = implode( '`, `', $columns );
return array( $value_sql, $column_sql, $columns );
}
/**
* Return data to be migrated for a batch of entities.
*
* @param array $entity_ids Ids of entities to migrate.
*
* @return array[] Data to be migrated. Would be of the form: array( 'data' => array( ... ), 'errors' => array( ... ) ).
*/
public function fetch_sanitized_migration_data( $entity_ids ) {
$this->clear_errors();
$data = $this->fetch_data_for_migration_for_ids( $entity_ids );
foreach ( $data['errors'] as $entity_id => $errors ) {
foreach ( $errors as $column_name => $error_message ) {
$this->add_error( "Error importing data for post with id $entity_id: column $column_name: $error_message" );
}
}
return array(
'data' => $data['data'],
'errors' => $this->get_errors(),
);
}
/**
* Migrate a batch of entities from the posts table to the corresponding table.
*
* @param array $entity_ids Ids of entities to migrate.
*
* @return void
*/
protected function process_migration_batch_for_ids_core( array $entity_ids ): void {
$data = $this->fetch_sanitized_migration_data( $entity_ids );
$this->process_migration_data( $data );
}
/**
* Process migration data for a batch of entities.
*
* @param array $data Data to be migrated. Should be of the form: array( 'data' => array( ... ) ) as returned by the `fetch_sanitized_migration_data` method.
*
* @return array Array of errors and exception if any.
*/
public function process_migration_data( array $data ) {
$this->clear_errors();
$exception = null;
if ( count( $data['data'] ) === 0 ) {
return array(
'errors' => $this->get_errors(),
'exception' => null,
);
}
try {
$entity_ids = array_keys( $data['data'] );
$existing_records = $this->get_already_existing_records( $entity_ids );
$to_insert = array_diff_key( $data['data'], $existing_records );
$this->process_insert_batch( $to_insert );
$to_update = array_intersect_key( $data['data'], $existing_records );
$this->process_update_batch( $to_update, $existing_records );
} catch ( \Exception $e ) {
$exception = $e;
}
return array(
'errors' => $this->get_errors(),
'exception' => $exception,
);
}
/**
* Process batch for insertion into destination table.
*
* @param array $batch Data to insert, will be of the form as returned by `data` in `fetch_data_for_migration_for_ids`.
*/
private function process_insert_batch( array $batch ): void {
if ( 0 === count( $batch ) ) {
return;
}
$queries = $this->generate_insert_sql_for_batch( $batch );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Queries should already be prepared.
$processed_rows_count = $this->db_query( $queries );
$this->maybe_add_insert_or_update_error( 'insert', $processed_rows_count );
}
/**
* Process batch for update into destination table.
*
* @param array $batch Data to insert, will be of the form as returned by `data` in `fetch_data_for_migration_for_ids`.
* @param array $ids_mapping Maps rows to update data with their original IDs.
*/
private function process_update_batch( array $batch, array $ids_mapping ): void {
if ( 0 === count( $batch ) ) {
return;
}
$queries = $this->generate_update_sql_for_batch( $batch, $ids_mapping );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Queries should already be prepared.
$processed_rows_count = $this->db_query( $queries ) / 2;
$this->maybe_add_insert_or_update_error( 'update', $processed_rows_count );
}
/**
* Fetch data for migration.
*
* @param array $entity_ids Entity IDs to fetch data for.
*
* @return array[] Data along with errors (if any), will of the form:
* array(
* 'data' => array(
* 'id_1' => array( 'column1' => value1, 'column2' => value2, ...),
* ...,
* ),
* 'errors' => array(
* 'id_1' => array( 'column1' => error1, 'column2' => value2, ...),
* ...,
* )
*/
private function fetch_data_for_migration_for_ids( array $entity_ids ): array {
if ( empty( $entity_ids ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
$entity_table_query = $this->build_entity_table_query( $entity_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Output of $this->build_entity_table_query is already prepared.
$entity_data = $this->db_get_results( $entity_table_query );
if ( empty( $entity_data ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
$entity_meta_rel_ids = array_column( $entity_data, 'entity_meta_rel_id' );
$meta_table_query = $this->build_meta_data_query( $entity_meta_rel_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Output of $this->build_meta_data_query is already prepared.
$meta_data = $this->db_get_results( $meta_table_query );
return $this->process_and_sanitize_data( $entity_data, $meta_data );
}
/**
* Fetch id mappings for records that are already inserted in the destination table.
*
* @param array $entity_ids List of entity IDs to verify.
*
* @return array Already migrated entities, would be of the form
* array(
* '$source_id1' => array(
* 'source_id' => $source_id1,
* 'destination_id' => $destination_id1
* 'modified' => 0 if it can be determined that the row doesn't need update, 1 otherwise
* ),
* ...
* )
*/
protected function get_already_existing_records( array $entity_ids ): array {
global $wpdb;
$source_table = $this->schema_config['source']['entity']['table_name'];
$source_destination_join_column = $this->schema_config['source']['entity']['destination_rel_column'];
$source_primary_key_column = $this->schema_config['source']['entity']['primary_key'];
$destination_table = $this->schema_config['destination']['table_name'];
$destination_source_join_column = $this->schema_config['destination']['source_rel_column'];
$destination_primary_key_column = $this->schema_config['destination']['primary_key'];
$entity_id_placeholder = implode( ',', array_fill( 0, count( $entity_ids ), '%d' ) );
$additional_where = $this->get_additional_where_clause_for_get_data_to_insert_or_update( $entity_ids );
$already_migrated_entity_ids = $this->db_get_results(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- All columns and table names are hardcoded.
"
SELECT source.`$source_primary_key_column` as source_id, destination.`$destination_primary_key_column` as destination_id
FROM `$destination_table` destination
JOIN `$source_table` source ON source.`$source_destination_join_column` = destination.`$destination_source_join_column`
WHERE source.`$source_primary_key_column` IN ( $entity_id_placeholder ) $additional_where
",
$entity_ids
)
// phpcs:enable
);
return array_column( $already_migrated_entity_ids, null, 'source_id' );
}
/**
* Get additional string to be appended to the WHERE clause of the SQL query used by get_data_to_insert_or_update.
*
* @param array $entity_ids The ids of the entities being inserted or updated.
* @return string Additional string for the WHERE clause, must either be empty or start with "AND" or "OR".
*/
protected function get_additional_where_clause_for_get_data_to_insert_or_update( array $entity_ids ): string {
return '';
}
/**
* Helper method to build query used to fetch data from core source table.
*
* @param array $entity_ids List of entity IDs to fetch.
*
* @return string Query that can be used to fetch data.
*/
private function build_entity_table_query( array $entity_ids ): string {
global $wpdb;
$source_entity_table = $this->schema_config['source']['entity']['table_name'];
$source_meta_rel_id_column = "`$source_entity_table`.`{$this->schema_config['source']['entity']['meta_rel_column']}`";
$source_primary_key_column = "`$source_entity_table`.`{$this->schema_config['source']['entity']['primary_key']}`";
$where_clause = "$source_primary_key_column IN (" . implode( ',', array_fill( 0, count( $entity_ids ), '%d' ) ) . ')';
$entity_keys = array();
foreach ( $this->core_column_mapping as $column_name => $column_schema ) {
if ( isset( $column_schema['select_clause'] ) ) {
$select_clause = $column_schema['select_clause'];
$entity_keys[] = "$select_clause AS $column_name";
} else {
$entity_keys[] = "$source_entity_table.$column_name";
}
}
$entity_column_string = implode( ', ', $entity_keys );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $source_meta_rel_id_column, $source_destination_rel_id_column etc is escaped for backticks. $where clause and $order_by should already be escaped.
$query = $wpdb->prepare(
"
SELECT
$source_meta_rel_id_column as entity_meta_rel_id,
$source_primary_key_column as primary_key_id,
$entity_column_string
FROM `$source_entity_table`
WHERE $where_clause;
",
$entity_ids
);
// phpcs:enable
return $query;
}
/**
* Helper method to build query that will be used to fetch data from source meta table.
*
* @param array $entity_ids List of IDs to fetch metadata for.
*
* @return string Query for fetching meta data.
*/
private function build_meta_data_query( array $entity_ids ): string {
global $wpdb;
$meta_table = $this->schema_config['source']['meta']['table_name'];
$meta_keys = array_keys( $this->meta_column_mapping );
$meta_key_column = $this->schema_config['source']['meta']['meta_key_column'];
$meta_value_column = $this->schema_config['source']['meta']['meta_value_column'];
$meta_table_relational_key = $this->schema_config['source']['meta']['entity_id_column'];
$meta_column_string = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
$entity_id_string = implode( ', ', array_fill( 0, count( $entity_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $meta_table_relational_key, $meta_key_column, $meta_value_column and $meta_table is escaped for backticks. $entity_id_string and $meta_column_string are placeholders.
$query = $wpdb->prepare(
"
SELECT `$meta_table_relational_key` as entity_id, `$meta_key_column` as meta_key, `$meta_value_column` as meta_value
FROM `$meta_table`
WHERE
`$meta_table_relational_key` IN ( $entity_id_string )
AND `$meta_key_column` IN ( $meta_column_string );
",
array_merge(
$entity_ids,
$meta_keys
)
);
// phpcs:enable
return $query;
}
/**
* Helper function to validate and combine data before we try to insert.
*
* @param array $entity_data Data from source table.
* @param array $meta_data Data from meta table.
*
* @return array[] Validated and combined data with errors.
*/
private function process_and_sanitize_data( array $entity_data, array $meta_data ): array {
$sanitized_entity_data = array();
$error_records = array();
$this->process_and_sanitize_entity_data( $sanitized_entity_data, $error_records, $entity_data );
$this->processs_and_sanitize_meta_data( $sanitized_entity_data, $error_records, $meta_data );
return array(
'data' => $sanitized_entity_data,
'errors' => $error_records,
);
}
/**
* Helper method to sanitize core source table.
*
* @param array $sanitized_entity_data Array containing sanitized data for insertion.
* @param array $error_records Error records.
* @param array $entity_data Original source data.
*/
private function process_and_sanitize_entity_data( array &$sanitized_entity_data, array &$error_records, array $entity_data ): void {
foreach ( $entity_data as $entity ) {
$row_data = array();
foreach ( $this->core_column_mapping as $column_name => $schema ) {
$custom_table_column_name = $schema['destination'] ?? $column_name;
$value = $entity->$column_name;
$value = $this->validate_data( $value, $schema['type'] );
if ( is_wp_error( $value ) ) {
$error_records[ $entity->primary_key_id ][ $custom_table_column_name ] = $value->get_error_code();
} else {
$row_data[ $custom_table_column_name ] = $value;
}
}
$sanitized_entity_data[ $entity->entity_meta_rel_id ] = $row_data;
}
}
/**
* Helper method to sanitize soure meta data.
*
* @param array $sanitized_entity_data Array containing sanitized data for insertion.
* @param array $error_records Error records.
* @param array $meta_data Original source data.
*/
private function processs_and_sanitize_meta_data( array &$sanitized_entity_data, array &$error_records, array $meta_data ): void {
foreach ( $meta_data as $datum ) {
$column_schema = $this->meta_column_mapping[ $datum->meta_key ];
if ( isset( $sanitized_entity_data[ $datum->entity_id ][ $column_schema['destination'] ] ) ) {
// We pick only the first meta if there are duplicates for a flat column, to be consistent with WP core behavior in handing duplicate meta which are marked as unique.
continue;
}
$value = $this->validate_data( $datum->meta_value, $column_schema['type'] );
if ( is_wp_error( $value ) ) {
$error_records[ $datum->entity_id ][ $column_schema['destination'] ] = "{$value->get_error_code()}: {$value->get_error_message()}";
} else {
$sanitized_entity_data[ $datum->entity_id ][ $column_schema['destination'] ] = $value;
}
}
}
/**
* Validate and transform data so that we catch as many errors as possible before inserting.
*
* @param mixed $value Actual data value.
* @param string $type Type of data, could be decimal, int, date, string.
*
* @return float|int|mixed|string|\WP_Error
*/
private function validate_data( $value, string $type ) {
switch ( $type ) {
case 'decimal':
$value = wc_format_decimal( floatval( $value ), false, true );
break;
case 'int':
$value = (int) $value;
break;
case 'bool':
$value = wc_string_to_bool( $value );
break;
case 'date':
try {
if ( '' === $value ) {
$value = null;
} else {
$value = ( new \DateTime( $value ) )->format( 'Y-m-d H:i:s' );
}
} catch ( \Exception $e ) {
return new \WP_Error( $e->getMessage() );
}
break;
case 'date_epoch':
try {
if ( '' === $value ) {
$value = null;
} else {
$value = ( new \DateTime( "@$value" ) )->format( 'Y-m-d H:i:s' );
}
} catch ( \Exception $e ) {
return new \WP_Error( $e->getMessage() );
}
break;
}
return $value;
}
/**
* Verify whether data was migrated properly for given IDs.
*
* @param array $source_ids List of source IDs.
*
* @return array List of IDs along with columns that failed to migrate.
*/
public function verify_migrated_data( array $source_ids ) : array {
global $wpdb;
$query = $this->build_verification_query( $source_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query should already be prepared.
$results = $wpdb->get_results( $query, ARRAY_A );
$results = $this->fill_source_metadata( $results, $source_ids );
return $this->verify_data( $results );
}
/**
* Generate query to fetch data from both source and destination tables. Use the results in `verify_data` to verify if data was migrated properly.
*
* @param array $source_ids Array of IDs in source table.
*
* @return string SELECT statement.
*/
protected function build_verification_query( $source_ids ) {
$source_table = $this->schema_config['source']['entity']['table_name'];
$destination_table = $this->schema_config['destination']['table_name'];
$destination_source_rel_column = $this->schema_config['destination']['source_rel_column'];
$source_destination_rel_column = $this->schema_config['source']['entity']['destination_rel_column'];
$source_destination_join_clause = "$destination_table ON $destination_table.$destination_source_rel_column = $source_table.$source_destination_rel_column";
$meta_select_clauses = array();
$source_select_clauses = array();
$destination_select_clauses = array();
foreach ( $this->core_column_mapping as $column_name => $schema ) {
$source_select_column = isset( $schema['select_clause'] ) ? $schema['select_clause'] : "$source_table.$column_name";
$source_select_clauses[] = "$source_select_column as {$source_table}_{$column_name}";
$destination_select_clauses[] = "$destination_table.{$schema['destination']} as {$destination_table}_{$schema['destination']}";
}
foreach ( $this->meta_column_mapping as $meta_key => $schema ) {
$destination_select_clauses[] = "$destination_table.{$schema['destination']} as {$destination_table}_{$schema['destination']}";
}
$select_clause = implode( ', ', array_merge( $source_select_clauses, $meta_select_clauses, $destination_select_clauses ) );
$where_clause = $this->get_where_clause_for_verification( $source_ids );
return "
SELECT $select_clause
FROM $source_table
LEFT JOIN $source_destination_join_clause
WHERE $where_clause
";
}
/**
* Fill source metadata for given IDs for verification. This will return filled data in following format:
* [
* {
* $source_table_$source_column: $value,
* ...,
* $destination_table_$destination_column: $value,
* ...
* meta_source_{$destination_column_name1}: $meta_value,
* ...
* },
* ...
* ]
*
* @param array $results Entity data from both source and destination table.
* @param array $source_ids List of source IDs.
*
* @return array Filled $results param with source metadata.
*/
private function fill_source_metadata( $results, $source_ids ) {
global $wpdb;
$meta_table = $this->schema_config['source']['meta']['table_name'];
$meta_entity_id_column = $this->schema_config['source']['meta']['entity_id_column'];
$meta_key_column = $this->schema_config['source']['meta']['meta_key_column'];
$meta_value_column = $this->schema_config['source']['meta']['meta_value_column'];
$meta_id_column = $this->schema_config['source']['meta']['meta_id_column'];
$meta_columns = array_keys( $this->meta_column_mapping );
$meta_columns_placeholder = implode( ', ', array_fill( 0, count( $meta_columns ), '%s' ) );
$source_ids_placeholder = implode( ', ', array_fill( 0, count( $source_ids ), '%d' ) );
$query = $wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
"SELECT $meta_entity_id_column as entity_id, $meta_key_column as meta_key, $meta_value_column as meta_value
FROM $meta_table
WHERE $meta_entity_id_column IN ($source_ids_placeholder)
AND $meta_key_column IN ($meta_columns_placeholder)
ORDER BY $meta_id_column ASC",
array_merge( $source_ids, $meta_columns )
);
//phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$meta_data = $wpdb->get_results( $query, ARRAY_A );
$source_metadata_rows = array();
foreach ( $meta_data as $meta_datum ) {
if ( ! isset( $source_metadata_rows[ $meta_datum['entity_id'] ] ) ) {
$source_metadata_rows[ $meta_datum['entity_id'] ] = array();
}
$destination_column = $this->meta_column_mapping[ $meta_datum['meta_key'] ]['destination'];
$alias = "meta_source_{$destination_column}";
if ( isset( $source_metadata_rows[ $meta_datum['entity_id'] ][ $alias ] ) ) {
// Only process first value, duplicate values mapping to flat columns are ignored to be consistent with WP core.
continue;
}
$source_metadata_rows[ $meta_datum['entity_id'] ][ $alias ] = $meta_datum['meta_value'];
}
foreach ( $results as $index => $result_row ) {
$source_id = $result_row[ $this->schema_config['source']['entity']['table_name'] . '_' . $this->schema_config['source']['entity']['primary_key'] ];
$results[ $index ] = array_merge( $result_row, ( $source_metadata_rows[ $source_id ] ?? array() ) );
}
return $results;
}
/**
* Helper function to generate where clause for fetching data for verification.
*
* @param array $source_ids Array of IDs from source table.
*
* @return string WHERE clause.
*/
protected function get_where_clause_for_verification( $source_ids ) {
global $wpdb;
$source_primary_id_column = $this->schema_config['source']['entity']['primary_key'];
$source_table = $this->schema_config['source']['entity']['table_name'];
$source_ids_placeholder = implode( ', ', array_fill( 0, count( $source_ids ), '%d' ) );
return $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
"$source_table.$source_primary_id_column IN ($source_ids_placeholder)",
$source_ids
);
}
/**
* Verify data from both source and destination tables and check if they were migrated properly.
*
* @param array $collected_data Collected data in array format, should be in same structure as returned from query in `$this->build_verification_query`.
*
* @return array Array of failed IDs if any, along with columns/meta_key names.
*/
protected function verify_data( $collected_data ) {
$failed_ids = array();
foreach ( $collected_data as $row ) {
$failed_ids = $this->verify_entity_columns( $row, $failed_ids );
$failed_ids = $this->verify_meta_columns( $row, $failed_ids );
}
return $failed_ids;
}
/**
* Helper method to verify and compare core columns.
*
* @param array $row Both migrated and source data for a single row.
* @param array $failed_ids Array of failed IDs.
*
* @return array Array of failed IDs if any, along with columns/meta_key names.
*/
private function verify_entity_columns( $row, $failed_ids ) {
$primary_key_column = "{$this->schema_config['source']['entity']['table_name']}_{$this->schema_config['source']['entity']['primary_key']}";
foreach ( $this->core_column_mapping as $column_name => $schema ) {
$source_alias = "{$this->schema_config['source']['entity']['table_name']}_$column_name";
$destination_alias = "{$this->schema_config['destination']['table_name']}_{$schema['destination']}";
$row = $this->pre_process_row( $row, $schema, $source_alias, $destination_alias );
if ( $row[ $source_alias ] !== $row[ $destination_alias ] ) {
if ( ! isset( $failed_ids[ $row[ $primary_key_column ] ] ) ) {
$failed_ids[ $row[ $primary_key_column ] ] = array();
}
$failed_ids[ $row[ $primary_key_column ] ][] = array(
'column' => $column_name,
'original_value' => $row[ $source_alias ],
'new_value' => $row[ $destination_alias ],
);
}
}
return $failed_ids;
}
/**
* Helper method to verify meta columns.
*
* @param array $row Both migrated and source data for a single row.
* @param array $failed_ids Array of failed IDs.
*
* @return array Array of failed IDs if any, along with columns/meta_key names.
*/
private function verify_meta_columns( $row, $failed_ids ) {
$primary_key_column = "{$this->schema_config['source']['entity']['table_name']}_{$this->schema_config['source']['entity']['primary_key']}";
foreach ( $this->meta_column_mapping as $meta_key => $schema ) {
$meta_alias = "meta_source_{$schema['destination']}";
$destination_alias = "{$this->schema_config['destination']['table_name']}_{$schema['destination']}";
$row = $this->pre_process_row( $row, $schema, $meta_alias, $destination_alias );
if ( $row[ $meta_alias ] !== $row[ $destination_alias ] ) {
if ( ! isset( $failed_ids[ $row[ $primary_key_column ] ] ) ) {
$failed_ids[ $row[ $primary_key_column ] ] = array();
}
$failed_ids[ $row[ $primary_key_column ] ][] = array(
'column' => $meta_key,
'original_value' => $row[ $meta_alias ],
'new_value' => $row[ $destination_alias ],
);
}
}
return $failed_ids;
}
/**
* Helper method to pre-process rows to make sure we parse the correct type.
*
* @param array $row Both migrated and source data for a single row.
* @param array $schema Column schema.
* @param string $alias Name of source column.
* @param string $destination_alias Name of destination column.
*
* @return array Processed row.
*/
private function pre_process_row( $row, $schema, $alias, $destination_alias ) {
if ( ! isset( $row[ $alias ] ) ) {
$row[ $alias ] = $this->get_type_defaults( $schema['type'] );
}
if ( is_null( $row[ $destination_alias ] ) ) {
$row[ $destination_alias ] = $this->get_type_defaults( $schema['type'] );
}
if ( in_array( $schema['type'], array( 'int', 'decimal', 'float' ), true ) ) {
if ( '' === $row[ $alias ] || null === $row[ $alias ] ) {
$row[ $alias ] = 0; // $wpdb->prepare forces empty values to 0.
}
$row[ $alias ] = wc_format_decimal( floatval( $row[ $alias ] ), false, true );
$row[ $destination_alias ] = wc_format_decimal( floatval( $row[ $destination_alias ] ), false, true );
}
if ( 'bool' === $schema['type'] ) {
$row[ $alias ] = wc_string_to_bool( $row[ $alias ] );
$row[ $destination_alias ] = wc_string_to_bool( $row[ $destination_alias ] );
}
if ( 'date_epoch' === $schema['type'] ) {
if ( '' === $row[ $alias ] || null === $row[ $alias ] ) {
$row[ $alias ] = null;
} else {
$row[ $alias ] = ( new \DateTime( "@{$row[ $alias ]}" ) )->format( 'Y-m-d H:i:s' );
}
if ( '0000-00-00 00:00:00' === $row[ $destination_alias ] ) {
$row[ $destination_alias ] = null;
}
}
return $row;
}
/**
* Helper method to get default value of a type.
*
* @param string $type Type.
*
* @return mixed Default value.
*/
private function get_type_defaults( $type ) {
switch ( $type ) {
case 'float':
case 'int':
case 'decimal':
return 0;
case 'string':
return '';
}
}
}
Database/Migrations/MetaToMetaTableMigrator.php 0000644 00000036670 15153704477 0015603 0 ustar 00 <?php
/**
* Generic Migration class to move any meta data associated to an entity, to a different meta table associated with a custom entity table.
*/
namespace Automattic\WooCommerce\Database\Migrations;
/**
* Base class for implementing migrations from the standard WordPress meta table
* to custom meta (key-value pairs) tables.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
abstract class MetaToMetaTableMigrator extends TableMigrator {
/**
* Schema config, see __construct for more details.
*
* @var array
*/
private $schema_config;
/**
* Returns config for the migration.
*
* @return array Meta config, must be in following format:
* array(
* 'source' => array(
* 'meta' => array(
* 'table_name' => source_meta_table_name,
* 'entity_id_column' => entity_id column name in source meta table,
* 'meta_key_column' => meta_key column',
* 'meta_value_column' => meta_value column',
* ),
* 'entity' => array(
* 'table_name' => entity table name for the meta table,
* 'source_id_column' => column name in entity table which maps to meta table,
* 'id_column' => id column in entity table,
* ),
* 'excluded_keys' => array of keys to exclude,
* ),
* 'destination' => array(
* 'meta' => array(
* 'table_name' => destination meta table name,
* 'entity_id_column' => entity_id column in meta table,
* 'meta_key_column' => meta key column,
* 'meta_value_column' => meta_value column,
* 'entity_id_type' => data type of entity id,
* 'meta_id_column' => id column in meta table,
* ),
* ),
* )
*/
abstract protected function get_meta_config(): array;
/**
* MetaToMetaTableMigrator constructor.
*/
public function __construct() {
$this->schema_config = $this->get_meta_config();
}
/**
* Return data to be migrated for a batch of entities.
*
* @param array $entity_ids Ids of entities to migrate.
*
* @return array[] Data to be migrated. Would be of the form: array( 'data' => array( ... ), 'errors' => array( ... ) ).
*/
public function fetch_sanitized_migration_data( $entity_ids ) {
$this->clear_errors();
$to_migrate = $this->fetch_data_for_migration_for_ids( $entity_ids );
if ( empty( $to_migrate ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
$already_migrated = $this->get_already_migrated_records( array_keys( $to_migrate ) );
return array(
'data' => $this->classify_update_insert_records( $to_migrate, $already_migrated ),
'errors' => $this->get_errors(),
);
}
/**
* Migrate a batch of entities from the posts table to the corresponding table.
*
* @param array $entity_ids Ids of entities ro migrate.
*/
protected function process_migration_batch_for_ids_core( array $entity_ids ): void {
$sanitized_data = $this->fetch_sanitized_migration_data( $entity_ids );
$this->process_migration_data( $sanitized_data );
}
/**
* Process migration data for a batch of entities.
*
* @param array $data Data to be migrated. Should be of the form: array( 'data' => array( ... ) ) as returned by the `fetch_sanitized_migration_data` method.
*
* @return array Array of errors and exception if any.
*/
public function process_migration_data( array $data ) {
if ( isset( $data['data'] ) ) {
$data = $data['data'];
}
$this->clear_errors();
$exception = null;
$to_insert = $data[0];
$to_update = $data[1];
try {
if ( ! empty( $to_insert ) ) {
$insert_queries = $this->generate_insert_sql_for_batch( $to_insert );
$processed_rows_count = $this->db_query( $insert_queries );
$this->maybe_add_insert_or_update_error( 'insert', $processed_rows_count );
}
if ( ! empty( $to_update ) ) {
$update_queries = $this->generate_update_sql_for_batch( $to_update );
$processed_rows_count = $this->db_query( $update_queries );
$this->maybe_add_insert_or_update_error( 'update', $processed_rows_count );
}
} catch ( \Exception $e ) {
$exception = $e;
}
return array(
'errors' => $this->get_errors(),
'exception' => $exception,
);
}
/**
* Generate update SQL for given batch.
*
* @param array $batch List of data to generate update SQL for. Should be in same format as output of $this->fetch_data_for_migration_for_ids.
*
* @return string Query to update batch records.
*/
private function generate_update_sql_for_batch( array $batch ): string {
global $wpdb;
$table = $this->schema_config['destination']['meta']['table_name'];
$meta_id_column = $this->schema_config['destination']['meta']['meta_id_column'];
$meta_key_column = $this->schema_config['destination']['meta']['meta_key_column'];
$meta_value_column = $this->schema_config['destination']['meta']['meta_value_column'];
$entity_id_column = $this->schema_config['destination']['meta']['entity_id_column'];
$columns = array( $meta_id_column, $entity_id_column, $meta_key_column, $meta_value_column );
$columns_sql = implode( '`, `', $columns );
$entity_id_column_placeholder = MigrationHelper::get_wpdb_placeholder_for_type( $this->schema_config['destination']['meta']['entity_id_type'] );
$placeholder_string = "%d, $entity_id_column_placeholder, %s, %s";
$values = array();
foreach ( $batch as $entity_id => $rows ) {
foreach ( $rows as $meta_key => $meta_details ) {
// phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders
$values[] = $wpdb->prepare(
"( $placeholder_string )",
array( $meta_details['id'], $entity_id, $meta_key, $meta_details['meta_value'] )
);
// phpcs:enable
}
}
$value_sql = implode( ',', $values );
$on_duplicate_key_clause = MigrationHelper::generate_on_duplicate_statement_clause( $columns );
return "INSERT INTO $table ( `$columns_sql` ) VALUES $value_sql $on_duplicate_key_clause";
}
/**
* Generate insert sql queries for batches.
*
* @param array $batch Data to generate queries for.
*
* @return string Insert SQL query.
*/
private function generate_insert_sql_for_batch( array $batch ): string {
global $wpdb;
$table = $this->schema_config['destination']['meta']['table_name'];
$meta_key_column = $this->schema_config['destination']['meta']['meta_key_column'];
$meta_value_column = $this->schema_config['destination']['meta']['meta_value_column'];
$entity_id_column = $this->schema_config['destination']['meta']['entity_id_column'];
$column_sql = "(`$entity_id_column`, `$meta_key_column`, `$meta_value_column`)";
$entity_id_column_placeholder = MigrationHelper::get_wpdb_placeholder_for_type( $this->schema_config['destination']['meta']['entity_id_type'] );
$placeholder_string = "$entity_id_column_placeholder, %s, %s";
$values = array();
foreach ( $batch as $entity_id => $rows ) {
foreach ( $rows as $meta_key => $meta_values ) {
foreach ( $meta_values as $meta_value ) {
$query_params = array(
$entity_id,
$meta_key,
$meta_value,
);
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
$value_sql = $wpdb->prepare( "$placeholder_string", $query_params );
$values[] = $value_sql;
}
}
}
$values_sql = implode( '), (', $values );
return "INSERT IGNORE INTO $table $column_sql VALUES ($values_sql)";
}
/**
* Fetch data for migration.
*
* @param array $entity_ids Array of IDs to fetch data for.
*
* @return array[] Data, will of the form:
* array(
* 'id_1' => array( 'column1' => array( value1_1, value1_2...), 'column2' => array(value2_1, value2_2...), ...),
* ...,
* )
*/
public function fetch_data_for_migration_for_ids( array $entity_ids ): array {
if ( empty( $entity_ids ) ) {
return array();
}
$meta_query = $this->build_meta_table_query( $entity_ids );
$meta_data_rows = $this->db_get_results( $meta_query );
if ( empty( $meta_data_rows ) ) {
return array();
}
foreach ( $meta_data_rows as $migrate_row ) {
if ( ! isset( $to_migrate[ $migrate_row->entity_id ] ) ) {
$to_migrate[ $migrate_row->entity_id ] = array();
}
if ( ! isset( $to_migrate[ $migrate_row->entity_id ][ $migrate_row->meta_key ] ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$to_migrate[ $migrate_row->entity_id ][ $migrate_row->meta_key ] = array();
}
$to_migrate[ $migrate_row->entity_id ][ $migrate_row->meta_key ][] = $migrate_row->meta_value;
}
return $to_migrate;
}
/**
* Helper method to get already migrated records. Will be used to find prevent migration of already migrated records.
*
* @param array $entity_ids List of entity ids to check for.
*
* @return array Already migrated records.
*/
private function get_already_migrated_records( array $entity_ids ): array {
global $wpdb;
$destination_table_name = $this->schema_config['destination']['meta']['table_name'];
$destination_id_column = $this->schema_config['destination']['meta']['meta_id_column'];
$destination_entity_id_column = $this->schema_config['destination']['meta']['entity_id_column'];
$destination_meta_key_column = $this->schema_config['destination']['meta']['meta_key_column'];
$destination_meta_value_column = $this->schema_config['destination']['meta']['meta_value_column'];
$entity_id_type_placeholder = MigrationHelper::get_wpdb_placeholder_for_type( $this->schema_config['destination']['meta']['entity_id_type'] );
$entity_ids_placeholder = implode( ',', array_fill( 0, count( $entity_ids ), $entity_id_type_placeholder ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$data_already_migrated = $this->db_get_results(
$wpdb->prepare(
"
SELECT
$destination_id_column meta_id,
$destination_entity_id_column entity_id,
$destination_meta_key_column meta_key,
$destination_meta_value_column meta_value
FROM $destination_table_name destination
WHERE destination.$destination_entity_id_column in ( $entity_ids_placeholder ) ORDER BY destination.$destination_entity_id_column
",
$entity_ids
)
);
// phpcs:enable
$already_migrated = array();
foreach ( $data_already_migrated as $migrate_row ) {
if ( ! isset( $already_migrated[ $migrate_row->entity_id ] ) ) {
$already_migrated[ $migrate_row->entity_id ] = array();
}
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
if ( ! isset( $already_migrated[ $migrate_row->entity_id ][ $migrate_row->meta_key ] ) ) {
$already_migrated[ $migrate_row->entity_id ][ $migrate_row->meta_key ] = array();
}
$already_migrated[ $migrate_row->entity_id ][ $migrate_row->meta_key ][] = array(
'id' => $migrate_row->meta_id,
'meta_value' => $migrate_row->meta_value,
);
// phpcs:enable
}
return $already_migrated;
}
/**
* Classify each record on whether to migrate or update.
*
* @param array $to_migrate Records to migrate.
* @param array $already_migrated Records already migrated.
*
* @return array[] Returns two arrays, first for records to migrate, and second for records to upgrade.
*/
private function classify_update_insert_records( array $to_migrate, array $already_migrated ): array {
$to_update = array();
$to_insert = array();
foreach ( $to_migrate as $entity_id => $rows ) {
foreach ( $rows as $meta_key => $meta_values ) {
// If there is no corresponding record in the destination table then insert.
// If there is single value in both already migrated and current then update.
// If there are multiple values in either already_migrated records or in to_migrate_records, then insert instead of updating.
if ( ! isset( $already_migrated[ $entity_id ][ $meta_key ] ) ) {
if ( ! isset( $to_insert[ $entity_id ] ) ) {
$to_insert[ $entity_id ] = array();
}
$to_insert[ $entity_id ][ $meta_key ] = $meta_values;
} else {
if ( 1 === count( $meta_values ) && 1 === count( $already_migrated[ $entity_id ][ $meta_key ] ) ) {
if ( $meta_values[0] === $already_migrated[ $entity_id ][ $meta_key ][0]['meta_value'] ) {
continue;
}
if ( ! isset( $to_update[ $entity_id ] ) ) {
$to_update[ $entity_id ] = array();
}
$to_update[ $entity_id ][ $meta_key ] = array(
'id' => $already_migrated[ $entity_id ][ $meta_key ][0]['id'],
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_value' => $meta_values[0],
);
continue;
}
// There are multiple meta entries, let's find the unique entries and insert.
$unique_meta_values = array_diff( $meta_values, array_column( $already_migrated[ $entity_id ][ $meta_key ], 'meta_value' ) );
if ( 0 === count( $unique_meta_values ) ) {
continue;
}
if ( ! isset( $to_insert[ $entity_id ] ) ) {
$to_insert[ $entity_id ] = array();
}
$to_insert[ $entity_id ][ $meta_key ] = $unique_meta_values;
}
}
}
return array( $to_insert, $to_update );
}
/**
* Helper method to build query used to fetch data from source meta table.
*
* @param array $entity_ids List of entity IDs to build meta query for.
*
* @return string Query that can be used to fetch data.
*/
private function build_meta_table_query( array $entity_ids ): string {
global $wpdb;
$source_meta_table = $this->schema_config['source']['meta']['table_name'];
$source_meta_key_column = $this->schema_config['source']['meta']['meta_key_column'];
$source_meta_value_column = $this->schema_config['source']['meta']['meta_value_column'];
$source_entity_id_column = $this->schema_config['source']['meta']['entity_id_column'];
$order_by = "source.$source_entity_id_column ASC";
$where_clause = "source.`$source_entity_id_column` IN (" . implode( ', ', array_fill( 0, count( $entity_ids ), '%d' ) ) . ')';
$entity_table = $this->schema_config['source']['entity']['table_name'];
$entity_id_column = $this->schema_config['source']['entity']['id_column'];
$entity_meta_id_mapping_column = $this->schema_config['source']['entity']['source_id_column'];
if ( $this->schema_config['source']['excluded_keys'] ) {
$key_placeholder = implode( ',', array_fill( 0, count( $this->schema_config['source']['excluded_keys'] ), '%s' ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $source_meta_key_column is escaped for backticks, $key_placeholder is hardcoded.
$exclude_clause = $wpdb->prepare( "source.$source_meta_key_column NOT IN ( $key_placeholder )", $this->schema_config['source']['excluded_keys'] );
$where_clause = "$where_clause AND $exclude_clause";
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $wpdb->prepare(
"
SELECT
source.`$source_entity_id_column` as source_entity_id,
entity.`$entity_id_column` as entity_id,
source.`$source_meta_key_column` as meta_key,
source.`$source_meta_value_column` as meta_value
FROM `$source_meta_table` source
JOIN `$entity_table` entity ON entity.`$entity_meta_id_mapping_column` = source.`$source_entity_id_column`
WHERE $where_clause ORDER BY $order_by
",
$entity_ids
);
// phpcs:enable
}
}
Database/Migrations/MigrationHelper.php 0000644 00000026102 15153704477 0014204 0 ustar 00 <?php
/**
* Helper class with utility functions for migrations.
*/
namespace Automattic\WooCommerce\Database\Migrations;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
/**
* Helper class to assist with migration related operations.
*/
class MigrationHelper {
/**
* Placeholders that we will use in building $wpdb queries.
*
* @var string[]
*/
private static $wpdb_placeholder_for_type = array(
'int' => '%d',
'decimal' => '%f',
'string' => '%s',
'date' => '%s',
'date_epoch' => '%s',
'bool' => '%d',
);
/**
* Helper method to escape backtick in various schema fields.
*
* @param array $schema_config Schema config.
*
* @return array Schema config escaped for backtick.
*/
public static function escape_schema_for_backtick( array $schema_config ): array {
array_walk( $schema_config['source']['entity'], array( self::class, 'escape_and_add_backtick' ) );
array_walk( $schema_config['source']['meta'], array( self::class, 'escape_and_add_backtick' ) );
array_walk( $schema_config['destination'], array( self::class, 'escape_and_add_backtick' ) );
return $schema_config;
}
/**
* Helper method to escape backtick in column and table names.
* WP does not provide a method to escape table/columns names yet, but hopefully soon in @link https://core.trac.wordpress.org/ticket/52506
*
* @param string|array $identifier Column or table name.
*
* @return array|string|string[] Escaped identifier.
*/
public static function escape_and_add_backtick( $identifier ) {
return '`' . str_replace( '`', '``', $identifier ) . '`';
}
/**
* Return $wpdb->prepare placeholder for data type.
*
* @param string $type Data type.
*
* @return string $wpdb placeholder.
*/
public static function get_wpdb_placeholder_for_type( string $type ): string {
return self::$wpdb_placeholder_for_type[ $type ];
}
/**
* Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
*
* @param array $columns List of column names.
*
* @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
*/
public static function generate_on_duplicate_statement_clause( array $columns ): string {
$db_util = wc_get_container()->get( DatabaseUtil::class );
return $db_util->generate_on_duplicate_statement_clause( $columns );
}
/**
* Migrate state codes in all the required places in the database, needed after they change for a given country.
*
* @param string $country_code The country that has the states for which the migration is needed.
* @param array $old_to_new_states_mapping An associative array where keys are the old state codes and values are the new state codes.
* @return bool True if there are more records that need to be migrated, false otherwise.
*/
public static function migrate_country_states( string $country_code, array $old_to_new_states_mapping ): bool {
$more_remaining = self::migrate_country_states_for_orders( $country_code, $old_to_new_states_mapping );
if ( ! $more_remaining ) {
self::migrate_country_states_for_misc_data( $country_code, $old_to_new_states_mapping );
}
return $more_remaining;
}
/**
* Migrate state codes in all the required places in the database (except orders).
*
* @param string $country_code The country that has the states for which the migration is needed.
* @param array $old_to_new_states_mapping An associative array where keys are the old state codes and values are the new state codes.
* @return void
*/
private static function migrate_country_states_for_misc_data( string $country_code, array $old_to_new_states_mapping ): void {
self::migrate_country_states_for_shipping_locations( $country_code, $old_to_new_states_mapping );
self::migrate_country_states_for_tax_rates( $country_code, $old_to_new_states_mapping );
self::migrate_country_states_for_store_location( $country_code, $old_to_new_states_mapping );
}
/**
* Migrate state codes in the shipping locations table.
*
* @param string $country_code The country that has the states for which the migration is needed.
* @param array $old_to_new_states_mapping An associative array where keys are the old state codes and values are the new state codes.
* @return void
*/
private static function migrate_country_states_for_shipping_locations( string $country_code, array $old_to_new_states_mapping ): void {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$sql = "SELECT location_id, location_code FROM {$wpdb->prefix}woocommerce_shipping_zone_locations WHERE location_code LIKE '{$country_code}:%'";
$locations_data = $wpdb->get_results( $sql, ARRAY_A );
foreach ( $locations_data as $location_data ) {
$old_state_code = substr( $location_data['location_code'], 3 );
if ( array_key_exists( $old_state_code, $old_to_new_states_mapping ) ) {
$new_location_code = "{$country_code}:{$old_to_new_states_mapping[$old_state_code]}";
$update_query = $wpdb->prepare(
"UPDATE {$wpdb->prefix}woocommerce_shipping_zone_locations SET location_code=%s WHERE location_id=%d",
$new_location_code,
$location_data['location_id']
);
$wpdb->query( $update_query );
}
}
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Migrate the state code for the store location.
*
* @param string $country_code The country that has the states for which the migration is needed.
* @param array $old_to_new_states_mapping An associative array where keys are the old state codes and values are the new state codes.
* @return void
*/
private static function migrate_country_states_for_store_location( string $country_code, array $old_to_new_states_mapping ): void {
$store_location = get_option( 'woocommerce_default_country', '' );
if ( StringUtil::starts_with( $store_location, "{$country_code}:" ) ) {
$old_location_code = substr( $store_location, 3 );
if ( array_key_exists( $old_location_code, $old_to_new_states_mapping ) ) {
$new_location_code = "{$country_code}:{$old_to_new_states_mapping[$old_location_code]}";
update_option( 'woocommerce_default_country', $new_location_code );
}
}
}
/**
* Migrate state codes for orders in the orders table and in the posts table.
* It will migrate only N*2*(number of states) records, being N equal to 100 by default
* but this number can be modified via the woocommerce_migrate_country_states_for_orders_batch_size filter.
*
* @param string $country_code The country that has the states for which the migration is needed.
* @param array $old_to_new_states_mapping An associative array where keys are the old state codes and values are the new state codes.
* @return bool True if there are more records that need to be migrated, false otherwise.
*/
private static function migrate_country_states_for_orders( string $country_code, array $old_to_new_states_mapping ): bool {
global $wpdb;
/**
* Filters the value of N, where the maximum count of database records that will be updated in one single run of migrate_country_states_for_orders
* is N*2*count($old_to_new_states_mapping) if the woocommerce_orders table exists, or N*count($old_to_new_states_mapping) otherwise.
*
* @param int $batch_size Default value for the count of records to update.
* @param string $country_code Country code for the update.
* @param array $old_to_new_states_mapping Associative array of old to new state codes.
*
* @since 7.2.0
*/
$limit = apply_filters( 'woocommerce_migrate_country_states_for_orders_batch_size', 100, $country_code, $old_to_new_states_mapping );
$cot_exists = wc_get_container()->get( DataSynchronizer::class )->check_orders_table_exists();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
foreach ( $old_to_new_states_mapping as $old_state => $new_state ) {
if ( $cot_exists ) {
$update_query = $wpdb->prepare(
"UPDATE {$wpdb->prefix}wc_order_addresses SET state=%s WHERE country=%s AND state=%s LIMIT %d",
$new_state,
$country_code,
$old_state,
$limit
);
$wpdb->query( $update_query );
}
// We need to split the update query for the postmeta table in two, select + update,
// because MySQL doesn't support the LIMIT keyword in multi-table UPDATE statements.
$select_meta_ids_query = $wpdb->prepare(
"SELECT meta_id FROM {$wpdb->prefix}postmeta,
(SELECT DISTINCT post_id FROM {$wpdb->prefix}postmeta
WHERE (meta_key = '_billing_country' OR meta_key='_shipping_country') AND meta_value=%s)
AS states_in_country
WHERE (meta_key='_billing_state' OR meta_key='_shipping_state')
AND meta_value=%s
AND {$wpdb->postmeta}.post_id = states_in_country.post_id
LIMIT %d",
$country_code,
$old_state,
$limit
);
$meta_ids = $wpdb->get_results( $select_meta_ids_query, ARRAY_A );
if ( ! empty( $meta_ids ) ) {
$meta_ids = ArrayUtil::select( $meta_ids, 'meta_id' );
$meta_ids_as_comma_separated = '(' . join( ',', $meta_ids ) . ')';
$update_query = $wpdb->prepare(
"UPDATE {$wpdb->prefix}postmeta
SET meta_value=%s
WHERE meta_id IN {$meta_ids_as_comma_separated}",
$new_state
);
$wpdb->query( $update_query );
}
}
$states_as_comma_separated = "('" . join( "','", array_keys( $old_to_new_states_mapping ) ) . "')";
$posts_exist_query = $wpdb->prepare(
"
SELECT 1 FROM {$wpdb->prefix}postmeta
WHERE (meta_key='_billing_state' OR meta_key='_shipping_state')
AND meta_value IN {$states_as_comma_separated}
AND post_id IN (
SELECT post_id FROM {$wpdb->prefix}postmeta WHERE
(meta_key = '_billing_country' OR meta_key='_shipping_country')
AND meta_value=%s
)",
$country_code
);
if ( $cot_exists ) {
$more_exist_query = $wpdb->prepare(
"
SELECT EXISTS(
SELECT 1 FROM {$wpdb->prefix}wc_order_addresses
WHERE country=%s AND state IN {$states_as_comma_separated}
)
OR EXISTS (
{$posts_exist_query}
)",
$country_code
);
} else {
$more_exist_query = "SELECT EXISTS ({$posts_exist_query})";
}
return (int) ( $wpdb->get_var( $more_exist_query ) ) !== 0;
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Migrate state codes in the tax rates table.
*
* @param string $country_code The country that has the states for which the migration is needed.
* @param array $old_to_new_states_mapping An associative array where keys are the old state codes and values are the new state codes.
* @return void
*/
private static function migrate_country_states_for_tax_rates( string $country_code, array $old_to_new_states_mapping ): void {
global $wpdb;
foreach ( $old_to_new_states_mapping as $old_state_code => $new_state_code ) {
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}woocommerce_tax_rates SET tax_rate_state=%s WHERE tax_rate_country=%s AND tax_rate_state=%s",
$new_state_code,
$country_code,
$old_state_code
)
);
}
}
}
Database/Migrations/TableMigrator.php 0000644 00000011436 15153704477 0013653 0 ustar 00 <?php
/**
* Base class for all the WP posts to order table migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations;
/**
* Base class for implementing WP posts to order tables migrations handlers.
* It mainly contains methods to deal with error handling.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
abstract class TableMigrator {
/**
* An array of cummulated error messages.
*
* @var array
*/
private $errors;
/**
* Clear the error messages list.
*
* @return void
*/
protected function clear_errors(): void {
$this->errors = array();
}
/**
* Add an error message to the errors list unless it's there already.
*
* @param string $error The error message to add.
* @return void
*/
protected function add_error( string $error ): void {
if ( is_null( $this->errors ) ) {
$this->errors = array();
}
if ( ! in_array( $error, $this->errors, true ) ) {
$this->errors[] = $error;
}
}
/**
* Get the list of error messages added.
*
* @return array
*/
protected function get_errors(): array {
return $this->errors;
}
/**
* Run $wpdb->query and add the error, if any, to the errors list.
*
* @param string $query The SQL query to run.
* @return mixed Whatever $wpdb->query returns.
*/
protected function db_query( string $query ) {
$wpdb = WC()->get_global( 'wpdb' );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->query( $query );
if ( '' !== $wpdb->last_error ) {
$this->add_error( $wpdb->last_error );
}
return $result;
}
/**
* Run $wpdb->get_results and add the error, if any, to the errors list.
*
* @param string|null $query The SQL query to run.
* @param string $output Any of ARRAY_A | ARRAY_N | OBJECT | OBJECT_K constants.
* @return mixed Whatever $wpdb->get_results returns.
*/
protected function db_get_results( string $query = null, string $output = OBJECT ) {
$wpdb = WC()->get_global( 'wpdb' );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->get_results( $query, $output );
if ( '' !== $wpdb->last_error ) {
$this->add_error( $wpdb->last_error );
}
return $result;
}
/**
* Migrate a batch of orders, logging any database error that could arise and the exception thrown if any.
*
* @param array $entity_ids Order ids to migrate.
* @return array An array containing the keys 'errors' (array of strings) and 'exception' (exception object or null).
*
* @deprecated 8.0.0 Use `fetch_sanitized_migration_data` and `process_migration_data` instead.
*/
public function process_migration_batch_for_ids( array $entity_ids ): array {
$this->clear_errors();
$exception = null;
try {
$this->process_migration_batch_for_ids_core( $entity_ids );
} catch ( \Exception $ex ) {
$exception = $ex;
}
return array(
'errors' => $this->get_errors(),
'exception' => $exception,
);
}
// phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn, Squiz.Commenting.FunctionCommentThrowTag.Missing -- Methods are not marked abstract for back compat.
/**
* Return data to be migrated for a batch of entities.
*
* @param array $entity_ids Ids of entities to migrate.
*
* @return array[] Data to be migrated. Would be of the form: array( 'data' => array( ... ), 'errors' => array( ... ) ).
*/
public function fetch_sanitized_migration_data( array $entity_ids ) {
throw new \Exception( 'Not implemented' );
}
/**
* Process migration data for a batch of entities.
*
* @param array $data Data to be migrated. Should be of the form: array( 'data' => array( ... ) ) as returned by the `fetch_sanitized_migration_data` method.
*
* @return array Array of errors and exception if any.
*/
public function process_migration_data( array $data ) {
throw new \Exception( 'Not implemented' );
}
// phpcs:enable
/**
* The core method that actually performs the migration for the supplied batch of order ids.
* It doesn't need to deal with database errors nor with exceptions.
*
* @param array $entity_ids Order ids to migrate.
* @return void
*
* @deprecated 8.0.0 Use `fetch_sanitized_migration_data` and `process_migration_data` instead.
*/
abstract protected function process_migration_batch_for_ids_core( array $entity_ids ): void;
/**
* Check if the amount of processed database rows matches the amount of orders to process, and log an error if not.
*
* @param string $operation Operation performed, 'insert' or 'update'.
* @param array|bool $received_rows_count Value returned by @wpdb after executing the query.
* @return void
*/
protected function maybe_add_insert_or_update_error( string $operation, $received_rows_count ) {
if ( false === $received_rows_count ) {
$this->add_error( "$operation operation didn't complete, the database query failed" );
}
}
}
Internal/Admin/ActivityPanels.php 0000644 00000003120 15153704477 0013031 0 ustar 00 <?php
/**
* WooCommerce Activity Panel.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Contains backend logic for the activity panel feature.
*/
class ActivityPanels {
/**
* Class instance.
*
* @var ActivityPanels instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
// Run after Automattic\WooCommerce\Internal\Admin\Loader.
add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
}
/**
* Adds fields so that we can store activity panel last read and open times.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'activity_panel_inbox_last_read',
'activity_panel_reviews_last_read',
)
);
}
/**
* Add alert count to the component settings.
*
* @param array $settings Component settings.
*/
public function component_settings( $settings ) {
$settings['alertCount'] = Notes::get_notes_count( array( 'error', 'update' ), array( 'unactioned' ) );
return $settings;
}
}
Internal/Admin/Analytics.php 0000644 00000021702 15153704477 0012027 0 ustar 00 <?php
/**
* WooCommerce Analytics.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\API\Reports\Cache;
use Automattic\WooCommerce\Admin\Features\Features;
/**
* Contains backend logic for the Analytics feature.
*/
class Analytics {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_analytics_enabled';
/**
* Clear cache tool identifier.
*/
const CACHE_TOOL_ID = 'clear_woocommerce_analytics_cache';
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Determines if the feature has been toggled on or off.
*
* @var boolean
*/
protected static $is_updated = false;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
if ( ! Features::is_enabled( 'analytics' ) ) {
return;
}
add_filter( 'woocommerce_component_settings_preload_endpoints', array( $this, 'add_preload_endpoints' ) );
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_menu', array( $this, 'register_pages' ) );
add_filter( 'woocommerce_debug_tools', array( $this, 'register_cache_clear_tool' ) );
}
/**
* Add the feature toggle to the features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
return $features;
}
/**
* Reloads the page when the option is toggled to make sure all Analytics features are loaded.
*
* @param string $old_value Old value.
* @param string $value New value.
*/
public static function reload_page_on_toggle( $old_value, $value ) {
if ( $old_value === $value ) {
return;
}
self::$is_updated = true;
}
/**
* Reload the page if the setting has been updated.
*/
public static function maybe_reload_page() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) {
return;
}
wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) );
exit();
}
/**
* Preload data from the countries endpoint.
*
* @param array $endpoints Array of preloaded endpoints.
* @return array
*/
public function add_preload_endpoints( $endpoints ) {
$endpoints['performanceIndicators'] = '/wc-analytics/reports/performance-indicators/allowed';
$endpoints['leaderboards'] = '/wc-analytics/leaderboards/allowed';
return $endpoints;
}
/**
* Adds fields so that we can store user preferences for the columns to display on a report.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'categories_report_columns',
'coupons_report_columns',
'customers_report_columns',
'orders_report_columns',
'products_report_columns',
'revenue_report_columns',
'taxes_report_columns',
'variations_report_columns',
'dashboard_sections',
'dashboard_chart_type',
'dashboard_chart_interval',
'dashboard_leaderboard_rows',
)
);
}
/**
* Register the cache clearing tool on the WooCommerce > Status > Tools page.
*
* @param array $debug_tools Available debug tool registrations.
* @return array Filtered debug tool registrations.
*/
public function register_cache_clear_tool( $debug_tools ) {
$settings_url = add_query_arg(
array(
'page' => 'wc-admin',
'path' => '/analytics/settings',
),
get_admin_url( null, 'admin.php' )
);
$debug_tools[ self::CACHE_TOOL_ID ] = array(
'name' => __( 'Clear analytics cache', 'woocommerce' ),
'button' => __( 'Clear', 'woocommerce' ),
'desc' => sprintf(
/* translators: 1: opening link tag, 2: closing tag */
__( 'This tool will reset the cached values used in WooCommerce Analytics. If numbers still look off, try %1$sReimporting Historical Data%2$s.', 'woocommerce' ),
'<a href="' . esc_url( $settings_url ) . '">',
'</a>'
),
'callback' => array( $this, 'run_clear_cache_tool' ),
);
return $debug_tools;
}
/**
* Registers report pages.
*/
public function register_pages() {
$report_pages = self::get_report_pages();
foreach ( $report_pages as $report_page ) {
if ( ! is_null( $report_page ) ) {
wc_admin_register_page( $report_page );
}
}
}
/**
* Get report pages.
*/
public static function get_report_pages() {
$overview_page = array(
'id' => 'woocommerce-analytics',
'title' => __( 'Analytics', 'woocommerce' ),
'path' => '/analytics/overview',
'icon' => 'dashicons-chart-bar',
'position' => 57, // After WooCommerce & Product menu items.
);
$report_pages = array(
$overview_page,
array(
'id' => 'woocommerce-analytics-overview',
'title' => __( 'Overview', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/overview',
'nav_args' => array(
'order' => 10,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-products',
'title' => __( 'Products', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/products',
'nav_args' => array(
'order' => 20,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-revenue',
'title' => __( 'Revenue', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/revenue',
'nav_args' => array(
'order' => 30,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-orders',
'title' => __( 'Orders', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/orders',
'nav_args' => array(
'order' => 40,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-variations',
'title' => __( 'Variations', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/variations',
'nav_args' => array(
'order' => 50,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-categories',
'title' => __( 'Categories', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/categories',
'nav_args' => array(
'order' => 60,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-coupons',
'title' => __( 'Coupons', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/coupons',
'nav_args' => array(
'order' => 70,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-taxes',
'title' => __( 'Taxes', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/taxes',
'nav_args' => array(
'order' => 80,
'parent' => 'woocommerce-analytics',
),
),
array(
'id' => 'woocommerce-analytics-downloads',
'title' => __( 'Downloads', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/downloads',
'nav_args' => array(
'order' => 90,
'parent' => 'woocommerce-analytics',
),
),
'yes' === get_option( 'woocommerce_manage_stock' ) ? array(
'id' => 'woocommerce-analytics-stock',
'title' => __( 'Stock', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/stock',
'nav_args' => array(
'order' => 100,
'parent' => 'woocommerce-analytics',
),
) : null,
array(
'id' => 'woocommerce-analytics-customers',
'title' => __( 'Customers', 'woocommerce' ),
'parent' => 'woocommerce',
'path' => '/customers',
),
array(
'id' => 'woocommerce-analytics-settings',
'title' => __( 'Settings', 'woocommerce' ),
'parent' => 'woocommerce-analytics',
'path' => '/analytics/settings',
'nav_args' => array(
'title' => __( 'Analytics', 'woocommerce' ),
'parent' => 'woocommerce-settings',
),
),
);
/**
* The analytics report items used in the menu.
*
* @since 6.4.0
*/
return apply_filters( 'woocommerce_analytics_report_menu_items', $report_pages );
}
/**
* "Clear" analytics cache by invalidating it.
*/
public function run_clear_cache_tool() {
Cache::invalidate();
return __( 'Analytics cache cleared.', 'woocommerce' );
}
}
Internal/Admin/BlockTemplateRegistry/BlockTemplateRegistry.php 0000644 00000003213 15153704477 0020633 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template registry.
*/
final class BlockTemplateRegistry {
/**
* Class instance.
*
* @var BlockTemplateRegistry|null
*/
private static $instance = null;
/**
* Templates.
*
* @var array
*/
protected $templates = array();
/**
* Get the instance of the class.
*/
public static function get_instance(): BlockTemplateRegistry {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a single template.
*
* @param BlockTemplateInterface $template Template to register.
*
* @throws \ValueError If a template with the same ID already exists.
*/
public function register( BlockTemplateInterface $template ) {
$id = $template->get_id();
if ( isset( $this->templates[ $id ] ) ) {
throw new \ValueError( 'A template with the specified ID already exists in the registry.' );
}
/**
* Fires when a template is registered.
*
* @param BlockTemplateInterface $template Template that was registered.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_register', $template );
$this->templates[ $id ] = $template;
}
/**
* Get the registered templates.
*/
public function get_all_registered(): array {
return $this->templates;
}
/**
* Get a single registered template.
*
* @param string $id ID of the template.
*/
public function get_registered( $id ): BlockTemplateInterface {
return isset( $this->templates[ $id ] ) ? $this->templates[ $id ] : null;
}
}
Internal/Admin/BlockTemplateRegistry/BlockTemplatesController.php 0000644 00000002675 15153704477 0021344 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
/**
* Block template controller.
*/
class BlockTemplatesController {
/**
* Block template registry
*
* @var BlockTemplateRegistry
*/
private $block_template_registry;
/**
* Block template transformer.
*
* @var TemplateTransformer
*/
private $template_transformer;
/**
* Init.
*/
public function init( $block_template_registry, $template_transformer ) {
$this->block_template_registry = $block_template_registry;
$this->template_transformer = $template_transformer;
add_action( 'rest_api_init', array( $this, 'register_templates' ) );
}
/**
* Register templates in the blocks endpoint.
*/
public function register_templates() {
$templates = $this->block_template_registry->get_all_registered();
foreach ( $templates as $template ) {
add_filter( 'pre_get_block_templates', function( $query_result, $query, $template_type ) use( $template ) {
if ( ! isset( $query['area'] ) || $query['area'] !== $template->get_area() ) {
return $query_result;
}
$wp_block_template = $this->template_transformer->transform( $template );
$query_result[] = $wp_block_template;
return $query_result;
}, 10, 3 );
}
}
} Internal/Admin/BlockTemplateRegistry/TemplateTransformer.php 0000644 00000002575 15153704477 0020364 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Template transformer.
*/
class TemplateTransformer {
/**
* Transform the WooCommerceBlockTemplate to a WP_Block_Template.
*
* @param object $block_template The product template.
*/
public function transform( BlockTemplateInterface $block_template ): \WP_Block_Template {
$template = new \WP_Block_Template();
$template->id = $block_template->get_id();
$template->theme = 'woocommerce/woocommerce';
$template->content = $block_template->get_formatted_template();
$template->source = 'plugin';
$template->slug = $block_template->get_id();
$template->type = 'wp_template';
$template->title = $block_template->get_title();
$template->description = $block_template->get_description();
$template->status = 'publish';
$template->has_theme_file = true;
$template->origin = 'plugin';
$template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
$template->area = $block_template->get_area();
return $template;
}
} Internal/Admin/BlockTemplates/AbstractBlock.php 0000644 00000012175 15153704477 0015533 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Block configuration used to specify blocks in BlockTemplate.
*/
class AbstractBlock implements BlockInterface {
/**
* The block name.
*
* @var string
*/
private $name;
/**
* The block ID.
*
* @var string
*/
private $id;
/**
* The block order.
*
* @var int
*/
private $order = 10;
/**
* The block attributes.
*
* @var array
*/
private $attributes = [];
/**
* The block template that this block belongs to.
*
* @var BlockTemplate
*/
private $root_template;
/**
* The parent container.
*
* @var ContainerInterface
*/
private $parent;
/**
* Block constructor.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param BlockContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
*/
public function __construct( array $config, BlockTemplateInterface &$root_template, ContainerInterface &$parent = null ) {
$this->validate( $config, $root_template, $parent );
$this->root_template = $root_template;
$this->parent = is_null( $parent ) ? $root_template : $parent;
$this->name = $config[ self::NAME_KEY ];
if ( ! isset( $config[ self::ID_KEY ] ) ) {
$this->id = $this->root_template->generate_block_id( $this->get_name() );
} else {
$this->id = $config[ self::ID_KEY ];
}
if ( isset( $config[ self::ORDER_KEY ] ) ) {
$this->order = $config[ self::ORDER_KEY ];
}
if ( isset( $config[ self::ATTRIBUTES_KEY ] ) ) {
$this->attributes = $config[ self::ATTRIBUTES_KEY ];
}
}
/**
* Validate block configuration.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param ContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
*/
protected function validate( array $config, BlockTemplateInterface &$root_template, ContainerInterface &$parent = null ) {
if ( isset( $parent ) && ( $parent->get_root_template() !== $root_template ) ) {
throw new \ValueError( 'The parent block must belong to the same template as the block.' );
}
if ( ! isset( $config[ self::NAME_KEY ] ) || ! is_string( $config[ self::NAME_KEY ] ) ) {
throw new \ValueError( 'The block name must be specified.' );
}
if ( isset( $config[ self::ORDER_KEY ] ) && ! is_int( $config[ self::ORDER_KEY ] ) ) {
throw new \ValueError( 'The block order must be an integer.' );
}
if ( isset( $config[ self::ATTRIBUTES_KEY ] ) && ! is_array( $config[ self::ATTRIBUTES_KEY ] ) ) {
throw new \ValueError( 'The block attributes must be an array.' );
}
}
/**
* Get the block name.
*/
public function get_name(): string {
return $this->name;
}
/**
* Get the block ID.
*/
public function get_id(): string {
return $this->id;
}
/**
* Get the block order.
*/
public function get_order(): int {
return $this->order;
}
/**
* Set the block order.
*
* @param int $order The block order.
*/
public function set_order( int $order ) {
$this->order = $order;
}
/**
* Get the block attributes.
*/
public function get_attributes(): array {
return $this->attributes;
}
/**
* Set the block attributes.
*
* @param array $attributes The block attributes.
*/
public function set_attributes( array $attributes ) {
$this->attributes = $attributes;
}
/**
* Get the template that this block belongs to.
*/
public function &get_root_template(): BlockTemplateInterface {
return $this->root_template;
}
/**
* Get the parent block container.
*/
public function &get_parent(): ContainerInterface {
return $this->parent;
}
/**
* Remove the block from its parent.
*/
public function remove() {
$this->parent->remove_block( $this->id );
}
/**
* Check if the block is detached from its parent block container or the template it belongs to.
*
* @return bool True if the block is detached from its parent block container or the template it belongs to.
*/
public function is_detached(): bool {
$is_in_parent = $this->parent->get_block( $this->id ) === $this;
$is_in_root_template = $this->get_root_template()->get_block( $this->id ) === $this;
return ! ( $is_in_parent && $is_in_root_template );
}
/**
* Get the block configuration as a formatted template.
*
* @return array The block configuration as a formatted template.
*/
public function get_formatted_template(): array {
$arr = [
$this->get_name(),
$this->get_attributes(),
];
return $arr;
}
}
Internal/Admin/BlockTemplates/AbstractBlockTemplate.php 0000644 00000006152 15153704477 0017225 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template class.
*/
abstract class AbstractBlockTemplate implements BlockTemplateInterface {
use BlockContainerTrait;
/**
* Get the template ID.
*/
abstract public function get_id(): string;
/**
* Get the template title.
*/
public function get_title(): string {
return '';
}
/**
* Get the template description.
*/
public function get_description(): string {
return '';
}
/**
* Get the template area.
*/
public function get_area(): string {
return 'uncategorized';
}
/**
* The block cache.
*
* @var BlockInterface[]
*/
private $block_cache = [];
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
return $this->block_cache[ $block_id ] ?? null;
}
/**
* Caches a block in the template. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's add_inner_block() method.
*
* @param BlockInterface $block The block to cache.
*
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \ValueError If the block template that the block belongs to is not this template.
*
* @ignore
*/
public function cache_block( BlockInterface &$block ) {
$id = $block->get_id();
if ( isset( $this->block_cache[ $id ] ) ) {
throw new \ValueError( 'A block with the specified ID already exists in the template.' );
}
if ( $block->get_root_template() !== $this ) {
throw new \ValueError( 'The block template that the block belongs to must be the same as this template.' );
}
$this->block_cache[ $id ] = $block;
}
/**
* Uncaches a block in the template. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's remove_block() method.
*
* @param string $block_id The block ID.
*
* @ignore
*/
public function uncache_block( string $block_id ) {
if ( isset( $this->block_cache[ $block_id ] ) ) {
unset( $this->block_cache[ $block_id ] );
}
}
/**
* Generate a block ID based on a base.
*
* @param string $id_base The base to use when generating an ID.
* @return string
*/
public function generate_block_id( string $id_base ): string {
$instance_count = 0;
do {
$instance_count++;
$block_id = $id_base . '-' . $instance_count;
} while ( isset( $this->block_cache[ $block_id ] ) );
return $block_id;
}
/**
* Get the root template.
*/
public function &get_root_template(): BlockTemplateInterface {
return $this;
}
/**
* Get the inner blocks as a formatted template.
*/
public function get_formatted_template(): array {
$inner_blocks = $this->get_inner_blocks_sorted_by_order();
$inner_blocks_formatted_template = array_map(
function( BlockInterface $block ) {
return $block->get_formatted_template();
},
$inner_blocks
);
return $inner_blocks_formatted_template;
}
}
Internal/Admin/BlockTemplates/Block.php 0000644 00000001356 15153704477 0014046 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Generic block with container properties to be used in BlockTemplate.
*/
class Block extends AbstractBlock implements BlockContainerInterface {
use BlockContainerTrait;
/**
* Add an inner block to this block.
*
* @param array $block_config The block data.
*/
public function &add_block( array $block_config ): BlockInterface {
$block = new Block( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}
Internal/Admin/BlockTemplates/BlockContainerTrait.php 0000644 00000023452 15153704477 0016716 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Trait for block containers.
*/
trait BlockContainerTrait {
/**
* The inner blocks.
*
* @var BlockInterface[]
*/
private $inner_blocks = [];
// phpcs doesn't take into account exceptions thrown by called methods.
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Add a block to the block container.
*
* @param BlockInterface $block The block.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \UnexpectedValueException If the block container is not the parent of the block.
* @throws \UnexpectedValueException If the block container's root template is not the same as the block's root template.
*/
protected function &add_inner_block( BlockInterface $block ): BlockInterface {
if ( $block->get_parent() !== $this ) {
throw new \UnexpectedValueException( 'The block container is not the parent of the block.' );
}
if ( $block->get_root_template() !== $this->get_root_template() ) {
throw new \UnexpectedValueException( 'The block container\'s root template is not the same as the block\'s root template.' );
}
$is_detached = method_exists( $this, 'is_detached' ) && $this->is_detached();
if ( $is_detached ) {
BlockTemplateLogger::get_instance()->warning(
'Block added to detached container. Block will not be included in the template, since the container will not be included in the template.',
[
'block' => $block,
'container' => $this,
'template' => $this->get_root_template(),
]
);
} else {
$this->get_root_template()->cache_block( $block );
}
$this->inner_blocks[] = &$block;
$this->do_after_add_block_action( $block );
$this->do_after_add_specific_block_action( $block );
return $block;
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Checks if a block is a descendant of the block container.
*
* @param BlockInterface $block The block.
*/
private function is_block_descendant( BlockInterface $block ): bool {
$parent = $block->get_parent();
if ( $parent === $this ) {
return true;
}
if ( ! $parent instanceof BlockInterface ) {
return false;
}
return $this->is_block_descendant( $parent );
}
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
foreach ( $this->inner_blocks as $block ) {
if ( $block->get_id() === $block_id ) {
return $block;
}
}
foreach ( $this->inner_blocks as $block ) {
if ( $block instanceof ContainerInterface ) {
$block = $block->get_block( $block_id );
if ( $block ) {
return $block;
}
}
}
return null;
}
/**
* Remove a block from the block container.
*
* @param string $block_id The block ID.
*
* @throws \UnexpectedValueException If the block container is not an ancestor of the block.
*/
public function remove_block( string $block_id ) {
$root_template = $this->get_root_template();
$block = $root_template->get_block( $block_id );
if ( ! $block ) {
return;
}
if ( ! $this->is_block_descendant( $block ) ) {
throw new \UnexpectedValueException( 'The block container is not an ancestor of the block.' );
}
// If the block is a container, remove all of its blocks.
if ( $block instanceof ContainerInterface ) {
$block->remove_blocks();
}
$parent = $block->get_parent();
$parent->remove_inner_block( $block );
}
/**
* Remove all blocks from the block container.
*/
public function remove_blocks() {
array_map(
function ( BlockInterface $block ) {
$this->remove_block( $block->get_id() );
},
$this->inner_blocks
);
}
/**
* Remove a block from the block container's inner blocks. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's remove_block() method.
*
* @param BlockInterface $block The block.
*/
public function remove_inner_block( BlockInterface $block ) {
// Remove block from root template's cache.
$root_template = $this->get_root_template();
$root_template->uncache_block( $block->get_id() );
$this->inner_blocks = array_filter(
$this->inner_blocks,
function ( BlockInterface $inner_block ) use ( $block ) {
return $inner_block !== $block;
}
);
BlockTemplateLogger::get_instance()->info(
'Block removed from template.',
[
'block' => $block,
'template' => $root_template,
]
);
$this->do_after_remove_block_action( $block );
$this->do_after_remove_specific_block_action( $block );
}
/**
* Get the inner blocks sorted by order.
*/
private function get_inner_blocks_sorted_by_order(): array {
$sorted_inner_blocks = $this->inner_blocks;
usort(
$sorted_inner_blocks,
function( BlockInterface $a, BlockInterface $b ) {
return $a->get_order() <=> $b->get_order();
}
);
return $sorted_inner_blocks;
}
/**
* Get the inner blocks as a formatted template.
*/
public function get_formatted_template(): array {
$arr = [
$this->get_name(),
$this->get_attributes(),
];
$inner_blocks = $this->get_inner_blocks_sorted_by_order();
if ( ! empty( $inner_blocks ) ) {
$arr[] = array_map(
function( BlockInterface $block ) {
return $block->get_formatted_template();
},
$inner_blocks
);
}
return $arr;
}
/**
* Do the `woocommerce_block_template_after_add_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is added to a block container.
*
* This action can be used to perform actions after a block is added to the block container,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_add_block', $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after adding block to template.',
'woocommerce_block_template_after_add_block',
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_add_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is added to a template with a specific area.
*
* This action can be used to perform actions after a specific block is added to a template with a specific area,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after adding block to template.',
"woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}",
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_after_remove_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is removed from a block container.
*
* This action can be used to perform actions after a block is removed from the block container,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_remove_block', $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after removing block from template.',
'woocommerce_block_template_after_remove_block',
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_remove_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is removed from a template with a specific area.
*
* This action can be used to perform actions after a specific block is removed from a template with a specific area,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after removing block from template.',
"woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}",
$block,
$e
);
}
}
/**
* Handle an exception thrown by an action.
*
* @param string $message The message.
* @param string $action_tag The action tag.
* @param BlockInterface $block The block.
* @param \Exception $e The exception.
*/
private function handle_exception_doing_action( string $message, string $action_tag, BlockInterface $block, \Exception $e ) {
BlockTemplateLogger::get_instance()->error(
$message,
[
'exception' => $e,
'action' => $action_tag,
'container' => $this,
'block' => $block,
'template' => $this->get_root_template(),
],
);
}
}
Internal/Admin/BlockTemplates/BlockTemplate.php 0000644 00000001400 15153704477 0015530 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template class.
*/
class BlockTemplate extends AbstractBlockTemplate {
/**
* Get the template ID.
*/
public function get_id(): string {
return 'woocommerce-block-template';
}
/**
* Add an inner block to this template.
*
* @param array $block_config The block data.
*/
public function add_block( array $block_config ): BlockInterface {
$block = new Block( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}
Internal/Admin/BlockTemplates/BlockTemplateLogger.php 0000644 00000011140 15153704477 0016672 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Logger for block template modifications.
*/
class BlockTemplateLogger {
/**
* Singleton instance.
*
* @var BlockTemplateLogger
*/
protected static $instance = null;
/**
* Logger instance.
*
* @var \WC_Logger
*/
protected $logger = null;
/**
* Get the singleton instance.
*/
public static function get_instance(): BlockTemplateLogger {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
protected function __construct() {
$this->logger = wc_get_logger();
}
/**
* Log an informational message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function info( string $message, array $info = [] ) {
$this->logger->info(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Log a warning message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function warning( string $message, array $info = [] ) {
$this->logger->warning(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Log an error message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function error( string $message, array $info = [] ) {
$this->logger->error(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Format a message for logging.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
private function format_message( string $message, array $info = [] ): string {
$formatted_message = sprintf(
"%s\n%s",
$message,
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
print_r( $this->format_info( $info ), true ),
);
return $formatted_message;
}
/**
* Format info for logging.
*
* @param array $info Info to log.
*/
private function format_info( array $info ): array {
$formatted_info = $info;
if ( isset( $info['exception'] ) && $info['exception'] instanceof \Exception ) {
$formatted_info['exception'] = $this->format_exception( $info['exception'] );
}
if ( isset( $info['container'] ) ) {
if ( $info['container'] instanceof BlockContainerInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
} elseif ( $info['container'] instanceof BlockTemplateInterface ) {
$formatted_info['container'] = $this->format_template( $info['container'] );
} elseif ( $info['container'] instanceof BlockInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
}
}
if ( isset( $info['block'] ) && $info['block'] instanceof BlockInterface ) {
$formatted_info['block'] = $this->format_block( $info['block'] );
}
if ( isset( $info['template'] ) && $info['template'] instanceof BlockTemplateInterface ) {
$formatted_info['template'] = $this->format_template( $info['template'] );
}
return $formatted_info;
}
/**
* Format an exception for logging.
*
* @param \Exception $exception Exception to format.
*/
private function format_exception( \Exception $exception ): array {
return [
'message' => $exception->getMessage(),
'source' => "{$exception->getFile()}: {$exception->getLine()}",
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
'trace' => print_r( $this->format_exception_trace( $exception->getTrace() ), true ),
];
}
/**
* Format an exception trace for logging.
*
* @param array $trace Exception trace to format.
*/
private function format_exception_trace( array $trace ): array {
$formatted_trace = [];
foreach ( $trace as $source ) {
$formatted_trace[] = "{$source['file']}: {$source['line']}";
}
return $formatted_trace;
}
/**
* Format a block template for logging.
*
* @param BlockTemplateInterface $template Template to format.
*/
private function format_template( BlockTemplateInterface $template ): string {
return "{$template->get_id()} (area: {$template->get_area()})";
}
/**
* Format a block for logging.
*
* @param BlockInterface $block Block to format.
*/
private function format_block( BlockInterface $block ): string {
return "{$block->get_id()} (name: {$block->get_name()})";
}
}
Internal/Admin/CategoryLookup.php 0000644 00000017764 15153704477 0013064 0 ustar 00 <?php
/**
* Keeps the product category lookup table in sync with live data.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* \Automattic\WooCommerce\Internal\Admin\CategoryLookup class.
*/
class CategoryLookup {
/**
* Stores changes to categories we need to sync.
*
* @var array
*/
protected $edited_product_cats = array();
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init hooks.
*/
public function init() {
add_action( 'generate_category_lookup_table', array( $this, 'regenerate' ) );
add_action( 'edit_product_cat', array( $this, 'before_edit' ), 99 );
add_action( 'edited_product_cat', array( $this, 'on_edit' ), 99 );
add_action( 'created_product_cat', array( $this, 'on_create' ), 99 );
add_action( 'init', array( $this, 'define_category_lookup_tables_in_wpdb' ) );
}
/**
* Regenerate all lookup table data.
*/
public function regenerate() {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE $wpdb->wc_category_lookup" );
$terms = get_terms(
'product_cat',
array(
'hide_empty' => false,
'fields' => 'id=>parent',
)
);
$hierarchy = array();
$inserts = array();
$this->unflatten_terms( $hierarchy, $terms, 0 );
$this->get_term_insert_values( $inserts, $hierarchy );
if ( ! $inserts ) {
return;
}
$insert_string = implode(
'),(',
array_map(
function( $item ) {
return implode( ',', $item );
},
$inserts
)
);
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_tree_id,category_id) VALUES ({$insert_string})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Store edits so we know when the parent ID changes.
*
* @param int $category_id Term ID being edited.
*/
public function before_edit( $category_id ) {
$category = get_term( $category_id, 'product_cat' );
$this->edited_product_cats[ $category_id ] = $category->parent;
}
/**
* When a product category gets edited, see if we need to sync the table.
*
* @param int $category_id Term ID being edited.
*/
public function on_edit( $category_id ) {
global $wpdb;
if ( ! isset( $this->edited_product_cats[ $category_id ] ) ) {
return;
}
$category_object = get_term( $category_id, 'product_cat' );
$prev_parent = $this->edited_product_cats[ $category_id ];
$new_parent = $category_object->parent;
// No edits - no need to modify relationships.
if ( $prev_parent === $new_parent ) {
return;
}
$this->delete( $category_id, $prev_parent );
$this->update( $category_id );
}
/**
* When a product category gets created, add a new lookup row.
*
* @param int $category_id Term ID being created.
*/
public function on_create( $category_id ) {
// If WooCommerce is being installed on a multisite, lookup tables haven't been created yet.
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return;
}
$this->update( $category_id );
}
/**
* Delete lookup table data from a tree.
*
* @param int $category_id Category ID to delete.
* @param int $category_tree_id Tree to delete from.
* @return void
*/
protected function delete( $category_id, $category_tree_id ) {
global $wpdb;
if ( ! $category_tree_id ) {
return;
}
$ancestors = get_ancestors( $category_tree_id, 'product_cat', 'taxonomy' );
$ancestors[] = $category_tree_id;
$children = get_term_children( $category_id, 'product_cat' );
$children[] = $category_id;
$id_list = implode( ',', array_map( 'intval', array_unique( array_filter( $children ) ) ) );
foreach ( $ancestors as $ancestor ) {
$wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d AND category_id IN ({$id_list})", $ancestor ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
/**
* Updates lookup table data for a category by ID.
*
* @param int $category_id Category ID to update.
*/
protected function update( $category_id ) {
global $wpdb;
$ancestors = get_ancestors( $category_id, 'product_cat', 'taxonomy' );
$children = get_term_children( $category_id, 'product_cat' );
$inserts = array();
$inserts[] = $this->get_insert_sql( $category_id, $category_id );
$children_ids = array_map( 'intval', array_unique( array_filter( $children ) ) );
foreach ( $ancestors as $ancestor ) {
$inserts[] = $this->get_insert_sql( $category_id, $ancestor );
foreach ( $children_ids as $child_category_id ) {
$inserts[] = $this->get_insert_sql( $child_category_id, $ancestor );
}
}
$insert_string = implode( ',', $inserts );
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_id, category_tree_id) VALUES {$insert_string}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get category lookup table values to insert.
*
* @param int $category_id Category ID to insert.
* @param int $category_tree_id Tree to insert into.
* @return string
*/
protected function get_insert_sql( $category_id, $category_tree_id ) {
global $wpdb;
return $wpdb->prepare( '(%d,%d)', $category_id, $category_tree_id );
}
/**
* Used to construct insert query recursively.
*
* @param array $inserts Array of data to insert.
* @param array $terms Terms to insert.
* @param array $parents Parent IDs the terms belong to.
*/
protected function get_term_insert_values( &$inserts, $terms, $parents = array() ) {
foreach ( $terms as $term ) {
$insert_parents = array_merge( array( $term['term_id'] ), $parents );
foreach ( $insert_parents as $parent ) {
$inserts[] = array(
$parent,
$term['term_id'],
);
}
$this->get_term_insert_values( $inserts, $term['descendants'], $insert_parents );
}
}
/**
* Convert flat terms array into nested array.
*
* @param array $hierarchy Array to put terms into.
* @param array $terms Array of terms (id=>parent).
* @param integer $parent Parent ID.
*/
protected function unflatten_terms( &$hierarchy, &$terms, $parent = 0 ) {
foreach ( $terms as $term_id => $parent_id ) {
if ( (int) $parent_id === $parent ) {
$hierarchy[ $term_id ] = array(
'term_id' => $term_id,
'descendants' => array(),
);
unset( $terms[ $term_id ] );
}
}
foreach ( $hierarchy as $term_id => $terms_array ) {
$this->unflatten_terms( $hierarchy[ $term_id ]['descendants'], $terms, $term_id );
}
}
/**
* Get category descendants.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_descendants( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_id FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d",
$category_id
)
)
);
}
/**
* Return all ancestor category ids for a category.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_ancestors( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_tree_id FROM $wpdb->wc_category_lookup WHERE category_id = %d",
$category_id
)
)
);
}
/**
* Add category lookup table to $wpdb object.
*/
public static function define_category_lookup_tables_in_wpdb() {
global $wpdb;
// List of tables without prefixes.
$tables = array(
'wc_category_lookup' => 'wc_category_lookup',
);
foreach ( $tables as $name => $table ) {
$wpdb->$name = $wpdb->prefix . $table;
$wpdb->tables[] = $table;
}
}
}
Internal/Admin/Coupons.php 0000644 00000006153 15153704477 0011531 0 ustar 00 <?php
/**
* WooCommerce Marketing > Coupons.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Notes\CouponPageMoved;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Coupons feature.
*/
class Coupons {
use CouponsMovedTrait;
/**
* Class instance.
*
* @var Coupons instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
// If the main marketing feature is disabled, don't modify coupon behavior.
if ( ! Features::is_enabled( 'marketing' ) ) {
return;
}
// Only support coupon modifications if coupons are enabled.
if ( ! wc_coupons_enabled() ) {
return;
}
( new CouponPageMoved() )->init();
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_add_marketing_coupon_script' ) );
add_action( 'woocommerce_register_post_type_shop_coupon', array( $this, 'move_coupons' ) );
add_action( 'admin_head', array( $this, 'fix_coupon_menu_highlight' ), 99 );
add_action( 'admin_menu', array( $this, 'maybe_add_coupon_menu_redirect' ) );
}
/**
* Maybe add menu item back in original spot to help people transition
*/
public function maybe_add_coupon_menu_redirect() {
if ( ! $this->should_display_legacy_menu() ) {
return;
}
add_submenu_page(
'woocommerce',
__( 'Coupons', 'woocommerce' ),
__( 'Coupons', 'woocommerce' ),
'manage_options',
'coupons-moved',
[ $this, 'coupon_menu_moved' ]
);
}
/**
* Call back for transition menu item
*/
public function coupon_menu_moved() {
wp_safe_redirect( $this->get_legacy_coupon_url(), 301 );
exit();
}
/**
* Modify registered post type shop_coupon
*
* @param array $args Array of post type parameters.
*
* @return array the filtered parameters.
*/
public function move_coupons( $args ) {
$args['show_in_menu'] = current_user_can( 'manage_woocommerce' ) ? 'woocommerce-marketing' : true;
return $args;
}
/**
* Undo WC modifications to $parent_file for 'shop_coupon'
*/
public function fix_coupon_menu_highlight() {
global $parent_file, $post_type;
if ( $post_type === 'shop_coupon' ) {
$parent_file = 'woocommerce-marketing'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
}
}
/**
* Maybe add our wc-admin coupon scripts if viewing coupon pages
*/
public function maybe_add_marketing_coupon_script() {
$curent_screen = PageController::get_instance()->get_current_page();
if ( ! isset( $curent_screen['id'] ) || $curent_screen['id'] !== 'woocommerce-coupons' ) {
return;
}
$rtl = is_rtl() ? '-rtl' : '';
wp_enqueue_style(
'wc-admin-marketing-coupons',
WCAdminAssets::get_url( "marketing-coupons/style{$rtl}", 'css' ),
array(),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'marketing-coupons', true );
}
}
Internal/Admin/CouponsMovedTrait.php 0000644 00000004233 15153704477 0013525 0 ustar 00 <?php
/**
* A Trait to help with managing the legacy coupon menu.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
/**
* CouponsMovedTrait trait.
*/
trait CouponsMovedTrait {
/**
* The GET query key for the legacy menu.
*
* @var string
*/
protected static $query_key = 'legacy_coupon_menu';
/**
* The key for storing an option in the DB.
*
* @var string
*/
protected static $option_key = 'wc_admin_show_legacy_coupon_menu';
/**
* Get the URL for the legacy coupon management.
*
* @return string The unescaped URL for the legacy coupon management page.
*/
protected static function get_legacy_coupon_url() {
return self::get_coupon_url( [ self::$query_key => true ] );
}
/**
* Get the URL for the coupon management page.
*
* @param array $args Additional URL query arguments.
*
* @return string
*/
protected static function get_coupon_url( $args = [] ) {
$args = array_merge(
[
'post_type' => 'shop_coupon',
],
$args
);
return add_query_arg( $args, admin_url( 'edit.php' ) );
}
/**
* Get the new URL for managing coupons.
*
* @param string $page The management page.
*
* @return string
*/
protected static function get_management_url( $page ) {
$path = '';
switch ( $page ) {
case 'coupon':
case 'coupons':
return self::get_coupon_url();
case 'marketing':
$path = self::get_marketing_path();
break;
}
return "wc-admin&path={$path}";
}
/**
* Get the WC Admin path for the marking page.
*
* @return string
*/
protected static function get_marketing_path() {
return '/marketing/overview';
}
/**
* Whether we should display the legacy coupon menu item.
*
* @return bool
*/
protected static function should_display_legacy_menu() {
return ( get_option( self::$option_key, 1 ) && ! Features::is_enabled( 'navigation' ) );
}
/**
* Set whether we should display the legacy coupon menu item.
*
* @param bool $display Whether the menu should be displayed or not.
*/
protected static function display_legacy_menu( $display = false ) {
update_option( self::$option_key, $display ? 1 : 0 );
}
}
Internal/Admin/CustomerEffortScoreTracks.php 0000644 00000042273 15153704477 0015221 0 ustar 00 <?php
/**
* WooCommerce Customer effort score tracks
*
* @package WooCommerce\Admin\Features
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Triggers customer effort score on several different actions.
*/
class CustomerEffortScoreTracks {
/**
* Option name for the CES Tracks queue.
*/
const CES_TRACKS_QUEUE_OPTION_NAME = 'woocommerce_ces_tracks_queue';
/**
* Option name for the clear CES Tracks queue for page.
*/
const CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME =
'woocommerce_clear_ces_tracks_queue_for_page';
/**
* Option name for the set of actions that have been shown.
*/
const SHOWN_FOR_ACTIONS_OPTION_NAME = 'woocommerce_ces_shown_for_actions';
/**
* Action name for product add/publish.
*/
const PRODUCT_ADD_PUBLISH_ACTION_NAME = 'product_add_publish';
/**
* Action name for product update.
*/
const PRODUCT_UPDATE_ACTION_NAME = 'product_update';
/**
* Action name for shop order update.
*/
const SHOP_ORDER_UPDATE_ACTION_NAME = 'shop_order_update';
/**
* Action name for settings change.
*/
const SETTINGS_CHANGE_ACTION_NAME = 'settings_change';
/**
* Action name for add product categories.
*/
const ADD_PRODUCT_CATEGORIES_ACTION_NAME = 'add_product_categories';
/**
* Action name for add product tags.
*/
const ADD_PRODUCT_TAGS_ACTION_NAME = 'add_product_tags';
/*
* Action name for add product attributes.
*/
const ADD_PRODUCT_ATTRIBUTES_ACTION_NAME = 'add_product_attributes';
/**
* Action name for import products.
*/
const IMPORT_PRODUCTS_ACTION_NAME = 'import_products';
/**
* Action name for search.
*/
const SEARCH_ACTION_NAME = 'ces_search';
/**
* Label for the snackbar that appears when a user submits the survey.
*
* @var string
*/
private $onsubmit_label;
/**
* Constructor. Sets up filters to hook into WooCommerce.
*/
public function __construct() {
$this->enable_survey_enqueing_if_tracking_is_enabled();
}
/**
* Add actions that require woocommerce_allow_tracking.
*/
private function enable_survey_enqueing_if_tracking_is_enabled() {
// Only hook up the action handlers if in wp-admin.
if ( ! is_admin() ) {
return;
}
// Do not hook up the action handlers if a mobile device is used.
if ( wp_is_mobile() ) {
return;
}
// Only enqueue a survey if tracking is allowed.
$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking', 'no' );
if ( ! $allow_tracking ) {
return;
}
add_action( 'admin_init', array( $this, 'maybe_clear_ces_tracks_queue' ) );
add_action( 'woocommerce_update_options', array( $this, 'run_on_update_options' ), 10, 3 );
add_action( 'product_cat_add_form', array( $this, 'add_script_track_product_categories' ), 10, 3 );
add_action( 'product_tag_add_form', array( $this, 'add_script_track_product_tags' ), 10, 3 );
add_action( 'woocommerce_attribute_added', array( $this, 'run_on_add_product_attributes' ), 10, 3 );
add_action( 'load-edit.php', array( $this, 'run_on_load_edit_php' ), 10, 3 );
add_action( 'product_page_product_importer', array( $this, 'run_on_product_import' ), 10, 3 );
// Only hook up the transition_post_status action handler
// if on the edit page.
global $pagenow;
if ( 'post.php' === $pagenow ) {
add_action(
'transition_post_status',
array(
$this,
'run_on_transition_post_status',
),
10,
3
);
}
$this->onsubmit_label = __( 'Thank you for your feedback!', 'woocommerce' );
}
/**
* Returns a generated script for tracking tags added on edit-tags.php page.
* CES survey is triggered via direct access to wc/customer-effort-score store
* via wp.data.dispatch method.
*
* Due to lack of options to directly hook ourselves into the ajax post request
* initiated by edit-tags.php page, we infer a successful request by observing
* an increase of the number of rows in tags table
*
* @param string $action Action name for the survey.
* @param string $title Title for the snackbar.
* @param string $first_question The text for the first question.
* @param string $second_question The text for the second question.
*
* @return string Generated JavaScript to append to page.
*/
private function get_script_track_edit_php( $action, $title, $first_question, $second_question ) {
return sprintf(
"(function( $ ) {
'use strict';
// Hook on submit button and sets a 500ms interval function
// to determine successful add tag or otherwise.
$('#addtag #submit').on( 'click', function() {
const initialCount = $('.tags tbody > tr').length;
const interval = setInterval( function() {
if ( $('.tags tbody > tr').length > initialCount ) {
// New tag detected.
clearInterval( interval );
wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onsubmitLabel: '%s' });
} else {
// Form is no longer loading, most likely failed.
if ( $( '#addtag .submit .spinner.is-active' ).length < 1 ) {
clearInterval( interval );
}
}
}, 500 );
});
})( jQuery );",
esc_js( $action ),
esc_js( $title ),
esc_js( $first_question ),
esc_js( $second_question ),
esc_js( $this->onsubmit_label )
);
}
/**
* Get the current published product count.
*
* @return integer The current published product count.
*/
private function get_product_count() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
$product_count = intval( $products->total );
return $product_count;
}
/**
* Get the current shop order count.
*
* @return integer The current shop order count.
*/
private function get_shop_order_count() {
$query = new \WC_Order_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
)
);
$shop_orders = $query->get_orders();
$shop_order_count = intval( $shop_orders->total );
return $shop_order_count;
}
/**
* Return whether the action has already been shown.
*
* @param string $action The action to check.
*
* @return bool Whether the action has already been shown.
*/
private function has_been_shown( $action ) {
$shown_for_features = get_option( self::SHOWN_FOR_ACTIONS_OPTION_NAME, array() );
$has_been_shown = in_array( $action, $shown_for_features, true );
return $has_been_shown;
}
/**
* Enqueue the item to the CES tracks queue.
*
* @param array $item The item to enqueue.
*/
private function enqueue_to_ces_tracks( $item ) {
$queue = get_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array()
);
$has_duplicate = array_filter(
$queue,
function ( $queue_item ) use ( $item ) {
return $queue_item['action'] === $item['action'];
}
);
if ( $has_duplicate ) {
return;
}
$queue[] = $item;
update_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
$queue
);
}
/**
* Enqueue the CES survey on using search dynamically.
*
* @param string $search_area Search area such as "product" or "shop_order".
* @param string $page_now Value of window.pagenow.
* @param string $admin_page Value of window.adminpage.
*/
public function enqueue_ces_survey_for_search( $search_area, $page_now, $admin_page ) {
if ( $this->has_been_shown( self::SEARCH_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SEARCH_ACTION_NAME,
'title' => __(
'How easy was it to use search?',
'woocommerce'
),
'firstQuestion' => __(
'The search feature in WooCommerce is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The search\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => $page_now,
'adminpage' => $admin_page,
'props' => (object) array(
'search_area' => $search_area,
),
)
);
}
/**
* Hook into the post status lifecycle, to detect relevant user actions
* that we want to survey about.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
* @param Post $post The post.
*/
public function run_on_transition_post_status(
$new_status,
$old_status,
$post
) {
if ( 'product' === $post->post_type ) {
$this->maybe_enqueue_ces_survey_for_product( $new_status, $old_status );
} elseif ( 'shop_order' === $post->post_type ) {
$this->enqueue_ces_survey_for_edited_shop_order();
}
}
/**
* Maybe enqueue the CES survey, if product is being added or edited.
*
* @param string $new_status The new status.
* @param string $old_status The old status.
*/
private function maybe_enqueue_ces_survey_for_product(
$new_status,
$old_status
) {
if ( 'publish' !== $new_status ) {
return;
}
if ( 'publish' !== $old_status ) {
$this->enqueue_ces_survey_for_new_product();
} else {
$this->enqueue_ces_survey_for_edited_product();
}
}
/**
* Enqueue the CES survey trigger for a new product.
*/
private function enqueue_ces_survey_for_new_product() {
if ( $this->has_been_shown( self::PRODUCT_ADD_PUBLISH_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::PRODUCT_ADD_PUBLISH_ACTION_NAME,
'title' => __(
'How easy was it to add a product?',
'woocommerce'
),
'firstQuestion' => __(
'The product creation screen is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The product creation screen\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product',
'adminpage' => 'post-php',
'props' => array(
'product_count' => $this->get_product_count(),
),
)
);
}
/**
* Enqueue the CES survey trigger for an existing product.
*/
private function enqueue_ces_survey_for_edited_product() {
if ( $this->has_been_shown( self::PRODUCT_UPDATE_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::PRODUCT_UPDATE_ACTION_NAME,
'title' => __(
'How easy was it to edit your product?',
'woocommerce'
),
'firstQuestion' => __(
'The product update process is easy to complete.',
'woocommerce'
),
'secondQuestion' => __(
'The product update process meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product',
'adminpage' => 'post-php',
'props' => array(
'product_count' => $this->get_product_count(),
),
)
);
}
/**
* Enqueue the CES survey trigger for an existing shop order.
*/
private function enqueue_ces_survey_for_edited_shop_order() {
if ( $this->has_been_shown( self::SHOP_ORDER_UPDATE_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SHOP_ORDER_UPDATE_ACTION_NAME,
'title' => __(
'How easy was it to update an order?',
'woocommerce'
),
'firstQuestion' => __(
'The order details screen is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The order details screen\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'shop_order',
'adminpage' => 'post-php',
'props' => array(
'order_count' => $this->get_shop_order_count(),
),
)
);
}
/**
* Maybe clear the CES tracks queue, executed on every page load. If the
* clear option is set it clears the queue. In practice, this executes a
* page load after the queued CES tracks are displayed on the client, which
* sets the clear option.
*/
public function maybe_clear_ces_tracks_queue() {
$clear_ces_tracks_queue_for_page = get_option(
self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME,
false
);
if ( ! $clear_ces_tracks_queue_for_page ) {
return;
}
$queue = get_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array()
);
$remaining_items = array_filter(
$queue,
function ( $item ) use ( $clear_ces_tracks_queue_for_page ) {
return $clear_ces_tracks_queue_for_page['pagenow'] !== $item['pagenow']
|| $clear_ces_tracks_queue_for_page['adminpage'] !== $item['adminpage'];
}
);
update_option(
self::CES_TRACKS_QUEUE_OPTION_NAME,
array_values( $remaining_items )
);
update_option( self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME, false );
}
/**
* Appends a script to footer to trigger CES on adding product categories.
*/
public function add_script_track_product_categories() {
if ( $this->has_been_shown( self::ADD_PRODUCT_CATEGORIES_ACTION_NAME ) ) {
return;
}
wc_enqueue_js(
$this->get_script_track_edit_php(
self::ADD_PRODUCT_CATEGORIES_ACTION_NAME,
__( 'How easy was it to add product category?', 'woocommerce' ),
__( 'The product category details screen is easy to use.', 'woocommerce' ),
__( "The product category details screen's functionality meets my needs.", 'woocommerce' )
)
);
}
/**
* Appends a script to footer to trigger CES on adding product tags.
*/
public function add_script_track_product_tags() {
if ( $this->has_been_shown( self::ADD_PRODUCT_TAGS_ACTION_NAME ) ) {
return;
}
wc_enqueue_js(
$this->get_script_track_edit_php(
self::ADD_PRODUCT_TAGS_ACTION_NAME,
__( 'How easy was it to add a product tag?', 'woocommerce' ),
__( 'The product tag details screen is easy to use.', 'woocommerce' ),
__( "The product tag details screen's functionality meets my needs.", 'woocommerce' )
)
);
}
/**
* Maybe enqueue the CES survey on product import, if step is done.
*/
public function run_on_product_import() {
// We're only interested in when the importer completes.
if ( empty( $_GET['step'] ) || 'done' !== $_GET['step'] ) { // phpcs:ignore CSRF ok.
return;
}
if ( $this->has_been_shown( self::IMPORT_PRODUCTS_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::IMPORT_PRODUCTS_ACTION_NAME,
'title' => __(
'How easy was it to import products?',
'woocommerce'
),
'firstQuestion' => __(
'The product import process is easy to complete.',
'woocommerce'
),
'secondQuestion' => __(
'The product import process meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product_page_product_importer',
'adminpage' => 'product_page_product_importer',
'props' => (object) array(),
)
);
}
/**
* Enqueue the CES survey trigger for setting changes.
*/
public function run_on_update_options() {
// $current_tab is set when WC_Admin_Settings::save_settings is called.
global $current_tab;
global $current_section;
if ( $this->has_been_shown( self::SETTINGS_CHANGE_ACTION_NAME ) ) {
return;
}
$props = array(
'settings_area' => $current_tab,
);
if ( $current_section ) {
$props['settings_section'] = $current_section;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::SETTINGS_CHANGE_ACTION_NAME,
'title' => __(
'How easy was it to update your settings?',
'woocommerce'
),
'firstQuestion' => __(
'The settings screen is easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'The settings screen\'s functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'woocommerce_page_wc-settings',
'adminpage' => 'woocommerce_page_wc-settings',
'props' => (object) $props,
)
);
}
/**
* Enqueue the CES survey on adding new product attributes.
*/
public function run_on_add_product_attributes() {
if ( $this->has_been_shown( self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME ) ) {
return;
}
$this->enqueue_to_ces_tracks(
array(
'action' => self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME,
'title' => __(
'How easy was it to add a product attribute?',
'woocommerce'
),
'firstQuestion' => __(
'Product attributes are easy to use.',
'woocommerce'
),
'secondQuestion' => __(
'Product attributes\' functionality meets my needs.',
'woocommerce'
),
'onsubmit_label' => $this->onsubmit_label,
'pagenow' => 'product_page_product_attributes',
'adminpage' => 'product_page_product_attributes',
'props' => (object) array(),
)
);
}
/**
* Determine on initiating CES survey on searching for product or orders.
*/
public function run_on_load_edit_php() {
$allowed_types = array( 'product', 'shop_order' );
$post_type = get_current_screen()->post_type;
// We're only interested for certain post types.
if ( ! in_array( $post_type, $allowed_types, true ) ) {
return;
}
// Determine whether request is search by "s" GET parameter.
if ( empty( $_GET['s'] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
return;
}
$page_now = 'edit-' . $post_type;
$this->enqueue_ces_survey_for_search( $post_type, $page_now, 'edit-php' );
}
}
Internal/Admin/Events.php 0000644 00000021674 15153704477 0011354 0 ustar 00 <?php
/**
* Handle cron events.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\DataSourcePoller;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
use Automattic\WooCommerce\Internal\Admin\Notes\AddFirstProduct;
use Automattic\WooCommerce\Internal\Admin\Notes\ChoosingTheme;
use Automattic\WooCommerce\Internal\Admin\Notes\CouponPageMoved;
use Automattic\WooCommerce\Internal\Admin\Notes\CustomizeStoreWithBlocks;
use Automattic\WooCommerce\Internal\Admin\Notes\CustomizingProductCatalog;
use Automattic\WooCommerce\Internal\Admin\Notes\EditProductsOnTheMove;
use Automattic\WooCommerce\Internal\Admin\Notes\EUVATNumber;
use Automattic\WooCommerce\Internal\Admin\Notes\FirstProduct;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\LaunchChecklist;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Internal\Admin\Notes\ManageOrdersOnTheGo;
use Automattic\WooCommerce\Internal\Admin\Notes\MarketingJetpack;
use Automattic\WooCommerce\Internal\Admin\Notes\MerchantEmailNotifications;
use Automattic\WooCommerce\Internal\Admin\Notes\MigrateFromShopify;
use Automattic\WooCommerce\Internal\Admin\Notes\MobileApp;
use Automattic\WooCommerce\Internal\Admin\Notes\NewSalesRecord;
use Automattic\WooCommerce\Internal\Admin\Notes\OnboardingPayments;
use Automattic\WooCommerce\Internal\Admin\Notes\OnlineClothingStore;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsMoreInfoNeeded;
use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsRemindMeLater;
use Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile;
use Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore;
use Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\UnsecuredReportFiles;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommerceSubscriptions;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaySuggestionsDataSourcePoller;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\RemoteFreeExtensionsDataSourcePoller;
/**
* Events Class.
*/
class Events {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Array of note class to be added or updated.
*
* @var array
*/
private static $note_classes_to_added_or_updated = array(
AddFirstProduct::class,
ChoosingTheme::class,
CustomizeStoreWithBlocks::class,
CustomizingProductCatalog::class,
EditProductsOnTheMove::class,
EUVATNumber::class,
FirstProduct::class,
LaunchChecklist::class,
MagentoMigration::class,
ManageOrdersOnTheGo::class,
MarketingJetpack::class,
MigrateFromShopify::class,
MobileApp::class,
NewSalesRecord::class,
OnboardingPayments::class,
OnlineClothingStore::class,
PaymentsMoreInfoNeeded::class,
PaymentsRemindMeLater::class,
PerformanceOnMobile::class,
PersonalizeStore::class,
RealTimeOrderAlerts::class,
TestCheckout::class,
TrackingOptIn::class,
WooCommercePayments::class,
WooCommerceSubscriptions::class,
);
/**
* The other note classes that are added in other places.
*
* @var array
*/
private static $other_note_classes = array(
CouponPageMoved::class,
InstallJPAndWCSPlugins::class,
OrderMilestones::class,
SellingOnlineCourses::class,
UnsecuredReportFiles::class,
WooSubscriptionsNotes::class,
);
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Cron event handlers.
*/
public function init() {
add_action( 'wc_admin_daily', array( $this, 'do_wc_admin_daily' ) );
add_filter( 'woocommerce_get_note_from_db', array( $this, 'get_note_from_db' ), 10, 1 );
// Initialize the WC_Notes_Refund_Returns Note to attach hook.
\WC_Notes_Refund_Returns::init();
}
/**
* Daily events to run.
*
* Note: Order_Milestones::possibly_add_note is hooked to this as well.
*/
public function do_wc_admin_daily() {
$this->possibly_add_notes();
$this->possibly_delete_notes();
$this->possibly_update_notes();
$this->possibly_refresh_data_source_pollers();
if ( $this->is_remote_inbox_notifications_enabled() ) {
DataSourcePoller::get_instance()->read_specs_from_data_sources();
RemoteInboxNotificationsEngine::run();
}
if ( $this->is_merchant_email_notifications_enabled() ) {
MerchantEmailNotifications::run();
}
if ( Features::is_enabled( 'onboarding' ) ) {
( new MailchimpScheduler() )->run();
}
}
/**
* Get note.
*
* @param Note $note_from_db The note object from the database.
*/
public function get_note_from_db( $note_from_db ) {
if ( ! $note_from_db instanceof Note || get_user_locale() === $note_from_db->get_locale() ) {
return $note_from_db;
}
$note_classes = array_merge( self::$note_classes_to_added_or_updated, self::$other_note_classes );
foreach ( $note_classes as $note_class ) {
if ( defined( "$note_class::NOTE_NAME" ) && $note_class::NOTE_NAME === $note_from_db->get_name() ) {
$note_from_class = method_exists( $note_class, 'get_note' ) ? $note_class::get_note() : null;
if ( $note_from_class instanceof Note ) {
$note = clone $note_from_db;
$note->set_title( $note_from_class->get_title() );
$note->set_content( $note_from_class->get_content() );
$actions = $note_from_class->get_actions();
foreach ( $actions as $action ) {
$matching_action = $note->get_action( $action->name );
if ( $matching_action && $matching_action->id ) {
$action->id = $matching_action->id;
}
}
$note->set_actions( $actions );
return $note;
}
break;
}
}
return $note_from_db;
}
/**
* Adds notes that should be added.
*/
protected function possibly_add_notes() {
foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
if ( method_exists( $note_class, 'possibly_add_note' ) ) {
$note_class::possibly_add_note();
}
}
}
/**
* Deletes notes that should be deleted.
*/
protected function possibly_delete_notes() {
PaymentsRemindMeLater::delete_if_not_applicable();
PaymentsMoreInfoNeeded::delete_if_not_applicable();
}
/**
* Updates notes that should be updated.
*/
protected function possibly_update_notes() {
foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
if ( method_exists( $note_class, 'possibly_update_note' ) ) {
$note_class::possibly_update_note();
}
}
}
/**
* Checks if remote inbox notifications are enabled.
*
* @return bool Whether remote inbox notifications are enabled.
*/
protected function is_remote_inbox_notifications_enabled() {
// Check if the feature flag is disabled.
if ( ! Features::is_enabled( 'remote-inbox-notifications' ) ) {
return false;
}
// Check if the site has opted out of marketplace suggestions.
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) !== 'yes' ) {
return false;
}
// All checks have passed.
return true;
}
/**
* Checks if merchant email notifications are enabled.
*
* @return bool Whether merchant email notifications are enabled.
*/
protected function is_merchant_email_notifications_enabled() {
// Check if the feature flag is disabled.
if ( get_option( 'woocommerce_merchant_email_notifications', 'no' ) !== 'yes' ) {
return false;
}
// All checks have passed.
return true;
}
/**
* Refresh transient for the following DataSourcePollers on wc_admin_daily cron job.
* - PaymentGatewaySuggestionsDataSourcePoller
* - RemoteFreeExtensionsDataSourcePoller
*/
protected function possibly_refresh_data_source_pollers() {
$completed_tasks = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );
if ( ! in_array( 'payments', $completed_tasks, true ) && ! in_array( 'woocommerce-payments', $completed_tasks, true ) ) {
PaymentGatewaySuggestionsDataSourcePoller::get_instance()->read_specs_from_data_sources();
}
if ( ! in_array( 'store_details', $completed_tasks, true ) && ! in_array( 'marketing', $completed_tasks, true ) ) {
RemoteFreeExtensionsDataSourcePoller::get_instance()->read_specs_from_data_sources();
}
}
}
Internal/Admin/FeaturePlugin.php 0000644 00000015136 15153704477 0012656 0 ustar 00 <?php
/**
* WooCommerce Admin: Feature plugin main class.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\MerchantEmailNotifications;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\PluginsInstaller;
use Automattic\WooCommerce\Admin\ReportExporter;
use Automattic\WooCommerce\Admin\ReportsSync;
use Automattic\WooCommerce\Internal\Admin\CategoryLookup;
use Automattic\WooCommerce\Internal\Admin\Events;
use Automattic\WooCommerce\Internal\Admin\Onboarding\Onboarding;
/**
* Feature plugin main class.
*
* @internal This file will not be bundled with woo core, only the feature plugin.
* @internal Note this is not called WC_Admin due to a class already existing in core with that name.
*/
class FeaturePlugin {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init the feature plugin, only if we can detect both Gutenberg and WooCommerce.
*/
public function init() {
// Bail if WC isn't initialized (This can be called from WCAdmin's entrypoint).
if ( ! defined( 'WC_ABSPATH' ) ) {
return;
}
// Load the page controller functions file first to prevent fatal errors when disabling WooCommerce Admin.
$this->define_constants();
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/page-controller-functions.php';
require_once WC_ADMIN_ABSPATH . '/src/Admin/Notes/DeprecatedNotes.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/core-functions.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/feature-config.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/wc-admin-update-functions.php';
require_once WC_ADMIN_ABSPATH . '/includes/react-admin/class-experimental-abtest.php';
if ( did_action( 'plugins_loaded' ) ) {
self::on_plugins_loaded();
} else {
// Make sure we hook into `plugins_loaded` before core's Automattic\WooCommerce\Package::init().
// If core is network activated but we aren't, the packaged version of WooCommerce Admin will
// attempt to use a data store that hasn't been loaded yet - because we've defined our constants here.
// See: https://github.com/woocommerce/woocommerce-admin/issues/3869.
add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ), 9 );
}
}
/**
* Setup plugin once all other plugins are loaded.
*
* @return void
*/
public function on_plugins_loaded() {
$this->hooks();
$this->includes();
}
/**
* Define Constants.
*/
protected function define_constants() {
$this->define( 'WC_ADMIN_APP', 'wc-admin-app' );
$this->define( 'WC_ADMIN_ABSPATH', WC_ABSPATH );
$this->define( 'WC_ADMIN_DIST_JS_FOLDER', 'assets/client/admin/' );
$this->define( 'WC_ADMIN_DIST_CSS_FOLDER', 'assets/client/admin/' );
$this->define( 'WC_ADMIN_PLUGIN_FILE', WC_PLUGIN_FILE );
/**
* Define the WC Admin Images Folder URL.
*
* @deprecated 6.7.0
* @var string
*/
if ( ! defined( 'WC_ADMIN_IMAGES_FOLDER_URL' ) ) {
/**
* Define the WC Admin Images Folder URL.
*
* @deprecated 6.7.0
* @var string
*/
define( 'WC_ADMIN_IMAGES_FOLDER_URL', plugins_url( 'assets/images', WC_PLUGIN_FILE ) );
}
/**
* Define the current WC Admin version.
*
* @deprecated 6.4.0
* @var string
*/
if ( ! defined( 'WC_ADMIN_VERSION_NUMBER' ) ) {
/**
* Define the current WC Admin version.
*
* @deprecated 6.4.0
* @var string
*/
define( 'WC_ADMIN_VERSION_NUMBER', '3.3.0' );
}
}
/**
* Include WC Admin classes.
*/
public function includes() {
// Initialize Database updates, option migrations, and Notes.
Events::instance()->init();
Notes::init();
// Initialize Plugins Installer.
PluginsInstaller::init();
PluginsHelper::init();
// Initialize API.
API\Init::instance();
if ( Features::is_enabled( 'onboarding' ) ) {
Onboarding::init();
}
if ( Features::is_enabled( 'analytics' ) ) {
// Initialize Reports syncing.
ReportsSync::init();
CategoryLookup::instance()->init();
// Initialize Reports exporter.
ReportExporter::init();
}
// Admin note providers.
// @todo These should be bundled in the features/ folder, but loading them from there currently has a load order issue.
new WooSubscriptionsNotes();
new OrderMilestones();
new TrackingOptIn();
new WooCommercePayments();
new InstallJPAndWCSPlugins();
new TestCheckout();
new SellingOnlineCourses();
new MagentoMigration();
// Initialize MerchantEmailNotifications.
MerchantEmailNotifications::init();
}
/**
* Set up our admin hooks and plugin loader.
*/
protected function hooks() {
add_filter( 'woocommerce_admin_features', array( $this, 'replace_supported_features' ), 0 );
Loader::get_instance();
WCAdminAssets::get_instance();
}
/**
* Overwrites the allowed features array using a local `feature-config.php` file.
*
* @param array $features Array of feature slugs.
*/
public function replace_supported_features( $features ) {
/**
* Get additional feature config
*
* @since 6.5.0
*/
$feature_config = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
$features = array_keys( array_filter( $feature_config ) );
return $features;
}
/**
* Define constant if not already set.
*
* @param string $name Constant name.
* @param string|bool $value Constant value.
*/
protected function define( $name, $value ) {
if ( ! defined( $name ) ) {
define( $name, $value );
}
}
/**
* Prevent cloning.
*/
private function __clone() {}
/**
* Prevent unserializing.
*/
public function __wakeup() {
die();
}
}
Internal/Admin/Homescreen.php 0000644 00000020505 15153704477 0012170 0 ustar 00 <?php
/**
* WooCommerce Homescreen.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Shipping;
/**
* Contains backend logic for the homescreen feature.
*/
class Homescreen {
/**
* Menu slug.
*/
const MENU_SLUG = 'wc-admin';
/**
* Class instance.
*
* @var Homescreen instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
add_action( 'admin_menu', array( $this, 'register_page' ) );
// In WC Core 5.1 $submenu manipulation occurs in admin_menu, not admin_head. See https://github.com/woocommerce/woocommerce/pull/29088.
if ( version_compare( WC_VERSION, '5.1', '>=' ) ) {
// priority is 20 to run after admin_menu hook for woocommerce runs, so that submenu is populated.
add_action( 'admin_menu', array( $this, 'possibly_remove_woocommerce_menu' ) );
add_action( 'admin_menu', array( $this, 'update_link_structure' ), 20 );
} else {
// priority is 20 to run after https://github.com/woocommerce/woocommerce/blob/a55ae325306fc2179149ba9b97e66f32f84fdd9c/includes/admin/class-wc-admin-menus.php#L165.
add_action( 'admin_head', array( $this, 'update_link_structure' ), 20 );
}
add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) );
if ( Features::is_enabled( 'shipping-smart-defaults' ) ) {
add_filter(
'woocommerce_admin_shared_settings',
array( $this, 'maybe_set_default_shipping_options_on_home' ),
9999
);
}
}
/**
* Set free shipping in the same country as the store default
* Flag rate in all other countries when any of the following conditions are ture
*
* - The store sells physical products, has JP and WCS installed and connected, and is located in the US.
* - The store sells physical products, and is not located in US/Canada/Australia/UK (irrelevant if JP is installed or not).
* - The store sells physical products and is located in US, but JP and WCS are not installed.
*
* @param array $settings shared admin settings.
* @return array
*/
public function maybe_set_default_shipping_options_on_home( $settings ) {
if ( ! function_exists( 'get_current_screen' ) ) {
return $settings;
}
$current_screen = get_current_screen();
// Abort if it's not the homescreen.
if ( ! isset( $current_screen->id ) || 'woocommerce_page_wc-admin' !== $current_screen->id ) {
return $settings;
}
// Abort if we already created the shipping options.
$already_created = get_option( 'woocommerce_admin_created_default_shipping_zones' );
if ( $already_created === 'yes' ) {
return $settings;
}
$zone_count = count( \WC_Data_Store::load( 'shipping-zone' )->get_zones() );
if ( $zone_count ) {
update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
update_option( 'woocommerce_admin_reviewed_default_shipping_zones', 'yes' );
return $settings;
}
$user_skipped_obw = $settings['onboarding']['profile']['skipped'] ?? false;
$store_address = $settings['preloadSettings']['general']['woocommerce_store_address'] ?? '';
$product_types = $settings['onboarding']['profile']['product_types'] ?? array();
$user_has_set_store_country = $settings['onboarding']['profile']['is_store_country_set'] ?? false;
// Do not proceed if user has not filled out their country in the onboarding profiler.
if ( ! $user_has_set_store_country ) {
return $settings;
}
// If user skipped the obw or has not completed the store_details
// then we assume the user is going to sell physical products.
if ( $user_skipped_obw || '' === $store_address ) {
$product_types[] = 'physical';
}
if ( false === in_array( 'physical', $product_types, true ) ) {
return $settings;
}
$country_code = wc_format_country_state_string( $settings['preloadSettings']['general']['woocommerce_default_country'] )['country'];
$country_name = WC()->countries->get_countries()[ $country_code ] ?? null;
$is_jetpack_installed = in_array( 'jetpack', $settings['plugins']['installedPlugins'] ?? array(), true );
$is_wcs_installed = in_array( 'woocommerce-services', $settings['plugins']['installedPlugins'] ?? array(), true );
if (
( 'US' === $country_code && $is_jetpack_installed )
||
( ! in_array( $country_code, array( 'CA', 'AU', 'GB', 'ES', 'IT', 'DE', 'FR', 'MX', 'CO', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true ) )
||
( 'US' === $country_code && false === $is_jetpack_installed && false === $is_wcs_installed )
) {
$zone = new \WC_Shipping_Zone();
$zone->set_zone_name( $country_name );
$zone->add_location( $country_code, 'country' );
$zone->add_shipping_method( 'free_shipping' );
update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
Shipping::delete_zone_count_transient();
}
return $settings;
}
/**
* Adds fields so that we can store performance indicators, row settings, and chart type settings for users.
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'homepage_layout',
'homepage_stats',
'task_list_tracked_started_tasks',
'help_panel_highlight_shown',
)
);
}
/**
* Registers home page.
*/
public function register_page() {
// Register a top-level item for users who cannot view the core WooCommerce menu.
if ( ! self::is_admin_user() ) {
wc_admin_register_page(
array(
'id' => 'woocommerce-home',
'title' => __( 'WooCommerce', 'woocommerce' ),
'path' => self::MENU_SLUG,
'capability' => 'read',
)
);
return;
}
wc_admin_register_page(
array(
'id' => 'woocommerce-home',
'title' => __( 'Home', 'woocommerce' ),
'parent' => 'woocommerce',
'path' => self::MENU_SLUG,
'order' => 0,
'capability' => 'read',
)
);
}
/**
* Check if the user can access the top-level WooCommerce item.
*
* @return bool
*/
public static function is_admin_user() {
if ( ! class_exists( 'WC_Admin_Menus', false ) ) {
include_once WC_ABSPATH . 'includes/admin/class-wc-admin-menus.php';
}
if ( method_exists( 'WC_Admin_Menus', 'can_view_woocommerce_menu_item' ) ) {
return \WC_Admin_Menus::can_view_woocommerce_menu_item() || current_user_can( 'manage_woocommerce' );
} else {
// We leave this line for WC versions <= 6.2.
return current_user_can( 'edit_others_shop_orders' ) || current_user_can( 'manage_woocommerce' );
}
}
/**
* Possibly remove the WooCommerce menu item if it was purely used to access wc-admin pages.
*/
public function possibly_remove_woocommerce_menu() {
global $menu;
if ( self::is_admin_user() ) {
return;
}
foreach ( $menu as $key => $menu_item ) {
if ( self::MENU_SLUG !== $menu_item[2] || 'read' !== $menu_item[1] ) {
continue;
}
unset( $menu[ $key ] );
}
}
/**
* Update the WooCommerce menu structure to make our main dashboard/handler
* the top level link for 'WooCommerce'.
*/
public function update_link_structure() {
global $submenu;
// User does not have capabilites to see the submenu.
if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) {
return;
}
$wc_admin_key = null;
foreach ( $submenu['woocommerce'] as $submenu_key => $submenu_item ) {
if ( self::MENU_SLUG === $submenu_item[2] ) {
$wc_admin_key = $submenu_key;
break;
}
}
if ( ! $wc_admin_key ) {
return;
}
$menu = $submenu['woocommerce'][ $wc_admin_key ];
// Move menu item to top of array.
unset( $submenu['woocommerce'][ $wc_admin_key ] );
array_unshift( $submenu['woocommerce'], $menu );
}
/**
* Preload options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_options( $options ) {
$options[] = 'woocommerce_default_homepage_layout';
$options[] = 'woocommerce_admin_install_timestamp';
return $options;
}
}
Internal/Admin/Loader.php 0000644 00000050404 15153704477 0011307 0 ustar 00 <?php
/**
* Register the scripts, styles, and includes needed for pieces of the WooCommerce Admin experience.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplatesController;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
use Automattic\WooCommerce\Internal\Admin\Settings;
/**
* Loader Class.
*/
class Loader {
/**
* Class instance.
*
* @var Loader instance
*/
protected static $instance = null;
/**
* An array of classes to load from the includes folder.
*
* @var array
*/
protected static $classes = array();
/**
* WordPress capability required to use analytics features.
*
* @var string
*/
protected static $required_capability = null;
/**
* An array of dependencies that have been preloaded (to avoid duplicates).
*
* @var array
*/
protected $preloaded_dependencies = array(
'script' => array(),
'style' => array(),
);
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
Features::get_instance();
WCAdminSharedSettings::get_instance();
Translations::get_instance();
WCAdminUser::get_instance();
Settings::get_instance();
SiteHealth::get_instance();
SystemStatusReport::get_instance();
wc_get_container()->get( Reviews::class );
wc_get_container()->get( ReviewsCommentsOverrides::class );
wc_get_container()->get( BlockTemplatesController::class );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
add_filter( 'admin_title', array( __CLASS__, 'update_admin_title' ) );
add_action( 'in_admin_header', array( __CLASS__, 'embed_page_header' ) );
add_action( 'admin_head', array( __CLASS__, 'remove_notices' ) );
add_action( 'admin_head', array( __CLASS__, 'smart_app_banner' ) );
add_action( 'admin_notices', array( __CLASS__, 'inject_before_notices' ), -9999 );
add_action( 'admin_notices', array( __CLASS__, 'inject_after_notices' ), PHP_INT_MAX );
// Added this hook to delete the field woocommerce_onboarding_homepage_post_id when deleting the homepage.
add_action( 'trashed_post', array( __CLASS__, 'delete_homepage' ) );
/*
* Remove the emoji script as it always defaults to replacing emojis with Twemoji images.
* Gutenberg has also disabled emojis. More on that here -> https://github.com/WordPress/gutenberg/pull/6151
*/
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
add_action( 'admin_init', array( __CLASS__, 'deactivate_wc_admin_plugin' ) );
add_action( 'load-themes.php', array( __CLASS__, 'add_appearance_theme_view_tracks_event' ) );
}
/**
* If WooCommerce Admin is installed and activated, it will attempt to deactivate and show a notice.
*/
public static function deactivate_wc_admin_plugin() {
$plugin_path = PluginsHelper::get_plugin_path_from_slug( 'woocommerce-admin' );
if ( is_plugin_active( $plugin_path ) ) {
$path = PluginsHelper::get_plugin_path_from_slug( 'woocommerce-admin' );
deactivate_plugins( $path );
$notice_action = is_network_admin() ? 'network_admin_notices' : 'admin_notices';
add_action(
$notice_action,
function() {
echo '<div class="error"><p>';
printf(
/* translators: %s: is referring to the plugin's name. */
esc_html__( 'The %1$s plugin has been deactivated as the latest improvements are now included with the %2$s plugin.', 'woocommerce' ),
'<code>WooCommerce Admin</code>',
'<code>WooCommerce</code>'
);
echo '</p></div>';
}
);
}
}
/**
* Returns breadcrumbs for the current page.
*/
private static function get_embed_breadcrumbs() {
return wc_admin_get_breadcrumbs();
}
/**
* Outputs breadcrumbs via PHP for the initial load of an embedded page.
*
* @param array $section Section to create breadcrumb from.
*/
private static function output_heading( $section ) {
echo esc_html( $section );
}
/**
* Set up a div for the header embed to render into.
* The initial contents here are meant as a place loader for when the PHP page initialy loads.
*/
public static function embed_page_header() {
if ( ! PageController::is_admin_page() && ! PageController::is_embed_page() ) {
return;
}
if ( ! PageController::is_embed_page() ) {
return;
}
$sections = self::get_embed_breadcrumbs();
$sections = is_array( $sections ) ? $sections : array( $sections );
?>
<div id="woocommerce-embedded-root" class="is-embed-loading">
<div class="woocommerce-layout">
<div class="woocommerce-layout__header is-embed-loading">
<h1 class="woocommerce-layout__header-heading">
<?php self::output_heading( end( $sections ) ); ?>
</h1>
</div>
</div>
</div>
<?php
}
/**
* Adds body classes to the main wp-admin wrapper, allowing us to better target elements in specific scenarios.
*
* @param string $admin_body_class Body class to add.
*/
public static function add_admin_body_classes( $admin_body_class = '' ) {
if ( ! PageController::is_admin_or_embed_page() ) {
return $admin_body_class;
}
$classes = explode( ' ', trim( $admin_body_class ) );
$classes[] = 'woocommerce-admin-page';
if ( PageController::is_embed_page() ) {
$classes[] = 'woocommerce-embed-page';
}
/**
* Some routes or features like onboarding hide the wp-admin navigation and masterbar.
* Setting `woocommerce_admin_is_loading` to true allows us to premeptively hide these
* elements while the JS app loads.
* This class needs to be removed by those feature components (like <ProfileWizard />).
*
* @param bool $is_loading If WooCommerce Admin is loading a fullscreen view.
*/
$is_loading = apply_filters( 'woocommerce_admin_is_loading', false );
if ( PageController::is_admin_page() && $is_loading ) {
$classes[] = 'woocommerce-admin-is-loading';
}
$admin_body_class = implode( ' ', array_unique( $classes ) );
return " $admin_body_class ";
}
/**
* Adds an iOS "Smart App Banner" for display on iOS Safari.
* See https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
*/
public static function smart_app_banner() {
if ( PageController::is_admin_or_embed_page() ) {
echo "
<meta name='apple-itunes-app' content='app-id=1389130815'>
";
}
}
/**
* Removes notices that should not be displayed on WC Admin pages.
*/
public static function remove_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Hello Dolly.
if ( function_exists( 'hello_dolly' ) ) {
remove_action( 'admin_notices', 'hello_dolly' );
}
}
/**
* Runs before admin notices action and hides them.
*/
public static function inject_before_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// The JITMs won't be shown in the Onboarding Wizard.
$is_onboarding = isset( $_GET['path'] ) && '/setup-wizard' === wc_clean( wp_unslash( $_GET['path'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
$maybe_hide_jitm = $is_onboarding ? '-hide' : '';
echo '<div class="woocommerce-layout__jitm' . sanitize_html_class( $maybe_hide_jitm ) . '" id="jp-admin-notices"></div>';
// Wrap the notices in a hidden div to prevent flickering before
// they are moved elsewhere in the page by WordPress Core.
echo '<div class="woocommerce-layout__notice-list-hide" id="wp__notice-list">';
if ( PageController::is_admin_page() ) {
// Capture all notices and hide them. WordPress Core looks for
// `.wp-header-end` and appends notices after it if found.
// https://github.com/WordPress/WordPress/blob/f6a37e7d39e2534d05b9e542045174498edfe536/wp-admin/js/common.js#L737 .
echo '<div class="wp-header-end" id="woocommerce-layout__notice-catcher"></div>';
}
}
/**
* Runs after admin notices and closes div.
*/
public static function inject_after_notices() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Close the hidden div used to prevent notices from flickering before
// they are inserted elsewhere in the page.
echo '</div>';
}
/**
* Edits Admin title based on section of wc-admin.
*
* @param string $admin_title Modifies admin title.
* @todo Can we do some URL rewriting so we can figure out which page they are on server side?
*/
public static function update_admin_title( $admin_title ) {
if (
! did_action( 'current_screen' ) ||
! PageController::is_admin_page()
) {
return $admin_title;
}
$sections = self::get_embed_breadcrumbs();
$pieces = array();
foreach ( $sections as $section ) {
$pieces[] = is_array( $section ) ? $section[1] : $section;
}
$pieces = array_reverse( $pieces );
$title = implode( ' ‹ ', $pieces );
/* translators: %1$s: updated title, %2$s: blog info name */
return sprintf( __( '%1$s ‹ %2$s', 'woocommerce' ), $title, get_bloginfo( 'name' ) );
}
/**
* Set up a div for the app to render into.
*/
public static function page_wrapper() {
?>
<div class="wrap">
<div id="root"></div>
</div>
<?php
}
/**
* Hooks extra necessary data into the component settings array already set in WooCommerce core.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public static function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
if ( ! function_exists( 'wc_blocks_container' ) ) {
global $wp_locale;
// inject data not available via older versions of wc_blocks/woo.
$settings['orderStatuses'] = self::get_order_statuses( wc_get_order_statuses() );
$settings['stockStatuses'] = self::get_order_statuses( wc_get_product_stock_status_options() );
$settings['currency'] = self::get_currency_settings();
$settings['locale'] = [
'siteLocale' => isset( $settings['siteLocale'] )
? $settings['siteLocale']
: get_locale(),
'userLocale' => isset( $settings['l10n']['userLocale'] )
? $settings['l10n']['userLocale']
: get_user_locale(),
'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
? $settings['l10n']['weekdaysShort']
: array_values( $wp_locale->weekday_abbrev ),
];
}
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
if ( class_exists( 'Jetpack' ) ) {
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
}
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
'rest_preload_api_request'
);
}
$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
if ( ! empty( $preload_options ) ) {
foreach ( $preload_options as $option ) {
$settings['preloadOptions'][ $option ] = get_option( $option );
}
}
$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
if ( ! empty( $preload_settings ) ) {
$setting_options = new \WC_REST_Setting_Options_V2_Controller();
foreach ( $preload_settings as $group ) {
$group_settings = $setting_options->get_group_settings( $group );
$preload_settings = [];
foreach ( $group_settings as $option ) {
if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
$preload_settings[ $option['id'] ] = $option['value'];
}
}
$settings['preloadSettings'][ $group ] = $preload_settings;
}
}
$user_controller = new \WP_REST_Users_Controller();
$request = new \WP_REST_Request();
$request->set_query_params( array( 'context' => 'edit' ) );
$user_response = $user_controller->get_current_item( $request );
$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();
$settings['currentUserData'] = $current_user_data;
$settings['reviewsEnabled'] = get_option( 'woocommerce_enable_reviews' );
$settings['manageStock'] = get_option( 'woocommerce_manage_stock' );
$settings['commentModeration'] = get_option( 'comment_moderation' );
$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );
// @todo On merge, once plugin images are added to core WooCommerce, `wcAdminAssetUrl` can be retired,
// and `wcAssetUrl` can be used in its place throughout the codebase.
$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
$settings['wcVersion'] = WC_VERSION;
$settings['siteUrl'] = site_url();
$settings['shopUrl'] = get_permalink( wc_get_page_id( 'shop' ) );
$settings['homeUrl'] = home_url();
$settings['dateFormat'] = get_option( 'date_format' );
$settings['timeZone'] = wc_timezone_string();
$settings['plugins'] = array(
'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
'activePlugins' => Plugins::get_active_plugins(),
);
// Plugins that depend on changing the translation work on the server but not the client -
// WooCommerce Branding is an example of this - so pass through the translation of
// 'WooCommerce' to wcSettings.
$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );
// We may have synced orders with a now-unregistered status.
// E.g An extension that added statuses is now inactive or removed.
$settings['unregisteredOrderStatuses'] = self::get_unregistered_order_statuses();
// The separator used for attributes found in Variation titles.
$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );
if ( ! empty( $preload_data_endpoints ) ) {
$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
? $settings['dataEndpoints']
: [];
foreach ( $preload_data_endpoints as $key => $endpoint ) {
// Handle error case: rest_do_request() doesn't guarantee success.
if ( empty( $preload_data[ $endpoint ] ) ) {
$settings['dataEndpoints'][ $key ] = array();
} else {
$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
}
}
}
$settings = self::get_custom_settings( $settings );
if ( PageController::is_embed_page() ) {
$settings['embedBreadcrumbs'] = self::get_embed_breadcrumbs();
}
$settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions();
$settings['connectNonce'] = wp_create_nonce( 'connect' );
$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
return $settings;
}
/**
* Format order statuses by removing a leading 'wc-' if present.
*
* @param array $statuses Order statuses.
* @return array formatted statuses.
*/
public static function get_order_statuses( $statuses ) {
$formatted_statuses = array();
foreach ( $statuses as $key => $value ) {
$formatted_key = preg_replace( '/^wc-/', '', $key );
$formatted_statuses[ $formatted_key ] = $value;
}
return $formatted_statuses;
}
/**
* Get all order statuses present in analytics tables that aren't registered.
*
* @return array Unregistered order statuses.
*/
public static function get_unregistered_order_statuses() {
$registered_statuses = wc_get_order_statuses();
$all_synced_statuses = OrdersDataStore::get_all_statuses();
$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
$formatted_status_keys = self::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
$formatted_statuses = array_keys( $formatted_status_keys );
return array_combine( $formatted_statuses, $formatted_statuses );
}
/**
* Register the admin settings for use in the WC REST API
*
* @param array $groups Array of setting groups.
* @return array
*/
public static function add_settings_group( $groups ) {
$groups[] = array(
'id' => 'wc_admin',
'label' => __( 'WooCommerce Admin', 'woocommerce' ),
'description' => __( 'Settings for WooCommerce admin reporting.', 'woocommerce' ),
);
return $groups;
}
/**
* Add WC Admin specific settings
*
* @param array $settings Array of settings in wc admin group.
* @return array
*/
public static function add_settings( $settings ) {
$unregistered_statuses = self::get_unregistered_order_statuses();
$registered_statuses = self::get_order_statuses( wc_get_order_statuses() );
$all_statuses = array_merge( $unregistered_statuses, $registered_statuses );
$settings[] = array(
'id' => 'woocommerce_excluded_report_order_statuses',
'option_key' => 'woocommerce_excluded_report_order_statuses',
'label' => __( 'Excluded report order statuses', 'woocommerce' ),
'description' => __( 'Statuses that should not be included when calculating report totals.', 'woocommerce' ),
'default' => array( 'pending', 'cancelled', 'failed' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_actionable_order_statuses',
'option_key' => 'woocommerce_actionable_order_statuses',
'label' => __( 'Actionable order statuses', 'woocommerce' ),
'description' => __( 'Statuses that require extra action on behalf of the store admin.', 'woocommerce' ),
'default' => array( 'processing', 'on-hold' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_default_date_range',
'option_key' => 'woocommerce_default_date_range',
'label' => __( 'Default Date Range', 'woocommerce' ),
'description' => __( 'Default Date Range', 'woocommerce' ),
'default' => 'period=month&compare=previous_year',
'type' => 'text',
);
return $settings;
}
/**
* Gets custom settings used for WC Admin.
*
* @param array $settings Array of settings to merge into.
* @return array
*/
public static function get_custom_settings( $settings ) {
$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
$wc_admin_group_settings = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
$settings['wcAdminSettings'] = array();
foreach ( $wc_admin_group_settings as $setting ) {
if ( ! empty( $setting['id'] ) ) {
$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
}
}
return $settings;
}
/**
* Return an object defining the currecy options for the site's current currency
*
* @return array Settings for the current currency {
* Array of settings.
*
* @type string $code Currency code.
* @type string $precision Number of decimals.
* @type string $symbol Symbol for currency.
* }
*/
public static function get_currency_settings() {
$code = get_woocommerce_currency();
return apply_filters(
'wc_currency_settings',
array(
'code' => $code,
'precision' => wc_get_price_decimals(),
'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $code ) ),
'symbolPosition' => get_option( 'woocommerce_currency_pos' ),
'decimalSeparator' => wc_get_price_decimal_separator(),
'thousandSeparator' => wc_get_price_thousand_separator(),
'priceFormat' => html_entity_decode( get_woocommerce_price_format() ),
)
);
}
/**
* Delete woocommerce_onboarding_homepage_post_id field when the homepage is deleted
*
* @param int $post_id The deleted post id.
*/
public static function delete_homepage( $post_id ) {
if ( 'page' !== get_post_type( $post_id ) ) {
return;
}
$homepage_id = intval( get_option( 'woocommerce_onboarding_homepage_post_id', false ) );
if ( $homepage_id === $post_id ) {
delete_option( 'woocommerce_onboarding_homepage_post_id' );
}
}
/**
* Adds the appearance_theme_view Tracks event.
*/
public static function add_appearance_theme_view_tracks_event() {
wc_admin_record_tracks_event( 'appearance_theme_view', array() );
}
}
Internal/Admin/Marketing/MarketingSpecs.php 0000644 00000013404 15153704477 0014740 0 ustar 00 <?php
/**
* Marketing Specs Handler
*
* Fetches the specifications for the marketing feature from WC.com API.
*/
namespace Automattic\WooCommerce\Internal\Admin\Marketing;
/**
* Marketing Specifications Class.
*
* @internal
* @since x.x.x
*/
class MarketingSpecs {
/**
* Name of recommended plugins transient.
*
* @var string
*/
const RECOMMENDED_PLUGINS_TRANSIENT = 'wc_marketing_recommended_plugins';
/**
* Name of knowledge base post transient.
*
* @var string
*/
const KNOWLEDGE_BASE_TRANSIENT = 'wc_marketing_knowledge_base';
/**
* Slug of the category specifying marketing extensions on the WooCommerce.com store.
*
* @var string
*/
const MARKETING_EXTENSION_CATEGORY_SLUG = 'marketing';
/**
* Slug of the subcategory specifying marketing channels on the WooCommerce.com store.
*
* @var string
*/
const MARKETING_CHANNEL_SUBCATEGORY_SLUG = 'sales-channels';
/**
* Load recommended plugins from WooCommerce.com
*
* @return array
*/
public function get_recommended_plugins(): array {
$plugins = get_transient( self::RECOMMENDED_PLUGINS_TRANSIENT );
if ( false === $plugins ) {
$request = wp_remote_get(
'https://woocommerce.com/wp-json/wccom/marketing-tab/1.3/recommendations.json',
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$plugins = [];
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$plugins = json_decode( $request['body'], true );
}
set_transient(
self::RECOMMENDED_PLUGINS_TRANSIENT,
$plugins,
// Expire transient in 15 minutes if remote get failed.
// Cache an empty result to avoid repeated failed requests.
empty( $plugins ) ? 900 : 3 * DAY_IN_SECONDS
);
}
return array_values( $plugins );
}
/**
* Return only the recommended marketing channels from WooCommerce.com.
*
* @return array
*/
public function get_recommended_marketing_channels(): array {
return array_filter( $this->get_recommended_plugins(), [ $this, 'is_marketing_channel_plugin' ] );
}
/**
* Return all recommended marketing extensions EXCEPT the marketing channels from WooCommerce.com.
*
* @return array
*/
public function get_recommended_marketing_extensions_excluding_channels(): array {
return array_filter(
$this->get_recommended_plugins(),
function ( array $plugin_data ) {
return $this->is_marketing_plugin( $plugin_data ) && ! $this->is_marketing_channel_plugin( $plugin_data );
}
);
}
/**
* Returns whether a plugin is a marketing extension.
*
* @param array $plugin_data The plugin properties returned by the API.
*
* @return bool
*/
protected function is_marketing_plugin( array $plugin_data ): bool {
$categories = $plugin_data['categories'] ?? [];
return in_array( self::MARKETING_EXTENSION_CATEGORY_SLUG, $categories, true );
}
/**
* Returns whether a plugin is a marketing channel.
*
* @param array $plugin_data The plugin properties returned by the API.
*
* @return bool
*/
protected function is_marketing_channel_plugin( array $plugin_data ): bool {
if ( ! $this->is_marketing_plugin( $plugin_data ) ) {
return false;
}
$subcategories = $plugin_data['subcategories'] ?? [];
foreach ( $subcategories as $subcategory ) {
if ( isset( $subcategory['slug'] ) && self::MARKETING_CHANNEL_SUBCATEGORY_SLUG === $subcategory['slug'] ) {
return true;
}
}
return false;
}
/**
* Load knowledge base posts from WooCommerce.com
*
* @param string|null $term Term of posts to retrieve.
* @return array
*/
public function get_knowledge_base_posts( ?string $term ): array {
$terms = array(
'marketing' => array(
'taxonomy' => 'category',
'term_id' => 1744,
'argument' => 'categories',
),
'coupons' => array(
'taxonomy' => 'post_tag',
'term_id' => 1377,
'argument' => 'tags',
),
);
// Default to the marketing category (if no term is set on the kb component).
if ( empty( $term ) || ! array_key_exists( $term, $terms ) ) {
$term = 'marketing';
}
$term_id = $terms[ $term ]['term_id'];
$argument = $terms[ $term ]['argument'];
$kb_transient = self::KNOWLEDGE_BASE_TRANSIENT . '_' . strtolower( $term );
$posts = get_transient( $kb_transient );
if ( false === $posts ) {
$request_url = add_query_arg(
array(
$argument => $term_id,
'page' => 1,
'per_page' => 8,
'_embed' => 1,
),
'https://woocommerce.com/wp-json/wp/v2/posts?utm_medium=product'
);
$request = wp_remote_get(
$request_url,
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$posts = [];
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$raw_posts = json_decode( $request['body'], true );
foreach ( $raw_posts as $raw_post ) {
$post = [
'title' => html_entity_decode( $raw_post['title']['rendered'] ),
'date' => $raw_post['date_gmt'],
'link' => $raw_post['link'],
'author_name' => isset( $raw_post['author_name'] ) ? html_entity_decode( $raw_post['author_name'] ) : '',
'author_avatar' => isset( $raw_post['author_avatar_url'] ) ? $raw_post['author_avatar_url'] : '',
];
$featured_media = $raw_post['_embedded']['wp:featuredmedia'] ?? [];
if ( count( $featured_media ) > 0 ) {
$image = current( $featured_media );
$post['image'] = add_query_arg(
array(
'resize' => '650,340',
'crop' => 1,
),
$image['source_url']
);
}
$posts[] = $post;
}
}
set_transient(
$kb_transient,
$posts,
// Expire transient in 15 minutes if remote get failed.
empty( $posts ) ? 900 : DAY_IN_SECONDS
);
}
return $posts;
}
}
Internal/Admin/Marketing.php 0000644 00000007404 15153704477 0012024 0 ustar 00 <?php
/**
* WooCommerce Marketing.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Marketing feature.
*/
class Marketing {
use CouponsMovedTrait;
/**
* Class instance.
*
* @var Marketing instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_action( 'admin_menu', array( $this, 'register_pages' ), 5 );
add_action( 'admin_menu', array( $this, 'add_parent_menu_item' ), 6 );
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 30 );
}
/**
* Add main marketing menu item.
*
* Uses priority of 9 so other items can easily be added at the default priority (10).
*/
public function add_parent_menu_item() {
if ( ! Features::is_enabled( 'navigation' ) ) {
add_menu_page(
__( 'Marketing', 'woocommerce' ),
__( 'Marketing', 'woocommerce' ),
'manage_woocommerce',
'woocommerce-marketing',
null,
'dashicons-megaphone',
58
);
}
PageController::get_instance()->connect_page(
[
'id' => 'woocommerce-marketing',
'title' => 'Marketing',
'capability' => 'manage_woocommerce',
'path' => 'wc-admin&path=/marketing',
]
);
}
/**
* Registers report pages.
*/
public function register_pages() {
$this->register_overview_page();
$controller = PageController::get_instance();
$defaults = [
'parent' => 'woocommerce-marketing',
'existing_page' => false,
];
$marketing_pages = apply_filters( 'woocommerce_marketing_menu_items', [] );
foreach ( $marketing_pages as $marketing_page ) {
if ( ! is_array( $marketing_page ) ) {
continue;
}
$marketing_page = array_merge( $defaults, $marketing_page );
if ( $marketing_page['existing_page'] ) {
$controller->connect_page( $marketing_page );
} else {
$controller->register_page( $marketing_page );
}
}
}
/**
* Register the main Marketing page, which is Marketing > Overview.
*
* This is done separately because we need to ensure the page is registered properly and
* that the link is done properly. For some reason the normal page registration process
* gives us the wrong menu link.
*/
protected function register_overview_page() {
global $submenu;
// First register the page.
PageController::get_instance()->register_page(
[
'id' => 'woocommerce-marketing-overview',
'title' => __( 'Overview', 'woocommerce' ),
'path' => 'wc-admin&path=/marketing',
'parent' => 'woocommerce-marketing',
'nav_args' => array(
'parent' => 'woocommerce-marketing',
'order' => 10,
),
]
);
// Now fix the path, since register_page() gets it wrong.
if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
return;
}
foreach ( $submenu['woocommerce-marketing'] as &$item ) {
// The "slug" (aka the path) is the third item in the array.
if ( 0 === strpos( $item[2], 'wc-admin' ) ) {
$item[2] = 'admin.php?page=' . $item[2];
}
}
}
/**
* Add settings for marketing feature.
*
* @param array $settings Component settings.
* @return array
*/
public function component_settings( $settings ) {
// Bail early if not on a wc-admin powered page.
if ( ! PageController::is_admin_page() ) {
return $settings;
}
$settings['marketing']['installedExtensions'] = InstalledExtensions::get_data();
return $settings;
}
}
Internal/Admin/Marketplace.php 0000644 00000002331 15153704477 0012325 0 ustar 00 <?php
/**
* WooCommerce Marketplace.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
/**
* Contains backend logic for the Marketplace feature.
*/
class Marketplace {
/**
* Class initialization, to be executed when the class is resolved by the container.
*/
final public function init() {
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
}
}
/**
* Registers report pages.
*/
public function register_pages() {
$marketplace_pages = self::get_marketplace_pages();
foreach ( $marketplace_pages as $marketplace_page ) {
if ( ! is_null( $marketplace_page ) ) {
wc_admin_register_page( $marketplace_page );
}
}
}
/**
* Get report pages.
*/
public static function get_marketplace_pages() {
$marketplace_pages = array(
array(
'id' => 'woocommerce-marketplace',
'parent' => 'woocommerce',
'title' => __( 'Extensions', 'woocommerce' ),
'path' => '/extensions',
),
);
/**
* The marketplace items used in the menu.
*
* @since 8.0
*/
return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages );
}
}
Internal/Admin/MobileAppBanner.php 0000644 00000001674 15153704477 0013104 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Determine if the mobile app banner shows on Android devices
*/
class MobileAppBanner {
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
}
/**
* Adds fields so that we can store user preferences for the mobile app banner
*
* @param array $user_data_fields User data fields.
* @return array
*/
public function add_user_data_fields( $user_data_fields ) {
return array_merge(
$user_data_fields,
array(
'android_app_banner_dismissed',
)
);
}
}
Internal/Admin/Notes/AddFirstProduct.php 0000644 00000005445 15153704477 0014237 0 ustar 00 <?php
/**
* WooCommerce Admin: Add First Product.
*
* Adds a note (type `email`) to bring the client back to the store setup flow.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Add_First_Product.
*/
class AddFirstProduct {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-add-first-product-note';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::wc_admin_active_for( 2 * DAY_IN_SECONDS ) || self::wc_admin_active_for( 5 * DAY_IN_SECONDS ) ) {
return;
}
// Don't show if there is a product.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
if ( 0 !== count( $products ) ) {
return;
}
// Don't show if there is an orders.
$args = array(
'limit' => 1,
'return' => 'ids',
);
$orders = wc_get_orders( $args );
if ( 0 !== count( $orders ) ) {
return;
}
// If you're updating the following please use sprintf to separate HTML tags.
// https://github.com/woocommerce/woocommerce-admin/pull/6617#discussion_r596889685.
$content_lines = array(
'{greetings}<br/><br/>',
/* translators: %s: line break */
sprintf( __( 'Nice one; you\'ve created a WooCommerce store! Now it\'s time to add your first product and get ready to start selling.%s', 'woocommerce' ), '<br/><br/>' ),
__( 'There are three ways to add your products: you can <strong>create products manually, import them at once via CSV file</strong>, or <strong>migrate them from another service</strong>.<br/><br/>', 'woocommerce' ),
/* translators: %1$s is an open anchor tag (<a>) and %2$s is a close link tag (</a>). */
sprintf( __( '%1$1sExplore our docs%2$2s for more information, or just get started!', 'woocommerce' ), '<a href="https://woocommerce.com/document/managing-products/?utm_source=help_panel&utm_medium=product">', '</a>' ),
);
$additional_data = array(
'role' => 'administrator',
);
$note = new Note();
$note->set_title( __( 'Add your first product', 'woocommerce' ) );
$note->set_content( implode( '', $content_lines ) );
$note->set_content_data( (object) $additional_data );
$note->set_image(
plugins_url(
'/images/admin_notes/dashboard-widget-setup.png',
WC_ADMIN_PLUGIN_FILE
)
);
$note->set_type( Note::E_WC_ADMIN_NOTE_EMAIL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'add-first-product', __( 'Add a product', 'woocommerce' ), admin_url( 'admin.php?page=wc-admin&task=products' ) );
return $note;
}
}
Internal/Admin/Notes/ChoosingTheme.php 0000644 00000002706 15153704477 0013727 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) choosing a theme note
*
* Adds notes to the merchant's inbox about choosing a theme.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Giving_Feedback_Notes
*/
class ChoosingTheme {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-choosing-a-theme';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We need to show choosing a theme notification after 1 day of install.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', DAY_IN_SECONDS ) ) {
return;
}
// Otherwise, create our new note.
$note = new Note();
$note->set_title( __( 'Choosing a theme?', 'woocommerce' ) );
$note->set_content( __( 'Check out the themes that are compatible with WooCommerce and choose one aligned with your brand and business needs.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'visit-the-theme-marketplace',
__( 'Visit the theme marketplace', 'woocommerce' ),
'https://woocommerce.com/product-category/themes/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Internal/Admin/Notes/CouponPageMoved.php 0000644 00000007463 15153704477 0014233 0 ustar 00 <?php
/**
* WooCommerce Admin Coupon Page Moved provider.
*
* Adds a notice when the store manager access the coupons page via the old WooCommerce > Coupons menu.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\CouponsMovedTrait;
use stdClass;
use WC_Data_Store;
/**
* Coupon_Page_Moved class.
*/
class CouponPageMoved {
use NoteTraits, CouponsMovedTrait;
const NOTE_NAME = 'wc-admin-coupon-page-moved';
/**
* Initialize our hooks.
*/
public function init() {
if ( ! wc_coupons_enabled() ) {
return;
}
add_action( 'admin_init', [ $this, 'possibly_add_note' ] );
add_action( 'admin_init', [ $this, 'redirect_to_coupons' ] );
add_action( 'woocommerce_newly_installed', [ $this, 'disable_legacy_menu_for_new_install' ] );
}
/**
* Checks if a note can and should be added.
*
* @return bool
*/
public static function can_be_added() {
if ( ! wc_coupons_enabled() ) {
return false;
}
// Don't add the notice if the legacy coupon menu is already disabled.
if ( ! self::should_display_legacy_menu() ) {
return false;
}
// Don't add the notice if it's been hidden by the user before.
if ( self::has_dismissed_note() ) {
return false;
}
// If we already have a notice, don't add a new one.
if ( self::has_unactioned_note() ) {
return false;
}
return isset( $_GET[ self::$query_key ] ) && (bool) $_GET[ self::$query_key ]; // phpcs:ignore WordPress.Security.NonceVerification
}
/**
* Get the note object for this class.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Coupon management has moved!', 'woocommerce' ) );
$note->set_content( __( 'Coupons can now be managed from Marketing > Coupons. Click the button below to remove the legacy WooCommerce > Coupons menu item.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_UPDATE );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( new stdClass() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'remove-legacy-coupon-menu',
__( 'Remove legacy coupon menu', 'woocommerce' ),
wc_admin_url( '&action=remove-coupon-menu' ),
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Find notes that have not been actioned.
*
* @return bool
*/
protected static function has_unactioned_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
return $note->get_status() === 'unactioned';
}
/**
* Whether any notes have been dismissed by the user previously.
*
* @return bool
*/
protected static function has_dismissed_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
return ! $note->get_is_deleted();
}
/**
* Get the data store object.
*
* @return DataStore The data store object.
*/
protected static function get_data_store() {
return WC_Data_Store::load( 'admin-note' );
}
/**
* Safe redirect to the coupon page to force page refresh.
*/
public function redirect_to_coupons() {
/* phpcs:disable WordPress.Security.NonceVerification */
if (
! isset( $_GET['page'] ) ||
'wc-admin' !== $_GET['page'] ||
! isset( $_GET['action'] ) ||
'remove-coupon-menu' !== $_GET['action'] ||
! defined( 'WC_ADMIN_PLUGIN_FILE' )
) {
return;
}
/* phpcs:enable */
$this->display_legacy_menu( false );
wp_safe_redirect( self::get_management_url( 'coupons' ) );
exit;
}
/**
* Disable legacy coupon menu when installing for the first time.
*/
public function disable_legacy_menu_for_new_install() {
$this->display_legacy_menu( false );
}
}
Internal/Admin/Notes/CustomizeStoreWithBlocks.php 0000644 00000004543 15153704477 0016165 0 ustar 00 <?php
/**
* WooCommerce Admin: Customize your online store with WooCommerce blocks.
*
* Adds a note to customize the client online store with WooCommerce blocks.
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Customize_Store_With_Blocks.
*/
class CustomizeStoreWithBlocks {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-customize-store-with-blocks';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// We want to show the note after fourteen days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 14 * DAY_IN_SECONDS ) ) {
return;
}
// Don't show if there aren't products.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
if ( 0 === count( $products ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Customize your online store with WooCommerce blocks', 'woocommerce' ) );
$note->set_content( __( 'With our blocks, you can select and display products, categories, filters, and more virtually anywhere on your site — no need to use shortcodes or edit lines of code. Learn more about how to use each one of them.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'customize-store-with-blocks',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-to-customize-your-online-store-with-woocommerce-blocks/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Internal/Admin/Notes/CustomizingProductCatalog.php 0000644 00000004215 15153704477 0016337 0 ustar 00 <?php
/**
* WooCommerce Admin: How to customize your product catalog note provider
*
* Adds a note with a link to the customizer a day after adding the first product
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Class CustomizingProductCatalog
*
* @package Automattic\WooCommerce\Admin\Notes
*/
class CustomizingProductCatalog {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-customizing-product-catalog';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'status' => array( 'publish' ),
'orderby' => 'post_date',
'order' => 'DESC',
)
);
$products = $query->get_products();
// we need at least 1 product.
if ( 0 === $products->total ) {
return;
}
$product = $products->products[0];
$created_timestamp = $product->get_date_created()->getTimestamp();
$is_a_day_old = ( time() - $created_timestamp ) >= DAY_IN_SECONDS;
// the product must be at least 1 day old.
if ( ! $is_a_day_old ) {
return;
}
// store must not been active more than 14 days.
if ( self::wc_admin_active_for( DAY_IN_SECONDS * 14 ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'How to customize your product catalog', 'woocommerce' ) );
$note->set_content( __( 'You want your product catalog and images to look great and align with your brand. This guide will give you all the tips you need to get your products looking great in your store.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'day-after-first-product',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/document/woocommerce-customizer/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Internal/Admin/Notes/EUVATNumber.php 0000644 00000003212 15153704477 0013221 0 ustar 00 <?php
/**
* WooCommerce Admin: EU VAT Number Note.
*
* Adds a note for EU store to install the EU VAT Number extension.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* EU_VAT_Number
*/
class EUVATNumber {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-eu-vat-number';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( 'yes' !== get_option( 'wc_connect_taxes_enabled', 'no' ) ) {
return;
}
$country_code = WC()->countries->get_base_country();
$eu_countries = WC()->countries->get_european_union_countries();
if ( ! in_array( $country_code, $eu_countries, true ) ) {
return;
}
$content = __( "If your store is based in the EU, we recommend using the EU VAT Number extension in addition to automated taxes. It provides your checkout with a field to collect and validate a customer's EU VAT number, if they have one.", 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Collect and validate EU VAT numbers at checkout', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/products/eu-vat-number/?utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Internal/Admin/Notes/EditProductsOnTheMove.php 0000644 00000003264 15153704477 0015371 0 ustar 00 <?php
/**
* WooCommerce Admin Edit products on the move note.
*
* Adds a note to download the mobile app.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Edit_Products_On_The_Move
*/
class EditProductsOnTheMove {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-edit-products-on-the-move';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if this store is at least a year old.
$year_in_seconds = 365 * DAY_IN_SECONDS;
if ( ! self::wc_admin_active_for( $year_in_seconds ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
return;
}
if ( PerformanceOnMobile::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Edit products on the move', 'woocommerce' ) );
$note->set_content( __( 'Edit and create new products from your mobile devices with the Woo app', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Internal/Admin/Notes/EmailNotification.php 0000644 00000012250 15153704477 0014564 0 ustar 00 <?php
/**
* Handles emailing user notes.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Include dependencies.
*/
if ( ! class_exists( 'WC_Email', false ) ) {
include_once WC_ABSPATH . 'includes/emails/class-wc-email.php';
}
/**
* EmailNotification Class.
*/
class EmailNotification extends \WC_Email {
/**
* Constructor.
*
* @param Note $note The notification to send.
*/
public function __construct( $note ) {
$this->note = $note;
$this->id = 'merchant_notification';
$this->template_base = WC_ADMIN_ABSPATH . 'includes/react-admin/emails/';
$this->placeholders = array(
'{greetings}' => __( 'Hi there,', 'woocommerce' ),
);
// Call parent constructor.
parent::__construct();
}
/**
* This email has no user-facing settings.
*/
public function init_form_fields() {}
/**
* This email has no user-facing settings.
*/
public function init_settings() {}
/**
* Return template filename.
*
* @param string $type Type of email to send.
* @return string
*/
public function get_template_filename( $type = 'html' ) {
if ( ! in_array( $type, array( 'html', 'plain' ), true ) ) {
return;
}
$content_data = $this->note->get_content_data();
$template_filename = "{$type}-merchant-notification.php";
if ( isset( $content_data->{"template_{$type}"} ) && file_exists( $this->template_base . $content_data->{ "template_{$type}" } ) ) {
$template_filename = $content_data[ "template_{$type}" ];
}
return $template_filename;
}
/**
* Return email type.
*
* @return string
*/
public function get_email_type() {
return class_exists( 'DOMDocument' ) ? 'html' : 'plain';
}
/**
* Get email heading.
*
* @return string
*/
public function get_default_heading() {
$content_data = $this->note->get_content_data();
if ( isset( $content_data->heading ) ) {
return $content_data->heading;
}
return $this->note->get_title();
}
/**
* Get email headers.
*
* @return string
*/
public function get_headers() {
$header = 'Content-Type: ' . $this->get_content_type() . "\r\n";
return apply_filters( 'woocommerce_email_headers', $header, $this->id, $this->object, $this );
}
/**
* Get email subject.
*
* @return string
*/
public function get_default_subject() {
return $this->note->get_title();
}
/**
* Get note content.
*
* @return string
*/
public function get_note_content() {
return $this->note->get_content();
}
/**
* Get note image.
*
* @return string
*/
public function get_image() {
return $this->note->get_image();
}
/**
* Get email action.
*
* @return stdClass
*/
public function get_actions() {
return $this->note->get_actions();
}
/**
* Get content html.
*
* @return string
*/
public function get_content_html() {
return wc_get_template_html(
$this->get_template_filename( 'html' ),
array(
'email_actions' => $this->get_actions(),
'email_content' => $this->format_string( $this->get_note_content() ),
'email_heading' => $this->format_string( $this->get_heading() ),
'email_image' => $this->get_image(),
'sent_to_admin' => true,
'plain_text' => false,
'email' => $this,
'opened_tracking_url' => $this->opened_tracking_url,
'trigger_note_action_url' => $this->trigger_note_action_url,
),
'',
$this->template_base
);
}
/**
* Get content plain.
*
* @return string
*/
public function get_content_plain() {
return wc_get_template_html(
$this->get_template_filename( 'plain' ),
array(
'email_heading' => $this->format_string( $this->get_heading() ),
'email_content' => $this->format_string( $this->get_note_content() ),
'email_actions' => $this->get_actions(),
'sent_to_admin' => true,
'plain_text' => true,
'email' => $this,
'trigger_note_action_url' => $this->trigger_note_action_url,
),
'',
$this->template_base
);
}
/**
* Trigger the sending of this email.
*
* @param string $user_email Email to send the note.
* @param int $user_id User id to to track the note.
* @param string $user_name User's name.
*/
public function trigger( $user_email, $user_id, $user_name ) {
$this->recipient = $user_email;
$this->opened_tracking_url = sprintf(
'%1$s/wp-json/wc-analytics/admin/notes/tracker/%2$d/user/%3$d',
site_url(),
$this->note->get_id(),
$user_id
);
$this->trigger_note_action_url = sprintf(
'%1$s&external_redirect=1¬e=%2$d&user=%3$d&action=',
wc_admin_url(),
$this->note->get_id(),
$user_id
);
if ( $user_name ) {
/* translators: %s = merchant name */
$this->placeholders['{greetings}'] = sprintf( __( 'Hi %s,', 'woocommerce' ), $user_name );
}
$this->send(
$this->get_recipient(),
$this->get_subject(),
$this->get_content(),
$this->get_headers(),
$this->get_attachments()
);
Notes::record_tracks_event_with_user( $user_id, 'email_note_sent', array( 'note_name' => $this->note->get_name() ) );
}
}
Internal/Admin/Notes/FirstProduct.php 0000644 00000004175 15153704477 0013625 0 ustar 00 <?php
/**
* WooCommerce Admin: Do you need help with adding your first product?
*
* Adds a note to ask the client if they need help adding their first product.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* First_Product.
*/
class FirstProduct {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-first-product';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after seven days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// Don't show if there are products.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'paginate' => true,
'return' => 'ids',
'status' => array( 'publish' ),
)
);
$products = $query->get_products();
$count = $products->total;
if ( 0 !== $count ) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you need help with adding your first product?', 'woocommerce' ) );
$note->set_content( __( 'This video tutorial will help you go through the process of adding your first product in WooCommerce.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'first-product-watch-tutorial',
__( 'Watch tutorial', 'woocommerce' ),
'https://www.youtube.com/watch?v=sFtXa00Jf_o&list=PLHdG8zvZd0E575Ia8Mu3w1h750YLXNfsC&index=24'
);
return $note;
}
}
Internal/Admin/Notes/GivingFeedbackNotes.php 0000644 00000003002 15153704477 0015022 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) Giving feedback notes provider
*
* Adds notes to the merchant's inbox about giving feedback.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Survey;
/**
* Giving_Feedback_Notes
*/
class GivingFeedbackNotes {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-store-notice-giving-feedback-2';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
// Otherwise, create our new note.
$note = new Note();
$note->set_title( __( 'You\'re invited to share your experience', 'woocommerce' ) );
$note->set_content( __( 'Now that you’ve chosen us as a partner, our goal is to make sure we\'re providing the right tools to meet your needs. We\'re looking forward to having your feedback on the store setup experience so we can improve it in the future.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'share-feedback',
__( 'Share feedback', 'woocommerce' ),
Survey::get_url( '/store-setup-survey' )
);
return $note;
}
}
Internal/Admin/Notes/InstallJPAndWCSPlugins.php 0000644 00000011122 15153704477 0015365 0 ustar 00 <?php
/**
* WooCommerce Admin Add Install Jetpack and WooCommerce Shipping & Tax Plugin Note Provider.
*
* Adds a note to the merchant's inbox prompting them to install the Jetpack
* and WooCommerce Shipping & Tax plugins after it fails to install during
* WooCommerce setup.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Install_JP_And_WCS_Plugins
*/
class InstallJPAndWCSPlugins {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-install-jp-and-wcs-plugins';
/**
* Constructor.
*/
public function __construct() {
add_action( 'woocommerce_note_action_install-jp-and-wcs-plugins', array( $this, 'install_jp_and_wcs_plugins' ) );
add_action( 'activated_plugin', array( $this, 'action_note' ) );
add_action( 'woocommerce_plugins_install_api_error', array( $this, 'on_install_error' ) );
add_action( 'woocommerce_plugins_install_error', array( $this, 'on_install_error' ) );
add_action( 'woocommerce_plugins_activate_error', array( $this, 'on_install_error' ) );
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$content = __( 'We noticed that there was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again and enjoy all the advantages of having the plugins connected to your store! Sorry for the inconvenience. The "Jetpack" and "WooCommerce Shipping & Tax" plugins will be installed & activated for free.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Uh oh... There was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again.', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'install-jp-and-wcs-plugins',
__( 'Install plugins', 'woocommerce' ),
false,
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Action the Install Jetpack and WooCommerce Shipping & Tax note, if any exists,
* and as long as both the Jetpack and WooCommerce Shipping & Tax plugins have been
* activated.
*/
public static function action_note() {
// Make sure that both plugins are active before actioning the note.
$active_plugin_slugs = PluginsHelper::get_active_plugin_slugs();
$jp_active = in_array( 'jetpack', $active_plugin_slugs, true );
$wcs_active = in_array( 'woocommerce-services', $active_plugin_slugs, true );
if ( ! $jp_active || ! $wcs_active ) {
return;
}
// Action any notes with a matching name.
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
foreach ( $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
if ( $note ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
}
}
/**
* Install the Jetpack and WooCommerce Shipping & Tax plugins in response to the action
* being clicked in the admin note.
*
* @param Note $note The note being actioned.
*/
public function install_jp_and_wcs_plugins( $note ) {
if ( self::NOTE_NAME !== $note->get_name() ) {
return;
}
$this->install_and_activate_plugin( 'jetpack' );
$this->install_and_activate_plugin( 'woocommerce-services' );
}
/**
* Installs and activates the specified plugin.
*
* @param string $plugin The plugin slug.
*/
private function install_and_activate_plugin( $plugin ) {
$install_request = array( 'plugin' => $plugin );
$installer = new \Automattic\WooCommerce\Admin\API\OnboardingPlugins();
$result = $installer->install_plugin( $install_request );
// @todo Use the error statuses to decide whether or not to action the note.
if ( is_wp_error( $result ) ) {
return;
}
$activate_request = array( 'plugins' => $plugin );
$installer->activate_plugins( $activate_request );
}
/**
* Create an alert notification in response to an error installing a plugin.
*
* @param string $slug The slug of the plugin being installed.
*/
public function on_install_error( $slug ) {
// Exit early if we're not installing the Jetpack or the WooCommerce Shipping & Tax plugins.
if ( 'jetpack' !== $slug && 'woocommerce-services' !== $slug ) {
return;
}
self::possibly_add_note();
}
}
Internal/Admin/Notes/LaunchChecklist.php 0000644 00000003270 15153704477 0014234 0 ustar 00 <?php
/**
* WooCommerce Admin Launch Checklist Note.
*
* Adds a note to cover pre-launch checklist items for store owners.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Launch_Checklist
*/
class LaunchChecklist {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-launch-checklist';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if completing the task list or completed 3 tasks in 10 days.
$completed_tasks = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );
$ten_days_in_seconds = 10 * DAY_IN_SECONDS;
if (
! get_option( 'woocommerce_task_list_complete' ) &&
(
count( $completed_tasks ) < 3 ||
self::is_wc_admin_active_in_date_range( 'week-1-4', $ten_days_in_seconds )
)
) {
return;
}
$content = __( 'To make sure you never get that sinking "what did I forget" feeling, we\'ve put together the essential pre-launch checklist.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Ready to launch your store?', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/posts/pre-launch-checklist-the-essentials/?utm_source=inbox&utm_medium=product' );
return $note;
}
}
Internal/Admin/Notes/MagentoMigration.php 0000644 00000004714 15153704477 0014440 0 ustar 00 <?php
/**
* WooCommerce Admin note on how to migrate from Magento.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* MagentoMigration
*/
class MagentoMigration {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-magento-migration';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( __CLASS__, 'possibly_add_note' ) );
add_action( 'woocommerce_admin_magento_migration_note', array( __CLASS__, 'save_note' ) );
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( empty( $onboarding_profile ) ) {
return;
}
if (
! isset( $onboarding_profile['other_platform'] ) ||
'magento' !== $onboarding_profile['other_platform']
) {
return;
}
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
WC()->queue()->schedule_single( time() + ( 5 * MINUTE_IN_SECONDS ), 'woocommerce_admin_magento_migration_note' );
}
/**
* Save the note to the database.
*/
public static function save_note() {
$note = self::get_note();
if ( self::note_exists() ) {
return;
}
$note->save();
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'How to Migrate from Magento to WooCommerce', 'woocommerce' ) );
$note->set_content( __( 'Changing platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-migrate-from-magento-to-woocommerce/?utm_source=inbox'
);
return $note;
}
}
Internal/Admin/Notes/ManageOrdersOnTheGo.php 0000644 00000003051 15153704477 0014760 0 ustar 00 <?php
/**
* WooCommerce Admin Manage orders on the go note.
*
* Adds a note to download the mobile app to manage orders on the go.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Manage_Orders_On_The_Go
*/
class ManageOrdersOnTheGo {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-manage-orders-on-the-go';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only add this note if this store is at least 6 months old.
if ( ! self::is_wc_admin_active_in_date_range( 'month-6+' ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Manage your orders on the go', 'woocommerce' ) );
$note->set_content( __( 'Look for orders, customer info, and process refunds in one click with the Woo app.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Internal/Admin/Notes/MarketingJetpack.php 0000644 00000007265 15153704477 0014423 0 ustar 00 <?php
/**
* WooCommerce Admin Jetpack Marketing Note Provider.
*
* Adds notes to the merchant's inbox concerning Jetpack Backup.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Suggest Jetpack Backup to Woo users.
*
* Note: This should probably live in the Jetpack plugin in the future.
*
* @see https://developer.woocommerce.com/2020/10/16/using-the-admin-notes-inbox-in-woocommerce/
*/
class MarketingJetpack {
// Shared Note Traits.
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-marketing-jetpack-backup';
/**
* Product IDs that include Backup.
*/
const BACKUP_IDS = [
2010,
2011,
2012,
2013,
2014,
2015,
2100,
2101,
2102,
2103,
2005,
2006,
2000,
2003,
2001,
2004,
];
/**
* Maybe add a note on Jetpack Backups for Jetpack sites older than a week without Backups.
*/
public static function possibly_add_note() {
/**
* Check if Jetpack is installed.
*/
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
if ( ! in_array( 'jetpack', $installed_plugins, true ) ) {
return;
}
$data_store = \WC_Data_Store::load( 'admin-note' );
// Do we already have this note?
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
// If Jetpack Backups was purchased after the note was created, mark this note as actioned.
if ( self::has_backups() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
return;
}
// Check requirements.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', DAY_IN_SECONDS * 3 ) || ! self::can_be_added() || self::has_backups() ) {
return;
}
// Add note.
$note = self::get_note();
$note->save();
}
/**
* Get the note.
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Protect your WooCommerce Store with Jetpack Backup.', 'woocommerce' ) );
$note->set_content( __( 'Store downtime means lost sales. One-click restores get you back online quickly if something goes wrong.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_layout( 'thumbnail' );
$note->set_image(
WC_ADMIN_IMAGES_FOLDER_URL . '/admin_notes/marketing-jetpack-2x.png'
);
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin-notes' );
$note->add_action(
'jetpack-backup-woocommerce',
__( 'Get backups', 'woocommerce' ),
esc_url( 'https://jetpack.com/upgrade/backup-woocommerce/?utm_source=inbox&utm_medium=automattic_referred&utm_campaign=jp_backup_to_woo' ),
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
/**
* Check if this blog already has a Jetpack Backups product.
*
* @return boolean Whether or not this blog has backups.
*/
protected static function has_backups() {
$product_ids = [];
$plan = get_option( 'jetpack_active_plan' );
if ( ! empty( $plan ) ) {
$product_ids[] = $plan['product_id'];
}
$products = get_option( 'jetpack_site_products' );
if ( ! empty( $products ) ) {
foreach ( $products as $product ) {
$product_ids[] = $product['product_id'];
}
}
return (bool) array_intersect( self::BACKUP_IDS, $product_ids );
}
}
Internal/Admin/Notes/MerchantEmailNotifications.php 0000644 00000006601 15153704477 0016434 0 ustar 00 <?php
/**
* Handles merchant email notifications
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
defined( 'ABSPATH' ) || exit;
/**
* Merchant email notifications.
* This gets all non-sent notes type `email` and sends them.
*/
class MerchantEmailNotifications {
/**
* Initialize the merchant email notifications.
*/
public static function init() {
add_action( 'admin_init', array( __CLASS__, 'trigger_notification_action' ) );
}
/**
* Trigger the note action.
*/
public static function trigger_notification_action() {
/* phpcs:disable WordPress.Security.NonceVerification */
if (
! isset( $_GET['external_redirect'] ) ||
1 !== intval( $_GET['external_redirect'] ) ||
! isset( $_GET['user'] ) ||
! isset( $_GET['note'] ) ||
! isset( $_GET['action'] )
) {
return;
}
$note_id = intval( $_GET['note'] );
$action_id = intval( $_GET['action'] );
$user_id = intval( $_GET['user'] );
/* phpcs:enable */
$note = Notes::get_note( $note_id );
if ( ! $note || Note::E_WC_ADMIN_NOTE_EMAIL !== $note->get_type() ) {
return;
}
$triggered_action = Notes::get_action_by_id( $note, $action_id );
if ( ! $triggered_action ) {
return;
}
Notes::trigger_note_action( $note, $triggered_action );
$url = $triggered_action->query;
// We will use "wp_safe_redirect" when it's an internal redirect.
if ( strpos( $url, 'http' ) === false ) {
wp_safe_redirect( $url );
} else {
header( 'Location: ' . $url );
}
exit();
}
/**
* Send all the notifications type `email`.
*/
public static function run() {
$data_store = Notes::load_data_store();
$notes = $data_store->get_notes(
array(
'type' => array( Note::E_WC_ADMIN_NOTE_EMAIL ),
'status' => array( 'unactioned' ),
)
);
foreach ( $notes as $note ) {
$note = Notes::get_note( $note->note_id );
if ( $note ) {
self::send_merchant_notification( $note );
$note->set_status( 'sent' );
$note->save();
}
}
}
/**
* Send the notification to the merchant.
*
* @param object $note The note to send.
*/
public static function send_merchant_notification( $note ) {
\WC_Emails::instance();
$users = self::get_notification_recipients( $note );
$email = new EmailNotification( $note );
foreach ( $users as $user ) {
if ( is_email( $user->user_email ) ) {
$name = self::get_merchant_preferred_name( $user );
$email->trigger( $user->user_email, $user->ID, $name );
}
}
}
/**
* Get the preferred name for user. First choice is
* the user's first name, and then display_name.
*
* @param WP_User $user Recipient to send the note to.
* @return string User's name.
*/
public static function get_merchant_preferred_name( $user ) {
$first_name = get_user_meta( $user->ID, 'first_name', true );
if ( $first_name ) {
return $first_name;
}
if ( $user->display_name ) {
return $user->display_name;
}
return '';
}
/**
* Get users by role to notify.
*
* @param object $note The note to send.
* @return array Users to notify
*/
public static function get_notification_recipients( $note ) {
$content_data = $note->get_content_data();
$role = 'administrator';
if ( isset( $content_data->role ) ) {
$role = $content_data->role;
}
$args = array( 'role' => $role );
return get_users( $args );
}
}
Internal/Admin/Notes/MigrateFromShopify.php 0000644 00000004330 15153704477 0014744 0 ustar 00 <?php
/**
* WooCommerce Admin: Migrate from Shopify to WooCommerce.
*
* Adds a note to ask the client if they want to migrate from Shopify to WooCommerce.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Migrate_From_Shopify.
*/
class MigrateFromShopify {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-migrate-from-shopify';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after two days.
$two_days = 2 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
if (
! isset( $onboarding_profile['setup_client'] ) ||
! isset( $onboarding_profile['selling_venues'] ) ||
! isset( $onboarding_profile['other_platform'] )
) {
return;
}
// Make sure the client is not setup.
if ( $onboarding_profile['setup_client'] ) {
return;
}
// We will show the notification when the client already is selling and is using Shopify.
if (
'other' !== $onboarding_profile['selling_venues'] ||
'shopify' !== $onboarding_profile['other_platform']
) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you want to migrate from Shopify to WooCommerce?', 'woocommerce' ) );
$note->set_content( __( 'Changing eCommerce platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'migrate-from-shopify',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/migrate-from-shopify-to-woocommerce/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Internal/Admin/Notes/MobileApp.php 0000644 00000002615 15153704477 0013042 0 ustar 00 <?php
/**
* WooCommerce Admin Mobile App Note Provider.
*
* Adds a note to the merchant's inbox showing the benefits of the mobile app.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Mobile_App
*/
class MobileApp {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-mobile-app';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the mobile app note after day 2.
$two_days_in_seconds = 2 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days_in_seconds ) ) {
return;
}
$content = __( 'Install the WooCommerce mobile app to manage orders, receive sales notifications, and view key metrics — wherever you are.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Install Woo mobile app', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_medium=product' );
return $note;
}
}
Internal/Admin/Notes/NewSalesRecord.php 0000644 00000012404 15153704477 0014047 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) New Sales Record Note Provider.
*
* Adds a note to the merchant's inbox when the previous day's sales are a new record.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* New_Sales_Record
*/
class NewSalesRecord {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-new-sales-record';
/**
* Option name for the sales record date in ISO 8601 (YYYY-MM-DD) date.
*/
const RECORD_DATE_OPTION_KEY = 'woocommerce_sales_record_date';
/**
* Option name for the sales record amount.
*/
const RECORD_AMOUNT_OPTION_KEY = 'woocommerce_sales_record_amount';
/**
* Returns the total of yesterday's sales.
*
* @param string $date Date for sales to sum (i.e. YYYY-MM-DD).
* @return floatval
*/
public static function sum_sales_for_date( $date ) {
$order_query = new \WC_Order_Query( array( 'date_created' => $date ) );
$orders = $order_query->get_orders();
$total = 0;
foreach ( (array) $orders as $order ) {
$total += $order->get_total();
}
return $total;
}
/**
* Possibly add a sales record note.
*/
public static function possibly_add_note() {
/**
* Filter to allow for disabling sales record milestones.
*
* @since 3.7.0
*
* @param boolean default true
*/
$sales_record_notes_enabled = apply_filters( 'woocommerce_admin_sales_record_milestone_enabled', true );
if ( ! $sales_record_notes_enabled ) {
return;
}
$yesterday = gmdate( 'Y-m-d', current_time( 'timestamp', 0 ) - DAY_IN_SECONDS );
$total = self::sum_sales_for_date( $yesterday );
// No sales yesterday? Bail.
if ( 0 >= $total ) {
return;
}
$record_date = get_option( self::RECORD_DATE_OPTION_KEY, '' );
$record_amt = floatval( get_option( self::RECORD_AMOUNT_OPTION_KEY, 0 ) );
// No previous entry? Just enter what we have and return without generating a note.
if ( empty( $record_date ) ) {
update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
return;
}
// Otherwise, if yesterdays total bested the record, update AND generate a note.
if ( $total > $record_amt ) {
update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
// We only want one sales record note at any time in the inbox, so we delete any other first.
Notes::delete_notes_with_name( self::NOTE_NAME );
$note = self::get_note_with_record_data( $record_date, $record_amt, $yesterday, $total );
$note->save();
}
}
/**
* Get the note with record data.
*
* @param string $record_date record date Y-m-d.
* @param float $record_amt record amount.
* @param string $yesterday yesterday's date Y-m-d.
* @param string $total total sales for yesterday.
*
* @return Note
*/
public static function get_note_with_record_data( $record_date, $record_amt, $yesterday, $total ) {
// Use F jS (March 7th) format for English speaking countries.
if ( substr( get_user_locale(), 0, 2 ) === 'en' ) {
$date_format = 'F jS';
} else {
// otherwise, fallback to the system date format.
$date_format = get_option( 'date_format' );
}
$formatted_yesterday = date_i18n( $date_format, strtotime( $yesterday ) );
$formatted_total = html_entity_decode( wp_strip_all_tags( wc_price( $total ) ) );
$formatted_record_date = date_i18n( $date_format, strtotime( $record_date ) );
$formatted_record_amt = html_entity_decode( wp_strip_all_tags( wc_price( $record_amt ) ) );
$content = sprintf(
/* translators: 1 and 4: Date (e.g. October 16th), 2 and 3: Amount (e.g. $160.00) */
__( 'Woohoo, %1$s was your record day for sales! Net sales was %2$s beating the previous record of %3$s set on %4$s.', 'woocommerce' ),
$formatted_yesterday,
$formatted_total,
$formatted_record_amt,
$formatted_record_date
);
$content_data = (object) array(
'old_record_date' => $record_date,
'old_record_amt' => $record_amt,
'new_record_date' => $yesterday,
'new_record_amt' => $total,
);
$report_url = '?page=wc-admin&path=/analytics/revenue&period=custom&compare=previous_year&after=' . $yesterday . '&before=' . $yesterday;
// And now, create our new note.
$note = new Note();
$note->set_title( __( 'New sales record!', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( $content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'view-report', __( 'View report', 'woocommerce' ), $report_url );
return $note;
}
/**
* Get the note. This is used for localizing the note.
*
* @return Note
*/
public static function get_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
$content_data = $note->get_content_data();
return self::get_note_with_record_data(
$content_data->old_record_date,
$content_data->old_record_amt,
$content_data->new_record_date,
$content_data->new_record_amt
);
}
}
Internal/Admin/Notes/OnboardingPayments.php 0000644 00000003364 15153704477 0014777 0 ustar 00 <?php
/**
* WooCommerce Admin: Payments reminder note.
*
* Adds a notes to complete the payment methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Onboarding_Payments.
*/
class OnboardingPayments {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-onboarding-payments-reminder';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after five days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 5 * DAY_IN_SECONDS ) ) {
return;
}
// Check to see if any gateways have been added.
$gateways = WC()->payment_gateways->get_available_payment_gateways();
$enabled_gateways = array_filter(
$gateways,
function( $gateway ) {
return 'yes' === $gateway->enabled;
}
);
if ( ! empty( $enabled_gateways ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Start accepting payments on your store!', 'woocommerce' ) );
$note->set_content( __( 'Take payments with the provider that’s right for you - choose from 100+ payment gateways for WooCommerce.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'view-payment-gateways',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/product-category/woocommerce-extensions/payment-gateways/?utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED,
true
);
return $note;
}
}
Internal/Admin/Notes/OnlineClothingStore.php 0000644 00000005271 15153704477 0015124 0 ustar 00 <?php
/**
* WooCommerce Admin: Start your online clothing store.
*
* Adds a note to ask the client if they are considering starting an online
* clothing store.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Online_Clothing_Store.
*/
class OnlineClothingStore {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-online-clothing-store';
/**
* Returns whether the industries includes fashion-apparel-accessories.
*
* @param array $industries The industries to search.
*
* @return bool Whether the industries includes fashion-apparel-accessories.
*/
private static function is_in_fashion_industry( $industries ) {
foreach ( $industries as $industry ) {
if ( 'fashion-apparel-accessories' === $industry['slug'] ) {
return true;
}
}
return false;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// We want to show the note after two days.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', 2 * DAY_IN_SECONDS ) ) {
return;
}
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// We need to show the notification when the industry is
// fashion/apparel/accessories.
if ( ! isset( $onboarding_profile['industry'] ) ) {
return;
}
if ( ! self::is_in_fashion_industry( $onboarding_profile['industry'] ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Start your online clothing store', 'woocommerce' ) );
$note->set_content( __( 'Starting a fashion website is exciting but it may seem overwhelming as well. In this article, we\'ll walk you through the setup process, teach you to create successful product listings, and show you how to market to your ideal audience.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'online-clothing-store',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/starting-an-online-clothing-store/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Internal/Admin/Notes/OrderMilestones.php 0000644 00000022226 15153704477 0014310 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) Order Milestones Note Provider.
*
* Adds a note to the merchant's inbox when certain order milestones are reached.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* Order_Milestones
*/
class OrderMilestones {
/**
* Name of the "other milestones" note.
*/
const NOTE_NAME = 'wc-admin-orders-milestone';
/**
* Option key name to store last order milestone.
*/
const LAST_ORDER_MILESTONE_OPTION_KEY = 'woocommerce_admin_last_orders_milestone';
/**
* Hook to process order milestones.
*/
const PROCESS_ORDERS_MILESTONE_HOOK = 'wc_admin_process_orders_milestone';
/**
* Allowed order statuses for calculating milestones.
*
* @var array
*/
protected $allowed_statuses = array(
'pending',
'processing',
'completed',
);
/**
* Orders count cache.
*
* @var int
*/
protected $orders_count = null;
/**
* Further order milestone thresholds.
*
* @var array
*/
protected $milestones = array(
1,
10,
100,
250,
500,
1000,
5000,
10000,
500000,
1000000,
);
/**
* Delay hook attachment until after the WC post types have been registered.
*
* This is required for retrieving the order count.
*/
public function __construct() {
/**
* Filter Order statuses that will count towards milestones.
*
* @since 3.5.0
*
* @param array $allowed_statuses Order statuses that will count towards milestones.
*/
$this->allowed_statuses = apply_filters( 'woocommerce_admin_order_milestone_statuses', $this->allowed_statuses );
add_action( 'woocommerce_after_register_post_type', array( $this, 'init' ) );
register_deactivation_hook( WC_PLUGIN_FILE, array( $this, 'clear_scheduled_event' ) );
}
/**
* Hook everything up.
*/
public function init() {
if ( ! wp_next_scheduled( self::PROCESS_ORDERS_MILESTONE_HOOK ) ) {
wp_schedule_event( time(), 'hourly', self::PROCESS_ORDERS_MILESTONE_HOOK );
}
add_action( 'wc_admin_installed', array( $this, 'backfill_last_milestone' ) );
add_action( self::PROCESS_ORDERS_MILESTONE_HOOK, array( $this, 'possibly_add_note' ) );
}
/**
* Clear out our hourly milestone hook upon plugin deactivation.
*/
public function clear_scheduled_event() {
wp_clear_scheduled_hook( self::PROCESS_ORDERS_MILESTONE_HOOK );
}
/**
* Get the total count of orders (in the allowed statuses).
*
* @param bool $no_cache Optional. Skip cache.
* @return int Total orders count.
*/
public function get_orders_count( $no_cache = false ) {
if ( $no_cache || is_null( $this->orders_count ) ) {
$status_counts = array_map( 'wc_orders_count', $this->allowed_statuses );
$this->orders_count = array_sum( $status_counts );
}
return $this->orders_count;
}
/**
* Backfill the store's current milestone.
*
* Used to avoid celebrating milestones that were reached before plugin activation.
*/
public function backfill_last_milestone() {
// If the milestone notes have been disabled via filter, bail.
if ( ! $this->are_milestones_enabled() ) {
return;
}
$this->set_last_milestone( $this->get_current_milestone() );
}
/**
* Get the store's last milestone.
*
* @return int Last milestone reached.
*/
public function get_last_milestone() {
return get_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, 0 );
}
/**
* Update the last reached milestone.
*
* @param int $milestone Last milestone reached.
*/
public function set_last_milestone( $milestone ) {
update_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, $milestone );
}
/**
* Calculate the current orders milestone.
*
* Based on the threshold values in $this->milestones.
*
* @return int Current orders milestone.
*/
public function get_current_milestone() {
$milestone_reached = 0;
$orders_count = $this->get_orders_count();
foreach ( $this->milestones as $milestone ) {
if ( $milestone <= $orders_count ) {
$milestone_reached = $milestone;
}
}
return $milestone_reached;
}
/**
* Get the appropriate note title for a given milestone.
*
* @param int $milestone Order milestone.
* @return string Note title for the milestone.
*/
public static function get_note_title_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return __( 'First order received', 'woocommerce' );
case 10:
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return sprintf(
/* translators: Number of orders processed. */
__( 'Congratulations on processing %s orders!', 'woocommerce' ),
wc_format_decimal( $milestone )
);
default:
return '';
}
}
/**
* Get the appropriate note content for a given milestone.
*
* @param int $milestone Order milestone.
* @return string Note content for the milestone.
*/
public static function get_note_content_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return __( 'Congratulations on getting your first order! Now is a great time to learn how to manage your orders.', 'woocommerce' );
case 10:
return __( "You've hit the 10 orders milestone! Look at you go. Browse some WooCommerce success stories for inspiration.", 'woocommerce' );
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return __( 'Another order milestone! Take a look at your Orders Report to review your orders to date.', 'woocommerce' );
default:
return '';
}
}
/**
* Get the appropriate note action for a given milestone.
*
* @param int $milestone Order milestone.
* @return array Note actoion (name, label, query) for the milestone.
*/
public static function get_note_action_for_milestone( $milestone ) {
switch ( $milestone ) {
case 1:
return array(
'name' => 'learn-more',
'label' => __( 'Learn more', 'woocommerce' ),
'query' => 'https://woocommerce.com/document/managing-orders/?utm_source=inbox&utm_medium=product',
);
case 10:
return array(
'name' => 'browse',
'label' => __( 'Browse', 'woocommerce' ),
'query' => 'https://woocommerce.com/success-stories/?utm_source=inbox&utm_medium=product',
);
case 100:
case 250:
case 500:
case 1000:
case 5000:
case 10000:
case 500000:
case 1000000:
return array(
'name' => 'review-orders',
'label' => __( 'Review your orders', 'woocommerce' ),
'query' => '?page=wc-admin&path=/analytics/orders',
);
default:
return array(
'name' => '',
'label' => '',
'query' => '',
);
}
}
/**
* Convenience method to see if the milestone notes are enabled.
*
* @return boolean True if milestone notifications are enabled.
*/
public function are_milestones_enabled() {
/**
* Filter to allow for disabling order milestones.
*
* @since 3.7.0
*
* @param boolean default true
*/
$milestone_notes_enabled = apply_filters( 'woocommerce_admin_order_milestones_enabled', true );
return $milestone_notes_enabled;
}
/**
* Get the note. This is used for localizing the note.
*
* @return Note
*/
public static function get_note() {
$note = Notes::get_note_by_name( self::NOTE_NAME );
if ( ! $note ) {
return false;
}
$content_data = $note->get_content_data();
if ( ! isset( $content_data->current_milestone ) ) {
return false;
}
return self::get_note_by_milestone(
$content_data->current_milestone
);
}
/**
* Get the note by milestones.
*
* @param int $current_milestone Current milestone.
*
* @return Note
*/
public static function get_note_by_milestone( $current_milestone ) {
$content_data = (object) array(
'current_milestone' => $current_milestone,
);
$note = new Note();
$note->set_title( self::get_note_title_for_milestone( $current_milestone ) );
$note->set_content( self::get_note_content_for_milestone( $current_milestone ) );
$note->set_content_data( $content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note_action = self::get_note_action_for_milestone( $current_milestone );
$note->add_action( $note_action['name'], $note_action['label'], $note_action['query'] );
return $note;
}
/**
* Checks if a note can and should be added.
*
* @return bool
*/
public function can_be_added() {
// If the milestone notes have been disabled via filter, bail.
if ( ! $this->are_milestones_enabled() ) {
return false;
}
$last_milestone = $this->get_last_milestone();
$current_milestone = $this->get_current_milestone();
if ( $current_milestone <= $last_milestone ) {
return false;
}
return true;
}
/**
* Add milestone notes for other significant thresholds.
*/
public function possibly_add_note() {
if ( ! self::can_be_added() ) {
return;
}
$current_milestone = $this->get_current_milestone();
$this->set_last_milestone( $current_milestone );
// We only want one milestone note at any time.
Notes::delete_notes_with_name( self::NOTE_NAME );
$note = $this->get_note_by_milestone( $current_milestone );
$note->save();
}
}
Internal/Admin/Notes/PaymentsMoreInfoNeeded.php 0000644 00000004010 15153704477 0015525 0 ustar 00 <?php
/**
* WooCommerce Admin Payments More Info Needed Inbox Note Provider
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage;
defined( 'ABSPATH' ) || exit;
/**
* PaymentsMoreInfoNeeded
*/
class PaymentsMoreInfoNeeded {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-payments-more-info-needed';
/**
* Should this note exist?
*/
public static function is_applicable() {
return self::should_display_note();
}
/**
* Returns true if we should display the note.
*
* @return bool
*/
public static function should_display_note() {
// WCPay welcome page must not be visible.
if ( WcPayWelcomePage::instance()->must_be_visible() ) {
return false;
}
// More than 30 days since viewing the welcome page.
$exit_survey_timestamp = get_option( 'wcpay_welcome_page_exit_survey_more_info_needed_timestamp', false );
if ( ! $exit_survey_timestamp ||
( time() - $exit_survey_timestamp < 30 * DAY_IN_SECONDS )
) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::should_display_note() ) {
return;
}
$content = __( 'We recently asked you if you wanted more information about WooPayments. Run your business and manage your payments in one place with the solution built and supported by WooCommerce.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Payments made simple with WooPayments', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more here', 'woocommerce' ), 'https://woocommerce.com/payments/' );
return $note;
}
}
Internal/Admin/Notes/PaymentsRemindMeLater.php 0000644 00000003676 15153704477 0015413 0 ustar 00 <?php
/**
* WooCommerce Admin Payment Reminder Me later
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage;
defined( 'ABSPATH' ) || exit;
/**
* PaymentsRemindMeLater
*/
class PaymentsRemindMeLater {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-payments-remind-me-later';
/**
* Should this note exist?
*/
public static function is_applicable() {
return self::should_display_note();
}
/**
* Returns true if we should display the note.
*
* @return bool
*/
public static function should_display_note() {
// WCPay welcome page must be visible.
if ( ! WcPayWelcomePage::instance()->must_be_visible() ) {
return false;
}
// Less than 3 days since viewing welcome page.
$view_timestamp = get_option( 'wcpay_welcome_page_viewed_timestamp', false );
if ( ! $view_timestamp ||
( time() - $view_timestamp < 3 * DAY_IN_SECONDS )
) {
return false;
}
return true;
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
if ( ! self::should_display_note() ) {
return;
}
$content = __( 'Save up to $800 in fees by managing transactions with WooPayments. With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Save big with WooPayments', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), admin_url( 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' ) );
return $note;
}
}
Internal/Admin/Notes/PerformanceOnMobile.php 0000644 00000003215 15153704477 0015055 0 ustar 00 <?php
/**
* WooCommerce Admin Performance on mobile note.
*
* Adds a note to download the mobile app, performance on mobile.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Performance_On_Mobile
*/
class PerformanceOnMobile {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-performance-on-mobile';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if this store is at least 9 months old.
$nine_months_in_seconds = MONTH_IN_SECONDS * 9;
if ( ! self::wc_admin_active_for( $nine_months_in_seconds ) ) {
return;
}
// Check that the previous mobile app notes have not been actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
return;
}
if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
return;
}
$note = new Note();
$note->set_title( __( 'Track your store performance on mobile', 'woocommerce' ) );
$note->set_content( __( 'Monitor your sales and high performing products with the Woo app.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
);
return $note;
}
}
Internal/Admin/Notes/PersonalizeStore.php 0000644 00000003646 15153704477 0014507 0 ustar 00 <?php
/**
* WooCommerce Admin Personalize Your Store Note Provider.
*
* Adds a note to the merchant's inbox prompting them to personalize their store.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Personalize_Store
*/
class PersonalizeStore {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-personalize-store';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only show the note to stores with homepage.
$homepage_id = get_option( 'woocommerce_onboarding_homepage_post_id', false );
if ( ! $homepage_id ) {
return;
}
// Show the note after task list is done.
$is_task_list_complete = get_option( 'woocommerce_task_list_complete', false );
// We want to show the note after day 5.
$five_days_in_seconds = 5 * DAY_IN_SECONDS;
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', $five_days_in_seconds ) && ! $is_task_list_complete ) {
return;
}
$content = __( 'The homepage is one of the most important entry points in your store. When done right it can lead to higher conversions and engagement. Don\'t forget to personalize the homepage that we created for your store during the onboarding.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Personalize your store\'s homepage', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'personalize-homepage', __( 'Personalize homepage', 'woocommerce' ), admin_url( 'post.php?post=' . $homepage_id . '&action=edit' ), Note::E_WC_ADMIN_NOTE_ACTIONED );
return $note;
}
}
Internal/Admin/Notes/RealTimeOrderAlerts.php 0000644 00000003012 15153704477 0015033 0 ustar 00 <?php
/**
* WooCommerce Admin Real Time Order Alerts Note.
*
* Adds a note to download the mobile app to monitor store activity.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Real_Time_Order_Alerts
*/
class RealTimeOrderAlerts {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-real-time-order-alerts';
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
// Only add this note if the store is 3 months old.
if ( ! self::is_wc_admin_active_in_date_range( 'month-3-6' ) ) {
return;
}
// Check that the previous mobile app note was not actioned.
if ( MobileApp::has_note_been_actioned() ) {
return;
}
$content = __( 'Get notifications about store activity, including new orders and product reviews directly on your mobile devices with the Woo app.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Get real-time order alerts anywhere', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product' );
return $note;
}
}
Internal/Admin/Notes/SellingOnlineCourses.php 0000644 00000004600 15153704477 0015274 0 ustar 00 <?php
/**
* WooCommerce Admin: Selling Online Courses note
*
* Adds a note to encourage selling online courses.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
/**
* Selling_Online_Courses
*/
class SellingOnlineCourses {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-selling-online-courses';
/**
* Attach hooks.
*/
public function __construct() {
add_action(
'update_option_' . OnboardingProfile::DATA_OPTION,
array( $this, 'check_onboarding_profile' ),
10,
3
);
}
/**
* Check to see if the profiler options match before possibly adding note.
*
* @param object $old_value The old option value.
* @param object $value The new option value.
* @param string $option The name of the option.
*/
public static function check_onboarding_profile( $old_value, $value, $option ) {
// Skip adding if this store is in the education/learning industry.
if ( ! isset( $value['industry'] ) ) {
return;
}
$industry_slugs = array_column( $value['industry'], 'slug' );
if ( ! in_array( 'education-and-learning', $industry_slugs, true ) ) {
return;
}
self::possibly_add_note();
}
/**
* Get the note.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Do you want to sell online courses?', 'woocommerce' ) );
$note->set_content( __( 'Online courses are a great solution for any business that can teach a new skill. Since courses don’t require physical product development or shipping, they’re affordable, fast to create, and can generate passive income for years to come. In this article, we provide you more information about selling courses using WooCommerce.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://woocommerce.com/posts/how-to-sell-online-courses-wordpress/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_ACTIONED
);
return $note;
}
}
Internal/Admin/Notes/TestCheckout.php 0000644 00000005327 15153704477 0013602 0 ustar 00 <?php
/**
* WooCommerce Admin Test Checkout.
*
* Adds a note to remind the user to test their store checkout.
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Test_Checkout
*/
class TestCheckout {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-test-checkout';
/**
* Completed tasks option name.
*/
const TASK_LIST_TRACKED_TASKS = 'woocommerce_task_list_tracked_completed_tasks';
/**
* Constructor.
*/
public function __construct() {
add_action( 'update_option_' . self::TASK_LIST_TRACKED_TASKS, array( $this, 'possibly_add_note' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// Make sure payments task was completed.
$completed_tasks = get_option( self::TASK_LIST_TRACKED_TASKS, array() );
if ( ! in_array( 'payments', $completed_tasks, true ) ) {
return;
}
// Make sure that products were added within the previous 1/2 hour.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'status' => 'publish',
'orderby' => 'date',
'order' => 'ASC',
)
);
$products = $query->get_products();
if ( 0 === count( $products ) ) {
return;
}
$oldest_product_timestamp = $products[0]->get_date_created()->getTimestamp();
$half_hour_in_seconds = 30 * MINUTE_IN_SECONDS;
if ( ( time() - $oldest_product_timestamp ) > $half_hour_in_seconds ) {
return;
}
$content = __( 'Make sure that your checkout is working properly before you launch your store. Go through your checkout process in its entirety: from adding a product to your cart, choosing a shipping location, and making a payment.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Don\'t forget to test your checkout', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'test-checkout', __( 'Test checkout', 'woocommerce' ), wc_get_page_permalink( 'shop' ) );
return $note;
}
}
Internal/Admin/Notes/TrackingOptIn.php 0000644 00000005405 15153704477 0013706 0 ustar 00 <?php
/**
* WooCommerce Admin Usage Tracking Opt In Note Provider.
*
* Adds a Usage Tracking Opt In extension note.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Tracking_Opt_In
*/
class TrackingOptIn {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-usage-tracking-opt-in';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'woocommerce_note_action_tracking-opt-in', array( $this, 'opt_in_to_tracking' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
// Only show this note to stores that are opted out.
if ( 'yes' === get_option( 'woocommerce_allow_tracking', 'no' ) ) {
return;
}
// We want to show the note after one week.
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
return;
}
/* translators: 1: open link to WooCommerce.com settings, 2: open link to WooCommerce.com tracking documentation, 3: close link tag. */
$content_format = __(
'Gathering usage data allows us to improve WooCommerce. Your store will be considered as we evaluate new features, judge the quality of an update, or determine if an improvement makes sense. You can always visit the %1$sSettings%3$s and choose to stop sharing data. %2$sRead more%3$s about what data we collect.',
'woocommerce'
);
$note_content = sprintf(
$content_format,
'<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=woocommerce_com' ) ) . '" target="_blank">',
'<a href="https://woocommerce.com/usage-tracking?utm_medium=product" target="_blank">',
'</a>'
);
$note = new Note();
$note->set_title( __( 'Help WooCommerce improve with usage tracking', 'woocommerce' ) );
$note->set_content( $note_content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'tracking-opt-in', __( 'Activate usage tracking', 'woocommerce' ), false, Note::E_WC_ADMIN_NOTE_ACTIONED, true );
return $note;
}
/**
* Opt in to usage tracking when note is actioned.
*
* @param Note $note Note being acted upon.
*/
public function opt_in_to_tracking( $note ) {
if ( self::NOTE_NAME === $note->get_name() ) {
// Opt in to tracking and schedule the first data update.
// Same mechanism as in WC_Admin_Setup_Wizard::wc_setup_store_setup_save().
update_option( 'woocommerce_allow_tracking', 'yes' );
wp_schedule_single_event( time() + 10, 'woocommerce_tracker_send_event', array( true ) );
}
}
}
Internal/Admin/Notes/UnsecuredReportFiles.php 0000644 00000004112 15153704477 0015300 0 ustar 00 <?php
/**
* WooCommerce Admin Unsecured Files Note.
*
* Adds a warning about potentially unsecured files.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
if ( ! class_exists( Note::class ) ) {
class_alias( WC_Admin_Note::class, Note::class );
}
/**
* Unsecured_Report_Files
*/
class UnsecuredReportFiles {
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-remove-unsecured-report-files';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Potentially unsecured files were found in your uploads directory', 'woocommerce' ) );
$note->set_content(
sprintf(
/* translators: 1: opening analytics docs link tag. 2: closing link tag */
__( 'Files that may contain %1$sstore analytics%2$s reports were found in your uploads directory - we recommend assessing and deleting any such files.', 'woocommerce' ),
'<a href="https://woocommerce.com/document/woocommerce-analytics/" target="_blank">',
'</a>'
)
);
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_ERROR );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn more', 'woocommerce' ),
'https://developer.woocommerce.com/?p=10410',
Note::E_WC_ADMIN_NOTE_UNACTIONED,
true
);
$note->add_action(
'dismiss',
__( 'Dismiss', 'woocommerce' ),
wc_admin_url(),
Note::E_WC_ADMIN_NOTE_ACTIONED,
false
);
return $note;
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
$note = self::get_note();
if ( self::note_exists() ) {
return;
}
$note->save();
}
/**
* Check if the note has been previously added.
*/
public static function note_exists() {
$data_store = \WC_Data_Store::load( 'admin-note' );
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
return ! empty( $note_ids );
}
}
Internal/Admin/Notes/WooCommercePayments.php 0000644 00000014400 15153704477 0015125 0 ustar 00 <?php
/**
* WooCommerce Admin WooCommerce Payments Note Provider.
*
* Adds a note to the merchant's inbox showing the benefits of the WooCommerce Payments.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* WooCommerce_Payments
*/
class WooCommercePayments {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-woocommerce-payments';
/**
* Name of the note for use in the database.
*/
const PLUGIN_SLUG = 'woocommerce-payments';
/**
* Name of the note for use in the database.
*/
const PLUGIN_FILE = 'woocommerce-payments/woocommerce-payments.php';
/**
* Attach hooks.
*/
public function __construct() {
add_action( 'init', array( $this, 'install_on_action' ) );
add_action( 'wc-admin-woocommerce-payments_add_note', array( $this, 'add_note' ) );
}
/**
* Maybe add a note on WooCommerce Payments for US based sites older than a week without the plugin installed.
*/
public static function possibly_add_note() {
if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) || 'US' !== WC()->countries->get_base_country() ) {
return;
}
$data_store = Notes::load_data_store();
// We already have this note? Then mark the note as actioned.
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( ! empty( $note_ids ) ) {
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
// If the WooCommerce Payments plugin was installed after the note was created, make sure it's marked as actioned.
if ( self::is_installed() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
$note->save();
}
return;
}
$current_date = new \DateTime();
$publish_date = new \DateTime( '2020-04-14' );
if ( $current_date >= $publish_date ) {
$note = self::get_note();
if ( self::can_be_added() ) {
$note->save();
}
return;
} else {
$hook_name = sprintf( '%s_add_note', self::NOTE_NAME );
if ( ! WC()->queue()->get_next( $hook_name ) ) {
WC()->queue()->schedule_single( $publish_date->getTimestamp(), $hook_name );
}
}
}
/**
* Add a note about WooCommerce Payments.
*
* @return Note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Try the new way to get paid', 'woocommerce' ) );
$note->set_content(
__( 'Securely accept credit and debit cards on your site. Manage transactions without leaving your WordPress dashboard. Only with <strong>WooPayments</strong>.', 'woocommerce' ) .
'<br><br>' .
sprintf(
/* translators: 1: opening link tag, 2: closing tag */
__( 'By clicking "Get started", you agree to our %1$sTerms of Service%2$s', 'woocommerce' ),
'<a href="https://wordpress.com/tos/" target="_blank">',
'</a>'
)
);
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/payments/?utm_medium=product', Note::E_WC_ADMIN_NOTE_UNACTIONED );
$note->add_action( 'get-started', __( 'Get started', 'woocommerce' ), wc_admin_url( '&action=setup-woocommerce-payments' ), Note::E_WC_ADMIN_NOTE_ACTIONED, true );
$note->add_nonce_to_action( 'get-started', 'setup-woocommerce-payments', '' );
// Create the note as "actioned" if the plugin is already installed.
if ( self::is_installed() ) {
$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
}
return $note;
}
/**
* Check if the WooCommerce Payments plugin is active or installed.
*/
protected static function is_installed() {
if ( defined( 'WC_Payments' ) ) {
return true;
}
include_once ABSPATH . '/wp-admin/includes/plugin.php';
return 0 === validate_plugin( self::PLUGIN_FILE );
}
/**
* Install and activate WooCommerce Payments.
*
* @return boolean Whether the plugin was successfully activated.
*/
private function install_and_activate_wcpay() {
$install_request = array( 'plugins' => self::PLUGIN_SLUG );
$installer = new \Automattic\WooCommerce\Admin\API\Plugins();
$result = $installer->install_plugins( $install_request );
if ( is_wp_error( $result ) ) {
return false;
}
wc_admin_record_tracks_event( 'woocommerce_payments_install', array( 'context' => 'inbox' ) );
$activate_request = array( 'plugins' => self::PLUGIN_SLUG );
$result = $installer->activate_plugins( $activate_request );
if ( is_wp_error( $result ) ) {
return false;
}
return true;
}
/**
* Install & activate WooCommerce Payments plugin, and redirect to setup.
*/
public function install_on_action() {
// TODO: Need to validate this request more strictly since we're taking install actions directly?
if (
! isset( $_GET['page'] ) ||
'wc-admin' !== $_GET['page'] ||
! isset( $_GET['action'] ) ||
'setup-woocommerce-payments' !== $_GET['action']
) {
return;
}
$data_store = Notes::load_data_store();
// We already have this note? Then mark the note as actioned.
$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
if ( empty( $note_ids ) ) {
return;
}
$note_id = array_pop( $note_ids );
$note = Notes::get_note( $note_id );
if ( false === $note ) {
return;
}
$action = $note->get_action( 'get-started' );
if ( ! $action ||
( isset( $action->nonce_action ) &&
(
empty( $_GET['_wpnonce'] ) ||
! wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), $action->nonce_action ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
)
)
) {
return;
}
if ( ! current_user_can( 'install_plugins' ) ) {
return;
}
$this->install_and_activate_wcpay();
// WooCommerce Payments is installed at this point, so link straight into the onboarding flow.
$connect_url = add_query_arg(
array(
'wcpay-connect' => '1',
'_wpnonce' => wp_create_nonce( 'wcpay-connect' ),
),
admin_url()
);
wp_safe_redirect( $connect_url );
exit;
}
}
Internal/Admin/Notes/WooCommerceSubscriptions.php 0000644 00000003602 15153704477 0016176 0 ustar 00 <?php
/**
* WooCommerce Admin: WooCommerce Subscriptions.
*
* Adds a note to learn more about WooCommerce Subscriptions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
/**
* WooCommerce_Subscriptions.
*/
class WooCommerceSubscriptions {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-woocommerce-subscriptions';
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$onboarding_data = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! isset( $onboarding_data['product_types'] ) || ! in_array( 'subscriptions', $onboarding_data['product_types'], true ) ) {
return;
}
if ( ! self::is_wc_admin_active_in_date_range( 'week-1', DAY_IN_SECONDS ) ) {
return;
}
$note = new Note();
$note->set_title( __( 'Do you need more info about WooCommerce Subscriptions?', 'woocommerce' ) );
$note->set_content( __( 'WooCommerce Subscriptions allows you to introduce a variety of subscriptions for physical or virtual products and services. Create product-of-the-month clubs, weekly service subscriptions or even yearly software billing packages. Add sign-up fees, offer free trials, or set expiration periods.', 'woocommerce' ) );
$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
$note->set_name( self::NOTE_NAME );
$note->set_content_data( (object) array() );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'learn-more',
__( 'Learn More', 'woocommerce' ),
'https://woocommerce.com/products/woocommerce-subscriptions/?utm_source=inbox&utm_medium=product',
Note::E_WC_ADMIN_NOTE_UNACTIONED,
true
);
return $note;
}
}
Internal/Admin/Notes/WooSubscriptionsNotes.php 0000644 00000034446 15153704477 0015546 0 ustar 00 <?php
/**
* WooCommerce Admin (Dashboard) WooCommerce.com Extension Subscriptions Note Provider.
*
* Adds notes to the merchant's inbox concerning WooCommerce.com extension subscriptions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\PageController;
/**
* Woo_Subscriptions_Notes
*/
class WooSubscriptionsNotes {
const LAST_REFRESH_OPTION_KEY = 'woocommerce_admin-wc-helper-last-refresh';
const NOTE_NAME = 'wc-admin-wc-helper-connection';
const CONNECTION_NOTE_NAME = 'wc-admin-wc-helper-connection';
const SUBSCRIPTION_NOTE_NAME = 'wc-admin-wc-helper-subscription';
const NOTIFY_WHEN_DAYS_LEFT = 60;
/**
* We want to bubble up expiration notices when they cross certain age
* thresholds. PHP 5.2 doesn't support constant arrays, so we do this.
*
* @return array
*/
private function get_bump_thresholds() {
return array( 60, 45, 20, 7, 1 ); // days.
}
/**
* Hook all the things.
*/
public function __construct() {
add_action( 'admin_head', array( $this, 'admin_head' ) );
add_action( 'update_option_woocommerce_helper_data', array( $this, 'update_option_woocommerce_helper_data' ), 10, 2 );
}
/**
* Reacts to changes in the helper option.
*
* @param array $old_value The previous value of the option.
* @param array $value The new value of the option.
*/
public function update_option_woocommerce_helper_data( $old_value, $value ) {
if ( ! is_array( $old_value ) ) {
$old_value = array();
}
if ( ! is_array( $value ) ) {
$value = array();
}
$old_auth = array_key_exists( 'auth', $old_value ) ? $old_value['auth'] : array();
$new_auth = array_key_exists( 'auth', $value ) ? $value['auth'] : array();
$old_token = array_key_exists( 'access_token', $old_auth ) ? $old_auth['access_token'] : '';
$new_token = array_key_exists( 'access_token', $new_auth ) ? $new_auth['access_token'] : '';
// The site just disconnected.
if ( ! empty( $old_token ) && empty( $new_token ) ) {
$this->remove_notes();
$this->add_no_connection_note();
return;
}
// The site is connected.
if ( $this->is_connected() ) {
$this->remove_notes();
$this->refresh_subscription_notes();
return;
}
}
/**
* Runs on `admin_head` hook. Checks the connection and refreshes subscription notes on relevant pages.
*/
public function admin_head() {
if ( ! PageController::is_admin_or_embed_page() ) {
// To avoid unnecessarily calling Helper API, we only want to refresh subscription notes,
// if the request is initiated from the wc admin dashboard or a WC related page which includes
// the Activity button in WC header.
return;
}
$this->check_connection();
if ( $this->is_connected() ) {
$refresh_notes = false;
// Did the user just do something on the helper page?.
if ( isset( $_GET['wc-helper-status'] ) ) { // @codingStandardsIgnoreLine.
$refresh_notes = true;
}
// Has it been more than a day since we last checked?
// Note: We do it this way and not wp_scheduled_task since WC_Helper_Options is not loaded for cron.
$time_now_gmt = current_time( 'timestamp', 0 );
$last_refresh = intval( get_option( self::LAST_REFRESH_OPTION_KEY, 0 ) );
if ( $last_refresh + DAY_IN_SECONDS <= $time_now_gmt ) {
update_option( self::LAST_REFRESH_OPTION_KEY, $time_now_gmt );
$refresh_notes = true;
}
if ( $refresh_notes ) {
$this->refresh_subscription_notes();
}
}
}
/**
* Checks the connection. Adds a note (as necessary) if there is no connection.
*/
public function check_connection() {
if ( ! $this->is_connected() ) {
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::CONNECTION_NOTE_NAME );
if ( ! empty( $note_ids ) ) {
// We already have a connection note. Exit early.
return;
}
$this->remove_notes();
$this->add_no_connection_note();
}
}
/**
* Whether or not we think the site is currently connected to WooCommerce.com.
*
* @return bool
*/
public function is_connected() {
$auth = \WC_Helper_Options::get( 'auth' );
return ( ! empty( $auth['access_token'] ) );
}
/**
* Returns the WooCommerce.com provided site ID for this site.
*
* @return int|false
*/
public function get_connected_site_id() {
if ( ! $this->is_connected() ) {
return false;
}
$auth = \WC_Helper_Options::get( 'auth' );
return absint( $auth['site_id'] );
}
/**
* Returns an array of product_ids whose subscriptions are active on this site.
*
* @return array
*/
public function get_subscription_active_product_ids() {
$site_id = $this->get_connected_site_id();
if ( ! $site_id ) {
return array();
}
$product_ids = array();
if ( $this->is_connected() ) {
$subscriptions = \WC_Helper::get_subscriptions();
foreach ( (array) $subscriptions as $subscription ) {
if ( in_array( $site_id, $subscription['connections'], true ) ) {
$product_ids[] = $subscription['product_id'];
}
}
}
return $product_ids;
}
/**
* Clears all connection or subscription notes.
*/
public function remove_notes() {
Notes::delete_notes_with_name( self::CONNECTION_NOTE_NAME );
Notes::delete_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
}
/**
* Adds a note prompting to connect to WooCommerce.com.
*/
public function add_no_connection_note() {
$note = self::get_note();
$note->save();
}
/**
* Get the WooCommerce.com connection note
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Connect to WooCommerce.com', 'woocommerce' ) );
$note->set_content( __( 'Connect to get important product notifications and updates.', 'woocommerce' ) );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::CONNECTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action(
'connect',
__( 'Connect', 'woocommerce' ),
'?page=wc-addons§ion=helper',
Note::E_WC_ADMIN_NOTE_UNACTIONED
);
return $note;
}
/**
* Gets the product_id (if any) associated with a note.
*
* @param Note $note The note object to interrogate.
* @return int|false
*/
public function get_product_id_from_subscription_note( &$note ) {
$content_data = $note->get_content_data();
if ( property_exists( $content_data, 'product_id' ) ) {
return intval( $content_data->product_id );
}
return false;
}
/**
* Removes notes for product_ids no longer active on this site.
*/
public function prune_inactive_subscription_notes() {
$active_product_ids = $this->get_subscription_active_product_ids();
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
foreach ( (array) $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
$product_id = $this->get_product_id_from_subscription_note( $note );
if ( ! empty( $product_id ) ) {
if ( ! in_array( $product_id, $active_product_ids, true ) ) {
$note->delete();
}
}
}
}
/**
* Finds a note for a given product ID, if the note exists at all.
*
* @param int $product_id The product ID to search for.
* @return Note|false
*/
public function find_note_for_product_id( $product_id ) {
$product_id = intval( $product_id );
$data_store = Notes::load_data_store();
$note_ids = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
foreach ( (array) $note_ids as $note_id ) {
$note = Notes::get_note( $note_id );
$found_product_id = $this->get_product_id_from_subscription_note( $note );
if ( $product_id === $found_product_id ) {
return $note;
}
}
return false;
}
/**
* Deletes a note for a given product ID, if the note exists at all.
*
* @param int $product_id The product ID to search for.
*/
public function delete_any_note_for_product_id( $product_id ) {
$product_id = intval( $product_id );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$note->delete();
}
}
/**
* Adds or updates a note for an expiring subscription.
*
* @param array $subscription The subscription to work with.
*/
public function add_or_update_subscription_expiring( $subscription ) {
$product_id = $subscription['product_id'];
$product_name = $subscription['product_name'];
$expires = intval( $subscription['expires'] );
$time_now_gmt = current_time( 'timestamp', 0 );
$days_until_expiration = intval( ceil( ( $expires - $time_now_gmt ) / DAY_IN_SECONDS ) );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$content_data = $note->get_content_data();
if ( property_exists( $content_data, 'days_until_expiration' ) ) {
// Note: There is no reason this property should not exist. This is just defensive programming.
$note_days_until_expiration = intval( $content_data->days_until_expiration );
if ( $days_until_expiration === $note_days_until_expiration ) {
// Note is already up to date. Bail.
return;
}
// If we have a note and we are at or have crossed a threshold, we should delete
// the old note and create a new one, thereby "bumping" the note to the top of the inbox.
$bump_thresholds = $this->get_bump_thresholds();
$crossing_threshold = false;
foreach ( (array) $bump_thresholds as $bump_threshold ) {
if ( ( $note_days_until_expiration > $bump_threshold ) && ( $days_until_expiration <= $bump_threshold ) ) {
$note->delete();
$note = false;
continue;
}
}
}
}
$note_title = sprintf(
/* translators: name of the extension subscription expiring soon */
__( '%s subscription expiring soon', 'woocommerce' ),
$product_name
);
$note_content = sprintf(
/* translators: number of days until the subscription expires */
__( 'Your subscription expires in %d days. Enable autorenew to avoid losing updates and access to support.', 'woocommerce' ),
$days_until_expiration
);
$note_content_data = (object) array(
'product_id' => $product_id,
'product_name' => $product_name,
'expired' => false,
'days_until_expiration' => $days_until_expiration,
);
if ( ! $note ) {
$note = new Note();
}
// Reset everything in case we are repurposing an expired note as an expiring note.
$note->set_title( $note_title );
$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->clear_actions();
$note->add_action(
'enable-autorenew',
__( 'Enable Autorenew', 'woocommerce' ),
'https://woocommerce.com/my-account/my-subscriptions/?utm_medium=product'
);
$note->set_content( $note_content );
$note->set_content_data( $note_content_data );
$note->save();
}
/**
* Adds a note for an expired subscription, or updates an expiring note to expired.
*
* @param array $subscription The subscription to work with.
*/
public function add_or_update_subscription_expired( $subscription ) {
$product_id = $subscription['product_id'];
$product_name = $subscription['product_name'];
$product_page = $subscription['product_url'];
$expires = intval( $subscription['expires'] );
$expires_date = gmdate( 'F jS', $expires );
$note = $this->find_note_for_product_id( $product_id );
if ( $note ) {
$note_content_data = $note->get_content_data();
if ( $note_content_data->expired ) {
// We've already got a full fledged expired note for this. Bail.
// Expired notes' content don't change with time.
return;
}
}
$note_title = sprintf(
/* translators: name of the extension subscription that expired */
__( '%s subscription expired', 'woocommerce' ),
$product_name
);
$note_content = sprintf(
/* translators: date the subscription expired, e.g. Jun 7th 2018 */
__( 'Your subscription expired on %s. Get a new subscription to continue receiving updates and access to support.', 'woocommerce' ),
$expires_date
);
$note_content_data = (object) array(
'product_id' => $product_id,
'product_name' => $product_name,
'expired' => true,
'expires' => $expires,
'expires_date' => $expires_date,
);
if ( ! $note ) {
$note = new Note();
}
$note->set_title( $note_title );
$note->set_content( $note_content );
$note->set_content_data( $note_content_data );
$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->clear_actions();
$note->add_action(
'renew-subscription',
__( 'Renew Subscription', 'woocommerce' ),
$product_page
);
$note->save();
}
/**
* For each active subscription on this site, checks the expiration date and creates/updates/deletes notes.
*/
public function refresh_subscription_notes() {
if ( ! $this->is_connected() ) {
return;
}
$this->prune_inactive_subscription_notes();
$subscriptions = \WC_Helper::get_subscriptions();
$active_product_ids = $this->get_subscription_active_product_ids();
foreach ( (array) $subscriptions as $subscription ) {
// Only concern ourselves with active products.
$product_id = $subscription['product_id'];
if ( ! in_array( $product_id, $active_product_ids, true ) ) {
continue;
}
// If the subscription will auto-renew, clean up and exit.
if ( $subscription['autorenew'] ) {
$this->delete_any_note_for_product_id( $product_id );
continue;
}
// If the subscription is not expiring by the first threshold, clean up and exit.
$bump_thresholds = $this->get_bump_thresholds();
$first_threshold = DAY_IN_SECONDS * $bump_thresholds[0];
$expires = intval( $subscription['expires'] );
$time_now_gmt = current_time( 'timestamp', 0 );
if ( $expires > $time_now_gmt + $first_threshold ) {
$this->delete_any_note_for_product_id( $product_id );
continue;
}
// Otherwise, if the subscription can still have auto-renew enabled, let them know that now.
if ( $expires > $time_now_gmt ) {
$this->add_or_update_subscription_expiring( $subscription );
continue;
}
// If we got this far, the subscription has completely expired, let them know.
$this->add_or_update_subscription_expired( $subscription );
}
}
}
Internal/Admin/Onboarding/Onboarding.php 0000644 00000001073 15153704477 0014243 0 ustar 00 <?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Initializes backend logic for the onboarding process.
*/
class Onboarding {
/**
* Initialize onboarding functionality.
*/
public static function init() {
OnboardingHelper::instance()->init();
OnboardingIndustries::init();
OnboardingJetpack::instance()->init();
OnboardingMailchimp::instance()->init();
OnboardingProfile::init();
OnboardingSetupWizard::instance()->init();
OnboardingSync::instance()->init();
OnboardingThemes::init();
}
}
Internal/Admin/Onboarding/OnboardingHelper.php 0000644 00000011502 15153704477 0015401 0 ustar 00 <?php
/**
* WooCommerce Onboarding Helper
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingHelper {
/**
* Class instance.
*
* @var OnboardingHelper instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
if ( ! is_admin() ) {
return;
}
add_action( 'current_screen', array( $this, 'add_help_tab' ), 60 );
add_action( 'current_screen', array( $this, 'reset_task_list' ) );
add_action( 'current_screen', array( $this, 'reset_extended_task_list' ) );
}
/**
* Update the help tab setup link to reset the onboarding profiler.
*/
public function add_help_tab() {
if ( ! function_exists( 'wc_get_screen_ids' ) ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
return;
}
// Remove the old help tab if it exists.
$help_tabs = $screen->get_help_tabs();
foreach ( $help_tabs as $help_tab ) {
if ( 'woocommerce_onboard_tab' !== $help_tab['id'] ) {
continue;
}
$screen->remove_help_tab( 'woocommerce_onboard_tab' );
}
// Add the new help tab.
$help_tab = array(
'title' => __( 'Setup wizard', 'woocommerce' ),
'id' => 'woocommerce_onboard_tab',
);
$setup_list = TaskLists::get_list( 'setup' );
$extended_list = TaskLists::get_list( 'extended' );
if ( $setup_list ) {
$help_tab['content'] = '<h2>' . __( 'WooCommerce Onboarding', 'woocommerce' ) . '</h2>';
$help_tab['content'] .= '<h3>' . __( 'Profile Setup Wizard', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to access the setup wizard again, please click on the button below.', 'woocommerce' ) . '</p>' .
'<p><a href="' . wc_admin_url( '&path=/setup-wizard' ) . '" class="button button-primary">' . __( 'Setup wizard', 'woocommerce' ) . '</a></p>';
$help_tab['content'] .= '<h3>' . __( 'Task List', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
( $setup_list->is_hidden()
? '<p><a href="' . wc_admin_url( '&reset_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
: '<p><a href="' . wc_admin_url( '&reset_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
);
}
if ( $extended_list ) {
$help_tab['content'] .= '<h3>' . __( 'Extended task List', 'woocommerce' ) . '</h3>';
$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the extended task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
( $extended_list->is_hidden()
? '<p><a href="' . wc_admin_url( '&reset_extended_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
: '<p><a href="' . wc_admin_url( '&reset_extended_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
);
}
$screen->add_help_tab( $help_tab );
}
/**
* Reset the onboarding task list and redirect to the dashboard.
*/
public function reset_task_list() {
if (
! PageController::is_admin_page() ||
! isset( $_GET['reset_task_list'] ) // phpcs:ignore CSRF ok.
) {
return;
}
$task_list = TaskLists::get_list( 'setup' );
if ( ! $task_list ) {
return;
}
$show = 1 === absint( $_GET['reset_task_list'] ); // phpcs:ignore CSRF ok.
$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.
if ( $update ) {
wc_admin_record_tracks_event(
'tasklist_toggled',
array(
'status' => $show ? 'enabled' : 'disabled',
)
);
}
wp_safe_redirect( wc_admin_url() );
exit;
}
/**
* Reset the extended task list and redirect to the dashboard.
*/
public function reset_extended_task_list() {
if (
! PageController::is_admin_page() ||
! isset( $_GET['reset_extended_task_list'] ) // phpcs:ignore CSRF ok.
) {
return;
}
$task_list = TaskLists::get_list( 'extended' );
if ( ! $task_list ) {
return;
}
$show = 1 === absint( $_GET['reset_extended_task_list'] ); // phpcs:ignore CSRF ok.
$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.
if ( $update ) {
wc_admin_record_tracks_event(
'extended_tasklist_toggled',
array(
'status' => $show ? 'disabled' : 'enabled',
)
);
}
wp_safe_redirect( wc_admin_url() );
exit;
}
}
Internal/Admin/Onboarding/OnboardingIndustries.php 0000644 00000005020 15153704477 0016311 0 ustar 00 <?php
/**
* WooCommerce Onboarding Industries
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Logic around onboarding industries.
*/
class OnboardingIndustries {
/**
* Init.
*/
public static function init() {
add_filter( 'woocommerce_admin_onboarding_preloaded_data', array( __CLASS__, 'preload_data' ) );
}
/**
* Get a list of allowed industries for the onboarding wizard.
*
* @return array
*/
public static function get_allowed_industries() {
/* With "use_description" we turn the description input on. With "description_label" we set the input label */
return apply_filters(
'woocommerce_admin_onboarding_industries',
array(
'fashion-apparel-accessories' => array(
'label' => __( 'Fashion, apparel, and accessories', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'health-beauty' => array(
'label' => __( 'Health and beauty', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'electronics-computers' => array(
'label' => __( 'Electronics and computers', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'food-drink' => array(
'label' => __( 'Food and drink', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'home-furniture-garden' => array(
'label' => __( 'Home, furniture, and garden', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'cbd-other-hemp-derived-products' => array(
'label' => __( 'CBD and other hemp-derived products', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'education-and-learning' => array(
'label' => __( 'Education and learning', 'woocommerce' ),
'use_description' => false,
'description_label' => '',
),
'other' => array(
'label' => __( 'Other', 'woocommerce' ),
'use_description' => true,
'description_label' => __( 'Description', 'woocommerce' ),
),
)
);
}
/**
* Add preloaded data to onboarding.
*
* @param array $settings Component settings.
* @return array
*/
public static function preload_data( $settings ) {
$settings['onboarding']['industries'] = self::get_allowed_industries();
return $settings;
}
}
Internal/Admin/Onboarding/OnboardingJetpack.php 0000644 00000003464 15153704477 0015553 0 ustar 00 <?php
/**
* WooCommerce Onboarding Jetpack
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
/**
* Contains logic around Jetpack setup during onboarding.
*/
class OnboardingJetpack {
/**
* Class instance.
*
* @var OnboardingJetpack instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'woocommerce_admin_plugins_pre_activate', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
add_action( 'woocommerce_admin_plugins_pre_install', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
// Always hook into Jetpack connection even if outside of admin.
add_action( 'jetpack_site_registered', array( $this, 'set_woocommerce_setup_jetpack_opted_in' ) );
}
/**
* Sets the woocommerce_setup_jetpack_opted_in to true when Jetpack connects to WPCOM.
*/
public function set_woocommerce_setup_jetpack_opted_in() {
update_option( 'woocommerce_setup_jetpack_opted_in', true );
}
/**
* Ensure that Jetpack gets installed and activated ahead of WooCommerce Payments
* if both are being installed/activated at the same time.
*
* See: https://github.com/Automattic/woocommerce-payments/issues/1663
* See: https://github.com/Automattic/jetpack/issues/19624
*
* @param array $plugins A list of plugins to install or activate.
*
* @return array
*/
public function activate_and_install_jetpack_ahead_of_wcpay( $plugins ) {
if ( in_array( 'jetpack', $plugins, true ) && in_array( 'woocommerce-payments', $plugins, true ) ) {
array_unshift( $plugins, 'jetpack' );
$plugins = array_unique( $plugins );
}
return $plugins;
}
}
Internal/Admin/Onboarding/OnboardingMailchimp.php 0000644 00000002301 15153704477 0016062 0 ustar 00 <?php
/**
* WooCommerce Onboarding Mailchimp
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
/**
* Logic around updating Mailchimp during onboarding.
*/
class OnboardingMailchimp {
/**
* Class instance.
*
* @var OnboardingMailchimp instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'woocommerce_onboarding_profile_data_updated', array( $this, 'on_profile_data_updated' ), 10, 2 );
}
/**
* Reset MailchimpScheduler if profile data is being updated with a new email.
*
* @param array $existing_data Existing option data.
* @param array $updating_data Updating option data.
*/
public function on_profile_data_updated( $existing_data, $updating_data ) {
if (
isset( $existing_data['store_email'] ) &&
isset( $updating_data['store_email'] ) &&
$existing_data['store_email'] !== $updating_data['store_email']
) {
MailchimpScheduler::reset();
}
}
}
Internal/Admin/Onboarding/OnboardingProducts.php 0000644 00000012534 15153704477 0015773 0 ustar 00 <?php
/**
* WooCommerce Onboarding Products
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PluginsHelper;
/**
* Class for handling product types and data around product types.
*/
class OnboardingProducts {
/**
* Name of product data transient.
*
* @var string
*/
const PRODUCT_DATA_TRANSIENT = 'wc_onboarding_product_data';
/**
* Get a list of allowed product types for the onboarding wizard.
*
* @return array
*/
public static function get_allowed_product_types() {
$products = array(
'physical' => array(
'label' => __( 'Physical products', 'woocommerce' ),
'default' => true,
),
'downloads' => array(
'label' => __( 'Downloads', 'woocommerce' ),
),
'subscriptions' => array(
'label' => __( 'Subscriptions', 'woocommerce' ),
),
'memberships' => array(
'label' => __( 'Memberships', 'woocommerce' ),
'product' => 958589,
),
'bookings' => array(
'label' => __( 'Bookings', 'woocommerce' ),
'product' => 390890,
),
'product-bundles' => array(
'label' => __( 'Bundles', 'woocommerce' ),
'product' => 18716,
),
'product-add-ons' => array(
'label' => __( 'Customizable products', 'woocommerce' ),
'product' => 18618,
),
);
$base_location = wc_get_base_location();
$has_cbd_industry = false;
if ( 'US' === $base_location['country'] ) {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! empty( $profile['industry'] ) ) {
$has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true );
}
}
if ( ! Features::is_enabled( 'subscriptions' ) || 'US' !== $base_location['country'] || $has_cbd_industry ) {
$products['subscriptions']['product'] = 27147;
}
return apply_filters( 'woocommerce_admin_onboarding_product_types', $products );
}
/**
* Get dynamic product data from API.
*
* @param array $product_types Array of product types.
* @return array
*/
public static function get_product_data( $product_types ) {
$locale = get_user_locale();
// Transient value is an array of product data keyed by locale.
$transient_value = get_transient( self::PRODUCT_DATA_TRANSIENT );
$transient_value = is_array( $transient_value ) ? $transient_value : array();
$woocommerce_products = $transient_value[ $locale ] ?? false;
if ( false === $woocommerce_products ) {
$woocommerce_products = wp_remote_get(
add_query_arg(
array(
'locale' => $locale,
),
'https://woocommerce.com/wp-json/wccom-extensions/1.0/search'
),
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
if ( is_wp_error( $woocommerce_products ) ) {
return $product_types;
}
$transient_value[ $locale ] = $woocommerce_products;
set_transient( self::PRODUCT_DATA_TRANSIENT, $transient_value, DAY_IN_SECONDS );
}
$data = json_decode( $woocommerce_products['body'] );
$products = array();
$product_data = array();
// Map product data by ID.
if ( isset( $data ) && isset( $data->products ) ) {
foreach ( $data->products as $product_datum ) {
if ( isset( $product_datum->id ) ) {
$products[ $product_datum->id ] = $product_datum;
}
}
}
// Loop over product types and append data.
foreach ( $product_types as $key => $product_type ) {
$product_data[ $key ] = $product_types[ $key ];
if ( isset( $product_type['product'] ) && isset( $products[ $product_type['product'] ] ) ) {
$price = html_entity_decode( $products[ $product_type['product'] ]->price );
$yearly_price = (float) str_replace( '$', '', $price );
$product_data[ $key ]['yearly_price'] = $yearly_price;
$product_data[ $key ]['description'] = $products[ $product_type['product'] ]->excerpt;
$product_data[ $key ]['more_url'] = $products[ $product_type['product'] ]->link;
$product_data[ $key ]['slug'] = strtolower( preg_replace( '~[^\pL\d]+~u', '-', $products[ $product_type['product'] ]->slug ) );
}
}
return $product_data;
}
/**
* Get the allowed product types with the polled data.
*
* @return array
*/
public static function get_product_types_with_data() {
return self::get_product_data( self::get_allowed_product_types() );
}
/**
* Get relevant purchaseable products for the site.
*
* @return array
*/
public static function get_relevant_products() {
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$installed = PluginsHelper::get_installed_plugin_slugs();
$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
$product_data = self::get_product_types_with_data();
$purchaseable = array();
$remaining = array();
foreach ( $product_types as $type ) {
if ( ! isset( $product_data[ $type ]['slug'] ) ) {
continue;
}
$purchaseable[] = $product_data[ $type ];
if ( ! in_array( $product_data[ $type ]['slug'], $installed, true ) ) {
$remaining[] = $product_data[ $type ]['label'];
}
}
return array(
'purchaseable' => $purchaseable,
'remaining' => $remaining,
);
}
}
Internal/Admin/Onboarding/OnboardingProfile.php 0000644 00000003564 15153704477 0015573 0 ustar 00 <?php
/**
* WooCommerce Onboarding Setup Wizard
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingProfile {
/**
* Profile data option name.
*/
const DATA_OPTION = 'woocommerce_onboarding_profile';
/**
* Add onboarding actions.
*/
public static function init() {
add_action( 'update_option_' . self::DATA_OPTION, array( __CLASS__, 'trigger_complete' ), 10, 2 );
}
/**
* Trigger the woocommerce_onboarding_profile_completed action
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public static function trigger_complete( $old_value, $value ) {
if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
return;
}
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
/**
* Action hook fired when the onboarding profile (or onboarding wizard,
* or profiler) is completed.
*
* @since 1.5.0
*/
do_action( 'woocommerce_onboarding_profile_completed' );
}
/**
* Check if the profiler still needs to be completed.
*
* @return bool
*/
public static function needs_completion() {
$onboarding_data = get_option( self::DATA_OPTION, array() );
$is_completed = isset( $onboarding_data['completed'] ) && true === $onboarding_data['completed'];
$is_skipped = isset( $onboarding_data['skipped'] ) && true === $onboarding_data['skipped'];
// @todo When merging to WooCommerce Core, we should set the `completed` flag to true during the upgrade progress.
// https://github.com/woocommerce/woocommerce-admin/pull/2300#discussion_r287237498.
return ! $is_completed && ! $is_skipped;
}
}
Internal/Admin/Onboarding/OnboardingSetupWizard.php 0000644 00000020721 15153704477 0016446 0 ustar 00 <?php
/**
* WooCommerce Onboarding Setup Wizard
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingSetupWizard {
/**
* Class instance.
*
* @var OnboardingSetupWizard instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Add onboarding actions.
*/
public function init() {
if ( ! is_admin() ) {
return;
}
// Old settings injection.
// Run after Automattic\WooCommerce\Internal\Admin\Loader.
add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
add_filter( 'woocommerce_admin_preload_settings', array( $this, 'preload_settings' ) );
add_filter( 'admin_body_class', array( $this, 'add_loading_classes' ) );
add_action( 'admin_init', array( $this, 'do_admin_redirects' ) );
add_action( 'current_screen', array( $this, 'redirect_to_profiler' ) );
add_filter( 'woocommerce_show_admin_notice', array( $this, 'remove_old_install_notice' ), 10, 2 );
}
/**
* Test whether the context of execution comes from async action scheduler.
* Note: this is a polyfill for wc_is_running_from_async_action_scheduler()
* which was introduced in WC 4.0.
*
* @return bool
*/
private function is_running_from_async_action_scheduler() {
if ( function_exists( '\wc_is_running_from_async_action_scheduler' ) ) {
return \wc_is_running_from_async_action_scheduler();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return isset( $_REQUEST['action'] ) && 'as_async_request_queue_runner' === $_REQUEST['action'];
}
/**
* Handle redirects to setup/welcome page after install and updates.
*
* For setup wizard, transient must be present, the user must have access rights, and we must ignore the network/bulk plugin updaters.
*/
public function do_admin_redirects() {
// Don't run this fn from Action Scheduler requests, as it would clear _wc_activation_redirect transient.
// That means OBW would never be shown.
if ( $this->is_running_from_async_action_scheduler() ) {
return;
}
// Setup wizard redirect.
if ( get_transient( '_wc_activation_redirect' ) && apply_filters( 'woocommerce_enable_setup_wizard', true ) ) {
$do_redirect = true;
$current_page = isset( $_GET['page'] ) ? wc_clean( wp_unslash( $_GET['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification
$is_onboarding_path = ! isset( $_GET['path'] ) || '/setup-wizard' === wc_clean( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
// On these pages, or during these events, postpone the redirect.
if ( wp_doing_ajax() || is_network_admin() || ! current_user_can( 'manage_woocommerce' ) ) {
$do_redirect = false;
}
// On these pages, or during these events, disable the redirect.
if (
( 'wc-admin' === $current_page && $is_onboarding_path ) ||
apply_filters( 'woocommerce_prevent_automatic_wizard_redirect', false ) ||
isset( $_GET['activate-multi'] ) // phpcs:ignore WordPress.Security.NonceVerification
) {
delete_transient( '_wc_activation_redirect' );
$do_redirect = false;
}
if ( $do_redirect ) {
delete_transient( '_wc_activation_redirect' );
wp_safe_redirect( wc_admin_url() );
exit;
}
}
}
/**
* Trigger the woocommerce_onboarding_profile_completed action
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public function trigger_profile_completed_action( $old_value, $value ) {
if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
return;
}
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
/**
* Action hook fired when the onboarding profile (or onboarding wizard,
* or profiler) is completed.
*
* @since 1.5.0
*/
do_action( 'woocommerce_onboarding_profile_completed' );
}
/**
* Returns true if the profiler should be displayed (not completed and not skipped).
*
* @return bool
*/
private function should_show() {
if ( $this->is_setup_wizard() ) {
return true;
}
return OnboardingProfile::needs_completion();
}
/**
* Redirect to the profiler on homepage if completion is needed.
*/
public function redirect_to_profiler() {
if ( ! $this->is_homepage() || ! OnboardingProfile::needs_completion() ) {
return;
}
wp_safe_redirect( wc_admin_url( '&path=/setup-wizard' ) );
exit;
}
/**
* Check if the current page is the profile wizard.
*
* @return bool
*/
private function is_setup_wizard() {
/* phpcs:disable WordPress.Security.NonceVerification */
return isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
isset( $_GET['path'] ) &&
'/setup-wizard' === $_GET['path'];
/* phpcs: enable */
}
/**
* Check if the current page is the homepage.
*
* @return bool
*/
private function is_homepage() {
/* phpcs:disable WordPress.Security.NonceVerification */
return isset( $_GET['page'] ) &&
'wc-admin' === $_GET['page'] &&
! isset( $_GET['path'] );
/* phpcs: enable */
}
/**
* Determine if the current page is one of the WC Admin pages.
*
* @return bool
*/
private function is_woocommerce_page() {
$current_page = PageController::get_instance()->get_current_page();
if ( ! $current_page || ! isset( $current_page['path'] ) ) {
return false;
}
return 0 === strpos( $current_page['path'], 'wc-admin' );
}
/**
* Add profiler items to component settings.
*
* @param array $settings Component settings.
*
* @return array
*/
public function component_settings( $settings ) {
$profile = (array) get_option( OnboardingProfile::DATA_OPTION, array() );
$settings['onboarding'] = array(
'profile' => $profile,
);
// Only fetch if the onboarding wizard OR the task list is incomplete or currently shown
// or the current page is one of the WooCommerce Admin pages.
if (
( ! $this->should_show() && ! count( TaskLists::get_visible() )
||
! $this->is_woocommerce_page()
)
) {
return $settings;
}
include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
$wccom_auth = \WC_Helper_Options::get( 'auth' );
$profile['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true;
$settings['onboarding']['currencySymbols'] = get_woocommerce_currency_symbols();
$settings['onboarding']['euCountries'] = WC()->countries->get_european_union_countries();
$settings['onboarding']['localeInfo'] = include WC()->plugin_path() . '/i18n/locale-info.php';
$settings['onboarding']['profile'] = $profile;
if ( $this->is_setup_wizard() ) {
$settings['onboarding']['pageCount'] = (int) ( wp_count_posts( 'page' ) )->publish;
$settings['onboarding']['postCount'] = (int) ( wp_count_posts( 'post' ) )->publish;
$settings['onboarding']['isBlockTheme'] = wc_current_theme_is_fse_theme();
}
return apply_filters( 'woocommerce_admin_onboarding_preloaded_data', $settings );
}
/**
* Preload WC setting options to prime state of the application.
*
* @param array $options Array of options to preload.
* @return array
*/
public function preload_settings( $options ) {
$options[] = 'general';
return $options;
}
/**
* Set the admin full screen class when loading to prevent flashes of unstyled content.
*
* @param bool $classes Body classes.
* @return array
*/
public function add_loading_classes( $classes ) {
/* phpcs:disable WordPress.Security.NonceVerification */
if ( $this->is_setup_wizard() ) {
$classes .= ' woocommerce-admin-full-screen';
}
/* phpcs: enable */
return $classes;
}
/**
* Remove the install notice that prompts the user to visit the old onboarding setup wizard.
*
* @param bool $show Show or hide the notice.
* @param string $notice The slug of the notice.
* @return bool
*/
public function remove_old_install_notice( $show, $notice ) {
if ( 'install' === $notice ) {
return false;
}
return $show;
}
}
Internal/Admin/Onboarding/OnboardingSync.php 0000644 00000007721 15153704477 0015106 0 ustar 00 <?php
/**
* WooCommerce Onboarding
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Contains backend logic for the onboarding profile and checklist feature.
*/
class OnboardingSync {
/**
* Class instance.
*
* @var OnboardingSync instance
*/
private static $instance = null;
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() {
add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( $this, 'send_profile_data_on_update' ), 10, 2 );
add_action( 'woocommerce_helper_connected', array( $this, 'send_profile_data_on_connect' ) );
if ( ! is_admin() ) {
return;
}
add_action( 'current_screen', array( $this, 'redirect_wccom_install' ) );
}
/**
* Send profile data to WooCommerce.com.
*/
private function send_profile_data() {
if ( 'yes' !== get_option( 'woocommerce_allow_tracking', 'no' ) ) {
return;
}
if ( ! class_exists( '\WC_Helper_API' ) || ! method_exists( '\WC_Helper_API', 'put' ) ) {
return;
}
if ( ! class_exists( '\WC_Helper_Options' ) ) {
return;
}
$auth = \WC_Helper_Options::get( 'auth' );
if ( empty( $auth['access_token'] ) || empty( $auth['access_token_secret'] ) ) {
return false;
}
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
$base_location = wc_get_base_location();
$defaults = array(
'plugins' => 'skipped',
'industry' => array(),
'product_types' => array(),
'product_count' => '0',
'selling_venues' => 'no',
'number_employees' => '1',
'revenue' => 'none',
'other_platform' => 'none',
'business_extensions' => array(),
'theme' => get_stylesheet(),
'setup_client' => false,
'store_location' => $base_location['country'],
'default_currency' => get_woocommerce_currency(),
);
// Prepare industries as an array of slugs if they are in array format.
if ( isset( $profile['industry'] ) && is_array( $profile['industry'] ) ) {
$industry_slugs = array();
foreach ( $profile['industry'] as $industry ) {
$industry_slugs[] = is_array( $industry ) ? $industry['slug'] : $industry;
}
$profile['industry'] = $industry_slugs;
}
$body = wp_parse_args( $profile, $defaults );
\WC_Helper_API::put(
'profile',
array(
'authenticated' => true,
'body' => wp_json_encode( $body ),
'headers' => array(
'Content-Type' => 'application/json',
),
)
);
}
/**
* Send profiler data on profiler change to completion.
*
* @param array $old_value Previous value.
* @param array $value Current value.
*/
public function send_profile_data_on_update( $old_value, $value ) {
if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
return;
}
$this->send_profile_data();
}
/**
* Send profiler data after a site is connected.
*/
public function send_profile_data_on_connect() {
$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
if ( ! isset( $profile['completed'] ) || ! $profile['completed'] ) {
return;
}
$this->send_profile_data();
}
/**
* Redirects the user to the task list if the task list is enabled and finishing a wccom checkout.
*
* @todo Once URL params are added to the redirect, we can check those instead of the referer.
*/
public function redirect_wccom_install() {
$task_list = TaskLists::get_list( 'setup' );
if (
! $task_list ||
$task_list->is_hidden() ||
! isset( $_SERVER['HTTP_REFERER'] ) ||
0 !== strpos( $_SERVER['HTTP_REFERER'], 'https://woocommerce.com/checkout?utm_medium=product' ) // phpcs:ignore sanitization ok.
) {
return;
}
wp_safe_redirect( wc_admin_url() );
}
}
Internal/Admin/Onboarding/OnboardingThemes.php 0000644 00000015676 15153704477 0015427 0 ustar 00 <?php
/**
* WooCommerce Onboarding Themes
*/
namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Init as OnboardingTasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\Schedulers\MailchimpScheduler;
/**
* Logic around onboarding themes.
*/
class OnboardingThemes {
/**
* Name of themes transient.
*
* @var string
*/
const THEMES_TRANSIENT = 'wc_onboarding_themes';
/**
* Init.
*/
public static function init() {
add_action( 'woocommerce_theme_installed', array( __CLASS__, 'delete_themes_transient' ) );
add_action( 'after_switch_theme', array( __CLASS__, 'delete_themes_transient' ) );
add_filter( 'woocommerce_rest_prepare_themes', array( __CLASS__, 'add_uploaded_theme_data' ) );
add_filter( 'woocommerce_admin_onboarding_preloaded_data', array( __CLASS__, 'preload_data' ) );
}
/**
* Get puchasable theme by slug.
*
* @param string $price_string string of price.
* @return float|null
*/
private static function get_price_from_string( $price_string ) {
$price_match = null;
// Parse price from string as it includes the currency symbol.
preg_match( '/\\d+\.\d{2}\s*/', $price_string, $price_match );
if ( count( $price_match ) > 0 ) {
return (float) $price_match[0];
}
return null;
}
/**
* Get puchasable theme by slug.
*
* @param string $slug from theme.
* @return array|null
*/
public static function get_paid_theme_by_slug( $slug ) {
$themes = self::get_themes();
$theme_key = array_search( $slug, array_column( $themes, 'slug' ), true );
$theme = false !== $theme_key ? $themes[ $theme_key ] : null;
if ( $theme && isset( $theme['id'] ) && isset( $theme['price'] ) ) {
$price = self::get_price_from_string( $theme['price'] );
if ( $price && $price > 0 ) {
return $themes[ $theme_key ];
}
}
return null;
}
/**
* Sort themes returned from WooCommerce.com
*
* @param array $themes Array of themes from WooCommerce.com.
* @return array
*/
public static function sort_woocommerce_themes( $themes ) {
usort(
$themes,
function ( $product_1, $product_2 ) {
if ( ! property_exists( $product_1, 'id' ) || ! property_exists( $product_1, 'slug' ) ) {
return 1;
}
if ( ! property_exists( $product_2, 'id' ) || ! property_exists( $product_2, 'slug' ) ) {
return 1;
}
if ( in_array( 'Storefront', array( $product_1->slug, $product_2->slug ), true ) ) {
return 'Storefront' === $product_1->slug ? -1 : 1;
}
return $product_1->id < $product_2->id ? 1 : -1;
}
);
return $themes;
}
/**
* Get a list of themes for the onboarding wizard.
*
* @return array
*/
public static function get_themes() {
$themes = get_transient( self::THEMES_TRANSIENT );
if ( false === $themes ) {
$theme_data = wp_remote_get(
'https://woocommerce.com/wp-json/wccom-extensions/1.0/search?category=themes',
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
);
$themes = array();
if ( ! is_wp_error( $theme_data ) ) {
$theme_data = json_decode( $theme_data['body'] );
$woo_themes = property_exists( $theme_data, 'products' ) ? $theme_data->products : array();
$sorted_themes = self::sort_woocommerce_themes( $woo_themes );
foreach ( $sorted_themes as $theme ) {
$slug = sanitize_title_with_dashes( $theme->slug );
$themes[ $slug ] = (array) $theme;
$themes[ $slug ]['is_installed'] = false;
$themes[ $slug ]['has_woocommerce_support'] = true;
$themes[ $slug ]['slug'] = $slug;
}
}
$installed_themes = wp_get_themes();
foreach ( $installed_themes as $slug => $theme ) {
$theme_data = self::get_theme_data( $theme );
if ( isset( $themes[ $slug ] ) ) {
$themes[ $slug ]['is_installed'] = true;
$themes[ $slug ]['image'] = $theme_data['image'];
} else {
$themes[ $slug ] = $theme_data;
}
}
$active_theme = get_option( 'stylesheet' );
/**
* The active theme may no be set if active_theme is not compatible with current version of WordPress.
* In this case, we should not add active theme to onboarding themes.
*/
if ( isset( $themes[ $active_theme ] ) ) {
// Add the WooCommerce support tag for default themes that don't explicitly declare support.
if ( function_exists( 'wc_is_wp_default_theme_active' ) && wc_is_wp_default_theme_active() ) {
$themes[ $active_theme ]['has_woocommerce_support'] = true;
}
$themes = array( $active_theme => $themes[ $active_theme ] ) + $themes;
}
set_transient( self::THEMES_TRANSIENT, $themes, DAY_IN_SECONDS );
}
$themes = apply_filters( 'woocommerce_admin_onboarding_themes', $themes );
return array_values( $themes );
}
/**
* Get theme data used in onboarding theme browser.
*
* @param WP_Theme $theme Theme to gather data from.
* @return array
*/
public static function get_theme_data( $theme ) {
return array(
'slug' => sanitize_text_field( $theme->stylesheet ),
'title' => $theme->get( 'Name' ),
'price' => '0.00',
'is_installed' => true,
'image' => $theme->get_screenshot(),
'has_woocommerce_support' => true,
);
}
/**
* Add theme data to response from themes controller.
*
* @param WP_REST_Response $response Rest response.
* @return WP_REST_Response
*/
public static function add_uploaded_theme_data( $response ) {
if ( ! isset( $response->data['theme'] ) ) {
return $response;
}
$theme = wp_get_theme( $response->data['theme'] );
$response->data['theme_data'] = self::get_theme_data( $theme );
return $response;
}
/**
* Delete the stored themes transient.
*/
public static function delete_themes_transient() {
delete_transient( self::THEMES_TRANSIENT );
}
/**
* Add preloaded data to onboarding.
*
* @param array $settings Component settings.
*
* @return array
*/
public static function preload_data( $settings ) {
$settings['onboarding']['activeTheme'] = get_option( 'stylesheet' );
$settings['onboarding']['themes'] = self::get_themes();
return $settings;
}
/**
* Gets an array of themes that can be installed & activated via the onboarding wizard.
*
* @return array
*/
public static function get_allowed_themes() {
$allowed_themes = array();
$themes = self::get_themes();
foreach ( $themes as $theme ) {
$price = preg_replace( '/&#?[a-z0-9]+;/i', '', $theme['price'] );
if ( $theme['is_installed'] || '0.00' === $price ) {
$allowed_themes[] = $theme['slug'];
}
}
return apply_filters( 'woocommerce_admin_onboarding_themes_whitelist', $allowed_themes );
}
}
Internal/Admin/Orders/COTRedirectionController.php 0000644 00000005405 15153704477 0016221 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
/**
* When Custom Order Tables are not the default order store (ie, posts are authoritative), we should take care of
* redirecting requests for the order editor and order admin list table to the equivalent posts-table screens.
*
* If the redirect logic is problematic, it can be unhooked using code like the following example:
*
* remove_action(
* 'admin_page_access_denied',
* array( wc_get_container()->get( COTRedirectionController::class ), 'handle_hpos_admin_requests' )
* );
*/
class COTRedirectionController {
use AccessiblePrivateMethods;
/**
* Add hooks needed to perform our magic.
*/
public function setup(): void {
// Only take action in cases where access to the admin screen would otherwise be denied.
self::add_action( 'admin_page_access_denied', array( $this, 'handle_hpos_admin_requests' ) );
}
/**
* Listen for denied admin requests and, if they appear to relate to HPOS admin screens, potentially
* redirect the user to the equivalent CPT-driven screens.
*
* @param array|null $query_params The query parameters to use when determining the redirect. If not provided, the $_GET superglobal will be used.
*/
private function handle_hpos_admin_requests( $query_params = null ) {
$query_params = is_array( $query_params ) ? $query_params : $_GET;
if ( ! isset( $query_params['page'] ) || 'wc-orders' !== $query_params['page'] ) {
return;
}
$params = wp_unslash( $query_params );
$action = $params['action'] ?? '';
unset( $params['page'] );
if ( 'edit' === $action && isset( $params['id'] ) ) {
$params['post'] = $params['id'];
unset( $params['id'] );
$new_url = add_query_arg( $params, get_admin_url( null, 'post.php' ) );
} elseif ( 'new' === $action ) {
unset( $params['action'] );
$params['post_type'] = 'shop_order';
$new_url = add_query_arg( $params, get_admin_url( null, 'post-new.php' ) );
} else {
// If nonce parameters are present and valid, rebuild them for the CPT admin list table.
if ( isset( $params['_wpnonce'] ) && check_admin_referer( 'bulk-orders' ) ) {
$params['_wp_http_referer'] = get_admin_url( null, 'edit.php?post_type=shop_order' );
$params['_wpnonce'] = wp_create_nonce( 'bulk-posts' );
}
// If an `id` array parameter is present, rename as `post`.
if ( isset( $params['id'] ) && is_array( $params['id'] ) ) {
$params['post'] = $params['id'];
unset( $params['id'] );
}
$params['post_type'] = 'shop_order';
$new_url = add_query_arg( $params, get_admin_url( null, 'edit.php' ) );
}
if ( ! empty( $new_url ) && wp_safe_redirect( $new_url, 301 ) ) {
exit;
}
}
}
Internal/Admin/Orders/Edit.php 0000644 00000031146 15153704477 0012226 0 ustar 00 <?php
/**
* Renders order edit page, works with both post and order object.
*/
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
/**
* Class Edit.
*/
class Edit {
/**
* Screen ID for the edit order screen.
*
* @var string
*/
private $screen_id;
/**
* Instance of the CustomMetaBox class. Used to render meta box for custom meta.
*
* @var CustomMetaBox
*/
private $custom_meta_box;
/**
* Instance of the TaxonomiesMetaBox class. Used to render meta box for taxonomies.
*
* @var TaxonomiesMetaBox
*/
private $taxonomies_meta_box;
/**
* Instance of WC_Order to be used in metaboxes.
*
* @var \WC_Order
*/
private $order;
/**
* Action name that the form is currently handling. Could be new_order or edit_order.
*
* @var string
*/
private $current_action;
/**
* Message to be displayed to the user. Index of message from the messages array registered when declaring shop_order post type.
*
* @var int
*/
private $message;
/**
* Controller for orders page. Used to determine redirection URLs.
*
* @var PageController
*/
private $orders_page_controller;
/**
* Hooks all meta-boxes for order edit page. This is static since this may be called by post edit form rendering.
*
* @param string $screen_id Screen ID.
* @param string $title Title of the page.
*/
public static function add_order_meta_boxes( string $screen_id, string $title ) {
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-data', sprintf( __( '%s data', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Data::output', $screen_id, 'normal', 'high' );
add_meta_box( 'woocommerce-order-items', __( 'Items', 'woocommerce' ), 'WC_Meta_Box_Order_Items::output', $screen_id, 'normal', 'high' );
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-notes', sprintf( __( '%s notes', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Notes::output', $screen_id, 'side', 'default' );
add_meta_box( 'woocommerce-order-downloads', __( 'Downloadable product permissions', 'woocommerce' ) . wc_help_tip( __( 'Note: Permissions for order items will automatically be granted when the order status changes to processing/completed.', 'woocommerce' ) ), 'WC_Meta_Box_Order_Downloads::output', $screen_id, 'normal', 'default' );
/* Translators: %s order type name. */
add_meta_box( 'woocommerce-order-actions', sprintf( __( '%s actions', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Actions::output', $screen_id, 'side', 'high' );
}
/**
* Hooks metabox save functions for order edit page.
*
* @return void
*/
public static function add_save_meta_boxes() {
/**
* Save Order Meta Boxes.
*
* In order:
* Save the order items.
* Save the order totals.
* Save the order downloads.
* Save order data - also updates status and sends out admin emails if needed. Last to show latest data.
* Save actions - sends out other emails. Last to show latest data.
*/
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Items::save', 10 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Downloads::save', 30, 2 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Data::save', 40 );
add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Actions::save', 50, 2 );
}
/**
* Enqueue necessary scripts for order edit page.
*/
private function enqueue_scripts() {
if ( wp_is_mobile() ) {
wp_enqueue_script( 'jquery-touch-punch' );
}
wp_enqueue_script( 'post' ); // Ensure existing JS libraries are still available for backward compat.
}
/**
* Returns the PageController for this edit form. This method is protected to allow child classes to overwrite the PageController object and return custom links.
*
* @since 8.0.0
*
* @return PageController PageController object.
*/
protected function get_page_controller() {
if ( ! isset( $this->orders_page_controller ) ) {
$this->orders_page_controller = wc_get_container()->get( PageController::class );
}
return $this->orders_page_controller;
}
/**
* Setup hooks, actions and variables needed to render order edit page.
*
* @param \WC_Order $order Order object.
*/
public function setup( \WC_Order $order ) {
$this->order = $order;
$current_screen = get_current_screen();
$current_screen->is_block_editor( false );
$this->screen_id = $current_screen->id;
if ( ! isset( $this->custom_meta_box ) ) {
$this->custom_meta_box = wc_get_container()->get( CustomMetaBox::class );
}
if ( ! isset( $this->taxonomies_meta_box ) ) {
$this->taxonomies_meta_box = wc_get_container()->get( TaxonomiesMetaBox::class );
}
$this->add_save_meta_boxes();
$this->handle_order_update();
$this->add_order_meta_boxes( $this->screen_id, __( 'Order', 'woocommerce' ) );
$this->add_order_specific_meta_box();
$this->add_order_taxonomies_meta_box();
/**
* From wp-admin/includes/meta-boxes.php.
*
* Fires after all built-in meta boxes have been added. Custom metaboxes may be enqueued here.
*
* @since 3.8.0.
*/
do_action( 'add_meta_boxes', $this->screen_id, $this->order );
/**
* Provides an opportunity to inject custom meta boxes into the order editor screen. This
* hook is an analog of `add_meta_boxes_<POST_TYPE>` as provided by WordPress core.
*
* @since 7.4.0
*
* @oaram WC_Order $order The order being edited.
*/
do_action( 'add_meta_boxes_' . $this->screen_id, $this->order );
$this->enqueue_scripts();
}
/**
* Set the current action for the form.
*
* @param string $action Action name.
*/
public function set_current_action( string $action ) {
$this->current_action = $action;
}
/**
* Hooks meta box for order specific meta.
*/
private function add_order_specific_meta_box() {
add_meta_box(
'order_custom',
__( 'Custom Fields', 'woocommerce' ),
array( $this, 'render_custom_meta_box' ),
$this->screen_id,
'normal'
);
}
/**
* Render custom meta box.
*
* @return void
*/
private function add_order_taxonomies_meta_box() {
$this->taxonomies_meta_box->add_taxonomies_meta_boxes( $this->screen_id, $this->order->get_type() );
}
/**
* Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
*
* @return void
*/
public function handle_order_update() {
if ( ! isset( $this->order ) ) {
return;
}
if ( 'edit_order' !== sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) ) ) {
return;
}
check_admin_referer( $this->get_order_edit_nonce_action() );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized later on by taxonomies_meta_box object.
$taxonomy_input = isset( $_POST['tax_input'] ) ? wp_unslash( $_POST['tax_input'] ) : null;
$this->taxonomies_meta_box->save_taxonomies( $this->order, $taxonomy_input );
/**
* Save meta for shop order.
*
* @param int Order ID.
* @param \WC_Order Post object.
*
* @since 2.1.0
*/
do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );
$this->custom_meta_box->handle_metadata_changes($this->order);
// Order updated message.
$this->message = 1;
$this->redirect_order( $this->order );
}
/**
* Helper method to redirect to order edit page.
*
* @since 8.0.0
*
* @param \WC_Order $order Order object.
*/
private function redirect_order( \WC_Order $order ) {
$redirect_to = $this->get_page_controller()->get_edit_url( $order->get_id() );
if ( isset( $this->message ) ) {
$redirect_to = add_query_arg( 'message', $this->message, $redirect_to );
}
wp_safe_redirect(
/**
* Filter the URL used to redirect after an order is updated. Similar to the WP post's `redirect_post_location` filter.
*
* @param string $redirect_to The redirect destination URL.
* @param int $order_id The order ID.
* @param \WC_Order $order The order object.
*
* @since 8.0.0
*/
apply_filters(
'woocommerce_redirect_order_location',
$redirect_to,
$order->get_id(),
$order
)
);
exit;
}
/**
* Helper method to get the name of order edit nonce.
*
* @return string Nonce action name.
*/
private function get_order_edit_nonce_action() {
return 'update-order_' . $this->order->get_id();
}
/**
* Render meta box for order specific meta.
*/
public function render_custom_meta_box() {
$this->custom_meta_box->output( $this->order );
}
/**
* Render order edit page.
*/
public function display() {
/**
* This is used by the order edit page to show messages in the notice fields.
* It should be similar to post_updated_messages filter, i.e.:
* array(
* {order_type} => array(
* 1 => 'Order updated.',
* 2 => 'Custom field updated.',
* ...
* ).
*
* The index to be displayed is computed from the $_GET['message'] variable.
*
* @since 7.4.0.
*/
$messages = apply_filters( 'woocommerce_order_updated_messages', array() );
$message = $this->message;
if ( isset( $_GET['message'] ) ) {
$message = absint( $_GET['message'] );
}
if ( isset( $message ) ) {
$message = $messages[ $this->order->get_type() ][ $message ] ?? false;
}
$this->render_wrapper_start( '', $message );
$this->render_meta_boxes();
$this->render_wrapper_end();
}
/**
* Helper function to render wrapper start.
*
* @param string $notice Notice to display, if any.
* @param string $message Message to display, if any.
*/
private function render_wrapper_start( $notice = '', $message = '' ) {
$post_type = get_post_type_object( $this->order->get_type() );
$edit_page_url = $this->get_page_controller()->get_edit_url( $this->order->get_id() );
$form_action = 'edit_order';
$referer = wp_get_referer();
$new_page_url = $this->get_page_controller()->get_new_page_url( $this->order->get_type() );
?>
<div class="wrap">
<h1 class="wp-heading-inline">
<?php
echo 'new_order' === $this->current_action ? esc_html( $post_type->labels->add_new_item ) : esc_html( $post_type->labels->edit_item );
?>
</h1>
<?php
if ( 'edit_order' === $this->current_action ) {
echo ' <a href="' . esc_url( $new_page_url ) . '" class="page-title-action">' . esc_html( $post_type->labels->add_new ) . '</a>';
}
?>
<hr class="wp-header-end">
<?php
if ( $notice ) :
?>
<div id="notice" class="notice notice-warning"><p
id="has-newer-autosave"><?php echo wp_kses_post( $notice ); ?></p></div>
<?php endif; ?>
<?php if ( $message ) : ?>
<div id="message" class="updated notice notice-success is-dismissible">
<p><?php echo wp_kses_post( $message ); ?></p></div>
<?php
endif;
?>
<form name="order" action="<?php echo esc_url( $edit_page_url ); ?>" method="post" id="order"
<?php
/**
* Fires inside the order edit form tag.
*
* @param \WC_Order $order Order object.
*
* @since 6.9.0
*/
do_action( 'order_edit_form_tag', $this->order );
?>
>
<?php wp_nonce_field( $this->get_order_edit_nonce_action() ); ?>
<?php
/**
* Fires at the top of the order edit form. Can be used as a replacement for edit_form_top hook for HPOS.
*
* @param \WC_Order $order Order object.
*
* @since 8.0.0
*/
do_action( 'order_edit_form_top', $this->order );
?>
<input type="hidden" id="hiddenaction" name="action" value="<?php echo esc_attr( $form_action ); ?>"/>
<input type="hidden" id="original_order_status" name="original_order_status" value="<?php echo esc_attr( $this->order->get_status() ); ?>"/>
<input type="hidden" id="referredby" name="referredby" value="<?php echo $referer ? esc_url( $referer ) : ''; ?>"/>
<input type="hidden" id="post_ID" name="post_ID" value="<?php echo esc_attr( $this->order->get_id() ); ?>"/>
<div id="poststuff">
<div id="post-body"
class="metabox-holder columns-<?php echo ( 1 === get_current_screen()->get_columns() ) ? '1' : '2'; ?>">
<?php
}
/**
* Helper function to render meta boxes.
*/
private function render_meta_boxes() {
?>
<div id="postbox-container-1" class="postbox-container">
<?php do_meta_boxes( $this->screen_id, 'side', $this->order ); ?>
</div>
<div id="postbox-container-2" class="postbox-container">
<?php
do_meta_boxes( $this->screen_id, 'normal', $this->order );
do_meta_boxes( $this->screen_id, 'advanced', $this->order );
?>
</div>
<?php
}
/**
* Helper function to render wrapper end.
*/
private function render_wrapper_end() {
?>
</div> <!-- /post-body -->
</div> <!-- /poststuff -->
</form>
</div> <!-- /wrap -->
<?php
}
}
Internal/Admin/Orders/EditLock.php 0000644 00000016770 15153704477 0013045 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
/**
* This class takes care of the edit lock logic when HPOS is enabled.
* For better interoperability with WordPress, edit locks are stored in the same format as posts. That is, as a metadata
* in the order object (key: '_edit_lock') in the format "timestamp:user_id".
*
* @since 7.8.0
*/
class EditLock {
const META_KEY_NAME = '_edit_lock';
/**
* Obtains lock information for a given order. If the lock has expired or it's assigned to an invalid user,
* the order is no longer considered locked.
*
* @param \WC_Order $order Order to check.
* @return bool|array
*/
public function get_lock( \WC_Order $order ) {
$lock = $order->get_meta( self::META_KEY_NAME, true, 'edit' );
if ( ! $lock ) {
return false;
}
$lock = explode( ':', $lock );
if ( 2 !== count( $lock ) ) {
return false;
}
$time = absint( $lock[0] );
$user_id = isset( $lock[1] ) ? absint( $lock[1] ) : 0;
if ( ! $time || ! get_user_by( 'id', $user_id ) ) {
return false;
}
/** This filter is documented in WP's wp-admin/includes/ajax-actions.php */
$time_window = apply_filters( 'wp_check_post_lock_window', 150 ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
if ( time() >= ( $time + $time_window ) ) {
return false;
}
return compact( 'time', 'user_id' );
}
/**
* Checks whether the order is being edited (i.e. locked) by another user.
*
* @param \WC_Order $order Order to check.
* @return bool TRUE if order is locked and currently being edited by another user. FALSE otherwise.
*/
public function is_locked_by_another_user( \WC_Order $order ) : bool {
$lock = $this->get_lock( $order );
return $lock && ( get_current_user_id() !== $lock['user_id'] );
}
/**
* Checks whether the order is being edited by any user.
*
* @param \WC_Order $order Order to check.
* @return boolean TRUE if order is locked and currently being edited by a user. FALSE otherwise.
*/
public function is_locked( \WC_Order $order ) : bool {
return (bool) $this->get_lock( $order );
}
/**
* Assigns an order's edit lock to the current user.
*
* @param \WC_Order $order The order to apply the lock to.
* @return array|bool FALSE if no user is logged-in, an array in the same format as {@see get_lock()} otherwise.
*/
public function lock( \WC_Order $order ) {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
$order->update_meta_data( self::META_KEY_NAME, time() . ':' . $user_id );
$order->save_meta_data();
return $order->get_meta( self::META_KEY_NAME, true, 'edit' );
}
/**
* Hooked to 'heartbeat_received' on the edit order page to refresh the lock on an order being edited by the current user.
*
* @param array $response The heartbeat response to be sent.
* @param array $data Data sent through the heartbeat.
* @return array Response to be sent.
*/
public function refresh_lock_ajax( $response, $data ) {
$order_id = absint( $data['wc-refresh-order-lock'] ?? 0 );
if ( ! $order_id ) {
return $response;
}
unset( $response['wp-refresh-post-lock'] );
$order = wc_get_order( $order_id );
if ( ! $order || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
return $response;
}
$response['wc-refresh-order-lock'] = array();
if ( ! $this->is_locked_by_another_user( $order ) ) {
$response['wc-refresh-order-lock']['lock'] = $this->lock( $order );
} else {
$current_lock = $this->get_lock( $order );
$user = get_user_by( 'id', $current_lock['user_id'] );
$response['wc-refresh-order-lock']['error'] = array(
// translators: %s is a user's name.
'message' => sprintf( __( '%s has taken over and is currently editing.', 'woocommerce' ), $user->display_name ),
'user_name' => $user->display_name,
'user_avatar_src' => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 64 ) ) : '',
'user_avatar_src_2x' => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 128 ) ) : '',
);
}
return $response;
}
/**
* Hooked to 'heartbeat_received' on the orders screen to refresh the locked status of orders in the list table.
*
* @param array $response The heartbeat response to be sent.
* @param array $data Data sent through the heartbeat.
* @return array Response to be sent.
*/
public function check_locked_orders_ajax( $response, $data ) {
if ( empty( $data['wc-check-locked-orders'] ) || ! is_array( $data['wc-check-locked-orders'] ) ) {
return $response;
}
$response['wc-check-locked-orders'] = array();
$order_ids = array_unique( array_map( 'absint', $data['wc-check-locked-orders'] ) );
foreach ( $order_ids as $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
continue;
}
if ( ! $this->is_locked_by_another_user( $order ) || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
continue;
}
$response['wc-check-locked-orders'][ $order_id ] = true;
}
return $response;
}
/**
* Outputs HTML for the lock dialog based on the status of the lock on the order (if any).
* Depending on who owns the lock, this could be a message with the chance to take over or a message indicating that
* someone else has taken over the order.
*
* @param \WC_Order $order Order object.
* @return void
*/
public function render_dialog( $order ) {
$locked = $this->is_locked_by_another_user( $order );
$lock = $this->get_lock( $order );
$user = get_user_by( 'id', $lock['user_id'] );
$edit_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_edit_url( $order->get_id() );
$sendback_url = wp_get_referer();
if ( ! $sendback_url ) {
$sendback_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_base_page_url( $order->get_type() );
}
$sendback_text = __( 'Go back', 'woocommerce' );
?>
<div id="post-lock-dialog" class="notification-dialog-wrap <?php echo $locked ? '' : 'hidden'; ?> order-lock-dialog">
<div class="notification-dialog-background"></div>
<div class="notification-dialog">
<?php if ( $locked ) : ?>
<div class="post-locked-message">
<div class="post-locked-avatar"><?php echo get_avatar( $user->ID, 64 ); ?></div>
<p class="currently-editing wp-tab-first" tabindex="0">
<?php
// translators: %s is a user's name.
echo esc_html( sprintf( __( '%s is currently editing this order. Do you want to take over?', 'woocommerce' ), esc_html( $user->display_name ) ) );
?>
</p>
<p>
<a class="button" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a>
<a class="button button-primary wp-tab-last" href="<?php echo esc_url( add_query_arg( 'claim-lock', '1', wp_nonce_url( $edit_url, 'claim-lock-' . $order->get_id() ) ) ); ?>"><?php esc_html_e( 'Take over', 'woocommerce' ); ?></a>
</p>
</div>
<?php else : ?>
<div class="post-taken-over">
<div class="post-locked-avatar"></div>
<p class="wp-tab-first" tabindex="0">
<span class="currently-editing"></span><br />
</p>
<p><a class="button button-primary wp-tab-last" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a></p>
</div>
<?php endif; ?>
</div>
</div>
<?php
}
}
Internal/Admin/Orders/ListTable.php 0000644 00000136351 15153704477 0013230 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WP_List_Table;
use WP_Screen;
/**
* Admin list table for orders as managed by the OrdersTableDataStore.
*/
class ListTable extends WP_List_Table {
/**
* Order type.
*
* @var string
*/
private $order_type;
/**
* Request vars.
*
* @var array
*/
private $request = array();
/**
* Contains the arguments to be used in the order query.
*
* @var array
*/
private $order_query_args = array();
/**
* Tracks if a filter (ie, date or customer filter) has been applied.
*
* @var bool
*/
private $has_filter = false;
/**
* Page controller instance for this request.
*
* @var PageController
*/
private $page_controller;
/**
* Tracks whether we're currently inside the trash.
*
* @var boolean
*/
private $is_trash = false;
/**
* Caches order counts by status.
*
* @var array
*/
private $status_count_cache = null;
/**
* Sets up the admin list table for orders (specifically, for orders managed by the OrdersTableDataStore).
*
* @see WC_Admin_List_Table_Orders for the corresponding class used in relation to the traditional WP Post store.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'order',
'plural' => 'orders',
'ajax' => false,
)
);
}
/**
* Init method, invoked by DI container.
*
* @internal This method is not intended to be used directly (except for testing).
* @param PageController $page_controller Page controller instance for this request.
*/
final public function init( PageController $page_controller ) {
$this->page_controller = $page_controller;
}
/**
* Performs setup work required before rendering the table.
*
* @param array $args Args to initialize this list table.
*
* @return void
*/
public function setup( $args = array() ): void {
$this->order_type = $args['order_type'] ?? 'shop_order';
add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
add_filter( "manage_{$this->screen->id}_columns", array( $this, 'get_columns' ), 0 );
add_filter( 'set_screen_option_edit_' . $this->order_type . '_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ), 10, 2 );
add_action( 'admin_footer', array( $this, 'enqueue_scripts' ) );
$this->items_per_page();
set_screen_options();
add_action( 'manage_' . wc_get_page_screen_id( $this->order_type ) . '_custom_column', array( $this, 'render_column' ), 10, 2 );
}
/**
* Generates content for a single row of the table.
*
* @since 7.8.0
*
* @param \WC_Order $order The current order.
*/
public function single_row( $order ) {
/**
* Filters the list of CSS class names for a given order row in the orders list table.
*
* @since 7.8.0
*
* @param string[] $classes An array of CSS class names.
* @param \WC_Order $order The order object.
*/
$css_classes = apply_filters(
'woocommerce_' . $this->order_type . '_list_table_order_css_classes',
array(
'order-' . $order->get_id(),
'type-' . $order->get_type(),
'status-' . $order->get_status(),
),
$order
);
$css_classes = array_unique( array_map( 'trim', $css_classes ) );
// Is locked?
$edit_lock = wc_get_container()->get( EditLock::class );
if ( $edit_lock->is_locked_by_another_user( $order ) ) {
$css_classes[] = 'wp-locked';
}
echo '<tr id="order-' . esc_attr( $order->get_id() ) . '" class="' . esc_attr( implode( ' ', $css_classes ) ) . '">';
$this->single_row_columns( $order );
echo '</tr>';
}
/**
* Render individual column.
*
* @param string $column_id Column ID to render.
* @param WC_Order $order Order object.
*/
public function render_column( $column_id, $order ) {
if ( ! $order ) {
return;
}
if ( is_callable( array( $this, 'render_' . $column_id . '_column' ) ) ) {
call_user_func( array( $this, 'render_' . $column_id . '_column' ), $order );
}
}
/**
* Handles output for the default column.
*
* @param \WC_Order $order Current WooCommerce order object.
* @param string $column_name Identifier for the custom column.
*/
public function column_default( $order, $column_name ) {
/**
* Fires for each custom column for a specific order type. This hook takes precedence over the generic
* action `manage_{$this->screen->id}_custom_column`.
*
* @param string $column_name Identifier for the custom column.
* @param \WC_Order $order Current WooCommerce order object.
*
* @since 7.3.0
*/
do_action( 'woocommerce_' . $this->order_type . '_list_table_custom_column', $column_name, $order );
/**
* Fires for each custom column in the Custom Order Table in the administrative screen.
*
* @param string $column_name Identifier for the custom column.
* @param \WC_Order $order Current WooCommerce order object.
*
* @since 7.0.0
*/
do_action( "manage_{$this->screen->id}_custom_column", $column_name, $order );
}
/**
* Sets up an items-per-page control.
*/
private function items_per_page(): void {
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => 'edit_' . $this->order_type . '_per_page',
)
);
}
/**
* Saves the items-per-page setting.
*
* @param mixed $default The default value.
* @param string $option The option being configured.
* @param int $value The submitted option value.
*
* @return mixed
*/
public function set_items_per_page( $default, string $option, int $value ) {
return 'edit_' . $this->order_type . '_per_page' === $option ? absint( $value ) : $default;
}
/**
* Render the table.
*
* @return void
*/
public function display() {
$post_type = get_post_type_object( $this->order_type );
$title = esc_html( $post_type->labels->name );
$add_new = esc_html( $post_type->labels->add_new );
$new_page_link = $this->page_controller->get_new_page_url( $this->order_type );
$search_label = '';
if ( ! empty( $this->order_query_args['s'] ) ) {
$search_label = '<span class="subtitle">';
$search_label .= sprintf(
/* translators: %s: Search query. */
__( 'Search results for: %s', 'woocommerce' ),
'<strong>' . esc_html( $this->order_query_args['s'] ) . '</strong>'
);
$search_label .= '</span>';
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wp_kses_post(
"
<div class='wrap'>
<h1 class='wp-heading-inline'>{$title}</h1>
<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
{$search_label}
<hr class='wp-header-end'>"
);
if ( $this->should_render_blank_state() ) {
$this->render_blank_state();
return;
}
$this->views();
echo '<form id="wc-orders-filter" method="get" action="' . esc_url( get_admin_url( null, 'admin.php' ) ) . '">';
$this->print_hidden_form_fields();
$this->search_box( esc_html__( 'Search orders', 'woocommerce' ), 'orders-search-input' );
parent::display();
echo '</form> </div>';
}
/**
* Renders advice in the event that no orders exist yet.
*
* @return void
*/
public function render_blank_state(): void {
?>
<div class="woocommerce-BlankState">
<h2 class="woocommerce-BlankState-message">
<?php esc_html_e( 'When you receive a new order, it will appear here.', 'woocommerce' ); ?>
</h2>
<div class="woocommerce-BlankState-buttons">
<a class="woocommerce-BlankState-cta button-primary button" target="_blank" href="https://docs.woocommerce.com/document/managing-orders/?utm_source=blankslate&utm_medium=product&utm_content=ordersdoc&utm_campaign=woocommerceplugin"><?php esc_html_e( 'Learn more about orders', 'woocommerce' ); ?></a>
</div>
<?php
/**
* Renders after the 'blank state' message for the order list table has rendered.
*
* @since 6.6.1
*/
do_action( 'wc_marketplace_suggestions_orders_empty_state' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
?>
</div>
<?php
}
/**
* Retrieves the list of bulk actions available for this table.
*
* @return array
*/
protected function get_bulk_actions() {
$selected_status = $this->order_query_args['status'] ?? false;
if ( array( 'trash' ) === $selected_status ) {
$actions = array(
'untrash' => __( 'Restore', 'woocommerce' ),
'delete' => __( 'Delete permanently', 'woocommerce' ),
);
} else {
$actions = array(
'mark_processing' => __( 'Change status to processing', 'woocommerce' ),
'mark_on-hold' => __( 'Change status to on-hold', 'woocommerce' ),
'mark_completed' => __( 'Change status to completed', 'woocommerce' ),
'mark_cancelled' => __( 'Change status to cancelled', 'woocommerce' ),
'trash' => __( 'Move to Trash', 'woocommerce' ),
);
}
if ( wc_string_to_bool( get_option( 'woocommerce_allow_bulk_remove_personal_data', 'no' ) ) ) {
$actions['remove_personal_data'] = __( 'Remove personal data', 'woocommerce' );
}
return $actions;
}
/**
* Gets a list of CSS classes for the WP_List_Table table tag.
*
* @since 7.8.0
*
* @return string[] Array of CSS classes for the table tag.
*/
protected function get_table_classes() {
/**
* Filters the list of CSS class names for the orders list table.
*
* @since 7.8.0
*
* @param string[] $classes An array of CSS class names.
* @param string $order_type The order type.
*/
$css_classes = apply_filters(
'woocommerce_' . $this->order_type . '_list_table_css_classes',
array_merge(
parent::get_table_classes(),
array(
'wc-orders-list-table',
'wc-orders-list-table-' . $this->order_type,
)
),
$this->order_type
);
return array_unique( array_map( 'trim', $css_classes ) );
}
/**
* Prepares the list of items for displaying.
*/
public function prepare_items() {
$limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );
$this->order_query_args = array(
'limit' => $limit,
'page' => $this->get_pagenum(),
'paginate' => true,
'type' => $this->order_type,
);
foreach ( array( 'status', 's', 'm', '_customer_user' ) as $query_var ) {
$this->request[ $query_var ] = sanitize_text_field( wp_unslash( $_REQUEST[ $query_var ] ?? '' ) );
}
/**
* Allows 3rd parties to filter the initial request vars before defaults and other logic is applied.
*
* @param array $request Request to be passed to `wc_get_orders()`.
*
* @since 7.3.0
*/
$this->request = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_request', $this->request );
$this->set_status_args();
$this->set_order_args();
$this->set_date_args();
$this->set_customer_args();
$this->set_search_args();
/**
* Provides an opportunity to modify the query arguments used in the (Custom Order Table-powered) order list
* table.
*
* @since 6.9.0
*
* @param array $query_args Arguments to be passed to `wc_get_orders()`.
*/
$order_query_args = (array) apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', $this->order_query_args );
/**
* Same as `woocommerce_order_list_table_prepare_items_query_args` but for a specific order type.
*
* @param array $query_args Arguments to be passed to `wc_get_orders()`.
*
* @since 7.3.0
*/
$order_query_args = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_prepare_items_query_args', $order_query_args );
// We must ensure the 'paginate' argument is set.
$order_query_args['paginate'] = true;
$orders = wc_get_orders( $order_query_args );
$this->items = $orders->orders;
$max_num_pages = $orders->max_num_pages;
// Check in case the user has attempted to page beyond the available range of orders.
if ( 0 === $max_num_pages && $this->order_query_args['page'] > 1 ) {
$count_query_args = $order_query_args;
$count_query_args['page'] = 1;
$count_query_args['limit'] = 1;
$order_count = wc_get_orders( $count_query_args );
$max_num_pages = (int) ceil( $order_count->total / $order_query_args['limit'] );
}
$this->set_pagination_args(
array(
'total_items' => $orders->total ?? 0,
'per_page' => $limit,
'total_pages' => $max_num_pages,
)
);
// Are we inside the trash?
$this->is_trash = 'trash' === $this->request['status'];
}
/**
* Updates the WC Order Query arguments as needed to support orderable columns.
*/
private function set_order_args() {
$sortable = $this->get_sortable_columns();
$field = sanitize_text_field( wp_unslash( $_GET['orderby'] ?? '' ) );
$direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );
if ( ! in_array( $field, $sortable, true ) ) {
$this->order_query_args['orderby'] = 'date';
$this->order_query_args['order'] = 'DESC';
return;
}
$this->order_query_args['orderby'] = $field;
$this->order_query_args['order'] = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
}
/**
* Implements date (month-based) filtering.
*/
private function set_date_args() {
$year_month = sanitize_text_field( wp_unslash( $_GET['m'] ?? '' ) );
if ( empty( $year_month ) || ! preg_match( '/^[0-9]{6}$/', $year_month ) ) {
return;
}
$year = (int) substr( $year_month, 0, 4 );
$month = (int) substr( $year_month, 4, 2 );
if ( $month < 0 || $month > 12 ) {
return;
}
$last_day_of_month = date_create( "$year-$month" )->format( 'Y-m-t' );
$this->order_query_args['date_created'] = "$year-$month-01..." . $last_day_of_month;
$this->has_filter = true;
}
/**
* Implements filtering of orders by customer.
*/
private function set_customer_args() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$customer = (int) wp_unslash( $_GET['_customer_user'] ?? '' );
if ( $customer < 1 ) {
return;
}
$this->order_query_args['customer'] = $customer;
$this->has_filter = true;
}
/**
* Implements filtering of orders by status.
*/
private function set_status_args() {
$status = array_filter( array_map( 'trim', (array) $this->request['status'] ) );
if ( empty( $status ) || in_array( 'all', $status, true ) ) {
/**
* Allows 3rd parties to set the default list of statuses for a given order type.
*
* @param string[] $statuses Statuses.
*
* @since 7.3.0
*/
$status = apply_filters(
'woocommerce_' . $this->order_type . '_list_table_default_statuses',
array_intersect(
array_keys( wc_get_order_statuses() ),
get_post_stati( array( 'show_in_admin_all_list' => true ), 'names' )
)
);
} else {
$this->has_filter = true;
}
$this->order_query_args['status'] = $status;
}
/**
* Implements order search.
*/
private function set_search_args(): void {
$search_term = trim( sanitize_text_field( $this->request['s'] ) );
if ( ! empty( $search_term ) ) {
$this->order_query_args['s'] = $search_term;
$this->has_filter = true;
}
}
/**
* Get the list of views for this table (all orders, completed orders, etc, each with a count of the number of
* corresponding orders).
*
* @return array
*/
public function get_views() {
$view_counts = array();
$view_links = array();
$statuses = $this->get_visible_statuses();
$current = ! empty( $this->request['status'] ) ? sanitize_text_field( $this->request['status'] ) : 'all';
$all_count = 0;
foreach ( array_keys( $statuses ) as $slug ) {
$total_in_status = $this->count_orders_by_status( $slug );
if ( $total_in_status > 0 ) {
$view_counts[ $slug ] = $total_in_status;
}
if ( ( get_post_status_object( $slug ) )->show_in_admin_all_list && 'auto-draft' !== $slug ) {
$all_count += $total_in_status;
}
}
$view_links['all'] = $this->get_view_link( 'all', __( 'All', 'woocommerce' ), $all_count, '' === $current || 'all' === $current );
foreach ( $view_counts as $slug => $count ) {
$view_links[ $slug ] = $this->get_view_link( $slug, $statuses[ $slug ], $count, $slug === $current );
}
return $view_links;
}
/**
* Count orders by status.
*
* @param string|string[] $status The order status we are interested in.
*
* @return int
*/
private function count_orders_by_status( $status ): int {
global $wpdb;
// Compute all counts and cache if necessary.
if ( is_null( $this->status_count_cache ) ) {
$orders_table = OrdersTableDataStore::get_orders_table_name();
$res = $wpdb->get_results(
$wpdb->prepare(
"SELECT status, COUNT(*) AS cnt FROM {$orders_table} WHERE type = %s GROUP BY status", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->order_type
),
ARRAY_A
);
$this->status_count_cache =
$res
? array_combine( array_column( $res, 'status' ), array_map( 'absint', array_column( $res, 'cnt' ) ) )
: array();
}
$status = (array) $status;
$count = array_sum( array_intersect_key( $this->status_count_cache, array_flip( $status ) ) );
/**
* Allows 3rd parties to modify the count of orders by status.
*
* @param int $count Number of orders for the given status.
* @param string[] $status List of order statuses in the count.
* @since 7.3.0
*/
return apply_filters(
'woocommerce_' . $this->order_type . '_list_table_order_count',
$count,
$status
);
}
/**
* Checks whether the blank state should be rendered or not. This depends on whether there are others with a visible
* status.
*
* @return boolean TRUE when the blank state should be rendered, FALSE otherwise.
*/
private function should_render_blank_state(): bool {
return ( ! $this->has_filter ) && 0 === $this->count_orders_by_status( array_keys( $this->get_visible_statuses() ) );
}
/**
* Returns a list of slug and labels for order statuses that should be visible in the status list.
*
* @return array slug => label array of order statuses.
*/
private function get_visible_statuses(): array {
return array_intersect_key(
array_merge(
wc_get_order_statuses(),
array(
'trash' => ( get_post_status_object( 'trash' ) )->label,
'draft' => ( get_post_status_object( 'draft' ) )->label,
'auto-draft' => ( get_post_status_object( 'auto-draft' ) )->label,
)
),
array_flip( get_post_stati( array( 'show_in_admin_status_list' => true ) ) )
);
}
/**
* Form a link to use in the list of table views.
*
* @param string $slug Slug used to identify the view (usually the order status slug).
* @param string $name Human-readable name of the view (usually the order status label).
* @param int $count Number of items in this view.
* @param bool $current If this is the current view.
*
* @return string
*/
private function get_view_link( string $slug, string $name, int $count, bool $current ): string {
$base_url = get_admin_url( null, 'admin.php?page=wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) );
$url = esc_url( add_query_arg( 'status', $slug, $base_url ) );
$name = esc_html( $name );
$count = absint( $count );
$class = $current ? 'class="current"' : '';
return "<a href='$url' $class>$name <span class='count'>($count)</span></a>";
}
/**
* Extra controls to be displayed between bulk actions and pagination.
*
* @param string $which Either 'top' or 'bottom'.
*/
protected function extra_tablenav( $which ) {
echo '<div class="alignleft actions">';
if ( 'top' === $which ) {
ob_start();
$this->months_filter();
$this->customers_filter();
/**
* Fires before the "Filter" button on the list table for orders and other order types.
*
* @since 7.3.0
*
* @param string $order_type The order type.
* @param string $which The location of the extra table nav: 'top' or 'bottom'.
*/
do_action( 'woocommerce_order_list_table_restrict_manage_orders', $this->order_type, $which );
$output = ob_get_clean();
if ( ! empty( $output ) ) {
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'order-query-submit' ) );
}
}
if ( $this->is_trash && $this->has_items() && current_user_can( 'edit_others_shop_orders' ) ) {
submit_button( __( 'Empty Trash', 'woocommerce' ), 'apply', 'delete_all', false );
}
/**
* Fires immediately following the closing "actions" div in the tablenav for the order
* list table.
*
* @since 7.3.0
*
* @param string $order_type The order type.
* @param string $which The location of the extra table nav: 'top' or 'bottom'.
*/
do_action( 'woocommerce_order_list_table_extra_tablenav', $this->order_type, $which );
echo '</div>';
}
/**
* Render the months filter dropdown.
*
* @return void
*/
private function months_filter() {
// XXX: [review] we may prefer to move this logic outside of the ListTable class.
global $wp_locale;
global $wpdb;
$orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() );
$utc_offset = wc_timezone_offset();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$order_dates = $wpdb->get_results(
"
SELECT DISTINCT YEAR( t.date_created_local ) AS year,
MONTH( t.date_created_local ) AS month
FROM ( SELECT DATE_ADD( date_created_gmt, INTERVAL $utc_offset SECOND ) AS date_created_local FROM $orders_table WHERE status != 'trash' ) t
ORDER BY year DESC, month DESC
"
);
$m = isset( $_GET['m'] ) ? (int) $_GET['m'] : 0;
echo '<select name="m" id="filter-by-date">';
echo '<option ' . selected( $m, 0, false ) . ' value="0">' . esc_html__( 'All dates', 'woocommerce' ) . '</option>';
foreach ( $order_dates as $date ) {
$month = zeroise( $date->month, 2 );
$month_year_text = sprintf(
/* translators: 1: Month name, 2: 4-digit year. */
esc_html_x( '%1$s %2$d', 'order dates dropdown', 'woocommerce' ),
$wp_locale->get_month( $month ),
$date->year
);
printf(
'<option %1$s value="%2$s">%3$s</option>\n',
selected( $m, $date->year . $month, false ),
esc_attr( $date->year . $month ),
esc_html( $month_year_text )
);
}
echo '</select>';
}
/**
* Render the customer filter dropdown.
*
* @return void
*/
public function customers_filter() {
$user_string = '';
$user_id = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['_customer_user'] ) ) {
$user_id = absint( $_GET['_customer_user'] );
$user = get_user_by( 'id', $user_id );
$user_string = sprintf(
/* translators: 1: user display name 2: user ID 3: user email */
esc_html__( '%1$s (#%2$s – %3$s)', 'woocommerce' ),
$user->display_name,
absint( $user->ID ),
$user->user_email
);
}
// Note: use of htmlspecialchars (below) is to prevent XSS when rendered by selectWoo.
?>
<select class="wc-customer-search" name="_customer_user" data-placeholder="<?php esc_attr_e( 'Filter by registered customer', 'woocommerce' ); ?>" data-allow_clear="true">
<option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo htmlspecialchars( wp_kses_post( $user_string ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></option>
</select>
<?php
}
/**
* Get list columns.
*
* @return array
*/
public function get_columns() {
/**
* Filters the list of columns.
*
* @param array $columns List of sortable columns.
*
* @since 7.3.0
*/
return apply_filters(
'woocommerce_' . $this->order_type . '_list_table_columns',
array(
'cb' => '<input type="checkbox" />',
'order_number' => esc_html__( 'Order', 'woocommerce' ),
'order_date' => esc_html__( 'Date', 'woocommerce' ),
'order_status' => esc_html__( 'Status', 'woocommerce' ),
'billing_address' => esc_html__( 'Billing', 'woocommerce' ),
'shipping_address' => esc_html__( 'Ship to', 'woocommerce' ),
'order_total' => esc_html__( 'Total', 'woocommerce' ),
'wc_actions' => esc_html__( 'Actions', 'woocommerce' ),
)
);
}
/**
* Defines the default sortable columns.
*
* @return string[]
*/
public function get_sortable_columns() {
/**
* Filters the list of sortable columns.
*
* @param array $sortable_columns List of sortable columns.
*
* @since 7.3.0
*/
return apply_filters(
'woocommerce_' . $this->order_type . '_list_table_sortable_columns',
array(
'order_number' => 'ID',
'order_date' => 'date',
'order_total' => 'order_total',
)
);
}
/**
* Specify the columns we wish to hide by default.
*
* @param array $hidden Columns set to be hidden.
* @param WP_Screen $screen Screen object.
*
* @return array
*/
public function default_hidden_columns( array $hidden, WP_Screen $screen ) {
if ( isset( $screen->id ) && wc_get_page_screen_id( 'shop-order' ) === $screen->id ) {
$hidden = array_merge(
$hidden,
array(
'billing_address',
'shipping_address',
'wc_actions',
)
);
}
return $hidden;
}
/**
* Checklist column, used for selecting items for processing by a bulk action.
*
* @param WC_Order $item The order object for the current row.
*
* @return string
*/
public function column_cb( $item ) {
ob_start();
?>
<input id="cb-select-<?php echo esc_attr( $item->get_id() ); ?>" type="checkbox" name="id[]" value="<?php echo esc_attr( $item->get_id() ); ?>" />
<div class="locked-indicator">
<span class="locked-indicator-icon" aria-hidden="true"></span>
<span class="screen-reader-text">
<?php
// translators: %s is an order ID.
echo esc_html( sprintf( __( 'Order %s is locked.', 'woocommerce' ), $item->get_id() ) );
?>
</span>
</div>
<?php
return ob_get_clean();
}
/**
* Renders the order number, customer name and provides a preview link.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_number_column( WC_Order $order ): void {
$buyer = '';
if ( $order->get_billing_first_name() || $order->get_billing_last_name() ) {
/* translators: 1: first name 2: last name */
$buyer = trim( sprintf( _x( '%1$s %2$s', 'full name', 'woocommerce' ), $order->get_billing_first_name(), $order->get_billing_last_name() ) );
} elseif ( $order->get_billing_company() ) {
$buyer = trim( $order->get_billing_company() );
} elseif ( $order->get_customer_id() ) {
$user = get_user_by( 'id', $order->get_customer_id() );
$buyer = ucwords( $user->display_name );
}
/**
* Filter buyer name in list table orders.
*
* @since 3.7.0
*
* @param string $buyer Buyer name.
* @param WC_Order $order Order data.
*/
$buyer = apply_filters( 'woocommerce_admin_order_buyer_name', $buyer, $order );
if ( $order->get_status() === 'trash' ) {
echo '<strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong>';
} else {
echo '<a href="#" class="order-preview" data-order-id="' . absint( $order->get_id() ) . '" title="' . esc_attr( __( 'Preview', 'woocommerce' ) ) . '">' . esc_html( __( 'Preview', 'woocommerce' ) ) . '</a>';
echo '<a href="' . esc_url( $this->get_order_edit_link( $order ) ) . '" class="order-view"><strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong></a>';
}
}
/**
* Get the edit link for an order.
*
* @param WC_Order $order Order object.
*
* @return string Edit link for the order.
*/
private function get_order_edit_link( WC_Order $order ) : string {
return $this->page_controller->get_edit_url( $order->get_id() );
}
/**
* Renders the order date.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_date_column( WC_Order $order ): void {
$order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';
if ( ! $order_timestamp ) {
echo '–';
return;
}
// Check if the order was created within the last 24 hours, and not in the future.
if ( $order_timestamp > strtotime( '-1 day', time() ) && $order_timestamp <= time() ) {
$show_date = sprintf(
/* translators: %s: human-readable time difference */
_x( '%s ago', '%s = human-readable time difference', 'woocommerce' ),
human_time_diff( $order->get_date_created()->getTimestamp(), time() )
);
} else {
$show_date = $order->get_date_created()->date_i18n( apply_filters( 'woocommerce_admin_order_date_format', __( 'M j, Y', 'woocommerce' ) ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
printf(
'<time datetime="%1$s" title="%2$s">%3$s</time>',
esc_attr( $order->get_date_created()->date( 'c' ) ),
esc_html( $order->get_date_created()->date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ),
esc_html( $show_date )
);
}
/**
* Renders the order status.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_status_column( WC_Order $order ): void {
$tooltip = '';
remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
$comment_count = get_comment_count( $order->get_id() );
add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
$approved_comments_count = absint( $comment_count['approved'] );
if ( $approved_comments_count ) {
$latest_notes = wc_get_order_notes(
array(
'order_id' => $order->get_id(),
'limit' => 1,
'orderby' => 'date_created_gmt',
)
);
$latest_note = current( $latest_notes );
if ( isset( $latest_note->content ) && 1 === $approved_comments_count ) {
$tooltip = wc_sanitize_tooltip( $latest_note->content );
} elseif ( isset( $latest_note->content ) ) {
/* translators: %d: notes count */
$tooltip = wc_sanitize_tooltip( $latest_note->content . '<br/><small style="display:block">' . sprintf( _n( 'Plus %d other note', 'Plus %d other notes', ( $approved_comments_count - 1 ), 'woocommerce' ), $approved_comments_count - 1 ) . '</small>' );
} else {
/* translators: %d: notes count */
$tooltip = wc_sanitize_tooltip( sprintf( _n( '%d note', '%d notes', $approved_comments_count, 'woocommerce' ), $approved_comments_count ) );
}
}
// Gracefully handle legacy statuses.
if ( in_array( $order->get_status(), array( 'trash', 'draft', 'auto-draft' ), true ) ) {
$status_name = ( get_post_status_object( $order->get_status() ) )->label;
} else {
$status_name = wc_get_order_status_name( $order->get_status() );
}
if ( $tooltip ) {
printf( '<mark class="order-status %s tips" data-tip="%s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), wp_kses_post( $tooltip ), esc_html( $status_name ) );
} else {
printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), esc_html( $status_name ) );
}
}
/**
* Renders order billing information.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_billing_address_column( WC_Order $order ): void {
$address = $order->get_formatted_billing_address();
if ( $address ) {
echo esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) );
if ( $order->get_payment_method() ) {
/* translators: %s: payment method */
echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_payment_method_title() ) ) . '</span>';
}
} else {
echo '–';
}
}
/**
* Renders order shipping information.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_shipping_address_column( WC_Order $order ): void {
$address = $order->get_formatted_shipping_address();
if ( $address ) {
echo '<a target="_blank" href="' . esc_url( $order->get_shipping_address_map_url() ) . '">' . esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) ) . '</a>';
if ( $order->get_shipping_method() ) {
/* translators: %s: shipping method */
echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_shipping_method() ) ) . '</span>';
}
} else {
echo '–';
}
}
/**
* Renders the order total.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_order_total_column( WC_Order $order ): void {
if ( $order->get_payment_method_title() ) {
/* translators: %s: method */
echo '<span class="tips" data-tip="' . esc_attr( sprintf( __( 'via %s', 'woocommerce' ), $order->get_payment_method_title() ) ) . '">' . wp_kses_post( $order->get_formatted_order_total() ) . '</span>';
} else {
echo wp_kses_post( $order->get_formatted_order_total() );
}
}
/**
* Renders order actions.
*
* @param WC_Order $order The order object for the current row.
*
* @return void
*/
public function render_wc_actions_column( WC_Order $order ): void {
echo '<p>';
/**
* Fires before the order action buttons (within the actions column for the order list table)
* are registered.
*
* @param WC_Order $order Current order object.
* @since 6.7.0
*/
do_action( 'woocommerce_admin_order_actions_start', $order );
$actions = array();
if ( $order->has_status( array( 'pending', 'on-hold' ) ) ) {
$actions['processing'] = array(
'url' => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=processing&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
'name' => __( 'Processing', 'woocommerce' ),
'action' => 'processing',
);
}
if ( $order->has_status( array( 'pending', 'on-hold', 'processing' ) ) ) {
$actions['complete'] = array(
'url' => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=completed&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
'name' => __( 'Complete', 'woocommerce' ),
'action' => 'complete',
);
}
/**
* Provides an opportunity to modify the action buttons within the order list table.
*
* @param array $action Order actions.
* @param WC_Order $order Current order object.
* @since 6.7.0
*/
$actions = apply_filters( 'woocommerce_admin_order_actions', $actions, $order );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wc_render_action_buttons( $actions );
/**
* Fires after the order action buttons (within the actions column for the order list table)
* are rendered.
*
* @param WC_Order $order Current order object.
* @since 6.7.0
*/
do_action( 'woocommerce_admin_order_actions_end', $order );
echo '</p>';
}
/**
* Outputs hidden fields used to retain state when filtering.
*
* @return void
*/
private function print_hidden_form_fields(): void {
echo '<input type="hidden" name="page" value="wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) . '" >'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$state_params = array(
'paged',
'status',
);
foreach ( $state_params as $param ) {
if ( ! isset( $_GET[ $param ] ) ) {
continue;
}
echo '<input type="hidden" name="' . esc_attr( $param ) . '" value="' . esc_attr( sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) ) . '" >';
}
}
/**
* Gets the current action selected from the bulk actions dropdown.
*
* @return string|false The action name. False if no action was selected.
*/
public function current_action() {
if ( ! empty( $_REQUEST['delete_all'] ) ) {
return 'delete_all';
}
return parent::current_action();
}
/**
* Handle bulk actions.
*/
public function handle_bulk_actions() {
$action = $this->current_action();
if ( ! $action ) {
return;
}
check_admin_referer( 'bulk-orders' );
$redirect_to = remove_query_arg( array( 'deleted', 'ids' ), wp_get_referer() );
$redirect_to = add_query_arg( 'paged', $this->get_pagenum(), $redirect_to );
if ( 'delete_all' === $action ) {
// Get all trashed orders.
$ids = wc_get_orders(
array(
'type' => $this->order_type,
'status' => 'trash',
'limit' => -1,
'return' => 'ids',
)
);
$action = 'delete';
} else {
$ids = isset( $_REQUEST['id'] ) ? array_reverse( array_map( 'absint', (array) $_REQUEST['id'] ) ) : array();
}
/**
* Allows 3rd parties to modify order IDs about to be affected by a bulk action.
*
* @param array Array of order IDs.
*/
$ids = apply_filters( // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
'woocommerce_bulk_action_ids',
$ids,
$action,
'order'
);
if ( ! $ids ) {
wp_safe_redirect( $redirect_to );
exit;
}
$report_action = '';
$changed = 0;
$action_handled = true;
if ( 'remove_personal_data' === $action ) {
$report_action = 'removed_personal_data';
$changed = $this->do_bulk_action_remove_personal_data( $ids );
} elseif ( 'trash' === $action ) {
$changed = $this->do_delete( $ids );
$report_action = 'trashed';
} elseif ( 'delete' === $action ) {
$changed = $this->do_delete( $ids, true );
$report_action = 'deleted';
} elseif ( 'untrash' === $action ) {
$changed = $this->do_untrash( $ids );
$report_action = 'untrashed';
} elseif ( false !== strpos( $action, 'mark_' ) ) {
$order_statuses = wc_get_order_statuses();
$new_status = substr( $action, 5 );
$report_action = 'marked_' . $new_status;
if ( isset( $order_statuses[ 'wc-' . $new_status ] ) ) {
$changed = $this->do_bulk_action_mark_orders( $ids, $new_status );
} else {
$action_handled = false;
}
} else {
$action_handled = false;
}
// Custom action.
if ( ! $action_handled ) {
$screen = get_current_screen()->id;
/**
* This action is documented in /wp-admin/edit.php (it is a core WordPress hook).
*
* @since 7.2.0
*
* @param string $redirect_to The URL to redirect to after processing the bulk actions.
* @param string $action The current bulk action.
* @param int[] $ids IDs for the orders to be processed.
*/
$custom_sendback = apply_filters( "handle_bulk_actions-{$screen}", $redirect_to, $action, $ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
}
if ( ! empty( $custom_sendback ) ) {
$redirect_to = $custom_sendback;
} elseif ( $changed ) {
$redirect_to = add_query_arg(
array(
'bulk_action' => $report_action,
'changed' => $changed,
'ids' => implode( ',', $ids ),
),
$redirect_to
);
}
wp_safe_redirect( $redirect_to );
exit;
}
/**
* Implements the "remove personal data" bulk action.
*
* @param array $order_ids The Order IDs.
* @return int Number of orders modified.
*/
private function do_bulk_action_remove_personal_data( $order_ids ): int {
$changed = 0;
foreach ( $order_ids as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
continue;
}
do_action( 'woocommerce_remove_order_personal_data', $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$changed++;
}
return $changed;
}
/**
* Implements the "mark <status>" bulk action.
*
* @param array $order_ids The order IDs to change.
* @param string $new_status The new order status.
* @return int Number of orders modified.
*/
private function do_bulk_action_mark_orders( $order_ids, $new_status ): int {
$changed = 0;
// Initialize payment gateways in case order has hooked status transition actions.
WC()->payment_gateways();
foreach ( $order_ids as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
continue;
}
$order->update_status( $new_status, __( 'Order status changed by bulk edit.', 'woocommerce' ), true );
do_action( 'woocommerce_order_edit_status', $id, $new_status ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$changed++;
}
return $changed;
}
/**
* Handles bulk trashing of orders.
*
* @param int[] $ids Order IDs to be trashed.
* @param bool $force_delete When set, the order will be completed deleted. Otherwise, it will be trashed.
*
* @return int Number of orders that were trashed.
*/
private function do_delete( array $ids, bool $force_delete = false ): int {
$changed = 0;
foreach ( $ids as $id ) {
$order = wc_get_order( $id );
$order->delete( $force_delete );
$updated_order = wc_get_order( $id );
if ( ( $force_delete && false === $updated_order ) || ( ! $force_delete && $updated_order->get_status() === 'trash' ) ) {
$changed++;
}
}
return $changed;
}
/**
* Handles bulk restoration of trashed orders.
*
* @param array $ids Order IDs to be restored to their previous status.
*
* @return int Number of orders that were restored from the trash.
*/
private function do_untrash( array $ids ): int {
$orders_store = wc_get_container()->get( OrdersTableDataStore::class );
$changed = 0;
foreach ( $ids as $id ) {
if ( $orders_store->untrash_order( wc_get_order( $id ) ) ) {
$changed++;
}
}
return $changed;
}
/**
* Show confirmation message that order status changed for number of orders.
*/
public function bulk_action_notices() {
if ( empty( $_REQUEST['bulk_action'] ) ) {
return;
}
$order_statuses = wc_get_order_statuses();
$number = absint( $_REQUEST['changed'] ?? 0 );
$bulk_action = wc_clean( wp_unslash( $_REQUEST['bulk_action'] ) );
$message = '';
// Check if any status changes happened.
foreach ( $order_statuses as $slug => $name ) {
if ( 'marked_' . str_replace( 'wc-', '', $slug ) === $bulk_action ) { // WPCS: input var ok, CSRF ok.
/* translators: %s: orders count */
$message = sprintf( _n( '%s order status changed.', '%s order statuses changed.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
}
}
switch ( $bulk_action ) {
case 'removed_personal_data':
/* translators: %s: orders count */
$message = sprintf( _n( 'Removed personal data from %s order.', 'Removed personal data from %s orders.', $number, 'woocommerce' ), number_format_i18n( $number ) );
echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
break;
case 'trashed':
/* translators: %s: orders count */
$message = sprintf( _n( '%s order moved to the Trash.', '%s orders moved to the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
case 'untrashed':
/* translators: %s: orders count */
$message = sprintf( _n( '%s order restored from the Trash.', '%s orders restored from the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
case 'deleted':
/* translators: %s: orders count */
$message = sprintf( _n( '%s order permanently deleted.', '%s orders permanently deleted.', $number, 'woocommerce' ), number_format_i18n( $number ) );
break;
}
if ( ! empty( $message ) ) {
echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
}
}
/**
* Enqueue list table scripts.
*
* @return void
*/
public function enqueue_scripts(): void {
echo $this->get_order_preview_template(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_enqueue_script( 'wc-orders' );
}
/**
* Returns the HTML for the order preview template.
*
* @return string HTML template.
*/
public function get_order_preview_template(): string {
$order_edit_url_placeholder =
wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
? esc_url( admin_url( 'admin.php?page=wc-orders&action=edit' ) ) . '&id={{ data.data.id }}'
: esc_url( admin_url( 'post.php?action=edit' ) ) . '&post={{ data.data.id }}';
ob_start();
?>
<script type="text/template" id="tmpl-wc-modal-view-order">
<div class="wc-backbone-modal wc-order-preview">
<div class="wc-backbone-modal-content">
<section class="wc-backbone-modal-main" role="main">
<header class="wc-backbone-modal-header">
<mark class="order-status status-{{ data.status }}"><span>{{ data.status_name }}</span></mark>
<?php /* translators: %s: order ID */ ?>
<h1><?php echo esc_html( sprintf( __( 'Order #%s', 'woocommerce' ), '{{ data.order_number }}' ) ); ?></h1>
<button class="modal-close modal-close-link dashicons dashicons-no-alt">
<span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
</button>
</header>
<article>
<?php do_action( 'woocommerce_admin_order_preview_start' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
<div class="wc-order-preview-addresses">
<div class="wc-order-preview-address">
<h2><?php esc_html_e( 'Billing details', 'woocommerce' ); ?></h2>
{{{ data.formatted_billing_address }}}
<# if ( data.data.billing.email ) { #>
<strong><?php esc_html_e( 'Email', 'woocommerce' ); ?></strong>
<a href="mailto:{{ data.data.billing.email }}">{{ data.data.billing.email }}</a>
<# } #>
<# if ( data.data.billing.phone ) { #>
<strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
<a href="tel:{{ data.data.billing.phone }}">{{ data.data.billing.phone }}</a>
<# } #>
<# if ( data.payment_via ) { #>
<strong><?php esc_html_e( 'Payment via', 'woocommerce' ); ?></strong>
{{{ data.payment_via }}}
<# } #>
</div>
<# if ( data.needs_shipping ) { #>
<div class="wc-order-preview-address">
<h2><?php esc_html_e( 'Shipping details', 'woocommerce' ); ?></h2>
<# if ( data.ship_to_billing ) { #>
{{{ data.formatted_billing_address }}}
<# } else { #>
<a href="{{ data.shipping_address_map_url }}" target="_blank">{{{ data.formatted_shipping_address }}}</a>
<# } #>
<# if ( data.shipping_via ) { #>
<strong><?php esc_html_e( 'Shipping method', 'woocommerce' ); ?></strong>
{{ data.shipping_via }}
<# } #>
</div>
<# } #>
<# if ( data.data.customer_note ) { #>
<div class="wc-order-preview-note">
<strong><?php esc_html_e( 'Note', 'woocommerce' ); ?></strong>
{{ data.data.customer_note }}
</div>
<# } #>
</div>
{{{ data.item_html }}}
<?php do_action( 'woocommerce_admin_order_preview_end' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
</article>
<footer>
<div class="inner">
{{{ data.actions_html }}}
<a class="button button-primary button-large" aria-label="<?php esc_attr_e( 'Edit this order', 'woocommerce' ); ?>" href="<?php echo $order_edit_url_placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a>
</div>
</footer>
</section>
</div>
</div>
<div class="wc-backbone-modal-backdrop modal-close"></div>
</script>
<?php
$html = ob_get_clean();
return $html;
}
}
Internal/Admin/Orders/MetaBoxes/CustomMetaBox.php 0000644 00000036731 15153704477 0015767 0 ustar 00 <?php
/**
* Meta box to edit and add custom meta values for an order.
*/
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use WC_Data_Store;
use WC_Meta_Data;
use WC_Order;
use WP_Ajax_Response;
/**
* Class CustomMetaBox.
*/
class CustomMetaBox {
/**
* Update nonce shared among different meta rows.
*
* @var string
*/
private $update_nonce;
/**
* Helper method to get formatted meta data array with proper keys. This can be directly fed to `list_meta()` method.
*
* @param \WC_Order $order Order object.
*
* @return array Meta data.
*/
private function get_formatted_order_meta_data( \WC_Order $order ) {
$metadata = $order->get_meta_data();
$metadata_to_list = array();
foreach ( $metadata as $meta ) {
$data = $meta->get_data();
if ( is_protected_meta( $data['key'], 'order' ) ) {
continue;
}
$metadata_to_list[] = array(
'meta_id' => $data['id'],
'meta_key' => $data['key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- False positive, not a meta query.
'meta_value' => maybe_serialize( $data['value'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- False positive, not a meta query.
);
}
return $metadata_to_list;
}
/**
* Renders the meta box to manage custom meta.
*
* @param \WP_Post|\WC_Order $order_or_post Post or order object that we are rendering for.
*/
public function output( $order_or_post ) {
if ( is_a( $order_or_post, \WP_Post::class ) ) {
$order = wc_get_order( $order_or_post );
} else {
$order = $order_or_post;
}
$this->render_custom_meta_form( $this->get_formatted_order_meta_data( $order ), $order );
}
/**
* Helper method to render layout and actual HTML
*
* @param array $metadata_to_list List of metadata to render.
* @param \WC_Order $order Order object.
*/
private function render_custom_meta_form( array $metadata_to_list, \WC_Order $order ) {
?>
<div id="postcustomstuff">
<div id="ajax-response"></div>
<?php
list_meta( $metadata_to_list );
$this->render_meta_form( $order );
?>
</div>
<p>
<?php
printf(
/* translators: 1: opening documentation tag 2: closing documentation tag. */
esc_html( __( 'Custom fields can be used to add extra metadata to an order that you can %1$suse in your theme%2$s.', 'woocommerce' ) ),
'<a href="' . esc_attr__( 'https://wordpress.org/support/article/custom-fields/', 'woocommerce' ) . '">',
'</a>'
);
?>
</p>
<?php
}
/**
* Compute keys to display in autofill when adding new meta key entry in custom meta box.
* Currently, returns empty keys, will be implemented after caching is merged.
*
* @param array|null $keys Keys to display in autofill.
* @param \WP_Post|\WC_Order $order Order object.
*
* @return array|mixed Array of keys to display in autofill.
*/
public function order_meta_keys_autofill( $keys, $order ) {
if ( is_a( $order, \WC_Order::class ) ) {
return array();
}
return $keys;
}
/**
* Reimplementation of WP core's `meta_form` function. Renders meta form box.
*
* @param \WC_Order $order WC_Order object.
*
* @return void
*/
public function render_meta_form( \WC_Order $order ) : void {
$meta_key_input_id = 'metakeyselect';
$keys = $this->order_meta_keys_autofill( null, $order );
/**
* Filters values for the meta key dropdown in the Custom Fields meta box.
*
* Compatibility filter for `postmeta_form_keys` filter.
*
* @since 6.9.0
*
* @param array|null $keys Pre-defined meta keys to be used in place of a postmeta query. Default null.
* @param \WC_Order $order The current post object.
*/
$keys = apply_filters( 'postmeta_form_keys', $keys, $order );
?>
<p><strong><?php esc_html_e( 'Add New Custom Field:', 'woocommerce' ); ?></strong></p>
<table id="newmeta">
<thead>
<tr>
<th class="left"><label for="<?php echo esc_attr( $meta_key_input_id ); ?>"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label></th>
<th><label for="metavalue"><?php esc_html_e( 'Value', 'woocommerce' ); ?></label></th>
</tr>
</thead>
<tbody>
<tr>
<td id="newmetaleft" class="left">
<?php if ( $keys ) { ?>
<select id="metakeyselect" name="metakeyselect">
<option value="#NONE#"><?php esc_html_e( '— Select —', 'woocommerce' ); ?></option>
<?php
foreach ( $keys as $key ) {
if ( is_protected_meta( $key, 'post' ) || ! current_user_can( 'edit_others_shop_order', $order->get_id() ) ) {
continue;
}
echo "\n<option value='" . esc_attr( $key ) . "'>" . esc_html( $key ) . '</option>';
}
?>
</select>
<input class="hide-if-js" type="text" id="metakeyinput" name="metakeyinput" value="" />
<a href="#postcustomstuff" class="hide-if-no-js" onclick="jQuery('#metakeyinput, #metakeyselect, #enternew, #cancelnew').toggle();return false;">
<span id="enternew"><?php esc_html_e( 'Enter new', 'woocommerce' ); ?></span>
<span id="cancelnew" class="hidden"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></span></a>
<?php } else { ?>
<input type="text" id="metakeyinput" name="metakeyinput" value="" />
<?php } ?>
</td>
<td><textarea id="metavalue" name="metavalue" rows="2" cols="25"></textarea></td>
</tr>
<tr><td colspan="2">
<div class="submit">
<?php
submit_button(
__( 'Add Custom Field', 'woocommerce' ),
'',
'addmeta',
false,
array(
'id' => 'newmeta-submit',
'data-wp-lists' => 'add:the-list:newmeta',
)
);
?>
</div>
<?php wp_nonce_field( 'add-meta', '_ajax_nonce-add-meta', false ); ?>
</td></tr>
</tbody>
</table>
<?php
}
/**
* Helper method to verify order edit permissions.
*
* @param int $order_id Order ID.
*
* @return ?WC_Order WC_Order object if the user can edit the order, die otherwise.
*/
private function verify_order_edit_permission_for_ajax( int $order_id ): ?WC_Order {
if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_others_shop_orders' ) ) {
wp_send_json_error( 'missing_capabilities' );
wp_die();
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_send_json_error( 'invalid_order_id' );
wp_die();
}
return $order;
}
/**
* Reimplementation of WP core's `wp_ajax_add_meta` method to support order custom meta updates with custom tables.
*/
public function add_meta_ajax() {
if ( ! check_ajax_referer( 'add-meta', '_ajax_nonce-add-meta' ) ) {
wp_send_json_error( 'invalid_nonce' );
wp_die();
}
$order_id = (int) $_POST['order_id'] ?? 0;
$order = $this->verify_order_edit_permission_for_ajax( $order_id );
if ( isset( $_POST['metakeyselect'] ) && '#NONE#' === $_POST['metakeyselect'] && empty( $_POST['metakeyinput'] ) ) {
wp_die( 1 );
}
if ( isset( $_POST['metakeyinput'] ) ) { // add meta.
$meta_key = sanitize_text_field( wp_unslash( $_POST['metakeyinput'] ) );
$meta_value = sanitize_text_field( wp_unslash( $_POST['metavalue'] ?? '' ) );
$this->handle_add_meta( $order, $meta_key, $meta_value );
} else { // update.
$meta = wp_unslash( $_POST['meta'] ?? array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitization done below in array_walk.
$this->handle_update_meta( $order, $meta );
}
}
/**
* Part of WP Core's `wp_ajax_add_meta`. This is re-implemented to support updating meta for custom tables.
*
* @param WC_Order $order Order object.
* @param string $meta_key Meta key.
* @param string $meta_value Meta value.
*
* @return void
*/
private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
$count = 0;
if ( is_protected_meta( $meta_key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
$metas_for_current_key = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
$meta_ids = wp_list_pluck( $metas_for_current_key, 'id' );
$order->add_meta_data( $meta_key, $meta_value );
$order->save_meta_data();
$metas_for_current_key_with_new = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
$meta_id = 0;
$new_meta_ids = wp_list_pluck( $metas_for_current_key_with_new, 'id' );
$new_meta_ids = array_values( array_diff( $new_meta_ids, $meta_ids ) );
if ( count( $new_meta_ids ) > 0 ) {
$meta_id = $new_meta_ids[0];
}
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
'id' => $meta_id,
'data' => $this->list_meta_row(
array(
'meta_id' => $meta_id,
'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
),
$count
),
'position' => 1,
)
);
$response->send();
}
/**
* Handles updating metadata.
*
* @param WC_Order $order Order object.
* @param array $meta Meta object to update.
*
* @return void
*/
private function handle_update_meta( WC_Order $order, array $meta ) {
if ( ! is_array( $meta ) ) {
wp_send_json_error( 'invalid_meta' );
wp_die();
}
array_walk( $meta, 'sanitize_text_field' );
$mid = (int) key( $meta );
if ( ! $mid ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
$key = $meta[ $mid ]['key'];
$value = $meta[ $mid ]['value'];
if ( is_protected_meta( $key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
if ( '' === trim( $key ) ) {
wp_send_json_error( 'invalid_meta_key' );
wp_die();
}
$count = 0;
$order->update_meta_data( $key, $value, $mid );
$order->save_meta_data();
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
'id' => $mid,
'old_id' => $mid,
'data' => $this->list_meta_row(
array(
'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
'meta_value' => $value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
'meta_id' => $mid,
),
$count
),
'position' => 0,
)
);
$response->send();
}
/**
* Outputs a single row of public meta data in the Custom Fields meta box.
*
* @since 2.5.0
*
* @param array $entry Meta entry.
* @param int $count Sequence number of meta entries.
* @return string
*/
private function list_meta_row( array $entry, int &$count ) : string {
if ( is_protected_meta( $entry['meta_key'], 'post' ) ) {
return '';
}
if ( ! $this->update_nonce ) {
$this->update_nonce = wp_create_nonce( 'add-meta' );
}
$r = '';
++ $count;
if ( is_serialized( $entry['meta_value'] ) ) {
if ( is_serialized_string( $entry['meta_value'] ) ) {
// This is a serialized string, so we should display it.
$entry['meta_value'] = maybe_unserialize( $entry['meta_value'] ); // // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
} else {
// This is a serialized array/object so we should NOT display it.
--$count;
return '';
}
}
$entry['meta_key'] = esc_attr( $entry['meta_key'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
$entry['meta_value'] = esc_textarea( $entry['meta_value'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
$entry['meta_id'] = (int) $entry['meta_id'];
$delete_nonce = wp_create_nonce( 'delete-meta_' . $entry['meta_id'] );
$r .= "\n\t<tr id='meta-{$entry['meta_id']}'>";
$r .= "\n\t\t<td class='left'><label class='screen-reader-text' for='meta-{$entry['meta_id']}-key'>" . __( 'Key', 'woocommerce' ) . "</label><input name='meta[{$entry['meta_id']}][key]' id='meta-{$entry['meta_id']}-key' type='text' size='20' value='{$entry['meta_key']}' />";
$r .= "\n\t\t<div class='submit'>";
$r .= get_submit_button( __( 'Delete', 'woocommerce' ), 'deletemeta small', "deletemeta[{$entry['meta_id']}]", false, array( 'data-wp-lists' => "delete:the-list:meta-{$entry['meta_id']}::_ajax_nonce:$delete_nonce" ) );
$r .= "\n\t\t";
$r .= get_submit_button( __( 'Update', 'woocommerce' ), 'updatemeta small', "meta-{$entry['meta_id']}-submit", false, array( 'data-wp-lists' => "add:the-list:meta-{$entry['meta_id']}::_ajax_nonce-add-meta={$this->update_nonce}" ) );
$r .= '</div>';
$r .= wp_nonce_field( 'change-meta', '_ajax_nonce', false, false );
$r .= '</td>';
$r .= "\n\t\t<td><label class='screen-reader-text' for='meta-{$entry['meta_id']}-value'>" . __( 'Value', 'woocommerce' ) . "</label><textarea name='meta[{$entry['meta_id']}][value]' id='meta-{$entry['meta_id']}-value' rows='2' cols='30'>{$entry['meta_value']}</textarea></td>\n\t</tr>";
return $r;
}
/**
* Reimplementation of WP core's `wp_ajax_delete_meta` method to support order custom meta updates with custom tables.
*
* @return void
*/
public function delete_meta_ajax() {
$meta_id = (int) $_POST['id'] ?? 0;
$order_id = (int) $_POST['order_id'] ?? 0;
if ( ! $meta_id || ! $order_id ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
check_ajax_referer( "delete-meta_$meta_id" );
$order = $this->verify_order_edit_permission_for_ajax( $order_id );
$meta_to_delete = wp_list_filter( $order->get_meta_data(), array( 'id' => $meta_id ) );
if ( empty( $meta_to_delete ) ) {
wp_send_json_error( 'invalid_meta_id' );
wp_die();
}
$order->delete_meta_data_by_mid( $meta_id );
if ( $order->save() ) {
wp_die( 1 );
}
wp_die( 0 );
}
/**
* Handle the possible changes in order metadata coming from an order edit page in admin
* (labeled "custom fields" in the UI).
*
* This method expects the $_POST array to contain a 'meta' key that is an associative
* array of [meta item id => [ 'key' => meta item name, 'value' => meta item value ];
* and also to contain (possibly empty) 'metakeyinput' and 'metavalue' keys.
*
* @param WC_Order $order The order to handle.
*/
public function handle_metadata_changes( $order ) {
$has_meta_changes = false;
$order_meta = $order->get_meta_data();
$order_meta =
array_combine(
array_map( fn( $meta ) => $meta->id, $order_meta ),
$order_meta
);
// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
foreach ( ( $_POST['meta'] ?? array() ) as $request_meta_id => $request_meta_data ) {
$request_meta_id = wp_unslash( $request_meta_id );
$request_meta_key = wp_unslash( $request_meta_data['key'] );
$request_meta_value = wp_unslash( $request_meta_data['value'] );
if ( array_key_exists( $request_meta_id, $order_meta ) &&
( $order_meta[ $request_meta_id ]->key !== $request_meta_key || $order_meta[ $request_meta_id ]->value !== $request_meta_value ) ) {
$order->update_meta_data( $request_meta_key, $request_meta_value, $request_meta_id );
$has_meta_changes = true;
}
}
$request_new_key = wp_unslash( $_POST['metakeyinput'] ?? '' );
$request_new_value = wp_unslash( $_POST['metavalue'] ?? '' );
if ( '' !== $request_new_key ) {
$order->add_meta_data( $request_new_key, $request_new_value );
$has_meta_changes = true;
}
// phpcs:enable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
if ( $has_meta_changes ) {
$order->save();
}
}
}
Internal/Admin/Orders/MetaBoxes/TaxonomiesMetaBox.php 0000644 00000010430 15153704477 0016627 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* TaxonomiesMetaBox class, renders taxonomy sidebar widget on order edit screen.
*/
class TaxonomiesMetaBox {
/**
* Order Table data store class.
*
* @var OrdersTableDataStore
*/
private $orders_table_data_store;
/**
* Dependency injection init method.
*
* @param OrdersTableDataStore $orders_table_data_store Order Table data store class.
*
* @return void
*/
public function init( OrdersTableDataStore $orders_table_data_store ) {
$this->orders_table_data_store = $orders_table_data_store;
}
/**
* Registers meta boxes to be rendered in order edit screen for taxonomies.
*
* Note: This is re-implementation of part of WP core's `register_and_do_post_meta_boxes` function. Since the code block that add meta box for taxonomies is not filterable, we have to re-implement it.
*
* @param string $screen_id Screen ID.
* @param string $order_type Order type to register meta boxes for.
*
* @return void
*/
public function add_taxonomies_meta_boxes( string $screen_id, string $order_type ) {
include_once ABSPATH . 'wp-admin/includes/meta-boxes.php';
$taxonomies = get_object_taxonomies( $order_type );
// All taxonomies.
foreach ( $taxonomies as $tax_name ) {
$taxonomy = get_taxonomy( $tax_name );
if ( ! $taxonomy->show_ui || false === $taxonomy->meta_box_cb ) {
continue;
}
if ( 'post_categories_meta_box' === $taxonomy->meta_box_cb ) {
$taxonomy->meta_box_cb = array( $this, 'order_categories_meta_box' );
}
if ( 'post_tags_meta_box' === $taxonomy->meta_box_cb ) {
$taxonomy->meta_box_cb = array( $this, 'order_tags_meta_box' );
}
$label = $taxonomy->labels->name;
if ( ! is_taxonomy_hierarchical( $tax_name ) ) {
$tax_meta_box_id = 'tagsdiv-' . $tax_name;
} else {
$tax_meta_box_id = $tax_name . 'div';
}
add_meta_box(
$tax_meta_box_id,
$label,
$taxonomy->meta_box_cb,
$screen_id,
'side',
'core',
array(
'taxonomy' => $tax_name,
'__back_compat_meta_box' => true,
)
);
}
}
/**
* Save handler for taxonomy data.
*
* @param \WC_Abstract_Order $order Order object.
* @param array|null $taxonomy_input Taxonomy input passed from input.
*/
public function save_taxonomies( \WC_Abstract_Order $order, $taxonomy_input ) {
if ( ! isset( $taxonomy_input ) ) {
return;
}
$sanitized_tax_input = $this->sanitize_tax_input( $taxonomy_input );
$sanitized_tax_input = $this->orders_table_data_store->init_default_taxonomies( $order, $sanitized_tax_input );
$this->orders_table_data_store->set_custom_taxonomies( $order, $sanitized_tax_input );
}
/**
* Sanitize taxonomy input by calling sanitize callbacks for each registered taxonomy.
*
* @param array|null $taxonomy_data Nonce verified taxonomy input.
*
* @return array Sanitized taxonomy input.
*/
private function sanitize_tax_input( $taxonomy_data ) : array {
$sanitized_tax_input = array();
if ( ! is_array( $taxonomy_data ) ) {
return $sanitized_tax_input;
}
// Convert taxonomy input to term IDs, to avoid ambiguity.
foreach ( $taxonomy_data as $taxonomy => $terms ) {
$tax_object = get_taxonomy( $taxonomy );
if ( $tax_object && isset( $tax_object->meta_box_sanitize_cb ) ) {
$sanitized_tax_input[ $taxonomy ] = call_user_func_array( $tax_object->meta_box_sanitize_cb, array( $taxonomy, $terms ) );
}
}
return $sanitized_tax_input;
}
/**
* Add the categories meta box to the order screen. This is just a wrapper around the post_categories_meta_box.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $box Meta box args.
*
* @return void
*/
public function order_categories_meta_box( $order, $box ) {
$post = get_post( $order->get_id() );
post_categories_meta_box( $post, $box );
}
/**
* Add the tags meta box to the order screen. This is just a wrapper around the post_tags_meta_box.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $box Meta box args.
*
* @return void
*/
public function order_tags_meta_box( $order, $box ) {
$post = get_post( $order->get_id() );
post_tags_meta_box( $post, $box );
}
}
Internal/Admin/Orders/PageController.php 0000644 00000037746 15153704477 0014275 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
/**
* Controls the different pages/screens associated to the "Orders" menu page.
*/
class PageController {
use AccessiblePrivateMethods;
/**
* The order type.
*
* @var string
*/
private $order_type = '';
/**
* Instance of the posts redirection controller.
*
* @var PostsRedirectionController
*/
private $redirection_controller;
/**
* Instance of the orders list table.
*
* @var ListTable
*/
private $orders_table;
/**
* Instance of orders edit form.
*
* @var Edit
*/
private $order_edit_form;
/**
* Current action.
*
* @var string
*/
private $current_action = '';
/**
* Order object to be used in edit/new form.
*
* @var \WC_Order
*/
private $order;
/**
* Verify that user has permission to edit orders.
*
* @return void
*/
private function verify_edit_permission() {
if ( 'edit_order' === $this->current_action && ( ! isset( $this->order ) || ! $this->order ) ) {
wp_die( esc_html__( 'You attempted to edit an order that does not exist. Perhaps it was deleted?', 'woocommerce' ) );
}
if ( $this->order->get_type() !== $this->order_type ) {
wp_die( esc_html__( 'Order type mismatch.', 'woocommerce' ) );
}
if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->edit_post, $this->order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to edit this order', 'woocommerce' ) );
}
if ( 'trash' === $this->order->get_status() ) {
wp_die( esc_html__( 'You cannot edit this item because it is in the Trash. Please restore it and try again.', 'woocommerce' ) );
}
}
/**
* Verify that user has permission to create order.
*
* @return void
*/
private function verify_create_permission() {
if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->publish_posts ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You don\'t have permission to create a new order', 'woocommerce' ) );
}
if ( isset( $this->order ) ) {
$this->verify_edit_permission();
}
}
/**
* Claims the lock for the order being edited/created (unless it belongs to someone else).
* Also handles the 'claim-lock' action which allows taking over the order forcefully.
*
* @return void
*/
private function handle_edit_lock() {
if ( ! $this->order ) {
return;
}
$edit_lock = wc_get_container()->get( EditLock::class );
$locked = $edit_lock->is_locked_by_another_user( $this->order );
// Take over order?
if ( ! empty( $_GET['claim-lock'] ) && wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'claim-lock-' . $this->order->get_id() ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$edit_lock->lock( $this->order );
wp_safe_redirect( $this->get_edit_url( $this->order->get_id() ) );
exit;
}
if ( ! $locked ) {
$edit_lock->lock( $this->order );
}
add_action(
'admin_footer',
function() use ( $edit_lock ) {
$edit_lock->render_dialog( $this->order );
}
);
}
/**
* Sets up the page controller, including registering the menu item.
*
* @return void
*/
public function setup(): void {
global $plugin_page, $pagenow;
$this->redirection_controller = new PostsRedirectionController( $this );
// Register menu.
if ( 'admin_menu' === current_action() ) {
$this->register_menu();
} else {
add_action( 'admin_menu', 'register_menu', 9 );
}
// Not on an Orders page.
if ( 'admin.php' !== $pagenow || 0 !== strpos( $plugin_page, 'wc-orders' ) ) {
return;
}
$this->set_order_type();
$this->set_action();
$page_suffix = ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type );
self::add_action( 'load-woocommerce_page_wc-orders' . $page_suffix, array( $this, 'handle_load_page_action' ) );
self::add_action( 'admin_title', array( $this, 'set_page_title' ) );
}
/**
* Perform initialization for the current action.
*/
private function handle_load_page_action() {
$screen = get_current_screen();
$screen->post_type = $this->order_type;
if ( method_exists( $this, 'setup_action_' . $this->current_action ) ) {
$this->{"setup_action_{$this->current_action}"}();
}
}
/**
* Set the document title for Orders screens to match what it would be with the shop_order CPT.
*
* @param string $admin_title The admin screen title before it's filtered.
*
* @return string The filtered admin title.
*/
private function set_page_title( $admin_title ) {
if ( ! $this->is_order_screen( $this->order_type ) ) {
return $admin_title;
}
$wp_order_type = get_post_type_object( $this->order_type );
$labels = get_post_type_labels( $wp_order_type );
if ( $this->is_order_screen( $this->order_type, 'list' ) ) {
$admin_title = sprintf(
// translators: 1: The label for an order type 2: The name of the website.
esc_html__( '%1$s ‹ %2$s — WordPress', 'woocommerce' ),
esc_html( $labels->name ),
esc_html( get_bloginfo( 'name' ) )
);
} elseif ( $this->is_order_screen( $this->order_type, 'edit' ) ) {
$admin_title = sprintf(
// translators: 1: The label for an order type 2: The title of the order 3: The name of the website.
esc_html__( '%1$s #%2$s ‹ %3$s — WordPress', 'woocommerce' ),
esc_html( $labels->edit_item ),
absint( $this->order->get_id() ),
esc_html( get_bloginfo( 'name' ) )
);
} elseif ( $this->is_order_screen( $this->order_type, 'new' ) ) {
$admin_title = sprintf(
// translators: 1: The label for an order type 2: The name of the website.
esc_html__( '%1$s ‹ %2$s — WordPress', 'woocommerce' ),
esc_html( $labels->add_new_item ),
esc_html( get_bloginfo( 'name' ) )
);
}
return $admin_title;
}
/**
* Determines the order type for the current screen.
*
* @return void
*/
private function set_order_type() {
global $plugin_page;
$this->order_type = str_replace( array( 'wc-orders--', 'wc-orders' ), '', $plugin_page );
$this->order_type = empty( $this->order_type ) ? 'shop_order' : $this->order_type;
$wc_order_type = wc_get_order_type( $this->order_type );
$wp_order_type = get_post_type_object( $this->order_type );
if ( ! $wc_order_type || ! $wp_order_type || ! $wp_order_type->show_ui || ! current_user_can( $wp_order_type->cap->edit_posts ) ) {
wp_die();
}
}
/**
* Sets the current action based on querystring arguments. Defaults to 'list_orders'.
*
* @return void
*/
private function set_action(): void {
switch ( isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '' ) {
case 'edit':
$this->current_action = 'edit_order';
break;
case 'new':
$this->current_action = 'new_order';
break;
default:
$this->current_action = 'list_orders';
break;
}
}
/**
* Registers the "Orders" menu.
*
* @return void
*/
public function register_menu(): void {
$order_types = wc_get_order_types( 'admin-menu' );
foreach ( $order_types as $order_type ) {
$post_type = get_post_type_object( $order_type );
add_submenu_page(
'woocommerce',
$post_type->labels->name,
$post_type->labels->menu_name,
$post_type->cap->edit_posts,
'wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ),
array( $this, 'output' )
);
}
// In some cases (such as if the authoritative order store was changed earlier in the current request) we
// need an extra step to remove the menu entry for the menu post type.
add_action(
'admin_init',
function() use ( $order_types ) {
foreach ( $order_types as $order_type ) {
remove_submenu_page( 'woocommerce', 'edit.php?post_type=' . $order_type );
}
}
);
}
/**
* Outputs content for the current orders screen.
*
* @return void
*/
public function output(): void {
switch ( $this->current_action ) {
case 'edit_order':
case 'new_order':
$this->order_edit_form->display();
break;
case 'list_orders':
default:
$this->orders_table->prepare_items();
$this->orders_table->display();
break;
}
}
/**
* Handles initialization of the orders list table.
*
* @return void
*/
private function setup_action_list_orders(): void {
$this->orders_table = wc_get_container()->get( ListTable::class );
$this->orders_table->setup(
array(
'order_type' => $this->order_type,
)
);
if ( $this->orders_table->current_action() ) {
$this->orders_table->handle_bulk_actions();
}
$this->strip_http_referer();
}
/**
* Perform a redirect to remove the `_wp_http_referer` and `_wpnonce` strings if present in the URL (see also
* wp-admin/edit.php where a similar process takes place), otherwise the size of this field builds to an
* unmanageable length over time.
*/
private function strip_http_referer(): void {
$current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
$stripped_url = remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), $current_url );
if ( $stripped_url !== $current_url ) {
wp_safe_redirect( $stripped_url );
exit;
}
}
/**
* Prepares the order edit form for creating or editing an order.
*
* @see \Automattic\WooCommerce\Internal\Admin\Orders\Edit.
* @since 8.1.0
*/
private function prepare_order_edit_form(): void {
if ( ! $this->order || ! in_array( $this->current_action, array( 'new_order', 'edit_order' ), true ) ) {
return;
}
$this->order_edit_form = $this->order_edit_form ?? new Edit();
$this->order_edit_form->setup( $this->order );
$this->order_edit_form->set_current_action( $this->current_action );
}
/**
* Handles initialization of the orders edit form.
*
* @return void
*/
private function setup_action_edit_order(): void {
global $theorder;
$this->order = wc_get_order( absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ) );
$this->verify_edit_permission();
$this->handle_edit_lock();
$theorder = $this->order;
$this->prepare_order_edit_form();
}
/**
* Handles initialization of the orders edit form with a new order.
*
* @return void
*/
private function setup_action_new_order(): void {
global $theorder;
$this->verify_create_permission();
$order_class_name = wc_get_order_type( $this->order_type )['class_name'];
if ( ! $order_class_name || ! class_exists( $order_class_name ) ) {
wp_die();
}
$this->order = new $order_class_name();
$this->order->set_object_read( false );
$this->order->set_status( 'auto-draft' );
$this->order->set_created_via( 'admin' );
$this->order->save();
$this->handle_edit_lock();
// Schedule auto-draft cleanup. We re-use the WP event here on purpose.
if ( ! wp_next_scheduled( 'wp_scheduled_auto_draft_delete' ) ) {
wp_schedule_event( time(), 'daily', 'wp_scheduled_auto_draft_delete' );
}
$theorder = $this->order;
$this->prepare_order_edit_form();
}
/**
* Returns the current order type.
*
* @return string
*/
public function get_order_type() {
return $this->order_type;
}
/**
* Helper method to generate a link to the main orders screen.
*
* @return string Orders screen URL.
*/
public function get_orders_url(): string {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
admin_url( 'admin.php?page=wc-orders' ) :
admin_url( 'edit.php?post_type=shop_order' );
}
/**
* Helper method to generate edit link for an order.
*
* @param int $order_id Order ID.
*
* @return string Edit link.
*/
public function get_edit_url( int $order_id ) : string {
if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
return admin_url( 'post.php?post=' . absint( $order_id ) ) . '&action=edit';
}
$order = wc_get_order( $order_id );
// Confirm we could obtain the order object (since it's possible it will not exist, due to a sync issue, or may
// have been deleted in a separate concurrent request).
if ( false === $order ) {
wc_get_logger()->debug(
sprintf(
/* translators: %d order ID. */
__( 'Attempted to determine the edit URL for order %d, however the order does not exist.', 'woocommerce' ),
$order_id
)
);
$order_type = 'shop_order';
} else {
$order_type = $order->get_type();
}
return add_query_arg(
array(
'action' => 'edit',
'id' => absint( $order_id ),
),
$this->get_base_page_url( $order_type )
);
}
/**
* Helper method to generate a link for creating order.
*
* @param string $order_type The order type. Defaults to 'shop_order'.
* @return string
*/
public function get_new_page_url( $order_type = 'shop_order' ) : string {
$url = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
add_query_arg( 'action', 'new', $this->get_base_page_url( $order_type ) ) :
admin_url( 'post-new.php?post_type=' . $order_type );
return $url;
}
/**
* Helper method to generate a link to the main screen for a custom order type.
*
* @param string $order_type The order type.
*
* @return string
*
* @throws \Exception When an invalid order type is passed.
*/
public function get_base_page_url( $order_type ): string {
$order_types_with_ui = wc_get_order_types( 'admin-menu' );
if ( ! in_array( $order_type, $order_types_with_ui, true ) ) {
// translators: %s is a custom order type.
throw new \Exception( sprintf( __( 'Invalid order type: %s.', 'woocommerce' ), esc_html( $order_type ) ) );
}
return admin_url( 'admin.php?page=wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ) );
}
/**
* Helper method to check if the current admin screen is related to orders.
*
* @param string $type Optional. The order type to check for. Default shop_order.
* @param string $action Optional. The purpose of the screen to check for. 'list', 'edit', or 'new'.
* Leave empty to check for any order screen.
*
* @return bool
*/
public function is_order_screen( $type = 'shop_order', $action = '' ) : bool {
if ( ! did_action( 'current_screen' ) ) {
wc_doing_it_wrong(
__METHOD__,
sprintf(
// translators: %s is the name of a function.
esc_html__( '%s must be called after the current_screen action.', 'woocommerce' ),
esc_html( __METHOD__ )
),
'7.9.0'
);
return false;
}
$valid_types = wc_get_order_types( 'view-order' );
if ( ! in_array( $type, $valid_types, true ) ) {
wc_doing_it_wrong(
__METHOD__,
sprintf(
// translators: %s is the name of an order type.
esc_html__( '%s is not a valid order type.', 'woocommerce' ),
esc_html( $type )
),
'7.9.0'
);
return false;
}
if ( wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
if ( $action ) {
switch ( $action ) {
case 'edit':
$is_action = 'edit_order' === $this->current_action;
break;
case 'list':
$is_action = 'list_orders' === $this->current_action;
break;
case 'new':
$is_action = 'new_order' === $this->current_action;
break;
default:
$is_action = false;
break;
}
}
$type_match = $type === $this->order_type;
$action_match = ! $action || $is_action;
} else {
$screen = get_current_screen();
if ( $action ) {
switch ( $action ) {
case 'edit':
$screen_match = 'post' === $screen->base && filter_input( INPUT_GET, 'post', FILTER_VALIDATE_INT );
break;
case 'list':
$screen_match = 'edit' === $screen->base;
break;
case 'new':
$screen_match = 'post' === $screen->base && 'add' === $screen->action;
break;
default:
$screen_match = false;
break;
}
}
$type_match = $type === $screen->post_type;
$action_match = ! $action || $screen_match;
}
return $type_match && $action_match;
}
}
Internal/Admin/Orders/PostsRedirectionController.php 0000644 00000011537 15153704477 0016707 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
/**
* When {@see OrdersTableDataStore} is in use, this class takes care of redirecting admins from CPT-based URLs
* to the new ones.
*/
class PostsRedirectionController {
/**
* Instance of the PageController class.
*
* @var PageController
*/
private $page_controller;
/**
* Constructor.
*
* @param PageController $page_controller Page controller instance. Used to generate links/URLs.
*/
public function __construct( PageController $page_controller ) {
$this->page_controller = $page_controller;
if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
return;
}
add_action(
'load-edit.php',
function() {
$this->maybe_redirect_to_orders_page();
}
);
add_action(
'load-post-new.php',
function() {
$this->maybe_redirect_to_new_order_page();
}
);
add_action(
'load-post.php',
function() {
$this->maybe_redirect_to_edit_order_page();
}
);
}
/**
* If needed, performs a redirection to the main orders page.
*
* @return void
*/
private function maybe_redirect_to_orders_page(): void {
$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
return;
}
// Respect query args, except for 'post_type'.
$query_args = wp_unslash( $_GET );
$action = $query_args['action'] ?? '';
$posts = $query_args['post'] ?? array();
unset( $query_args['post_type'], $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );
// Remap 'post_status' arg.
if ( isset( $query_args['post_status'] ) ) {
$query_args['status'] = $query_args['post_status'];
unset( $query_args['post_status'] );
}
$new_url = $this->page_controller->get_base_page_url( $post_type );
$new_url = add_query_arg( $query_args, $new_url );
// Handle bulk actions.
if ( $action && in_array( $action, array( 'trash', 'untrash', 'delete', 'mark_processing', 'mark_on-hold', 'mark_completed', 'mark_cancelled' ), true ) ) {
check_admin_referer( 'bulk-posts' );
$new_url = add_query_arg(
array(
'action' => $action,
'id' => $posts,
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),
$new_url
);
}
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* If needed, performs a redirection to the new order page.
*
* @return void
*/
private function maybe_redirect_to_new_order_page(): void {
$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
return;
}
// Respect query args, except for 'post_type'.
$query_args = wp_unslash( $_GET );
unset( $query_args['post_type'] );
$new_url = $this->page_controller->get_new_page_url( $post_type );
$new_url = add_query_arg( $query_args, $new_url );
wp_safe_redirect( $new_url, 301 );
exit;
}
/**
* If needed, performs a redirection to the edit order page.
*
* @return void
*/
private function maybe_redirect_to_edit_order_page(): void {
$post_id = absint( $_GET['post'] ?? 0 );
$redirect_from_types = wc_get_order_types( 'admin-menu' );
$redirect_from_types[] = 'shop_order_placehold';
if ( ! $post_id || ! in_array( get_post_type( $post_id ), $redirect_from_types, true ) || ! isset( $_GET['action'] ) ) {
return;
}
// Respect query args, except for 'post'.
$query_args = wp_unslash( $_GET );
$action = $query_args['action'];
unset( $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );
$new_url = '';
switch ( $action ) {
case 'edit':
$new_url = $this->page_controller->get_edit_url( $post_id );
break;
case 'trash':
case 'untrash':
case 'delete':
// Re-generate nonce if validation passes.
check_admin_referer( $action . '-post_' . $post_id );
$new_url = add_query_arg(
array(
'action' => $action,
'order' => array( $post_id ),
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),
$this->page_controller->get_orders_url()
);
break;
default:
break;
}
if ( ! $new_url ) {
return;
}
$new_url = add_query_arg( $query_args, $new_url );
wp_safe_redirect( $new_url, 301 );
exit;
}
}
Internal/Admin/ProductForm/Component.php 0000644 00000005547 15153704477 0014317 0 ustar 00 <?php
/**
* Abstract class for product form components.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* Component class.
*/
abstract class Component {
/**
* Product Component traits.
*/
use ComponentTrait;
/**
* Component additional arguments.
*
* @var array
*/
protected $additional_args;
/**
* Constructor
*
* @param string $id Component id.
* @param string $plugin_id Plugin id.
* @param array $additional_args Array containing additional arguments.
*/
public function __construct( $id, $plugin_id, $additional_args ) {
$this->id = $id;
$this->plugin_id = $plugin_id;
$this->additional_args = $additional_args;
}
/**
* Component arguments.
*
* @return array
*/
public function get_additional_args() {
return $this->additional_args;
}
/**
* Component arguments.
*
* @param string $key key of argument.
* @return mixed
*/
public function get_additional_argument( $key ) {
return self::get_argument_from_path( $this->additional_args, $key );
}
/**
* Get the component as JSON.
*
* @return array
*/
public function get_json() {
return array_merge(
array(
'id' => $this->get_id(),
'plugin_id' => $this->get_plugin_id(),
),
$this->get_additional_args()
);
}
/**
* Sorting function for product form component.
*
* @param Component $a Component a.
* @param Component $b Component b.
* @param array $sort_by key and order to sort by.
* @return int
*/
public static function sort( $a, $b, $sort_by = array() ) {
$key = $sort_by['key'];
$a_val = $a->get_additional_argument( $key );
$b_val = $b->get_additional_argument( $key );
if ( 'asc' === $sort_by['order'] ) {
return $a_val <=> $b_val;
} else {
return $b_val <=> $a_val;
}
}
/**
* Gets argument by dot notation path.
*
* @param array $arguments Arguments array.
* @param string $path Path for argument key.
* @param string $delimiter Path delimiter, default: '.'.
* @return mixed|null
*/
public static function get_argument_from_path( $arguments, $path, $delimiter = '.' ) {
$path_keys = explode( $delimiter, $path );
$num_keys = count( $path_keys );
$val = $arguments;
for ( $i = 0; $i < $num_keys; $i++ ) {
$key = $path_keys[ $i ];
if ( array_key_exists( $key, $val ) ) {
$val = $val[ $key ];
} else {
$val = null;
break;
}
}
return $val;
}
/**
* Array of required arguments.
*
* @var array
*/
protected $required_arguments = array();
/**
* Get missing arguments of args array.
*
* @param array $args field arguments.
* @return array
*/
public function get_missing_arguments( $args ) {
return array_values(
array_filter(
$this->required_arguments,
function( $arg_key ) use ( $args ) {
return null === self::get_argument_from_path( $args, $arg_key );
}
)
);
}
}
Internal/Admin/ProductForm/ComponentTrait.php 0000644 00000001315 15153704477 0015310 0 ustar 00 <?php
/**
* Product Form Traits
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
defined( 'ABSPATH' ) || exit;
/**
* ComponentTrait class.
*/
trait ComponentTrait {
/**
* Component ID.
*
* @var string
*/
protected $id;
/**
* Plugin ID.
*
* @var string
*/
protected $plugin_id;
/**
* Product form component location.
*
* @var string
*/
protected $location;
/**
* Product form component order.
*
* @var number
*/
protected $order;
/**
* Return id.
*
* @return string
*/
public function get_id() {
return $this->id;
}
/**
* Return plugin id.
*
* @return string
*/
public function get_plugin_id() {
return $this->plugin_id;
}
}
Internal/Admin/ProductForm/Field.php 0000644 00000002422 15153704477 0013365 0 ustar 00 <?php
/**
* Handles product form field related methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* Field class.
*/
class Field extends Component {
/**
* Constructor
*
* @param string $id Field id.
* @param string $plugin_id Plugin id.
* @param array $additional_args Array containing the necessary arguments.
* $args = array(
* 'type' => (string) Field type. Required.
* 'section' => (string) Field location. Required.
* 'order' => (int) Field order.
* 'properties' => (array) Field properties.
* ).
* @throws \Exception If there are missing arguments.
*/
public function __construct( $id, $plugin_id, $additional_args ) {
parent::__construct( $id, $plugin_id, $additional_args );
$this->required_arguments = array(
'type',
'section',
'properties.name',
'properties.label',
);
$missing_arguments = self::get_missing_arguments( $additional_args );
if ( count( $missing_arguments ) > 0 ) {
throw new \Exception(
sprintf(
/* translators: 1: Missing arguments list. */
esc_html__( 'You are missing required arguments of WooCommerce ProductForm Field: %1$s', 'woocommerce' ),
join( ', ', $missing_arguments )
)
);
}
}
}
Internal/Admin/ProductForm/FormFactory.php 0000644 00000016476 15153704477 0014613 0 ustar 00 <?php
/**
* WooCommerce Product Form Factory
*
* @package Woocommerce ProductForm
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
use WP_Error;
/**
* Factory that contains logic for the WooCommerce Product Form.
*/
class FormFactory {
/**
* Class instance.
*
* @var Form instance
*/
protected static $instance = null;
/**
* Store form fields.
*
* @var array
*/
protected static $form_fields = array();
/**
* Store form cards.
*
* @var array
*/
protected static $form_subsections = array();
/**
* Store form sections.
*
* @var array
*/
protected static $form_sections = array();
/**
* Store form tabs.
*
* @var array
*/
protected static $form_tabs = array();
/**
* Get class instance.
*/
final public static function instance() {
if ( ! static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init.
*/
public function init() { }
/**
* Adds a field to the product form.
*
* @param string $id Field id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* $args = array(
* 'type' => (string) Field type. Required.
* 'section' => (string) Field location. Required.
* 'order' => (int) Field order.
* 'properties' => (array) Field properties.
* 'name' => (string) Field name.
* ).
* @return Field|WP_Error New field or WP_Error.
*/
public static function add_field( $id, $plugin_id, $args ) {
$new_field = self::create_item( 'field', 'Field', $id, $plugin_id, $args );
if ( is_wp_error( $new_field ) ) {
return $new_field;
}
self::$form_fields[ $id ] = $new_field;
return $new_field;
}
/**
* Adds a Subsection to the product form.
*
* @param string $id Subsection id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* @return Subsection|WP_Error New subsection or WP_Error.
*/
public static function add_subsection( $id, $plugin_id, $args = array() ) {
$new_subsection = self::create_item( 'subsection', 'Subsection', $id, $plugin_id, $args );
if ( is_wp_error( $new_subsection ) ) {
return $new_subsection;
}
self::$form_subsections[ $id ] = $new_subsection;
return $new_subsection;
}
/**
* Adds a section to the product form.
*
* @param string $id Card id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* @return Section|WP_Error New section or WP_Error.
*/
public static function add_section( $id, $plugin_id, $args ) {
$new_section = self::create_item( 'section', 'Section', $id, $plugin_id, $args );
if ( is_wp_error( $new_section ) ) {
return $new_section;
}
self::$form_sections[ $id ] = $new_section;
return $new_section;
}
/**
* Adds a tab to the product form.
*
* @param string $id Card id.
* @param string $plugin_id Plugin id.
* @param array $args Array containing the necessary arguments.
* @return Tab|WP_Error New section or WP_Error.
*/
public static function add_tab( $id, $plugin_id, $args ) {
$new_tab = self::create_item( 'tab', 'Tab', $id, $plugin_id, $args );
if ( is_wp_error( $new_tab ) ) {
return $new_tab;
}
self::$form_tabs[ $id ] = $new_tab;
return $new_tab;
}
/**
* Returns list of registered fields.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered fields.
*/
public static function get_fields( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'field', 'Field', $sort_by );
}
/**
* Returns list of registered cards.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered cards.
*/
public static function get_subsections( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'subsection', 'Subsection', $sort_by );
}
/**
* Returns list of registered sections.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered sections.
*/
public static function get_sections( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'section', 'Section', $sort_by );
}
/**
* Returns list of registered tabs.
*
* @param array $sort_by key and order to sort by.
* @return array list of registered tabs.
*/
public static function get_tabs( $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
return self::get_items( 'tab', 'Tab', $sort_by );
}
/**
* Returns list of registered items.
*
* @param string $type Form component type.
* @return array List of registered items.
*/
private static function get_item_list( $type ) {
$mapping = array(
'field' => self::$form_fields,
'subsection' => self::$form_subsections,
'section' => self::$form_sections,
'tab' => self::$form_tabs,
);
if ( array_key_exists( $type, $mapping ) ) {
return $mapping[ $type ];
}
return array();
}
/**
* Returns list of registered items.
*
* @param string $type Form component type.
* @param class-string $class_name Class of component type.
* @param array $sort_by key and order to sort by.
* @return array list of registered items.
*/
private static function get_items( $type, $class_name, $sort_by = array(
'key' => 'order',
'order' => 'asc',
) ) {
$item_list = self::get_item_list( $type );
$class = 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm\\' . $class_name;
$items = array_values( $item_list );
if ( class_exists( $class ) && method_exists( $class, 'sort' ) ) {
usort(
$items,
function ( $a, $b ) use ( $sort_by, $class ) {
return $class::sort( $a, $b, $sort_by );
}
);
}
return $items;
}
/**
* Creates a new item.
*
* @param string $type Form component type.
* @param class-string $class_name Class of component type.
* @param string $id Item id.
* @param string $plugin_id Plugin id.
* @param array $args additional arguments for item.
* @return Field|Card|Section|Tab|WP_Error New product form item or WP_Error.
*/
private static function create_item( $type, $class_name, $id, $plugin_id, $args ) {
$item_list = self::get_item_list( $type );
$class = 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm\\' . $class_name;
if ( ! class_exists( $class ) ) {
return new WP_Error(
'wc_product_form_' . $type . '_missing_form_class',
sprintf(
/* translators: 1: missing class name. */
esc_html__( '%1$s class does not exist.', 'woocommerce' ),
$class
)
);
}
if ( isset( $item_list[ $id ] ) ) {
return new WP_Error(
'wc_product_form_' . $type . '_duplicate_field_id',
sprintf(
/* translators: 1: Item type 2: Duplicate registered item id. */
esc_html__( 'You have attempted to register a duplicate form %1$s with WooCommerce Form: %2$s', 'woocommerce' ),
$type,
'`' . $id . '`'
)
);
}
$defaults = array(
'order' => 20,
);
$item_arguments = wp_parse_args( $args, $defaults );
try {
return new $class( $id, $plugin_id, $item_arguments );
} catch ( \Exception $e ) {
return new WP_Error(
'wc_product_form_' . $type . '_class_creation',
$e->getMessage()
);
}
}
}
Internal/Admin/ProductForm/Section.php 0000644 00000002234 15153704477 0013747 0 ustar 00 <?php
/**
* Handles product form section related methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* Section class.
*/
class Section extends Component {
/**
* Constructor
*
* @param string $id Section id.
* @param string $plugin_id Plugin id.
* @param array $additional_args Array containing additional arguments.
* $args = array(
* 'order' => (int) Section order.
* 'title' => (string) Section description.
* 'description' => (string) Section description.
* ).
* @throws \Exception If there are missing arguments.
*/
public function __construct( $id, $plugin_id, $additional_args ) {
parent::__construct( $id, $plugin_id, $additional_args );
$this->required_arguments = array(
'title',
);
$missing_arguments = self::get_missing_arguments( $additional_args );
if ( count( $missing_arguments ) > 0 ) {
throw new \Exception(
sprintf(
/* translators: 1: Missing arguments list. */
esc_html__( 'You are missing required arguments of WooCommerce ProductForm Section: %1$s', 'woocommerce' ),
join( ', ', $missing_arguments )
)
);
}
}
}
Internal/Admin/ProductForm/Subsection.php 0000644 00000000304 15153704477 0014455 0 ustar 00 <?php
/**
* Handles product form SubSection related methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* SubSection class.
*/
class Subsection extends Component {}
Internal/Admin/ProductForm/Tab.php 0000644 00000002322 15153704477 0013047 0 ustar 00 <?php
/**
* Handles product form tab related methods.
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductForm;
/**
* Field class.
*/
class Tab extends Component {
/**
* Constructor
*
* @param string $id Field id.
* @param string $plugin_id Plugin id.
* @param array $additional_args Array containing the necessary arguments.
* $args = array(
* 'name' => (string) Tab name. Required.
* 'title' => (string) Tab title. Required.
* 'order' => (int) Tab order.
* 'properties' => (array) Tab properties.
* ).
* @throws \Exception If there are missing arguments.
*/
public function __construct( $id, $plugin_id, $additional_args ) {
parent::__construct( $id, $plugin_id, $additional_args );
$this->required_arguments = array(
'name',
'title',
);
$missing_arguments = self::get_missing_arguments( $additional_args );
if ( count( $missing_arguments ) > 0 ) {
throw new \Exception(
sprintf(
/* translators: 1: Missing arguments list. */
esc_html__( 'You are missing required arguments of WooCommerce ProductForm Tab: %1$s', 'woocommerce' ),
join( ', ', $missing_arguments )
)
);
}
}
}
Internal/Admin/ProductReviews/Reviews.php 0000644 00000052471 15153704477 0014520 0 ustar 00 <?php
/**
* Products > Reviews
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WP_Ajax_Response;
use WP_Comment;
use WP_Screen;
/**
* Handles backend logic for the Reviews component.
*/
class Reviews {
use AccessiblePrivateMethods;
/**
* Admin page identifier.
*/
const MENU_SLUG = 'product-reviews';
/**
* Reviews page hook name.
*
* @var string|null
*/
protected $reviews_page_hook = null;
/**
* Reviews list table instance.
*
* @var ReviewsListTable|null
*/
protected $reviews_list_table;
/**
* Constructor.
*/
public function __construct() {
self::add_action( 'admin_menu', [ $this, 'add_reviews_page' ] );
self::add_action( 'admin_enqueue_scripts', [ $this, 'load_javascript' ] );
// These ajax callbacks need a low priority to ensure they run before their WordPress core counterparts.
self::add_action( 'wp_ajax_edit-comment', [ $this, 'handle_edit_review' ], -1 );
self::add_action( 'wp_ajax_replyto-comment', [ $this, 'handle_reply_to_review' ], -1 );
self::add_filter( 'parent_file', [ $this, 'edit_review_parent_file' ] );
self::add_filter( 'gettext', [ $this, 'edit_comments_screen_text' ], 10, 2 );
self::add_action( 'admin_notices', [ $this, 'display_notices' ] );
}
/**
* Gets the required capability to access the reviews page and manage product reviews.
*
* @param string $context The context for which the capability is needed (e.g. `view` or `moderate`).
* @return string
*/
public static function get_capability( string $context = 'view' ) : string {
/**
* Filters whether the current user can manage product reviews.
*
* This is aligned to {@see \wc_rest_check_product_reviews_permissions()}
*
* @since 6.7.0
*
* @param string $capability The capability (defaults to `moderate_comments` for viewing and `edit_products` for editing).
* @param string $context The context for which the capability is needed.
*/
return (string) apply_filters( 'woocommerce_product_reviews_page_capability', $context === 'view' ? 'moderate_comments' : 'edit_products', $context );
}
/**
* Registers the Product Reviews submenu page.
*
* @return void
*/
private function add_reviews_page() : void {
$this->reviews_page_hook = add_submenu_page(
'edit.php?post_type=product',
__( 'Reviews', 'woocommerce' ),
__( 'Reviews', 'woocommerce' ) . $this->get_pending_count_bubble(),
static::get_capability(),
static::MENU_SLUG,
[ $this, 'render_reviews_list_table' ]
);
self::add_action( "load-{$this->reviews_page_hook}", array( $this, 'load_reviews_screen' ) );
}
/**
* Retrieves the URL to the product reviews page.
*
* @return string
*/
public static function get_reviews_page_url() : string {
return add_query_arg(
[
'post_type' => 'product',
'page' => static::MENU_SLUG,
],
admin_url( 'edit.php' )
);
}
/**
* Determines whether the current page is the reviews page.
*
* @global WP_Screen $current_screen
*
* @return bool
*/
public function is_reviews_page() : bool {
global $current_screen;
return isset( $current_screen->base ) && $current_screen->base === 'product_page_' . static::MENU_SLUG;
}
/**
* Loads the JavaScript required for inline replies and quick edit.
*
* @return void
*/
private function load_javascript() : void {
if ( $this->is_reviews_page() ) {
wp_enqueue_script( 'admin-comments' );
enqueue_comment_hotkeys_js();
}
}
/**
* Determines if the object is a review or a reply to a review.
*
* @param WP_Comment|mixed $object Object to check.
* @return bool
*/
protected function is_review_or_reply( $object ) : bool {
$is_review_or_reply = $object instanceof WP_Comment && in_array( $object->comment_type, [ 'review', 'comment' ], true ) && get_post_type( $object->comment_post_ID ) === 'product';
/**
* Filters whether the object is a review or a reply to a review.
*
* @since 6.7.0
*
* @param bool $is_review_or_reply Whether the object in context is a review or a reply to a review.
* @param WP_Comment|mixed $object The object in context.
*/
return (bool) apply_filters( 'woocommerce_product_reviews_is_product_review_or_reply', $is_review_or_reply, $object );
}
/**
* Ajax callback for editing a review.
*
* This functionality is taken from {@see wp_ajax_edit_comment()} and is largely copy and pasted. The only thing
* we want to change is the review row HTML in the response. WordPress core uses a comment list table and we need
* to use our own {@see ReviewsListTable} class to support our custom columns.
*
* This ajax callback is registered with a lower priority than WordPress core's so that our code can run
* first. If the supplied comment ID is not a review or a reply to a review, then we `return` early from this method
* to allow the WordPress core callback to take over.
*
* @return void
*/
private function handle_edit_review(): void {
// Don't interfere with comment functionality relating to the reviews meta box within the product editor.
if ( sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ) === 'single' ) {
return;
}
check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' );
$comment_id = isset( $_POST['comment_ID'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['comment_ID'] ) ) : 0;
if ( empty( $comment_id ) || ! current_user_can( 'edit_comment', $comment_id ) ) {
wp_die( -1 );
}
$review = get_comment( $comment_id );
// Bail silently if this is not a review, or a reply to a review. That allows `wp_ajax_edit_comment()` to handle any further actions.
if ( ! $this->is_review_or_reply( $review ) ) {
return;
}
if ( empty( $review->comment_ID ) ) {
wp_die( -1 );
}
if ( empty( $_POST['content'] ) ) {
wp_die( esc_html__( 'Error: Please type your review text.', 'woocommerce' ) );
}
if ( isset( $_POST['status'] ) ) {
$_POST['comment_status'] = sanitize_text_field( wp_unslash( $_POST['status'] ) );
}
$updated = edit_comment();
if ( is_wp_error( $updated ) ) {
wp_die( esc_html( $updated->get_error_message() ) );
}
$position = isset( $_POST['position'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['position'] ) ) : -1;
$wp_list_table = $this->make_reviews_list_table();
ob_start();
$wp_list_table->single_row( $review );
$review_list_item = ob_get_clean();
$x = new WP_Ajax_Response();
$x->add(
array(
'what' => 'edit_comment',
'id' => $review->comment_ID,
'data' => $review_list_item,
'position' => $position,
)
);
$x->send();
}
/**
* Ajax callback for replying to a review inline.
*
* This functionality is taken from {@see wp_ajax_replyto_comment()} and is largely copy and pasted. The only thing
* we want to change is the review row HTML in the response. WordPress core uses a comment list table and we need
* to use our own {@see ReviewsListTable} class to support our custom columns.
*
* This ajax callback is registered with a lower priority than WordPress core's so that our code can run
* first. If the supplied comment ID is not a review or a reply to a review, then we `return` early from this method
* to allow the WordPress core callback to take over.
*
* @return void
*/
private function handle_reply_to_review() : void {
// Don't interfere with comment functionality relating to the reviews meta box within the product editor.
if ( sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ) === 'single' ) {
return;
}
check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' );
$comment_post_ID = isset( $_POST['comment_post_ID'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['comment_post_ID'] ) ) : 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
$post = get_post( $comment_post_ID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
if ( ! $post ) {
wp_die( -1 );
}
// Inline Review replies will use the `detail` mode. If that's not what we have, then let WordPress core take over.
if ( isset( $_REQUEST['mode'] ) && $_REQUEST['mode'] === 'dashboard' ) {
return;
}
// If this is not a a reply to a review, bail silently to let WordPress core take over.
if ( get_post_type( $post ) !== 'product' ) {
return;
}
if ( ! current_user_can( 'edit_post', $comment_post_ID ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
wp_die( -1 );
}
if ( empty( $post->post_status ) ) {
wp_die( 1 );
} elseif ( in_array( $post->post_status, array( 'draft', 'pending', 'trash' ), true ) ) {
wp_die( esc_html__( 'Error: You can\'t reply to a review on a draft product.', 'woocommerce' ) );
}
$user = wp_get_current_user();
if ( $user->exists() ) {
$user_ID = $user->ID;
$comment_author = wp_slash( $user->display_name );
$comment_author_email = wp_slash( $user->user_email );
$comment_author_url = wp_slash( $user->user_url );
// WordPress core already sanitizes `content` during the `pre_comment_content` hook, which is why it's not needed here, {@see wp_filter_comment()} and {@see kses_init_filters()}.
$comment_content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$comment_type = isset( $_POST['comment_type'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_type'] ) ) : 'comment';
if ( current_user_can( 'unfiltered_html' ) ) {
if ( ! isset( $_POST['_wp_unfiltered_html_comment'] ) ) {
$_POST['_wp_unfiltered_html_comment'] = '';
}
if ( wp_create_nonce( 'unfiltered-html-comment' ) != $_POST['_wp_unfiltered_html_comment'] ) {
kses_remove_filters(); // Start with a clean slate.
kses_init_filters(); // Set up the filters.
remove_filter( 'pre_comment_content', 'wp_filter_post_kses' );
add_filter( 'pre_comment_content', 'wp_filter_kses' );
}
}
} else {
wp_die( esc_html__( 'Sorry, you must be logged in to reply to a review.', 'woocommerce' ) );
}
if ( $comment_content === '' ) {
wp_die( esc_html__( 'Error: Please type your reply text.', 'woocommerce' ) );
}
$comment_parent = 0;
if ( isset( $_POST['comment_ID'] ) ) {
$comment_parent = absint( wp_unslash( $_POST['comment_ID'] ) );
}
$comment_auto_approved = false;
$commentdata = compact( 'comment_post_ID', 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'comment_type', 'comment_parent', 'user_ID' );
// Automatically approve parent comment.
if ( ! empty( $_POST['approve_parent'] ) ) {
$parent = get_comment( $comment_parent );
if ( $parent && $parent->comment_approved === '0' && $parent->comment_post_ID === $comment_post_ID ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
if ( ! current_user_can( 'edit_comment', $parent->comment_ID ) ) {
wp_die( -1 );
}
if ( wp_set_comment_status( $parent, 'approve' ) ) {
$comment_auto_approved = true;
}
}
}
$comment_id = wp_new_comment( $commentdata );
if ( is_wp_error( $comment_id ) ) {
wp_die( esc_html( $comment_id->get_error_message() ) );
}
$comment = get_comment( $comment_id );
if ( ! $comment ) {
wp_die( 1 );
}
$position = ( isset( $_POST['position'] ) && (int) $_POST['position'] ) ? (int) $_POST['position'] : '-1';
ob_start();
$wp_list_table = $this->make_reviews_list_table();
$wp_list_table->single_row( $comment );
$comment_list_item = ob_get_clean();
$response = array(
'what' => 'comment',
'id' => $comment->comment_ID,
'data' => $comment_list_item,
'position' => $position,
);
$counts = wp_count_comments();
$response['supplemental'] = array(
'in_moderation' => $counts->moderated,
'i18n_comments_text' => sprintf(
/* translators: %s: Number of reviews. */
_n( '%s Review', '%s Reviews', $counts->approved, 'woocommerce' ),
number_format_i18n( $counts->approved )
),
'i18n_moderation_text' => sprintf(
/* translators: %s: Number of reviews. */
_n( '%s Review in moderation', '%s Reviews in moderation', $counts->moderated, 'woocommerce' ),
number_format_i18n( $counts->moderated )
),
);
if ( $comment_auto_approved && isset( $parent ) ) {
$response['supplemental']['parent_approved'] = $parent->comment_ID;
$response['supplemental']['parent_post_id'] = $parent->comment_post_ID;
}
$x = new WP_Ajax_Response();
$x->add( $response );
$x->send();
}
/**
* Displays notices on the Reviews page.
*
* @return void
*/
protected function display_notices() : void {
if ( $this->is_reviews_page() ) {
$this->maybe_display_reviews_bulk_action_notice();
}
}
/**
* May display the bulk action admin notice.
*
* @return void
*/
protected function maybe_display_reviews_bulk_action_notice() : void {
$messages = $this->get_bulk_action_notice_messages();
echo ! empty( $messages ) ? '<div id="moderated" class="updated"><p>' . implode( "<br/>\n", $messages ) . '</p></div>' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Gets the applicable bulk action admin notice messages.
*
* @return array
*/
protected function get_bulk_action_notice_messages() : array {
$approved = isset( $_REQUEST['approved'] ) ? (int) $_REQUEST['approved'] : 0;
$unapproved = isset( $_REQUEST['unapproved'] ) ? (int) $_REQUEST['unapproved'] : 0;
$deleted = isset( $_REQUEST['deleted'] ) ? (int) $_REQUEST['deleted'] : 0;
$trashed = isset( $_REQUEST['trashed'] ) ? (int) $_REQUEST['trashed'] : 0;
$untrashed = isset( $_REQUEST['untrashed'] ) ? (int) $_REQUEST['untrashed'] : 0;
$spammed = isset( $_REQUEST['spammed'] ) ? (int) $_REQUEST['spammed'] : 0;
$unspammed = isset( $_REQUEST['unspammed'] ) ? (int) $_REQUEST['unspammed'] : 0;
$messages = [];
if ( $approved > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review approved', '%s reviews approved', $approved, 'woocommerce' ), $approved );
}
if ( $unapproved > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review unapproved', '%s reviews unapproved', $unapproved, 'woocommerce' ), $unapproved );
}
if ( $spammed > 0 ) {
$ids = isset( $_REQUEST['ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ids'] ) ) : 0;
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review marked as spam.', '%s reviews marked as spam.', $spammed, 'woocommerce' ), $spammed ) . ' <a href="' . esc_url( wp_nonce_url( "edit-comments.php?doaction=undo&action=unspam&ids=$ids", 'bulk-comments' ) ) . '">' . __( 'Undo', 'woocommerce' ) . '</a><br />';
}
if ( $unspammed > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review restored from the spam', '%s reviews restored from the spam', $unspammed, 'woocommerce' ), $unspammed );
}
if ( $trashed > 0 ) {
$ids = isset( $_REQUEST['ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ids'] ) ) : 0;
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review moved to the Trash.', '%s reviews moved to the Trash.', $trashed, 'woocommerce' ), $trashed ) . ' <a href="' . esc_url( wp_nonce_url( "edit-comments.php?doaction=undo&action=untrash&ids=$ids", 'bulk-comments' ) ) . '">' . __( 'Undo', 'woocommerce' ) . '</a><br />';
}
if ( $untrashed > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review restored from the Trash', '%s reviews restored from the Trash', $untrashed, 'woocommerce' ), $untrashed );
}
if ( $deleted > 0 ) {
/* translators: %s is an integer higher than 0 (1, 2, 3...) */
$messages[] = sprintf( _n( '%s review permanently deleted', '%s reviews permanently deleted', $deleted, 'woocommerce' ), $deleted );
}
return $messages;
}
/**
* Counts the number of pending product reviews/replies, and returns the notification bubble if there's more than zero.
*
* @return string Empty string if there are no pending reviews, or bubble HTML if there are.
*/
protected function get_pending_count_bubble() : string {
$count = (int) get_comments(
[
'type__in' => [ 'review', 'comment' ],
'status' => '0',
'post_type' => 'product',
'count' => true,
]
);
/**
* Provides an opportunity to alter the pending comment count used within
* the product reviews admin list table.
*
* @since 7.0.0
*
* @param array $count Current count of comments pending review.
*/
$count = apply_filters( 'woocommerce_product_reviews_pending_count', $count );
if ( empty( $count ) ) {
return '';
}
return ' <span class="awaiting-mod count-' . esc_attr( $count ) . '"><span class="pending-count">' . esc_html( $count ) . '</span></span>';
}
/**
* Highlights Product -> Reviews admin menu item when editing a review or a reply to a review.
*
* @global string $submenu_file
*
* @param string|mixed $parent_file Parent menu item.
* @return string
*/
protected function edit_review_parent_file( $parent_file ) {
global $submenu_file, $current_screen;
if ( isset( $current_screen->id, $_GET['c'] ) && $current_screen->id === 'comment' ) {
$comment_id = absint( $_GET['c'] );
$comment = get_comment( $comment_id );
if ( isset( $comment->comment_parent ) && $comment->comment_parent > 0 ) {
$comment = get_comment( $comment->comment_parent );
}
if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) {
$parent_file = 'edit.php?post_type=product';
$submenu_file = 'product-reviews'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
return $parent_file;
}
/**
* Replaces Edit/Moderate Comment title/headline with Edit Review, when editing/moderating a review.
*
* @param string|mixed $translation Translated text.
* @param string|mixed $text Text to translate.
* @return string|mixed Translated text.
*/
protected function edit_comments_screen_text( $translation, $text ) {
global $comment;
// Bail out if not a text we should replace.
if ( ! in_array( $text, [ 'Edit Comment', 'Moderate Comment' ], true ) ) {
return $translation;
}
// Try to get comment from query params when not in context already.
if ( ! $comment && isset( $_GET['action'], $_GET['c'] ) && $_GET['action'] === 'editcomment' ) {
$comment_id = absint( $_GET['c'] );
$comment = get_comment( $comment_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
$is_reply = isset( $comment->comment_parent ) && $comment->comment_parent > 0;
// Only replace the translated text if we are editing a comment left on a product (ie. a review).
if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) {
if ( $text === 'Edit Comment' ) {
$translation = $is_reply
? __( 'Edit Review Reply', 'woocommerce' )
: __( 'Edit Review', 'woocommerce' );
} elseif ( $text === 'Moderate Comment' ) {
$translation = $is_reply
? __( 'Moderate Review Reply', 'woocommerce' )
: __( 'Moderate Review', 'woocommerce' );
}
}
return $translation;
}
/**
* Returns a new instance of `ReviewsListTable`, with the screen argument specified.
*
* @return ReviewsListTable
*/
protected function make_reviews_list_table() : ReviewsListTable {
return new ReviewsListTable( [ 'screen' => $this->reviews_page_hook ? $this->reviews_page_hook : 'product_page_product-reviews' ] );
}
/**
* Initializes the list table.
*
* @return void
*/
protected function load_reviews_screen() : void {
$this->reviews_list_table = $this->make_reviews_list_table();
$this->reviews_list_table->process_bulk_action();
}
/**
* Renders the Reviews page.
*
* @return void
*/
public function render_reviews_list_table() : void {
$this->reviews_list_table->prepare_items();
ob_start();
?>
<div class="wrap">
<h2><?php echo esc_html( get_admin_page_title() ); ?></h2>
<?php $this->reviews_list_table->views(); ?>
<form id="reviews-filter" method="get">
<?php $page = isset( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : static::MENU_SLUG; ?>
<input type="hidden" name="page" value="<?php echo esc_attr( $page ); ?>" />
<input type="hidden" name="post_type" value="product" />
<input type="hidden" name="pagegen_timestamp" value="<?php echo esc_attr( current_time( 'mysql', true ) ); ?>" />
<?php $this->reviews_list_table->search_box( __( 'Search Reviews', 'woocommerce' ), 'reviews' ); ?>
<?php $this->reviews_list_table->display(); ?>
</form>
</div>
<?php
wp_comment_reply( '-1', true, 'detail' );
wp_comment_trashnotice();
/**
* Filters the contents of the product reviews list table output.
*
* @since 6.7.0
*
* @param string $output The HTML output of the list table.
* @param ReviewsListTable $reviews_list_table The reviews list table instance.
*/
echo apply_filters( 'woocommerce_product_reviews_list_table', ob_get_clean(), $this->reviews_list_table ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
Internal/Admin/ProductReviews/ReviewsCommentsOverrides.php 0000644 00000010544 15153704477 0020104 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WP_Comment_Query;
use WP_Screen;
/**
* Tweaks the WordPress comments page to exclude reviews.
*/
class ReviewsCommentsOverrides {
use AccessiblePrivateMethods;
const REVIEWS_MOVED_NOTICE_ID = 'product_reviews_moved';
/**
* Constructor.
*/
public function __construct() {
self::add_action( 'admin_notices', array( $this, 'display_notices' ) );
self::add_filter( 'woocommerce_dismiss_admin_notice_capability', array( $this, 'get_dismiss_capability' ), 10, 2 );
self::add_filter( 'comments_list_table_query_args', array( $this, 'exclude_reviews_from_comments' ) );
}
/**
* Renders admin notices.
*/
protected function display_notices() : void {
$screen = get_current_screen();
if ( empty( $screen ) || $screen->base !== 'edit-comments' ) {
return;
}
$this->maybe_display_reviews_moved_notice();
}
/**
* May render an admin notice informing the user that reviews were moved to a new page.
*
* @return void
*/
protected function maybe_display_reviews_moved_notice() : void {
if ( $this->should_display_reviews_moved_notice() ) {
$this->display_reviews_moved_notice();
}
}
/**
* Checks if the admin notice informing the user that reviews were moved to a new page should be displayed.
*
* @return bool
*/
protected function should_display_reviews_moved_notice() : bool {
// Do not display if the user does not have the capability to see the new page.
if ( ! WC()->call_function( 'current_user_can', Reviews::get_capability() ) ) {
return false;
}
// Do not display if the current user has dismissed this notice.
if ( WC()->call_function( 'get_user_meta', get_current_user_id(), 'dismissed_' . static::REVIEWS_MOVED_NOTICE_ID . '_notice', true ) ) {
return false;
}
return true;
}
/**
* Renders an admin notice informing the user that reviews were moved to a new page.
*
* @return void
*/
protected function display_reviews_moved_notice() : void {
$dismiss_url = wp_nonce_url(
add_query_arg(
[
'wc-hide-notice' => urlencode( static::REVIEWS_MOVED_NOTICE_ID ),
]
),
'woocommerce_hide_notices_nonce',
'_wc_notice_nonce'
);
?>
<div class="notice notice-info is-dismissible">
<p><strong><?php esc_html_e( 'Product reviews have moved!', 'woocommerce' ); ?></strong></p>
<p><?php esc_html_e( 'Product reviews can now be managed from Products > Reviews.', 'woocommerce' ); ?></p>
<p class="submit">
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=product&page=product-reviews' ) ); ?>" class="button-primary"><?php esc_html_e( 'Visit new location', 'woocommerce' ); ?></a>
</p>
<button type="button" class="notice-dismiss" onclick="window.location = '<?php echo esc_url( $dismiss_url ); ?>';"><span class="screen-reader-text"><?php esc_html_e( 'Dismiss this notice.', 'woocommerce' ); ?></span></button>
</div>
<?php
}
/**
* Gets the capability required to dismiss the notice.
*
* This is required so that users who do not have the manage_woocommerce capability (e.g. Editors) can still dismiss
* the notice displayed in the Comments page.
*
* @param string|mixed $default_capability The default required capability.
* @param string|mixed $notice_name The notice name.
* @return string
*/
protected function get_dismiss_capability( $default_capability, $notice_name ) {
return $notice_name === self::REVIEWS_MOVED_NOTICE_ID ? Reviews::get_capability() : $default_capability;
}
/**
* Excludes product reviews from showing in the comments page.
*
* @param array|mixed $args {@see WP_Comment_Query} query args.
* @return array
*/
protected function exclude_reviews_from_comments( $args ) : array {
$screen = get_current_screen();
// We only wish to intervene if the edit comments screen has been requested.
if ( ! $screen instanceof WP_Screen || 'edit-comments' !== $screen->id ) {
return $args;
}
if ( ! empty( $args['post_type'] ) && $args['post_type'] !== 'any' ) {
$post_types = (array) $args['post_type'];
} else {
$post_types = get_post_types();
}
$index = array_search( 'product', $post_types );
if ( $index !== false ) {
unset( $post_types[ $index ] );
}
if ( ! is_array( $args ) ) {
$args = [];
}
$args['post_type'] = $post_types;
return $args;
}
}
Internal/Admin/ProductReviews/ReviewsListTable.php 0000644 00000133171 15153704477 0016321 0 ustar 00 <?php
/**
* Product > Reviews
*/
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
use WC_Product;
use WP_Comment;
use WP_Comments_List_Table;
use WP_List_Table;
use WP_Post;
/**
* Handles the Product Reviews page.
*/
class ReviewsListTable extends WP_List_Table {
/**
* Memoization flag to determine if the current user can edit the current review.
*
* @var bool
*/
private $current_user_can_edit_review = false;
/**
* Memoization flag to determine if the current user can moderate reviews.
*
* @var bool
*/
private $current_user_can_moderate_reviews;
/**
* Current rating of reviews to display.
*
* @var int
*/
private $current_reviews_rating = 0;
/**
* Current product the reviews should be displayed for.
*
* @var WC_Product|null Product or null for all products.
*/
private $current_product_for_reviews;
/**
* Constructor.
*
* @param array|string $args Array or string of arguments.
*/
public function __construct( $args = [] ) {
parent::__construct(
wp_parse_args(
$args,
[
'plural' => 'product-reviews',
'singular' => 'product-review',
]
)
);
$this->current_user_can_moderate_reviews = current_user_can( Reviews::get_capability( 'moderate' ) );
}
/**
* Prepares reviews for display.
*
* @return void
*/
public function prepare_items() : void {
$this->set_review_status();
$this->set_review_type();
$this->current_reviews_rating = isset( $_REQUEST['review_rating'] ) ? absint( $_REQUEST['review_rating'] ) : 0;
$this->set_review_product();
$args = [
'number' => $this->get_per_page(),
'post_type' => 'product',
];
// Include the order & orderby arguments.
$args = wp_parse_args( $this->get_sort_arguments(), $args );
// Handle the review item types filter.
$args = wp_parse_args( $this->get_filter_type_arguments(), $args );
// Handle the reviews rating filter.
$args = wp_parse_args( $this->get_filter_rating_arguments(), $args );
// Handle the review product filter.
$args = wp_parse_args( $this->get_filter_product_arguments(), $args );
// Include the review status arguments.
$args = wp_parse_args( $this->get_status_arguments(), $args );
// Include the search argument.
$args = wp_parse_args( $this->get_search_arguments(), $args );
// Include the offset argument.
$args = wp_parse_args( $this->get_offset_arguments(), $args );
/**
* Provides an opportunity to alter the comment query arguments used within
* the product reviews admin list table.
*
* @since 7.0.0
*
* @param array $args Comment query args.
*/
$args = (array) apply_filters( 'woocommerce_product_reviews_list_table_prepare_items_args', $args );
$comments = get_comments( $args );
update_comment_cache( $comments );
$this->items = $comments;
$this->set_pagination_args(
[
'total_items' => get_comments( $this->get_total_comments_arguments( $args ) ),
'per_page' => $this->get_per_page(),
]
);
}
/**
* Returns the number of items to show per page.
*
* @return int Customized per-page value if available, or 20 as the default.
*/
protected function get_per_page() : int {
return $this->get_items_per_page( 'edit_comments_per_page' );
}
/**
* Sets the product to filter reviews by.
*
* @return void
*/
protected function set_review_product() : void {
$product_id = isset( $_REQUEST['product_id'] ) ? absint( $_REQUEST['product_id'] ) : null;
$product = $product_id ? wc_get_product( $product_id ) : null;
if ( $product instanceof WC_Product ) {
$this->current_product_for_reviews = $product;
}
}
/**
* Sets the `$comment_status` global based on the current request.
*
* @global string $comment_status
*
* @return void
*/
protected function set_review_status() : void {
global $comment_status;
$comment_status = sanitize_text_field( wp_unslash( $_REQUEST['comment_status'] ?? 'all' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
if ( ! in_array( $comment_status, [ 'all', 'moderated', 'approved', 'spam', 'trash' ], true ) ) {
$comment_status = 'all'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
/**
* Sets the `$comment_type` global based on the current request.
*
* @global string $comment_type
*
* @return void
*/
protected function set_review_type() : void {
global $comment_type;
$review_type = sanitize_text_field( wp_unslash( $_REQUEST['review_type'] ?? 'all' ) );
if ( 'all' !== $review_type && ! empty( $review_type ) ) {
$comment_type = $review_type; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
/**
* Builds the `orderby` and `order` arguments based on the current request.
*
* @return array
*/
protected function get_sort_arguments() : array {
$orderby = sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ?? '' ) );
$order = sanitize_text_field( wp_unslash( $_REQUEST['order'] ?? '' ) );
$args = [];
if ( ! in_array( $orderby, $this->get_sortable_columns(), true ) ) {
$orderby = 'comment_date_gmt';
}
// If ordering by "rating", then we need to adjust to sort by meta value.
if ( 'rating' === $orderby ) {
$orderby = 'meta_value_num';
$args['meta_key'] = 'rating';
}
if ( ! in_array( strtolower( $order ), [ 'asc', 'desc' ], true ) ) {
$order = 'desc';
}
return wp_parse_args(
[
'orderby' => $orderby,
'order' => strtolower( $order ),
],
$args
);
}
/**
* Builds the `type` argument based on the current request.
*
* @return array
*/
protected function get_filter_type_arguments() : array {
$args = [];
$item_type = isset( $_REQUEST['review_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['review_type'] ) ) : 'all';
if ( 'all' === $item_type ) {
return $args;
}
$args['type'] = $item_type;
return $args;
}
/**
* Builds the `meta_query` arguments based on the current request.
*
* @return array
*/
protected function get_filter_rating_arguments() : array {
$args = [];
if ( empty( $this->current_reviews_rating ) ) {
return $args;
}
$args['meta_query'] = [
[
'key' => 'rating',
'value' => (int) $this->current_reviews_rating,
'compare' => '=',
'type' => 'NUMERIC',
],
];
return $args;
}
/**
* Gets the `post_id` argument based on the current request.
*
* @return array
*/
public function get_filter_product_arguments() : array {
$args = [];
if ( $this->current_product_for_reviews instanceof WC_Product ) {
$args['post_id'] = $this->current_product_for_reviews->get_id();
}
return $args;
}
/**
* Gets the `status` argument based on the current request.
*
* @return array
*/
protected function get_status_arguments() : array {
$args = [];
global $comment_status;
if ( ! empty( $comment_status ) && 'all' !== $comment_status && array_key_exists( $comment_status, $this->get_status_filters() ) ) {
$args['status'] = $this->convert_status_to_query_value( $comment_status );
}
return $args;
}
/**
* Gets the `search` argument based on the current request.
*
* @return array
*/
protected function get_search_arguments() : array {
$args = [];
if ( ! empty( $_REQUEST['s'] ) ) {
$args['search'] = sanitize_text_field( wp_unslash( $_REQUEST['s'] ) );
}
return $args;
}
/**
* Returns the `offset` argument based on the current request.
*
* @return array
*/
protected function get_offset_arguments() : array {
$args = [];
if ( isset( $_REQUEST['start'] ) ) {
$args['offset'] = absint( wp_unslash( $_REQUEST['start'] ) );
} else {
$args['offset'] = ( $this->get_pagenum() - 1 ) * $this->get_per_page();
}
return $args;
}
/**
* Returns the arguments used to count the total number of comments.
*
* @param array $default_query_args Query args for the main request.
* @return array
*/
protected function get_total_comments_arguments( array $default_query_args ) : array {
return wp_parse_args(
[
'count' => true,
'offset' => 0,
'number' => 0,
],
$default_query_args
);
}
/**
* Displays the product reviews HTML table.
*
* Reimplements {@see WP_Comment_::display()} but we change the ID to match the one output by {@see WP_Comments_List_Table::display()}.
* This will automatically handle additional CSS for consistency with the comments page.
*
* @return void
*/
public function display() : void {
$this->display_tablenav( 'top' );
$this->screen->render_screen_reader_content( 'heading_list' );
?>
<table class="wp-list-table <?php echo esc_attr( implode( ' ', $this->get_table_classes() ) ); ?>">
<thead>
<tr>
<?php $this->print_column_headers(); ?>
</tr>
</thead>
<tbody id="the-comment-list" data-wp-lists="list:comment">
<?php $this->display_rows_or_placeholder(); ?>
</tbody>
<tfoot>
<tr>
<?php $this->print_column_headers( false ); ?>
</tr>
</tfoot>
</table>
<?php
$this->display_tablenav( 'bottom' );
}
/**
* Render a single row HTML.
*
* @global WP_Post $post
* @global WP_Comment $comment
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
public function single_row( $item ) : void {
global $post, $comment;
// Overrides the comment global for properly rendering rows.
$comment = $item; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$the_comment_class = (string) wp_get_comment_status( $comment->comment_ID );
$the_comment_class = implode( ' ', get_comment_class( $the_comment_class, $comment->comment_ID, $comment->comment_post_ID ) );
// Sets the post for the product in context.
$post = get_post( $comment->comment_post_ID ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$this->current_user_can_edit_review = current_user_can( 'edit_comment', $comment->comment_ID );
?>
<tr id="comment-<?php echo esc_attr( $comment->comment_ID ); ?>" class="comment <?php echo esc_attr( $the_comment_class ); ?>">
<?php $this->single_row_columns( $comment ); ?>
</tr>
<?php
}
/**
* Generate and display row actions links.
*
* @see WP_Comments_List_Table::handle_row_actions() for consistency.
*
* @global string $comment_status Status for the current listed comments.
*
* @param WP_Comment|mixed $item The product review or reply in context.
* @param string|mixed $column_name Current column name.
* @param string|mixed $primary Primary column name.
* @return string
*/
protected function handle_row_actions( $item, $column_name, $primary ) : string {
global $comment_status;
if ( $primary !== $column_name || ! $this->current_user_can_edit_review ) {
return '';
}
$review_status = wp_get_comment_status( $item );
$url = add_query_arg(
[
'c' => urlencode( $item->comment_ID ),
],
admin_url( 'comment.php' )
);
$approve_url = wp_nonce_url( add_query_arg( 'action', 'approvecomment', $url ), "approve-comment_$item->comment_ID" );
$unapprove_url = wp_nonce_url( add_query_arg( 'action', 'unapprovecomment', $url ), "approve-comment_$item->comment_ID" );
$spam_url = wp_nonce_url( add_query_arg( 'action', 'spamcomment', $url ), "delete-comment_$item->comment_ID" );
$unspam_url = wp_nonce_url( add_query_arg( 'action', 'unspamcomment', $url ), "delete-comment_$item->comment_ID" );
$trash_url = wp_nonce_url( add_query_arg( 'action', 'trashcomment', $url ), "delete-comment_$item->comment_ID" );
$untrash_url = wp_nonce_url( add_query_arg( 'action', 'untrashcomment', $url ), "delete-comment_$item->comment_ID" );
$delete_url = wp_nonce_url( add_query_arg( 'action', 'deletecomment', $url ), "delete-comment_$item->comment_ID" );
$actions = [
'approve' => '',
'unapprove' => '',
'reply' => '',
'quickedit' => '',
'edit' => '',
'spam' => '',
'unspam' => '',
'trash' => '',
'untrash' => '',
'delete' => '',
];
if ( $comment_status && 'all' !== $comment_status ) {
if ( 'approved' === $review_status ) {
$actions['unapprove'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-u vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $unapprove_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:e7e7d3:action=dim-comment&new=unapproved" ),
esc_attr__( 'Unapprove this review', 'woocommerce' ),
esc_html__( 'Unapprove', 'woocommerce' )
);
} elseif ( 'unapproved' === $review_status ) {
$actions['approve'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-a vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $approve_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:e7e7d3:action=dim-comment&new=approved" ),
esc_attr__( 'Approve this review', 'woocommerce' ),
esc_html__( 'Approve', 'woocommerce' )
);
}
} else {
$actions['approve'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-a aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $approve_url ),
esc_attr( "dim:the-comment-list:comment-{$item->comment_ID}:unapproved:e7e7d3:e7e7d3:new=approved" ),
esc_attr__( 'Approve this review', 'woocommerce' ),
esc_html__( 'Approve', 'woocommerce' )
);
$actions['unapprove'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-u aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $unapprove_url ),
esc_attr( "dim:the-comment-list:comment-{$item->comment_ID}:unapproved:e7e7d3:e7e7d3:new=unapproved" ),
esc_attr__( 'Unapprove this review', 'woocommerce' ),
esc_html__( 'Unapprove', 'woocommerce' )
);
}
if ( 'spam' !== $review_status ) {
$actions['spam'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-s vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $spam_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::spam=1" ),
esc_attr__( 'Mark this review as spam', 'woocommerce' ),
/* translators: "Mark as spam" link. */
esc_html_x( 'Spam', 'verb', 'woocommerce' )
);
} else {
$actions['unspam'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-z vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $unspam_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:66cc66:unspam=1" ),
esc_attr__( 'Restore this review from the spam', 'woocommerce' ),
esc_html_x( 'Not Spam', 'review', 'woocommerce' )
);
}
if ( 'trash' === $review_status ) {
$actions['untrash'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="vim-z vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $untrash_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:66cc66:untrash=1" ),
esc_attr__( 'Restore this review from the Trash', 'woocommerce' ),
esc_html__( 'Restore', 'woocommerce' )
);
}
if ( 'spam' === $review_status || 'trash' === $review_status || ! EMPTY_TRASH_DAYS ) {
$actions['delete'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="delete vim-d vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $delete_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::delete=1" ),
esc_attr__( 'Delete this review permanently', 'woocommerce' ),
esc_html__( 'Delete Permanently', 'woocommerce' )
);
} else {
$actions['trash'] = sprintf(
'<a href="%s" data-wp-lists="%s" class="delete vim-d vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
esc_url( $trash_url ),
esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::trash=1" ),
esc_attr__( 'Move this review to the Trash', 'woocommerce' ),
esc_html_x( 'Trash', 'verb', 'woocommerce' )
);
}
if ( 'spam' !== $review_status && 'trash' !== $review_status ) {
$actions['edit'] = sprintf(
'<a href="%s" aria-label="%s">%s</a>',
esc_url(
add_query_arg(
[
'action' => 'editcomment',
'c' => urlencode( $item->comment_ID ),
],
admin_url( 'comment.php' )
)
),
esc_attr__( 'Edit this review', 'woocommerce' ),
esc_html__( 'Edit', 'woocommerce' )
);
$format = '<button type="button" data-comment-id="%d" data-post-id="%d" data-action="%s" class="%s button-link" aria-expanded="false" aria-label="%s">%s</button>';
$actions['quickedit'] = sprintf(
$format,
esc_attr( $item->comment_ID ),
esc_attr( $item->comment_post_ID ),
'edit',
'vim-q comment-inline',
esc_attr__( 'Quick edit this review inline', 'woocommerce' ),
esc_html__( 'Quick Edit', 'woocommerce' )
);
$actions['reply'] = sprintf(
$format,
esc_attr( $item->comment_ID ),
esc_attr( $item->comment_post_ID ),
'replyto',
'vim-r comment-inline',
esc_attr__( 'Reply to this review', 'woocommerce' ),
esc_html__( 'Reply', 'woocommerce' )
);
}
$always_visible = 'excerpt' === get_user_setting( 'posts_list_mode', 'list' );
$output = '<div class="' . ( $always_visible ? 'row-actions visible' : 'row-actions' ) . '">';
$i = 0;
foreach ( array_filter( $actions ) as $action => $link ) {
++$i;
if ( ( ( 'approve' === $action || 'unapprove' === $action ) && 2 === $i ) || 1 === $i ) {
$sep = '';
} else {
$sep = ' | ';
}
if ( ( 'reply' === $action || 'quickedit' === $action ) && ! wp_doing_ajax() ) {
$action .= ' hide-if-no-js';
} elseif ( ( 'untrash' === $action && 'trash' === $review_status ) || ( 'unspam' === $action && 'spam' === $review_status ) ) {
if ( '1' === get_comment_meta( $item->comment_ID, '_wp_trash_meta_status', true ) ) {
$action .= ' approve';
} else {
$action .= ' unapprove';
}
}
$output .= "<span class='$action'>$sep$link</span>";
}
$output .= '</div>';
$output .= '<button type="button" class="toggle-row"><span class="screen-reader-text">' . esc_html__( 'Show more details', 'woocommerce' ) . '</span></button>';
return $output;
}
/**
* Gets the columns for the table.
*
* @return array Table columns and their headings.
*/
public function get_columns() : array {
$columns = [
'cb' => '<input type="checkbox" />',
'type' => _x( 'Type', 'review type', 'woocommerce' ),
'author' => __( 'Author', 'woocommerce' ),
'rating' => __( 'Rating', 'woocommerce' ),
'comment' => _x( 'Review', 'column name', 'woocommerce' ),
'response' => __( 'Product', 'woocommerce' ),
'date' => _x( 'Submitted on', 'column name', 'woocommerce' ),
];
/**
* Filters the table columns.
*
* @since 6.7.0
*
* @param array $columns
*/
return (array) apply_filters( 'woocommerce_product_reviews_table_columns', $columns );
}
/**
* Gets the name of the default primary column.
*
* @return string Name of the primary colum.
*/
protected function get_primary_column_name() : string {
return 'comment';
}
/**
* Gets a list of sortable columns.
*
* Key is the column ID and value is which database column we perform the sorting on.
* The `rating` column uses a unique key instead, as that requires sorting by meta value.
*
* @return array
*/
protected function get_sortable_columns() : array {
return [
'author' => 'comment_author',
'response' => 'comment_post_ID',
'date' => 'comment_date_gmt',
'type' => 'comment_type',
'rating' => 'rating',
];
}
/**
* Returns a list of available bulk actions.
*
* @global string $comment_status
*
* @return array
*/
protected function get_bulk_actions() : array {
global $comment_status;
$actions = [];
if ( in_array( $comment_status, [ 'all', 'approved' ], true ) ) {
$actions['unapprove'] = __( 'Unapprove', 'woocommerce' );
}
if ( in_array( $comment_status, [ 'all', 'moderated' ], true ) ) {
$actions['approve'] = __( 'Approve', 'woocommerce' );
}
if ( in_array( $comment_status, [ 'all', 'moderated', 'approved', 'trash' ], true ) ) {
$actions['spam'] = _x( 'Mark as spam', 'review', 'woocommerce' );
}
if ( 'trash' === $comment_status ) {
$actions['untrash'] = __( 'Restore', 'woocommerce' );
} elseif ( 'spam' === $comment_status ) {
$actions['unspam'] = _x( 'Not spam', 'review', 'woocommerce' );
}
if ( in_array( $comment_status, [ 'trash', 'spam' ], true ) || ! EMPTY_TRASH_DAYS ) {
$actions['delete'] = __( 'Delete permanently', 'woocommerce' );
} else {
$actions['trash'] = __( 'Move to Trash', 'woocommerce' );
}
return $actions;
}
/**
* Returns the current action select in bulk actions menu.
*
* This is overridden in order to support `delete_all` for use in {@see ReviewsListTable::process_bulk_action()}
*
* {@see WP_Comments_List_Table::current_action()} for reference.
*
* @return string|false
*/
public function current_action() {
if ( isset( $_REQUEST['delete_all'] ) || isset( $_REQUEST['delete_all2'] ) ) {
return 'delete_all';
}
return parent::current_action();
}
/**
* Processes the bulk actions.
*
* @return void
*/
public function process_bulk_action() : void {
if ( ! $this->current_user_can_moderate_reviews ) {
return;
}
if ( $this->current_action() ) {
check_admin_referer( 'bulk-product-reviews' );
$query_string = remove_query_arg( [ 'page', '_wpnonce' ], wp_unslash( ( $_SERVER['QUERY_STRING'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// Replace current nonce with bulk-comments nonce.
$comments_nonce = wp_create_nonce( 'bulk-comments' );
$query_string = add_query_arg( '_wpnonce', $comments_nonce, $query_string );
// Redirect to edit-comments.php, which will handle processing the action for us.
wp_safe_redirect( esc_url_raw( admin_url( 'edit-comments.php?' . $query_string ) ) );
exit;
} elseif ( ! empty( $_GET['_wp_http_referer'] ) ) {
wp_safe_redirect( remove_query_arg( [ '_wp_http_referer', '_wpnonce' ] ) );
exit;
}
}
/**
* Returns an array of supported statuses and their labels.
*
* @return array
*/
protected function get_status_filters() : array {
return [
/* translators: %s: Number of reviews. */
'all' => _nx_noop(
'All <span class="count">(%s)</span>',
'All <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'moderated' => _nx_noop(
'Pending <span class="count">(%s)</span>',
'Pending <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'approved' => _nx_noop(
'Approved <span class="count">(%s)</span>',
'Approved <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'spam' => _nx_noop(
'Spam <span class="count">(%s)</span>',
'Spam <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
/* translators: %s: Number of reviews. */
'trash' => _nx_noop(
'Trash <span class="count">(%s)</span>',
'Trash <span class="count">(%s)</span>',
'product reviews',
'woocommerce'
),
];
}
/**
* Returns the available status filters.
*
* @see WP_Comments_List_Table::get_views() for consistency.
*
* @global int $post_id
* @global string $comment_status
* @global string $comment_type
*
* @return array An associative array of fully-formed comment status links. Includes 'All', 'Pending', 'Approved', 'Spam', and 'Trash'.
*/
protected function get_views() : array {
global $post_id, $comment_status, $comment_type;
$status_links = [];
$status_labels = $this->get_status_filters();
if ( ! EMPTY_TRASH_DAYS ) {
unset( $status_labels['trash'] );
}
$link = $this->get_view_url( (string) $comment_type, (int) $post_id );
foreach ( $status_labels as $status => $label ) {
$current_link_attributes = '';
if ( $status === $comment_status ) {
$current_link_attributes = ' class="current" aria-current="page"';
}
$link = add_query_arg( 'comment_status', urlencode( $status ), $link );
$number_reviews_for_status = $this->get_review_count( $status, (int) $post_id );
$count_html = sprintf(
'<span class="%s-count">%s</span>',
( 'moderated' === $status ) ? 'pending' : $status,
number_format_i18n( $number_reviews_for_status )
);
$status_links[ $status ] = '<a href="' . esc_url( $link ) . '"' . $current_link_attributes . '>' . sprintf( translate_nooped_plural( $label, $number_reviews_for_status ), $count_html ) . '</a>';
}
return $status_links;
}
/**
* Gets the base URL for a view, excluding the status (that should be appended).
*
* @param string $comment_type Comment type filter.
* @param int $post_id Current post ID.
* @return string
*/
protected function get_view_url( string $comment_type, int $post_id ) : string {
$link = Reviews::get_reviews_page_url();
if ( ! empty( $comment_type ) && 'all' !== $comment_type ) {
$link = add_query_arg( 'comment_type', urlencode( $comment_type ), $link );
}
if ( ! empty( $post_id ) ) {
$link = add_query_arg( 'p', absint( $post_id ), $link );
}
return $link;
}
/**
* Gets the number of reviews (including review replies) for a given status.
*
* @param string $status Status key from {@see ReviewsListTable::get_status_filters()}.
* @param int $product_id ID of the product if we're filtering by product in this request. Otherwise, `0` for no product filters.
* @return int
*/
protected function get_review_count( string $status, int $product_id ) : int {
return (int) get_comments(
[
'type__in' => [ 'review', 'comment' ],
'status' => $this->convert_status_to_query_value( $status ),
'post_type' => 'product',
'post_id' => $product_id,
'count' => true,
]
);
}
/**
* Converts a status key into its equivalent `comment_approved` database column value.
*
* @param string $status Status key from {@see ReviewsListTable::get_status_filters()}.
* @return string
*/
protected function convert_status_to_query_value( string $status ) : string {
// These keys exactly match the database column.
if ( in_array( $status, [ 'spam', 'trash' ], true ) ) {
return $status;
}
switch ( $status ) {
case 'moderated':
return '0';
case 'approved':
return '1';
default:
return 'all';
}
}
/**
* Outputs the text to display when there are no reviews to display.
*
* @see WP_List_Table::no_items()
*
* @global string $comment_status
*
* @return void
*/
public function no_items() : void {
global $comment_status;
if ( 'moderated' === $comment_status ) {
esc_html_e( 'No reviews awaiting moderation.', 'woocommerce' );
} else {
esc_html_e( 'No reviews found.', 'woocommerce' );
}
}
/**
* Renders the checkbox column.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_cb( $item ) : void {
ob_start();
if ( $this->current_user_can_edit_review ) {
?>
<label class="screen-reader-text" for="cb-select-<?php echo esc_attr( $item->comment_ID ); ?>"><?php esc_html_e( 'Select review', 'woocommerce' ); ?></label>
<input
id="cb-select-<?php echo esc_attr( $item->comment_ID ); ?>"
type="checkbox"
name="delete_comments[]"
value="<?php echo esc_attr( $item->comment_ID ); ?>"
/>
<?php
}
echo $this->filter_column_output( 'cb', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the review column.
*
* @see WP_Comments_List_Table::column_comment() for consistency.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_comment( $item ) : void {
$in_reply_to = $this->get_in_reply_to_review_text( $item );
ob_start();
if ( $in_reply_to ) {
echo $in_reply_to . '<br><br>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
printf(
'%1$s%2$s%3$s',
'<div class="comment-text">',
get_comment_text( $item->comment_ID ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
'</div>'
);
if ( $this->current_user_can_edit_review ) {
?>
<div id="inline-<?php echo esc_attr( $item->comment_ID ); ?>" class="hidden">
<textarea class="comment" rows="1" cols="1"><?php echo esc_textarea( $item->comment_content ); ?></textarea>
<div class="author-email"><?php echo esc_attr( $item->comment_author_email ); ?></div>
<div class="author"><?php echo esc_attr( $item->comment_author ); ?></div>
<div class="author-url"><?php echo esc_attr( $item->comment_author_url ); ?></div>
<div class="comment_status"><?php echo esc_html( $item->comment_approved ); ?></div>
</div>
<?php
}
echo $this->filter_column_output( 'comment', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Gets the in-reply-to-review text.
*
* @param WP_Comment|mixed $reply Reply to review.
* @return string
*/
private function get_in_reply_to_review_text( $reply ) : string {
$review = $reply->comment_parent ? get_comment( $reply->comment_parent ) : null;
if ( ! $review ) {
return '';
}
$parent_review_link = get_comment_link( $review );
$review_author_name = get_comment_author( $review );
return sprintf(
/* translators: %s: Parent review link with review author name. */
ent2ncr( __( 'In reply to %s.', 'woocommerce' ) ),
'<a href="' . esc_url( $parent_review_link ) . '">' . esc_html( $review_author_name ) . '</a>'
);
}
/**
* Renders the author column.
*
* @see WP_Comments_List_Table::column_author() for consistency.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_author( $item ) : void {
global $comment_status;
$author_url = $this->get_item_author_url();
$author_url_display = $this->get_item_author_url_for_display( $author_url );
if ( get_option( 'show_avatars' ) ) {
$author_avatar = get_avatar( $item, 32, 'mystery' );
} else {
$author_avatar = '';
}
ob_start();
echo '<strong>' . $author_avatar; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
comment_author();
echo '</strong><br>';
if ( ! empty( $author_url ) ) :
?>
<a title="<?php echo esc_attr( $author_url ); ?>" href="<?php echo esc_url( $author_url ); ?>" rel="noopener noreferrer"><?php echo esc_html( $author_url_display ); ?></a>
<br>
<?php
endif;
if ( $this->current_user_can_edit_review ) :
if ( ! empty( $item->comment_author_email ) && is_email( $item->comment_author_email ) ) :
?>
<a href="mailto:<?php echo esc_attr( $item->comment_author_email ); ?>"><?php echo esc_html( $item->comment_author_email ); ?></a><br>
<?php
endif;
$link = add_query_arg(
[
's' => urlencode( get_comment_author_IP( $item->comment_ID ) ),
'page' => Reviews::MENU_SLUG,
'mode' => 'detail',
],
'admin.php'
);
if ( 'spam' === $comment_status ) :
$link = add_query_arg( [ 'comment_status' => 'spam' ], $link );
endif;
?>
<a href="<?php echo esc_url( $link ); ?>"><?php comment_author_IP( $item->comment_ID ); ?></a>
<?php
endif;
echo $this->filter_column_output( 'author', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Gets the item author URL.
*
* @return string
*/
private function get_item_author_url() : string {
$author_url = get_comment_author_url();
$protocols = [ 'https://', 'http://' ];
if ( in_array( $author_url, $protocols ) ) {
$author_url = '';
}
return $author_url;
}
/**
* Gets the item author URL for display.
*
* @param string $author_url The review or reply author URL (raw).
* @return string
*/
private function get_item_author_url_for_display( $author_url ) : string {
$author_url_display = untrailingslashit( preg_replace( '|^http(s)?://(www\.)?|i', '', $author_url ) );
if ( strlen( $author_url_display ) > 50 ) {
$author_url_display = wp_html_excerpt( $author_url_display, 49, '…' );
}
return $author_url_display;
}
/**
* Renders the "submitted on" column.
*
* Note that the output is consistent with {@see WP_Comments_List_Table::column_date()}.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_date( $item ) : void {
$submitted = sprintf(
/* translators: 1 - Product review date, 2: Product review time. */
__( '%1$s at %2$s', 'woocommerce' ),
/* translators: Review date format. See https://www.php.net/manual/datetime.format.php */
get_comment_date( __( 'Y/m/d', 'woocommerce' ), $item ),
/* translators: Review time format. See https://www.php.net/manual/datetime.format.php */
get_comment_date( __( 'g:i a', 'woocommerce' ), $item )
);
ob_start();
?>
<div class="submitted-on">
<?php
if ( 'approved' === wp_get_comment_status( $item ) && ! empty( $item->comment_post_ID ) ) :
printf(
'<a href="%1$s">%2$s</a>',
esc_url( get_comment_link( $item ) ),
esc_html( $submitted )
);
else :
echo esc_html( $submitted );
endif;
?>
</div>
<?php
echo $this->filter_column_output( 'date', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the product column.
*
* @see WP_Comments_List_Table::column_response() for consistency.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_response( $item ) : void {
$product_post = get_post();
ob_start();
if ( $product_post ) :
?>
<div class="response-links">
<?php
if ( current_user_can( 'edit_product', $product_post->ID ) ) :
$post_link = "<a href='" . esc_url( get_edit_post_link( $product_post->ID ) ) . "' class='comments-edit-item-link'>";
$post_link .= esc_html( get_the_title( $product_post->ID ) ) . '</a>';
else :
$post_link = esc_html( get_the_title( $product_post->ID ) );
endif;
echo $post_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$post_type_object = get_post_type_object( $product_post->post_type );
?>
<a href="<?php echo esc_url( get_permalink( $product_post->ID ) ); ?>" class="comments-view-item-link">
<?php echo esc_html( $post_type_object->labels->view_item ); ?>
</a>
<span class="post-com-count-wrapper post-com-count-<?php echo esc_attr( $product_post->ID ); ?>">
<?php $this->comments_bubble( $product_post->ID, get_pending_comments_num( $product_post->ID ) ); ?>
</span>
</div>
<?php
endif;
echo $this->filter_column_output( 'response', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the type column.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_type( $item ) : void {
$type = 'review' === $item->comment_type
? '☆ ' . __( 'Review', 'woocommerce' )
: __( 'Reply', 'woocommerce' );
echo $this->filter_column_output( 'type', esc_html( $type ), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders the rating column.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @return void
*/
protected function column_rating( $item ) : void {
$rating = get_comment_meta( $item->comment_ID, 'rating', true );
ob_start();
if ( ! empty( $rating ) && is_numeric( $rating ) ) {
$rating = (int) $rating;
$accessibility_label = sprintf(
/* translators: 1: number representing a rating */
__( '%1$d out of 5', 'woocommerce' ),
$rating
);
$stars = str_repeat( '★', $rating );
$stars .= str_repeat( '☆', 5 - $rating );
?>
<span aria-label="<?php echo esc_attr( $accessibility_label ); ?>"><?php echo esc_html( $stars ); ?></span>
<?php
}
echo $this->filter_column_output( 'rating', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Renders any custom columns.
*
* @param WP_Comment|mixed $item Review or reply being rendered.
* @param string|mixed $column_name Name of the column being rendered.
* @return void
*/
protected function column_default( $item, $column_name ) : void {
ob_start();
/**
* Fires when the default column output is displayed for a single row.
*
* This action can be used to render custom columns that have been added.
*
* @since 6.7.0
*
* @param WP_Comment $item The review or reply being rendered.
*/
do_action( 'woocommerce_product_reviews_table_column_' . $column_name, $item );
echo $this->filter_column_output( $column_name, ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Runs a filter hook for a given column content.
*
* @param string|mixed $column_name The column being output.
* @param string|mixed $output The output content (may include HTML).
* @param WP_Comment|mixed $item The review or reply being rendered.
* @return string
*/
protected function filter_column_output( $column_name, $output, $item ) : string {
/**
* Filters the output of a column.
*
* @since 6.7.0
*
* @param string $output The column output.
* @param WP_Comment $item The product review being rendered.
*/
return (string) apply_filters( 'woocommerce_product_reviews_table_column_' . $column_name . '_content', $output, $item );
}
/**
* Renders the extra controls to be displayed between bulk actions and pagination.
*
* @global string $comment_status
* @global string $comment_type
*
* @param string|mixed $which Position (top or bottom).
* @return void
*/
protected function extra_tablenav( $which ) : void {
global $comment_status, $comment_type;
echo '<div class="alignleft actions">';
if ( 'top' === $which ) {
ob_start();
echo '<input type="hidden" name="comment_status" value="' . esc_attr( $comment_status ?? 'all' ) . '" />';
$this->review_type_dropdown( $comment_type );
$this->review_rating_dropdown( $this->current_reviews_rating );
$this->product_search( $this->current_product_for_reviews );
echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, [ 'id' => 'post-query-submit' ] );
}
if ( ( 'spam' === $comment_status || 'trash' === $comment_status ) && $this->has_items() && $this->current_user_can_moderate_reviews ) {
wp_nonce_field( 'bulk-destroy', '_destroy_nonce' );
$title = 'spam' === $comment_status
? esc_attr__( 'Empty Spam', 'woocommerce' )
: esc_attr__( 'Empty Trash', 'woocommerce' );
submit_button( $title, 'apply', 'delete_all', false );
}
echo '</div>';
}
/**
* Displays a review type drop-down for filtering reviews in the Product Reviews list table.
*
* @see WP_Comments_List_Table::comment_type_dropdown() for consistency.
*
* @param string|mixed $current_type The current comment item type slug.
* @return void
*/
protected function review_type_dropdown( $current_type ) : void {
/**
* Sets the possible options used in the Product Reviews List Table's filter-by-review-type
* selector.
*
* @since 7.0.0
*
* @param array Map of possible review types.
*/
$item_types = apply_filters(
'woocommerce_product_reviews_list_table_item_types',
array(
'all' => __( 'All types', 'woocommerce' ),
'comment' => __( 'Replies', 'woocommerce' ),
'review' => __( 'Reviews', 'woocommerce' ),
)
);
?>
<label class="screen-reader-text" for="filter-by-review-type"><?php esc_html_e( 'Filter by review type', 'woocommerce' ); ?></label>
<select id="filter-by-review-type" name="review_type">
<?php foreach ( $item_types as $type => $label ) : ?>
<option value="<?php echo esc_attr( $type ); ?>" <?php selected( $type, $current_type ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Displays a review rating drop-down for filtering reviews in the Product Reviews list table.
*
* @param int|string|mixed $current_rating Rating to display reviews for.
* @return void
*/
public function review_rating_dropdown( $current_rating ) : void {
$rating_options = [
'0' => __( 'All ratings', 'woocommerce' ),
'1' => '★',
'2' => '★★',
'3' => '★★★',
'4' => '★★★★',
'5' => '★★★★★',
];
?>
<label class="screen-reader-text" for="filter-by-review-rating"><?php esc_html_e( 'Filter by review rating', 'woocommerce' ); ?></label>
<select id="filter-by-review-rating" name="review_rating">
<?php foreach ( $rating_options as $rating => $label ) : ?>
<?php
$title = 0 === (int) $rating
? $label
: sprintf(
/* translators: %s: Star rating (1-5). */
__( '%s-star rating', 'woocommerce' ),
$rating
);
?>
<option value="<?php echo esc_attr( $rating ); ?>" <?php selected( $rating, (string) $current_rating ); ?> title="<?php echo esc_attr( $title ); ?>"><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Displays a product search input for filtering reviews by product in the Product Reviews list table.
*
* @param WC_Product|null $current_product The current product (or null when displaying all reviews).
* @return void
*/
protected function product_search( ?WC_Product $current_product ) : void {
?>
<label class="screen-reader-text" for="filter-by-product"><?php esc_html_e( 'Filter by product', 'woocommerce' ); ?></label>
<select
id="filter-by-product"
class="wc-product-search"
name="product_id"
style="width: 200px;"
data-placeholder="<?php esc_attr_e( 'Search for a product…', 'woocommerce' ); ?>"
data-action="woocommerce_json_search_products"
data-allow_clear="true">
<?php if ( $current_product instanceof WC_Product ) : ?>
<option value="<?php echo esc_attr( $current_product->get_id() ); ?>" selected="selected"><?php echo esc_html( $current_product->get_formatted_name() ); ?></option>
<?php endif; ?>
</select>
<?php
}
/**
* Displays a review count bubble.
*
* Based on {@see WP_List_Table::comments_bubble()}, but overridden, so we can customize the URL and text output.
*
* @param int|mixed $post_id The product ID.
* @param int|mixed $pending_comments Number of pending reviews.
*
* @return void
*/
protected function comments_bubble( $post_id, $pending_comments ) : void {
$approved_review_count = get_comments_number();
$approved_reviews_number = number_format_i18n( $approved_review_count );
$pending_reviews_number = number_format_i18n( $pending_comments );
$approved_only_phrase = sprintf(
/* translators: %s: Number of reviews. */
_n( '%s review', '%s reviews', $approved_review_count, 'woocommerce' ),
$approved_reviews_number
);
$approved_phrase = sprintf(
/* translators: %s: Number of reviews. */
_n( '%s approved review', '%s approved reviews', $approved_review_count, 'woocommerce' ),
$approved_reviews_number
);
$pending_phrase = sprintf(
/* translators: %s: Number of reviews. */
_n( '%s pending review', '%s pending reviews', $pending_comments, 'woocommerce' ),
$pending_reviews_number
);
if ( ! $approved_review_count && ! $pending_comments ) {
// No reviews at all.
printf(
'<span aria-hidden="true">—</span><span class="screen-reader-text">%s</span>',
esc_html__( 'No reviews', 'woocommerce' )
);
} elseif ( $approved_review_count && 'trash' === get_post_status( $post_id ) ) {
// Don't link the comment bubble for a trashed product.
printf(
'<span class="post-com-count post-com-count-approved"><span class="comment-count-approved" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
esc_html( $approved_reviews_number ),
$pending_comments ? esc_html( $approved_phrase ) : esc_html( $approved_only_phrase )
);
} elseif ( $approved_review_count ) {
// Link the comment bubble to approved reviews.
printf(
'<a href="%s" class="post-com-count post-com-count-approved"><span class="comment-count-approved" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></a>',
esc_url(
add_query_arg(
[
'product_id' => urlencode( $post_id ),
'comment_status' => 'approved',
],
Reviews::get_reviews_page_url()
)
),
esc_html( $approved_reviews_number ),
$pending_comments ? esc_html( $approved_phrase ) : esc_html( $approved_only_phrase )
);
} else {
// Don't link the comment bubble when there are no approved reviews.
printf(
'<span class="post-com-count post-com-count-no-comments"><span class="comment-count comment-count-no-comments" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
esc_html( $approved_reviews_number ),
$pending_comments ? esc_html__( 'No approved reviews', 'woocommerce' ) : esc_html__( 'No reviews', 'woocommerce' )
);
}
if ( $pending_comments ) {
printf(
'<a href="%s" class="post-com-count post-com-count-pending"><span class="comment-count-pending" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></a>',
esc_url(
add_query_arg(
[
'product_id' => urlencode( $post_id ),
'comment_status' => 'moderated',
],
Reviews::get_reviews_page_url()
)
),
esc_html( $pending_reviews_number ),
esc_html( $pending_phrase )
);
} else {
printf(
'<span class="post-com-count post-com-count-pending post-com-count-no-pending"><span class="comment-count comment-count-no-pending" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
esc_html( $pending_reviews_number ),
$approved_review_count ? esc_html__( 'No pending reviews', 'woocommerce' ) : esc_html__( 'No reviews', 'woocommerce' )
);
}
}
}
Internal/Admin/ProductReviews/ReviewsUtil.php 0000644 00000001535 15153704477 0015351 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;
/**
* A utility class for handling comments that are product reviews.
*/
class ReviewsUtil {
/**
* Removes product reviews from the edit-comments page to fix the "Mine" tab counter.
*
* @param array|mixed $clauses A compacted array of comment query clauses.
* @return array|mixed
*/
public static function comments_clauses_without_product_reviews( $clauses ) {
global $wpdb, $current_screen;
if ( isset( $current_screen->base ) && 'edit-comments' === $current_screen->base ) {
$clauses['join'] .= " LEFT JOIN {$wpdb->posts} AS wp_posts_to_exclude_reviews ON comment_post_ID = wp_posts_to_exclude_reviews.ID ";
$clauses['where'] .= ( $clauses['where'] ? ' AND ' : '' ) . " wp_posts_to_exclude_reviews.post_type NOT IN ('product') ";
}
return $clauses;
}
}
Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php 0000644 00000100530 15153704477 0020460 0 ustar 00 <?php
/**
* Gets a list of fallback methods if remote fetching is disabled.
*/
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;
defined( 'ABSPATH' ) || exit;
/**
* Default Free Extensions
*/
class DefaultFreeExtensions {
/**
* Get default specs.
*
* @return array Default specs.
*/
public static function get_all() {
$bundles = array(
array(
'key' => 'obw/basics',
'title' => __( 'Get the basics', 'woocommerce' ),
'plugins' => array(
self::get_plugin( 'woocommerce-payments' ),
self::get_plugin( 'woocommerce-services:shipping' ),
self::get_plugin( 'woocommerce-services:tax' ),
self::get_plugin( 'jetpack' ),
),
),
array(
'key' => 'obw/grow',
'title' => __( 'Grow your store', 'woocommerce' ),
'plugins' => array(
self::get_plugin( 'mailpoet' ),
self::get_plugin( 'codistoconnect' ),
self::get_plugin( 'google-listings-and-ads' ),
self::get_plugin( 'pinterest-for-woocommerce' ),
self::get_plugin( 'facebook-for-woocommerce' ),
),
),
array(
'key' => 'task-list/reach',
'title' => __( 'Reach out to customers', 'woocommerce' ),
'plugins' => array(
self::get_plugin( 'mailpoet:alt' ),
self::get_plugin( 'mailchimp-for-woocommerce' ),
self::get_plugin( 'klaviyo' ),
self::get_plugin( 'creative-mail-by-constant-contact' ),
),
),
array(
'key' => 'task-list/grow',
'title' => __( 'Grow your store', 'woocommerce' ),
'plugins' => array(
self::get_plugin( 'google-listings-and-ads:alt' ),
self::get_plugin( 'tiktok-for-business' ),
self::get_plugin( 'pinterest-for-woocommerce:alt' ),
self::get_plugin( 'facebook-for-woocommerce:alt' ),
self::get_plugin( 'codistoconnect:alt' ),
),
),
array(
'key' => 'obw/core-profiler',
'title' => __( 'Grow your store', 'woocommerce' ),
'plugins' => self::with_core_profiler_fields(
array(
self::get_plugin( 'woocommerce-payments' ),
self::get_plugin( 'woocommerce-services:shipping' ),
self::get_plugin( 'jetpack' ),
self::get_plugin( 'pinterest-for-woocommerce' ),
self::get_plugin( 'mailpoet' ),
self::get_plugin( 'google-listings-and-ads' ),
self::get_plugin( 'woocommerce-services:tax' ),
)
),
),
);
$bundles = wp_json_encode( $bundles );
return json_decode( $bundles );
}
/**
* Get the plugin arguments by slug.
*
* @param string $slug Slug.
* @return array
*/
public static function get_plugin( $slug ) {
$plugins = array(
'google-listings-and-ads' => array(
'min_php_version' => '7.4',
'name' => __( 'Google Listings & Ads', 'woocommerce' ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Drive sales with %1$sGoogle Listings and Ads%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/google-listings-and-ads" target="_blank">',
'</a>'
),
'image_url' => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
'is_built_by_wc' => true,
'is_visible' => array(
array(
'type' => 'not',
'operand' => array(
array(
'type' => 'plugins_activated',
'plugins' => array( 'google-listings-and-ads' ),
),
),
),
),
),
'google-listings-and-ads:alt' => array(
'name' => __( 'Google Listings & Ads', 'woocommerce' ),
'description' => __( 'Reach more shoppers and drive sales for your store. Integrate with Google to list your products for free and launch paid ad campaigns.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
'is_built_by_wc' => true,
),
'facebook-for-woocommerce' => array(
'name' => __( 'Facebook for WooCommerce', 'woocommerce' ),
'description' => __( 'List products and create ads on Facebook and Instagram with <a href="https://woocommerce.com/products/facebook/">Facebook for WooCommerce</a>', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/facebook.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-facebook',
'is_visible' => false,
'is_built_by_wc' => false,
),
'facebook-for-woocommerce:alt' => array(
'name' => __( 'Facebook for WooCommerce', 'woocommerce' ),
'description' => __( 'List products and create ads on Facebook and Instagram.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/facebook.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-facebook',
'is_visible' => false,
'is_built_by_wc' => false,
),
'pinterest-for-woocommerce' => array(
'name' => __( 'Pinterest for WooCommerce', 'woocommerce' ),
'description' => __( 'Get your products in front of Pinners searching for ideas and things to buy.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
'is_built_by_wc' => true,
'min_php_version' => '7.3',
),
'pinterest-for-woocommerce:alt' => array(
'name' => __( 'Pinterest for WooCommerce', 'woocommerce' ),
'description' => __( 'Get your products in front of Pinterest users searching for ideas and things to buy. Get started with Pinterest and make your entire product catalog browsable.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
'is_built_by_wc' => true,
),
'mailpoet' => array(
'name' => __( 'MailPoet', 'woocommerce' ),
'description' => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=mailpoet-newsletters',
'is_built_by_wc' => true,
),
'mailchimp-for-woocommerce' => array(
'name' => __( 'Mailchimp', 'woocommerce' ),
'description' => __( 'Send targeted campaigns, recover abandoned carts and much more with Mailchimp.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailchimp-for-woocommerce.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=mailchimp-woocommerce',
'is_built_by_wc' => false,
),
'klaviyo' => array(
'name' => __( 'Klaviyo', 'woocommerce' ),
'description' => __( 'Grow and retain customers with intelligent, impactful email and SMS marketing automation and a consolidated view of customer interactions.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/klaviyo.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=klaviyo_settings',
'is_built_by_wc' => false,
),
'creative-mail-by-constant-contact' => array(
'name' => __( 'Creative Mail for WooCommerce', 'woocommerce' ),
'description' => __( 'Create on-brand store campaigns, fast email promotions and customer retargeting with Creative Mail.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/creative-mail-by-constant-contact.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=creativemail',
'is_built_by_wc' => false,
),
'codistoconnect' => array(
'name' => __( 'Codisto for WooCommerce', 'woocommerce' ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Sell on Amazon, eBay, Walmart and more directly from WooCommerce with %1$sCodisto%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/pt-br/products/amazon-ebay-integration/?quid=c247a85321c9e93e7c3c6f1eb072e6e5" target="_blank">',
'</a>'
),
'image_url' => plugins_url( '/assets/images/onboarding/codistoconnect.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=codisto-settings',
'is_built_by_wc' => true,
),
'codistoconnect:alt' => array(
'name' => __( 'Codisto for WooCommerce', 'woocommerce' ),
'description' => __( 'Sell on Amazon, eBay, Walmart and more directly from WooCommerce.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/codistoconnect.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=codisto-settings',
'is_built_by_wc' => true,
),
'woocommerce-payments' => array(
'name' => __( 'WooPayments', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/wcpay.svg', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Accept credit cards and other popular payment methods with %1$sWooPayments%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/woocommerce-payments" target="_blank">',
'</a>'
),
'is_visible' => array(
array(
'type' => 'or',
'operands' => array(
array(
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CA',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'DE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'ES',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'FR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'GB',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'IE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'IT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'NZ',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'BE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'NL',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PL',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CH',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'HK',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SG',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CY',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'DK',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'EE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'FI',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'GR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'LU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'LT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'LV',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'NO',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'MT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SI',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SK',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'BG',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CZ',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'HR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'HU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'RO',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'JP',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AE',
'operation' => '=',
),
),
),
DefaultPaymentGateways::get_rules_for_cbd( false ),
),
'is_built_by_wc' => true,
'min_wp_version' => '5.9',
),
'woocommerce-services:shipping' => array(
'name' => __( 'WooCommerce Shipping', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/woo.svg', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Print shipping labels with %1$sWooCommerce Shipping%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/shipping" target="_blank">',
'</a>'
),
'is_visible' => array(
array(
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
),
array(
'type' => 'not',
'operand' => array(
array(
'type' => 'plugins_activated',
'plugins' => array( 'woocommerce-services' ),
),
),
),
array(
'type' => 'or',
'operands' => array(
array(
array(
'type' => 'option',
'transformers' => array(
array(
'use' => 'dot_notation',
'arguments' => array(
'path' => 'product_types',
),
),
array(
'use' => 'count',
),
),
'option_name' => 'woocommerce_onboarding_profile',
'value' => 1,
'default' => array(),
'operation' => '!=',
),
),
array(
array(
'type' => 'option',
'transformers' => array(
array(
'use' => 'dot_notation',
'arguments' => array(
'path' => 'product_types.0',
),
),
),
'option_name' => 'woocommerce_onboarding_profile',
'value' => 'downloads',
'default' => '',
'operation' => '!=',
),
),
),
),
),
'is_built_by_wc' => true,
),
'woocommerce-services:tax' => array(
'name' => __( 'WooCommerce Tax', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/woo.svg', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Get automated sales tax with %1$sWooCommerce Tax%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/tax" target="_blank">',
'</a>'
),
'is_visible' => array(
array(
'type' => 'or',
'operands' => array(
array(
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'FR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'GB',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'DE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CA',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'GR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'BE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'DK',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SE',
'operation' => '=',
),
),
),
array(
'type' => 'not',
'operand' => array(
array(
'type' => 'plugins_activated',
'plugins' => array( 'woocommerce-services' ),
),
),
),
),
'is_built_by_wc' => true,
),
'jetpack' => array(
'name' => __( 'Jetpack', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/jetpack.svg', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Enhance speed and security with %1$sJetpack%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/jetpack" target="_blank">',
'</a>'
),
'is_visible' => array(
array(
'type' => 'not',
'operand' => array(
array(
'type' => 'plugins_activated',
'plugins' => array( 'jetpack' ),
),
),
),
),
'is_built_by_wc' => false,
'min_wp_version' => '6.0',
),
'mailpoet' => array(
'name' => __( 'MailPoet', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Level up your email marketing with %1$sMailPoet%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/mailpoet" target="_blank">',
'</a>'
),
'manage_url' => 'admin.php?page=mailpoet-newsletters',
'is_visible' => array(
array(
'type' => 'not',
'operand' => array(
array(
'type' => 'plugins_activated',
'plugins' => array( 'mailpoet' ),
),
),
),
),
'is_built_by_wc' => true,
),
'mailpoet:alt' => array(
'name' => __( 'MailPoet', 'woocommerce' ),
'description' => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=mailpoet-newsletters',
'is_built_by_wc' => true,
),
'tiktok-for-business' => array(
'name' => __( 'TikTok for WooCommerce', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/tiktok.svg', WC_PLUGIN_FILE ),
'description' =>
__( 'Grow your online sales by promoting your products on TikTok to over one billion monthly active users around the world.', 'woocommerce' ),
'manage_url' => 'admin.php?page=tiktok',
'is_visible' => array(
array(
'type' => 'or',
'operands' => array(
array(
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CA',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'MX',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'BE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CZ',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'DK',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'FI',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'FR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'DE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'GR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'HU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'IE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'IT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'NL',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PL',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PT',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'RO',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'ES',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'GB',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'CH',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'NO',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'NZ',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SG',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'MY',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'PH',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'ID',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'VN',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'TH',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'KR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'IL',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'RU',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'UA',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'TR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'SA',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'BR',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'JP',
'operation' => '=',
),
),
),
),
'is_built_by_wc' => false,
),
'tiktok-for-business:alt' => array(
'name' => __( 'TikTok for WooCommerce', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/tiktok.svg', WC_PLUGIN_FILE ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Create ad campaigns and reach one billion global users with %1$sTikTok for WooCommerce%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/tiktok-for-woocommerce" target="_blank">',
'</a>'
),
'manage_url' => 'admin.php?page=tiktok',
'is_built_by_wc' => false,
'is_visible' => false,
),
);
$plugin = $plugins[ $slug ];
$plugin['key'] = $slug;
return $plugin;
}
/**
* Decorate plugin data with core profiler fields.
*
* - Updated description for the core-profiler.
* - Adds learn_more_link and label.
* - Adds install_priority, which is used to sort the plugins. The value is determined by the plugin size. Lower = smaller.
*
* @param array $plugins Array of plugins.
*
* @return array
*/
public static function with_core_profiler_fields( array $plugins ) {
$_plugins = array(
'woocommerce-payments' => array(
'label' => __( 'Get paid with WooPayments', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE ),
'description' => __( "Securely accept payments and manage payment activity straight from your store's dashboard", 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/woocommerce-payments',
'install_priority' => 5,
),
'woocommerce-services:shipping' => array(
'label' => __( 'Print shipping labels with WooCommerce Shipping', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE ),
'description' => __( 'Print USPS and DHL labels directly from your dashboard and save on shipping.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/woocommerce-shipping',
'install_priority' => 3,
),
'jetpack' => array(
'label' => __( 'Boost content creation with Jetpack AI Assistant', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-jetpack.svg', WC_PLUGIN_FILE ),
'description' => __( 'Save time on content creation — unlock high-quality blog posts and pages using AI.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/jetpack',
'install_priority' => 8,
),
'pinterest-for-woocommerce' => array(
'label' => __( 'Showcase your products with Pinterest', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-pinterest.svg', WC_PLUGIN_FILE ),
'description' => __( 'Get your products in front of a highly engaged audience.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/pinterest-for-woocommerce',
'install_priority' => 2,
),
'mailpoet' => array(
'label' => __( 'Reach your customers with MailPoet', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-mailpoet.svg', WC_PLUGIN_FILE ),
'description' => __( 'Send purchase follow-up emails, newsletters, and promotional campaigns.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/mailpoet',
'install_priority' => 7,
),
'tiktok-for-business' => array(
'label' => __( 'Create ad campaigns with TikTok', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-tiktok.svg', WC_PLUGIN_FILE ),
'description' => __( 'Create advertising campaigns and reach one billion global users.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/tiktok-for-woocommerce',
'install_priority' => 1,
),
'google-listings-and-ads' => array(
'label' => __( 'Drive sales with Google Listings & Ads', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-google.svg', WC_PLUGIN_FILE ),
'description' => __( 'Reach millions of active shoppers across Google with free product listings and ads.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/google-listings-and-ads',
'install_priority' => 6,
),
'woocommerce-services:tax' => array(
'label' => __( 'Get automated tax rates with WooCommerce Tax', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE ),
'description' => __( 'Automatically calculate how much sales tax should be collected – by city, country, or state.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/tax',
'install_priority' => 4,
),
);
// Copy shipping for the core-profiler and remove is_visible conditions, except for the country restriction.
$_plugins['woocommerce-services:shipping']['is_visible'] = [
array(
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
),
];
$remove_plugins_activated_rule = function( $is_visible ) {
$is_visible = array_filter(
array_map(
function( $rule ) {
if ( is_object( $rule ) || ! isset( $rule['operand'] ) ) {
return $rule;
}
return array_filter(
$rule['operand'],
function( $operand ) {
return 'plugins_activated' !== $operand['type'];
}
);
},
$is_visible
)
);
return empty( $is_visible ) ? true : $is_visible;
};
foreach ( $plugins as &$plugin ) {
if ( isset( $_plugins[ $plugin['key'] ] ) ) {
$plugin = array_merge( $plugin, $_plugins[ $plugin['key'] ] );
if ( isset( $plugin['is_visible'] ) && is_array( $plugin['is_visible'] ) ) {
$plugin['is_visible'] = $remove_plugins_activated_rule( $plugin['is_visible'] );
}
}
}
return $plugins;
}
}
Internal/Admin/RemoteFreeExtensions/EvaluateExtension.php 0000644 00000003136 15153704477 0017661 0 ustar 00 <?php
/**
* Evaluates the spec and returns a status.
*/
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RuleEvaluator;
/**
* Evaluates the extension and returns it.
*/
class EvaluateExtension {
/**
* Evaluates the extension and returns it.
*
* @param object $extension The extension to evaluate.
* @return object The evaluated extension.
*/
public static function evaluate( $extension ) {
global $wp_version;
$rule_evaluator = new RuleEvaluator();
if ( isset( $extension->is_visible ) ) {
$is_visible = $rule_evaluator->evaluate( $extension->is_visible );
$extension->is_visible = $is_visible;
} else {
$extension->is_visible = true;
}
// Run PHP and WP version chcecks.
if ( true === $extension->is_visible ) {
if ( isset( $extension->min_php_version ) && ! version_compare( PHP_VERSION, $extension->min_php_version, '>=' ) ) {
$extension->is_visible = false;
}
if ( isset( $extension->min_wp_version ) && ! version_compare( $wp_version, $extension->min_wp_version, '>=' ) ) {
$extension->is_visible = false;
}
}
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
$activated_plugins = PluginsHelper::get_active_plugin_slugs();
$extension->is_installed = in_array( explode( ':', $extension->key )[0], $installed_plugins, true );
$extension->is_activated = in_array( explode( ':', $extension->key )[0], $activated_plugins, true );
return $extension;
}
}
Internal/Admin/RemoteFreeExtensions/Init.php 0000644 00000004004 15153704477 0015114 0 ustar 00 <?php
/**
* Handles running payment method specs
*/
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\DefaultFreeExtensions;
/**
* Remote Payment Methods engine.
* This goes through the specs and gets eligible payment methods.
*/
class Init {
/**
* Constructor.
*/
public function __construct() {
add_action( 'woocommerce_updated', array( __CLASS__, 'delete_specs_transient' ) );
}
/**
* Go through the specs and run them.
*
* @param array $allowed_bundles Optional array of allowed bundles to be returned.
* @return array
*/
public static function get_extensions( $allowed_bundles = array() ) {
$bundles = array();
$specs = self::get_specs();
foreach ( $specs as $spec ) {
$spec = (object) $spec;
$bundle = (array) $spec;
$bundle['plugins'] = array();
if ( ! empty( $allowed_bundles ) && ! in_array( $spec->key, $allowed_bundles, true ) ) {
continue;
}
foreach ( $spec->plugins as $plugin ) {
$extension = EvaluateExtension::evaluate( (object) $plugin );
if ( ! property_exists( $extension, 'is_visible' ) || $extension->is_visible ) {
$bundle['plugins'][] = $extension;
}
}
$bundles[] = $bundle;
}
return $bundles;
}
/**
* Delete the specs transient.
*/
public static function delete_specs_transient() {
RemoteFreeExtensionsDataSourcePoller::get_instance()->delete_specs_transient();
}
/**
* Get specs or fetch remotely if they don't exist.
*/
public static function get_specs() {
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return DefaultFreeExtensions::get_all();
}
$specs = RemoteFreeExtensionsDataSourcePoller::get_instance()->get_specs_from_data_sources();
// Fetch specs if they don't yet exist.
if ( false === $specs || ! is_array( $specs ) || 0 === count( $specs ) ) {
return DefaultFreeExtensions::get_all();
}
return $specs;
}
}
Internal/Admin/RemoteFreeExtensions/RemoteFreeExtensionsDataSourcePoller.php 0000644 00000001371 15153704477 0023463 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;
/**
* Specs data source poller class for remote free extensions.
*/
class RemoteFreeExtensionsDataSourcePoller extends \Automattic\WooCommerce\Admin\DataSourcePoller {
const ID = 'remote_free_extensions';
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/obw-free-extensions/3.0/extensions.json',
);
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self(
self::ID,
self::DATA_SOURCES,
array(
'spec_key' => 'key',
)
);
}
return self::$instance;
}
}
Internal/Admin/RemoteInboxNotifications.php 0000644 00000001644 15153704477 0015070 0 ustar 00 <?php
/**
* Remote Inbox Notifications feature.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
/**
* Remote Inbox Notifications feature logic.
*/
class RemoteInboxNotifications {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_show_marketplace_suggestions';
/**
* Class instance.
*
* @var RemoteInboxNotifications instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( Features::is_enabled( 'remote-inbox-notifications' ) ) {
RemoteInboxNotificationsEngine::init();
}
}
}
Internal/Admin/Schedulers/CustomersScheduler.php 0000644 00000007055 15153704477 0016031 0 ustar 00 <?php
/**
* Customer syncing related functions and actions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
/**
* CustomersScheduler Class.
*/
class CustomersScheduler extends ImportScheduler {
/**
* Slug to identify the scheduler.
*
* @var string
*/
public static $name = 'customers';
/**
* Attach customer lookup update hooks.
*
* @internal
*/
public static function init() {
CustomersDataStore::init();
parent::init();
}
/**
* Add customer dependencies.
*
* @internal
* @return array
*/
public static function get_dependencies() {
return array(
'delete_batch_init' => OrdersScheduler::get_action( 'delete_batch_init' ),
);
}
/**
* Get the customer IDs and total count that need to be synced.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported customers.
*/
public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
$customer_roles = apply_filters( 'woocommerce_analytics_import_customer_roles', array( 'customer' ) );
$query_args = array(
'fields' => 'ID',
'orderby' => 'ID',
'order' => 'ASC',
'number' => $limit,
'paged' => $page,
'role__in' => $customer_roles,
);
if ( is_int( $days ) ) {
$query_args['date_query'] = array(
'after' => gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) ),
);
}
if ( $skip_existing ) {
add_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
}
$customer_query = new \WP_User_Query( $query_args );
remove_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
return (object) array(
'total' => $customer_query->get_total(),
'ids' => $customer_query->get_results(),
);
}
/**
* Exclude users that already exist in our customer lookup table.
*
* Meant to be hooked into 'pre_user_query' action.
*
* @internal
* @param WP_User_Query $wp_user_query WP_User_Query to modify.
*/
public static function exclude_existing_customers_from_query( $wp_user_query ) {
global $wpdb;
$wp_user_query->query_where .= " AND NOT EXISTS (
SELECT ID FROM {$wpdb->prefix}wc_customer_lookup
WHERE {$wpdb->prefix}wc_customer_lookup.user_id = {$wpdb->users}.ID
)";
}
/**
* Get total number of rows imported.
*
* @internal
* @return int
*/
public static function get_total_imported() {
global $wpdb;
return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
}
/**
* Imports a single customer.
*
* @internal
* @param int $user_id User ID.
* @return void
*/
public static function import( $user_id ) {
CustomersDataStore::update_registered_customer( $user_id );
}
/**
* Delete a batch of customers.
*
* @internal
* @param int $batch_size Number of items to delete.
* @return void
*/
public static function delete( $batch_size ) {
global $wpdb;
$customer_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT customer_id FROM {$wpdb->prefix}wc_customer_lookup ORDER BY customer_id ASC LIMIT %d",
$batch_size
)
);
foreach ( $customer_ids as $customer_id ) {
CustomersDataStore::delete_customer( $customer_id );
}
}
}
Internal/Admin/Schedulers/ImportInterface.php 0000644 00000001306 15153704477 0015272 0 ustar 00 <?php
/**
* Import related abstract functions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
interface ImportInterface {
/**
* Get items based on query and return IDs along with total available.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported items.
*/
public static function get_items( $limit, $page, $days, $skip_existing );
/**
* Get total number of items already imported.
*
* @internal
* @return null
*/
public static function get_total_imported();
}
Internal/Admin/Schedulers/ImportScheduler.php 0000644 00000011457 15153704477 0015320 0 ustar 00 <?php
/**
* Import related functions and actions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits;
/**
* ImportScheduler class.
*/
abstract class ImportScheduler implements ImportInterface {
/**
* Import stats option name.
*/
const IMPORT_STATS_OPTION = 'woocommerce_admin_import_stats';
/**
* Scheduler traits.
*/
use SchedulerTraits {
get_batch_sizes as get_scheduler_batch_sizes;
}
/**
* Returns true if an import is in progress.
*
* @internal
* @return bool
*/
public static function is_importing() {
$pending_jobs = self::queue()->search(
array(
'status' => 'pending',
'per_page' => 1,
'claimed' => false,
'search' => 'import',
'group' => self::$group,
)
);
if ( empty( $pending_jobs ) ) {
$in_progress = self::queue()->search(
array(
'status' => 'in-progress',
'per_page' => 1,
'search' => 'import',
'group' => self::$group,
)
);
}
return ! empty( $pending_jobs ) || ! empty( $in_progress );
}
/**
* Get batch sizes.
*
* @internal
* @retun array
*/
public static function get_batch_sizes() {
return array_merge(
self::get_scheduler_batch_sizes(),
array(
'delete' => 10,
'import' => 25,
'queue' => 100,
)
);
}
/**
* Get all available scheduling actions.
* Used to determine action hook names and clear events.
*
* @internal
* @return array
*/
public static function get_scheduler_actions() {
return array(
'import_batch_init' => 'wc-admin_import_batch_init_' . static::$name,
'import_batch' => 'wc-admin_import_batch_' . static::$name,
'delete_batch_init' => 'wc-admin_delete_batch_init_' . static::$name,
'delete_batch' => 'wc-admin_delete_batch_' . static::$name,
'import' => 'wc-admin_import_' . static::$name,
);
}
/**
* Queue the imports into multiple batches.
*
* @internal
* @param integer|boolean $days Number of days to import.
* @param boolean $skip_existing Skip exisiting records.
*/
public static function import_batch_init( $days, $skip_existing ) {
$batch_size = static::get_batch_size( 'import' );
$items = static::get_items( 1, 1, $days, $skip_existing );
if ( 0 === $items->total ) {
return;
}
$num_batches = ceil( $items->total / $batch_size );
self::queue_batches( 1, $num_batches, 'import_batch', array( $days, $skip_existing ) );
}
/**
* Imports a batch of items to update.
*
* @internal
* @param int $batch_number Batch number to import (essentially a query page number).
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip exisiting records.
* @return void
*/
public static function import_batch( $batch_number, $days, $skip_existing ) {
$batch_size = static::get_batch_size( 'import' );
$properties = array(
'batch_number' => $batch_number,
'batch_size' => $batch_size,
'type' => static::$name,
);
wc_admin_record_tracks_event( 'import_job_start', $properties );
// When we are skipping already imported items, the table of items to import gets smaller in
// every batch, so we want to always import the first page.
$page = $skip_existing ? 1 : $batch_number;
$items = static::get_items( $batch_size, $page, $days, $skip_existing );
foreach ( $items->ids as $id ) {
static::import( $id );
}
$import_stats = get_option( self::IMPORT_STATS_OPTION, array() );
$imported_count = absint( $import_stats[ static::$name ]['imported'] ) + count( $items->ids );
$import_stats[ static::$name ]['imported'] = $imported_count;
update_option( self::IMPORT_STATS_OPTION, $import_stats );
$properties['imported_count'] = $imported_count;
wc_admin_record_tracks_event( 'import_job_complete', $properties );
}
/**
* Queue item deletion in batches.
*
* @internal
*/
public static function delete_batch_init() {
global $wpdb;
$batch_size = static::get_batch_size( 'delete' );
$count = static::get_total_imported();
if ( 0 === $count ) {
return;
}
$num_batches = ceil( $count / $batch_size );
self::queue_batches( 1, $num_batches, 'delete_batch' );
}
/**
* Delete a batch by passing the count to be deleted to the child delete method.
*
* @internal
* @return void
*/
public static function delete_batch() {
wc_admin_record_tracks_event( 'delete_import_data_job_start', array( 'type' => static::$name ) );
$batch_size = static::get_batch_size( 'delete' );
static::delete( $batch_size );
ReportsCache::invalidate();
wc_admin_record_tracks_event( 'delete_import_data_job_complete', array( 'type' => static::$name ) );
}
}
Internal/Admin/Schedulers/MailchimpScheduler.php 0000644 00000007160 15153704477 0015745 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
/**
* Class MailchimpScheduler
*
* @package Automattic\WooCommerce\Admin\Schedulers
*/
class MailchimpScheduler {
const SUBSCRIBE_ENDPOINT = 'https://woocommerce.com/wp-json/wccom/v1/subscribe';
const SUBSCRIBE_ENDPOINT_DEV = 'http://woocommerce.test/wp-json/wccom/v1/subscribe';
const SUBSCRIBED_OPTION_NAME = 'woocommerce_onboarding_subscribed_to_mailchimp';
const SUBSCRIBED_ERROR_COUNT_OPTION_NAME = 'woocommerce_onboarding_subscribed_to_mailchimp_error_count';
const MAX_ERROR_THRESHOLD = 3;
const LOGGER_CONTEXT = 'mailchimp_scheduler';
/**
* The logger instance.
*
* @var \WC_Logger_Interface|null
*/
private $logger;
/**
* MailchimpScheduler constructor.
*
* @internal
* @param \WC_Logger_Interface|null $logger Logger instance.
*/
public function __construct( \WC_Logger_Interface $logger = null ) {
if ( null === $logger ) {
$logger = wc_get_logger();
}
$this->logger = $logger;
}
/**
* Attempt to subscribe store_email to MailChimp.
*
* @internal
*/
public function run() {
// Abort if we've already subscribed to MailChimp.
if ( 'yes' === get_option( self::SUBSCRIBED_OPTION_NAME ) ) {
return false;
}
$profile_data = get_option( 'woocommerce_onboarding_profile' );
if ( ! isset( $profile_data['is_agree_marketing'] ) || false === $profile_data['is_agree_marketing'] ) {
return false;
}
// Abort if store_email doesn't exist.
if ( ! isset( $profile_data['store_email'] ) ) {
return false;
}
// Abort if failed requests reaches the threshold.
if ( intval( get_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, 0 ) ) >= self::MAX_ERROR_THRESHOLD ) {
return false;
}
$response = $this->make_request( $profile_data['store_email'] );
if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) {
$this->handle_request_error();
return false;
}
$body = json_decode( $response['body'] );
if ( isset( $body->success ) && true === $body->success ) {
update_option( self::SUBSCRIBED_OPTION_NAME, 'yes' );
return true;
}
$this->handle_request_error( $body );
return false;
}
/**
* Make an HTTP request to the API.
*
* @internal
* @param string $store_email Email address to subscribe.
*
* @return mixed
*/
public function make_request( $store_email ) {
if ( true === defined( 'WP_ENVIRONMENT_TYPE' ) && 'development' === constant( 'WP_ENVIRONMENT_TYPE' ) ) {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT_DEV;
} else {
$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT;
}
return wp_remote_post(
$subscribe_endpoint,
array(
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
'method' => 'POST',
'body' => array(
'email' => $store_email,
),
)
);
}
/**
* Reset options.
*
* @internal
*/
public static function reset() {
delete_option( self::SUBSCRIBED_OPTION_NAME );
delete_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME );
}
/**
* Handle subscribe API error.
*
* @internal
* @param string $extra_msg Extra message to log.
*/
private function handle_request_error( $extra_msg = null ) {
// phpcs:ignore
$msg = isset( $extra_msg ) ? 'Incorrect response from Mailchimp API with: ' . print_r( $extra_msg, true ) : 'Error getting a response from Mailchimp API.';
$this->logger->error( $msg, array( 'source' => self::LOGGER_CONTEXT ) );
$accumulated_error_count = intval( get_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, 0 ) ) + 1;
update_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, $accumulated_error_count );
}
}
Internal/Admin/Schedulers/OrdersScheduler.php 0000644 00000020553 15153704477 0015301 0 ustar 00 <?php
/**
* Order syncing related functions and actions.
*/
namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\Overrides\Order;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* OrdersScheduler Class.
*/
class OrdersScheduler extends ImportScheduler {
/**
* Slug to identify the scheduler.
*
* @var string
*/
public static $name = 'orders';
/**
* Attach order lookup update hooks.
*
* @internal
*/
public static function init() {
// Activate WC_Order extension.
\Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
\Automattic\WooCommerce\Admin\Overrides\OrderRefund::add_filters();
// Order and refund data must be run on these hooks to ensure meta data is set.
add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
OrdersStatsDataStore::init();
CouponsDataStore::init();
ProductsDataStore::init();
TaxesDataStore::init();
parent::init();
}
/**
* Add customer dependencies.
*
* @internal
* @return array
*/
public static function get_dependencies() {
return array(
'import_batch_init' => \Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler::get_action( 'import_batch_init' ),
);
}
/**
* Get the order/refund IDs and total count that need to be synced.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*/
public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
return self::get_items_from_orders_table( $limit, $page, $days, $skip_existing );
} else {
return self::get_items_from_posts_table( $limit, $page, $days, $skip_existing );
}
}
/**
* Helper method to ger order/refund IDS and total count that needs to be synced.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*
* @return object Total counts.
*/
private static function get_items_from_posts_table( $limit, $page, $days, $skip_existing ) {
global $wpdb;
$where_clause = '';
$offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
if ( is_int( $days ) ) {
$days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
$where_clause .= " AND post_date_gmt >= '{$days_ago}'";
}
if ( $skip_existing ) {
$where_clause .= " AND NOT EXISTS (
SELECT 1 FROM {$wpdb->prefix}wc_order_stats
WHERE {$wpdb->prefix}wc_order_stats.order_id = {$wpdb->posts}.ID
)";
}
$count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}"
); // phpcs:ignore unprepared SQL ok.
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}
ORDER BY post_date_gmt ASC
LIMIT %d
OFFSET %d",
$limit,
$offset
)
) : array(); // phpcs:ignore unprepared SQL ok.
return (object) array(
'total' => absint( $count ),
'ids' => $order_ids,
);
}
/**
* Helper method to ger order/refund IDS and total count that needs to be synced from HPOS.
*
* @internal
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*
* @return object Total counts.
*/
private static function get_items_from_orders_table( $limit, $page, $days, $skip_existing ) {
global $wpdb;
$where_clause = '';
$offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
$order_table = OrdersTableDataStore::get_orders_table_name();
if ( is_int( $days ) ) {
$days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
$where_clause .= " AND orders.date_created_gmt >= '{$days_ago}'";
}
if ( $skip_existing ) {
$where_clause .= "AND NOT EXiSTS (
SELECT 1 FROM {$wpdb->prefix}wc_order_stats
WHERE {$wpdb->prefix}wc_order_stats.order_id = orders.id
)
";
}
$count = $wpdb->get_var(
"
SELECT COUNT(*) FROM {$order_table} AS orders
WHERE type in ( 'shop_order', 'shop_order_refund' )
AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
{$where_clause}
"
); // phpcs:ignore unprepared SQL ok.
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM {$order_table} AS orders
WHERE type IN ( 'shop_order', 'shop_order_refund' )
AND status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}
ORDER BY date_created_gmt ASC
LIMIT %d
OFFSET %d",
$limit,
$offset
)
) : array(); // phpcs:ignore unprepared SQL ok.
return (object) array(
'total' => absint( $count ),
'ids' => $order_ids,
);
}
/**
* Get total number of rows imported.
*
* @internal
*/
public static function get_total_imported() {
global $wpdb;
return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats" );
}
/**
* Schedule this import if the post is an order or refund.
*
* @param int $order_id Post ID.
*
* @internal
*/
public static function possibly_schedule_import( $order_id ) {
if ( ! OrderUtil::is_order( $order_id, array( 'shop_order' ) ) && 'woocommerce_refund_created' !== current_filter() ) {
return;
}
self::schedule_action( 'import', array( $order_id ) );
}
/**
* Imports a single order or refund to update lookup tables for.
* If an error is encountered in one of the updates, a retry action is scheduled.
*
* @internal
* @param int $order_id Order or refund ID.
* @return void
*/
public static function import( $order_id ) {
$order = wc_get_order( $order_id );
// If the order isn't found for some reason, skip the sync.
if ( ! $order ) {
return;
}
$type = $order->get_type();
// If the order isn't the right type, skip sync.
if ( 'shop_order' !== $type && 'shop_order_refund' !== $type ) {
return;
}
// If the order has no id or date created, skip sync.
if ( ! $order->get_id() || ! $order->get_date_created() ) {
return;
}
$results = array(
OrdersStatsDataStore::sync_order( $order_id ),
ProductsDataStore::sync_order_products( $order_id ),
CouponsDataStore::sync_order_coupons( $order_id ),
TaxesDataStore::sync_order_taxes( $order_id ),
CustomersDataStore::sync_order_customer( $order_id ),
);
if ( 'shop_order' === $type ) {
$order_refunds = $order->get_refunds();
foreach ( $order_refunds as $refund ) {
OrdersStatsDataStore::sync_order( $refund->get_id() );
}
}
ReportsCache::invalidate();
}
/**
* Delete a batch of orders.
*
* @internal
* @param int $batch_size Number of items to delete.
* @return void
*/
public static function delete( $batch_size ) {
global $wpdb;
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d",
$batch_size
)
);
foreach ( $order_ids as $order_id ) {
OrdersStatsDataStore::delete_order( $order_id );
}
}
}
Internal/Admin/Settings.php 0000644 00000031114 15153704477 0011676 0 ustar 00 <?php
/**
* WooCommerce Settings.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\API\Plugins;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\WCPayPromotion\Init as WCPayPromotionInit;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use WC_Marketplace_Suggestions;
/**
* Contains logic in regards to WooCommerce Admin Settings.
*/
class Settings {
/**
* Class instance.
*
* @var Settings instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
// Old settings injection.
add_filter( 'woocommerce_components_settings', array( $this, 'add_component_settings' ) );
// New settings injection.
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'add_component_settings' ) );
add_filter( 'woocommerce_settings_groups', array( $this, 'add_settings_group' ) );
add_filter( 'woocommerce_settings-wc_admin', array( $this, 'add_settings' ) );
}
/**
* Format order statuses by removing a leading 'wc-' if present.
*
* @param array $statuses Order statuses.
* @return array formatted statuses.
*/
public static function get_order_statuses( $statuses ) {
$formatted_statuses = array();
foreach ( $statuses as $key => $value ) {
$formatted_key = preg_replace( '/^wc-/', '', $key );
$formatted_statuses[ $formatted_key ] = $value;
}
return $formatted_statuses;
}
/**
* Get all order statuses present in analytics tables that aren't registered.
*
* @return array Unregistered order statuses.
*/
private function get_unregistered_order_statuses() {
$registered_statuses = wc_get_order_statuses();
$all_synced_statuses = OrdersDataStore::get_all_statuses();
$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
$formatted_status_keys = self::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
$formatted_statuses = array_keys( $formatted_status_keys );
return array_combine( $formatted_statuses, $formatted_statuses );
}
/**
* Return an object defining the currecy options for the site's current currency
*
* @return array Settings for the current currency {
* Array of settings.
*
* @type string $code Currency code.
* @type string $precision Number of decimals.
* @type string $symbol Symbol for currency.
* }
*/
public static function get_currency_settings() {
$code = get_woocommerce_currency();
//phpcs:ignore
return apply_filters(
'wc_currency_settings',
array(
'code' => $code,
'precision' => wc_get_price_decimals(),
'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $code ) ),
'symbolPosition' => get_option( 'woocommerce_currency_pos' ),
'decimalSeparator' => wc_get_price_decimal_separator(),
'thousandSeparator' => wc_get_price_thousand_separator(),
'priceFormat' => html_entity_decode( get_woocommerce_price_format() ),
)
);
}
/**
* Hooks extra necessary data into the component settings array already set in WooCommerce core.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
if ( ! function_exists( 'wc_blocks_container' ) ) {
global $wp_locale;
// inject data not available via older versions of wc_blocks/woo.
$settings['orderStatuses'] = self::get_order_statuses( wc_get_order_statuses() );
$settings['stockStatuses'] = self::get_order_statuses( wc_get_product_stock_status_options() );
$settings['currency'] = self::get_currency_settings();
$settings['locale'] = array(
'siteLocale' => isset( $settings['siteLocale'] )
? $settings['siteLocale']
: get_locale(),
'userLocale' => isset( $settings['l10n']['userLocale'] )
? $settings['l10n']['userLocale']
: get_user_locale(),
'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
? $settings['l10n']['weekdaysShort']
: array_values( $wp_locale->weekday_abbrev ),
);
}
//phpcs:ignore
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
if ( class_exists( 'Jetpack' ) ) {
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
}
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
'rest_preload_api_request'
);
}
//phpcs:ignore
$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
if ( ! empty( $preload_options ) ) {
foreach ( $preload_options as $option ) {
$settings['preloadOptions'][ $option ] = get_option( $option );
}
}
//phpcs:ignore
$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
if ( ! empty( $preload_settings ) ) {
$setting_options = new \WC_REST_Setting_Options_V2_Controller();
foreach ( $preload_settings as $group ) {
$group_settings = $setting_options->get_group_settings( $group );
$preload_settings = array();
foreach ( $group_settings as $option ) {
if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
$preload_settings[ $option['id'] ] = $option['value'];
}
}
$settings['preloadSettings'][ $group ] = $preload_settings;
}
}
$user_controller = new \WP_REST_Users_Controller();
$request = new \WP_REST_Request();
$request->set_query_params( array( 'context' => 'edit' ) );
$user_response = $user_controller->get_current_item( $request );
$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();
$settings['currentUserData'] = $current_user_data;
$settings['reviewsEnabled'] = get_option( 'woocommerce_enable_reviews' );
$settings['manageStock'] = get_option( 'woocommerce_manage_stock' );
$settings['commentModeration'] = get_option( 'comment_moderation' );
$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );
/**
* Deprecate wcAdminAssetUrl as we no longer need it after The Merge.
* Use wcAssetUrl instead.
*
* @deprecated 6.7.0
* @var string
*/
$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
$settings['wcVersion'] = WC_VERSION;
$settings['siteUrl'] = site_url();
$settings['shopUrl'] = get_permalink( wc_get_page_id( 'shop' ) );
$settings['homeUrl'] = home_url();
$settings['dateFormat'] = get_option( 'date_format' );
$settings['timeZone'] = wc_timezone_string();
$settings['plugins'] = array(
'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
'activePlugins' => Plugins::get_active_plugins(),
);
// Plugins that depend on changing the translation work on the server but not the client -
// WooCommerce Branding is an example of this - so pass through the translation of
// 'WooCommerce' to wcSettings.
$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );
// We may have synced orders with a now-unregistered status.
// E.g An extension that added statuses is now inactive or removed.
$settings['unregisteredOrderStatuses'] = $this->get_unregistered_order_statuses();
// The separator used for attributes found in Variation titles.
//phpcs:ignore
$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );
if ( ! empty( $preload_data_endpoints ) ) {
$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
? $settings['dataEndpoints']
: array();
foreach ( $preload_data_endpoints as $key => $endpoint ) {
// Handle error case: rest_do_request() doesn't guarantee success.
if ( empty( $preload_data[ $endpoint ] ) ) {
$settings['dataEndpoints'][ $key ] = array();
} else {
$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
}
}
}
$settings = $this->get_custom_settings( $settings );
if ( PageController::is_embed_page() ) {
$settings['embedBreadcrumbs'] = wc_admin_get_breadcrumbs();
}
$settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions();
$settings['connectNonce'] = wp_create_nonce( 'connect' );
$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
$settings['features'] = $this->get_features();
$settings['isWooPayEligible'] = WCPayPromotionInit::is_woopay_eligible();
return $settings;
}
/**
* Removes non necesary feature properties for the client side.
*
* @return array
*/
public function get_features() {
$features = FeaturesUtil::get_features( true, true );
$new_features = array();
foreach ( array_keys( $features ) as $feature_id ) {
$new_features[ $feature_id ] = array(
'is_enabled' => $features[ $feature_id ]['is_enabled'],
'is_experimental' => $features[ $feature_id ]['is_experimental'] ?? false,
);
}
return $new_features;
}
/**
* Register the admin settings for use in the WC REST API
*
* @param array $groups Array of setting groups.
* @return array
*/
public function add_settings_group( $groups ) {
$groups[] = array(
'id' => 'wc_admin',
'label' => __( 'WooCommerce Admin', 'woocommerce' ),
'description' => __( 'Settings for WooCommerce admin reporting.', 'woocommerce' ),
);
return $groups;
}
/**
* Add WC Admin specific settings
*
* @param array $settings Array of settings in wc admin group.
* @return array
*/
public function add_settings( $settings ) {
$unregistered_statuses = $this->get_unregistered_order_statuses();
$registered_statuses = self::get_order_statuses( wc_get_order_statuses() );
$all_statuses = array_merge( $unregistered_statuses, $registered_statuses );
$settings[] = array(
'id' => 'woocommerce_excluded_report_order_statuses',
'option_key' => 'woocommerce_excluded_report_order_statuses',
'label' => __( 'Excluded report order statuses', 'woocommerce' ),
'description' => __( 'Statuses that should not be included when calculating report totals.', 'woocommerce' ),
'default' => array( 'pending', 'cancelled', 'failed' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_actionable_order_statuses',
'option_key' => 'woocommerce_actionable_order_statuses',
'label' => __( 'Actionable order statuses', 'woocommerce' ),
'description' => __( 'Statuses that require extra action on behalf of the store admin.', 'woocommerce' ),
'default' => array( 'processing', 'on-hold' ),
'type' => 'multiselect',
'options' => $all_statuses,
);
$settings[] = array(
'id' => 'woocommerce_default_date_range',
'option_key' => 'woocommerce_default_date_range',
'label' => __( 'Default Date Range', 'woocommerce' ),
'description' => __( 'Default Date Range', 'woocommerce' ),
'default' => 'period=month&compare=previous_year',
'type' => 'text',
);
$settings[] = array(
'id' => 'woocommerce_date_type',
'option_key' => 'woocommerce_date_type',
'label' => __( 'Date Type', 'woocommerce' ),
'description' => __( 'Database date field considered for Revenue and Orders reports', 'woocommerce' ),
'type' => 'select',
'options' => array(
'date_created' => 'date_created',
'date_paid' => 'date_paid',
'date_completed' => 'date_completed',
),
);
return $settings;
}
/**
* Gets custom settings used for WC Admin.
*
* @param array $settings Array of settings to merge into.
* @return array
*/
private function get_custom_settings( $settings ) {
$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
$wc_admin_group_settings = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
$settings['wcAdminSettings'] = array();
foreach ( $wc_admin_group_settings as $setting ) {
if ( ! empty( $setting['id'] ) ) {
$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
}
}
return $settings;
}
}
Internal/Admin/SettingsNavigationFeature.php 0000644 00000010521 15153704477 0015231 0 ustar 00 <?php
/**
* WooCommerce Settings.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\PageController;
/**
* Contains backend logic for the Settings feature.
*/
class SettingsNavigationFeature {
/**
* Option name used to toggle this feature.
*/
const TOGGLE_OPTION_NAME = 'woocommerce_settings_enabled';
/**
* Class instance.
*
* @var Settings instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_filter( 'woocommerce_settings_features', array( $this, 'add_feature_toggle' ) );
if ( 'yes' !== get_option( 'woocommerce_settings_enabled', 'no' ) ) {
return;
}
add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'add_component_settings' ) );
// Run this after the original WooCommerce settings have been added.
add_action( 'admin_menu', array( $this, 'register_pages' ), 60 );
add_action( 'init', array( $this, 'redirect_core_settings_pages' ) );
}
/**
* Add the necessary data to initially load the WooCommerce Settings pages.
*
* @param array $settings Array of component settings.
* @return array Array of component settings.
*/
public static function add_component_settings( $settings ) {
if ( ! is_admin() ) {
return $settings;
}
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$pages = array();
foreach ( $setting_pages as $setting_page ) {
$pages = $setting_page->add_settings_page( $pages );
}
$settings['settingsPages'] = $pages;
return $settings;
}
/**
* Add the feature toggle to the features settings.
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
$features[] = array(
'title' => __( 'Settings', 'woocommerce' ),
'desc' => __(
'Adds the new WooCommerce settings UI.',
'woocommerce'
),
'id' => 'woocommerce_settings_enabled',
'type' => 'checkbox',
);
return $features;
}
/**
* Registers settings pages.
*/
public function register_pages() {
$controller = PageController::get_instance();
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$settings = array();
foreach ( $setting_pages as $setting_page ) {
$settings = $setting_page->add_settings_page( $settings );
}
$order = 0;
foreach ( $settings as $key => $setting ) {
$order += 10;
$settings_page = array(
'parent' => 'woocommerce-settings',
'title' => $setting,
'id' => 'settings-' . $key,
'path' => "/settings/$key",
'nav_args' => array(
'capability' => 'manage_woocommerce',
'order' => $order,
'parent' => 'woocommerce-settings',
),
);
// Replace the old menu with the first settings item.
if ( 10 === $order ) {
$this->replace_settings_page( $settings_page );
}
$controller->register_page( $settings_page );
}
}
/**
* Replace the Settings page in the original WooCommerce menu.
*
* @param array $page Page used to replace the original.
*/
protected function replace_settings_page( $page ) {
global $submenu;
// Check if WooCommerce parent menu has been registered.
if ( ! isset( $submenu['woocommerce'] ) ) {
return;
}
foreach ( $submenu['woocommerce'] as &$item ) {
// The "slug" (aka the path) is the third item in the array.
if ( 0 === strpos( $item[2], 'wc-settings' ) ) {
$item[2] = wc_admin_url( "&path={$page['path']}" );
}
}
}
/**
* Redirect the old settings page URLs to the new ones.
*/
public function redirect_core_settings_pages() {
/* phpcs:disable WordPress.Security.NonceVerification */
if ( ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] ) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$default_setting = isset( $setting_pages[0] ) ? $setting_pages[0]->get_id() : '';
$setting = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : $default_setting;
/* phpcs:enable */
wp_safe_redirect( wc_admin_url( "&path=/settings/$setting" ) );
exit;
}
}
Internal/Admin/ShippingLabelBanner.php 0000644 00000010226 15153704477 0013746 0 ustar 00 <?php
/**
* WooCommerce Shipping Label banner.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;
/**
* Shows print shipping label banner on edit order page.
*/
class ShippingLabelBanner {
/**
* Singleton for the display rules class
*
* @var ShippingLabelBannerDisplayRules
*/
private $shipping_label_banner_display_rules;
/**
* Constructor
*/
public function __construct() {
if ( ! is_admin() ) {
return;
}
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ), 6, 2 );
}
/**
* Check if WooCommerce Shipping makes sense for this merchant.
*
* @return bool
*/
private function should_show_meta_box() {
if ( ! $this->shipping_label_banner_display_rules ) {
$jetpack_version = null;
$jetpack_connected = null;
$wcs_version = null;
$wcs_tos_accepted = null;
if ( defined( 'JETPACK__VERSION' ) ) {
$jetpack_version = JETPACK__VERSION;
}
if ( class_exists( Jetpack_Connection_Manager::class ) ) {
$jetpack_connected = ( new Jetpack_Connection_Manager() )->has_connected_owner();
}
if ( class_exists( '\WC_Connect_Loader' ) ) {
$wcs_version = \WC_Connect_Loader::get_wcs_version();
}
if ( class_exists( '\WC_Connect_Options' ) ) {
$wcs_tos_accepted = \WC_Connect_Options::get_option( 'tos_accepted' );
}
$incompatible_plugins = class_exists( '\WC_Shipping_Fedex_Init' ) ||
class_exists( '\WC_Shipping_UPS_Init' ) ||
class_exists( '\WC_Integration_ShippingEasy' ) ||
class_exists( '\WC_ShipStation_Integration' );
$this->shipping_label_banner_display_rules =
new ShippingLabelBannerDisplayRules(
$jetpack_version,
$jetpack_connected,
$wcs_version,
$wcs_tos_accepted,
$incompatible_plugins
);
}
return $this->shipping_label_banner_display_rules->should_display_banner();
}
/**
* Add metabox to order page.
*
* @param string $post_type current post type.
* @param \WP_Post $post Current post object.
*/
public function add_meta_boxes( $post_type, $post ) {
if ( 'shop_order' !== $post_type ) {
return;
}
$order = wc_get_order( $post );
if ( $this->should_show_meta_box() ) {
add_meta_box(
'woocommerce-admin-print-label',
__( 'Shipping Label', 'woocommerce' ),
array( $this, 'meta_box' ),
null,
'normal',
'high',
array(
'context' => 'shipping_label',
'order' => $post->ID,
'items' => $this->count_shippable_items( $order ),
)
);
add_action( 'admin_enqueue_scripts', array( $this, 'add_print_shipping_label_script' ) );
}
}
/**
* Count shippable items
*
* @param \WC_Order $order Current order.
* @return int
*/
private function count_shippable_items( \WC_Order $order ) {
$count = 0;
foreach ( $order->get_items() as $item ) {
if ( $item instanceof \WC_Order_Item_Product ) {
$product = $item->get_product();
if ( $product && $product->needs_shipping() ) {
$count += $item->get_quantity();
}
}
}
return $count;
}
/**
* Adds JS to order page to render shipping banner.
*
* @param string $hook current page hook.
*/
public function add_print_shipping_label_script( $hook ) {
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style(
'print-shipping-label-banner-style',
WCAdminAssets::get_url( "print-shipping-label-banner/style{$rtl}", 'css' ),
array( 'wp-components' ),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'print-shipping-label-banner', true );
$payload = array(
'nonce' => wp_create_nonce( 'wp_rest' ),
'baseURL' => get_rest_url(),
'wcs_server_connection' => true,
);
wp_localize_script( 'print-shipping-label-banner', 'wcConnectData', $payload );
}
/**
* Render placeholder metabox.
*
* @param \WP_Post $post current post.
* @param array $args empty args.
*/
public function meta_box( $post, $args ) {
?>
<div id="wc-admin-shipping-banner-root" class="woocommerce <?php echo esc_attr( 'wc-admin-shipping-banner' ); ?>" data-args="<?php echo esc_attr( wp_json_encode( $args['args'] ) ); ?>">
</div>
<?php
}
}
Internal/Admin/ShippingLabelBannerDisplayRules.php 0000644 00000012266 15153704477 0016315 0 ustar 00 <?php
/**
* WooCommerce Shipping Label Banner Display Rules.
*/
namespace Automattic\WooCommerce\Internal\Admin;
/**
* Determines whether or not the Shipping Label Banner should be displayed
*/
class ShippingLabelBannerDisplayRules {
/**
* Holds the installed Jetpack version.
*
* @var string
*/
private $jetpack_version;
/**
* Whether or not the installed Jetpack is connected.
*
* @var bool
*/
private $jetpack_connected;
/**
* Holds the installed WooCommerce Shipping & Tax version.
*
* @var string
*/
private $wcs_version;
/**
* Whether or not there're plugins installed incompatible with the banner.
*
* @var bool
*/
private $no_incompatible_plugins_installed;
/**
* Whether or not the WooCommerce Shipping & Tax ToS has been accepted.
*
* @var bool
*/
private $wcs_tos_accepted;
/**
* Minimum supported Jetpack version.
*
* @var string
*/
private $min_jetpack_version = '4.4';
/**
* Minimum supported WooCommerce Shipping & Tax version.
*
* @var string
*/
private $min_wcs_version = '1.22.5';
/**
* Supported countries by USPS, see: https://webpmt.usps.gov/pmt010.cfm
*
* @var array
*/
private $supported_countries = array( 'US', 'AS', 'PR', 'VI', 'GU', 'MP', 'UM', 'FM', 'MH' );
/**
* Array of supported currency codes.
*
* @var array
*/
private $supported_currencies = array( 'USD' );
/**
* Constructor.
*
* @param string $jetpack_version Installed Jetpack version to check.
* @param bool $jetpack_connected Is Jetpack connected?.
* @param string $wcs_version Installed WooCommerce Shipping & Tax version to check.
* @param bool $wcs_tos_accepted WooCommerce Shipping & Tax Terms of Service accepted?.
* @param bool $incompatible_plugins_installed Are there any incompatible plugins installed?.
*/
public function __construct( $jetpack_version, $jetpack_connected, $wcs_version, $wcs_tos_accepted, $incompatible_plugins_installed ) {
$this->jetpack_version = $jetpack_version;
$this->jetpack_connected = $jetpack_connected;
$this->wcs_version = $wcs_version;
$this->wcs_tos_accepted = $wcs_tos_accepted;
$this->no_incompatible_plugins_installed = ! $incompatible_plugins_installed;
}
/**
* Determines whether banner is eligible for display (does not include a/b logic).
*/
public function should_display_banner() {
return $this->banner_not_dismissed() &&
$this->jetpack_installed_and_active() &&
$this->jetpack_up_to_date() &&
$this->jetpack_connected &&
$this->no_incompatible_plugins_installed &&
$this->order_has_shippable_products() &&
$this->store_in_us_and_usd() &&
( $this->wcs_not_installed() || (
$this->wcs_up_to_date() && ! $this->wcs_tos_accepted
) );
}
/**
* Checks if the banner was not dismissed by the user.
*
* @return bool
*/
private function banner_not_dismissed() {
$dismissed_timestamp_ms = get_option( 'woocommerce_shipping_dismissed_timestamp' );
if ( ! is_numeric( $dismissed_timestamp_ms ) ) {
return true;
}
$dismissed_timestamp_ms = intval( $dismissed_timestamp_ms );
$dismissed_timestamp = intval( round( $dismissed_timestamp_ms / 1000 ) );
$expired_timestamp = $dismissed_timestamp + 24 * 60 * 60; // 24 hours from click time
$dismissed_for_good = -1 === $dismissed_timestamp_ms;
$dismissed_24h = time() < $expired_timestamp;
return ! $dismissed_for_good && ! $dismissed_24h;
}
/**
* Checks if jetpack is installed and active.
*
* @return bool
*/
private function jetpack_installed_and_active() {
return ! ! $this->jetpack_version;
}
/**
* Checks if Jetpack version is supported.
*
* @return bool
*/
private function jetpack_up_to_date() {
return version_compare( $this->jetpack_version, $this->min_jetpack_version, '>=' );
}
/**
* Checks if there's a shippable product in the current order.
*
* @return bool
*/
private function order_has_shippable_products() {
$post = get_post();
if ( ! $post ) {
return false;
}
$order = wc_get_order( get_post()->ID );
if ( ! $order ) {
return false;
}
// At this point (no packaging data), only show if there's at least one existing and shippable product.
foreach ( $order->get_items() as $item ) {
if ( $item instanceof \WC_Order_Item_Product ) {
$product = $item->get_product();
if ( $product && $product->needs_shipping() ) {
return true;
}
}
}
return false;
}
/**
* Checks if the store is in the US and has its default currency set to USD.
*
* @return bool
*/
private function store_in_us_and_usd() {
$base_currency = get_woocommerce_currency();
$base_location = wc_get_base_location();
return in_array( $base_currency, $this->supported_currencies, true ) && in_array( $base_location['country'], $this->supported_countries, true );
}
/**
* Checks if WooCommerce Shipping & Tax is not installed.
*
* @return bool
*/
private function wcs_not_installed() {
return ! $this->wcs_version;
}
/**
* Checks if WooCommerce Shipping & Tax is up to date.
*/
private function wcs_up_to_date() {
return $this->wcs_version && version_compare( $this->wcs_version, $this->min_wcs_version, '>=' );
}
}
Internal/Admin/SiteHealth.php 0000644 00000004502 15153704477 0012131 0 ustar 00 <?php
/**
* Customize Site Health recommendations for WooCommerce.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* SiteHealth class.
*/
class SiteHealth {
/**
* Class instance.
*
* @var SiteHealth instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'site_status_should_suggest_persistent_object_cache', array( $this, 'should_suggest_persistent_object_cache' ) );
}
/**
* Counts specific types of WooCommerce entities to determine if a persistent object cache would be beneficial.
*
* Note that if all measured WooCommerce entities are below their thresholds, this will return null so that the
* other normal WordPress checks will still be run.
*
* @param true|null $check A non-null value will short-circuit WP's normal tests for this.
*
* @return true|null True if the store would benefit from a persistent object cache. Otherwise null.
*/
public function should_suggest_persistent_object_cache( $check ) {
// Skip this if some other filter has already determined yes.
if ( true === $check ) {
return $check;
}
$thresholds = array(
'orders' => 100,
'products' => 100,
);
foreach ( $thresholds as $key => $threshold ) {
try {
switch ( $key ) {
case 'orders':
$orders_query = new \WC_Order_Query(
array(
'status' => 'any',
'limit' => 1,
'paginate' => true,
'return' => 'ids',
)
);
$orders_results = $orders_query->get_orders();
if ( $orders_results->total >= $threshold ) {
$check = true;
}
break;
case 'products':
$products_query = new \WC_Product_Query(
array(
'status' => 'any',
'limit' => 1,
'paginate' => true,
'return' => 'ids',
)
);
$products_results = $products_query->get_products();
if ( $products_results->total >= $threshold ) {
$check = true;
}
break;
}
} catch ( \Exception $exception ) {
break;
}
if ( ! is_null( $check ) ) {
break;
}
}
return $check;
}
}
Internal/Admin/Survey.php 0000644 00000001400 15153704477 0011366 0 ustar 00 <?php
/**
* Survey helper methods.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* Survey Class.
*/
class Survey {
/**
* Survey URL.
*/
const SURVEY_URL = 'https://automattic.survey.fm';
/**
* Get a survey's URL from a path.
*
* @param string $path Path of the survey.
* @param array $query Query arguments as key value pairs.
* @return string Full URL to survey.
*/
public static function get_url( $path, $query = array() ) {
$url = self::SURVEY_URL . $path;
$query_args = apply_filters( 'woocommerce_admin_survey_query', $query );
if ( ! empty( $query_args ) ) {
$query_string = http_build_query( $query_args );
$url = $url . '?' . $query_string;
}
return $url;
}
}
Internal/Admin/SystemStatusReport.php 0000644 00000013537 15153704477 0013773 0 ustar 00 <?php
/**
* Add additional system status report sections.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Notes\Notes;
defined( 'ABSPATH' ) || exit;
/**
* SystemStatusReport class.
*/
class SystemStatusReport {
/**
* Class instance.
*
* @var SystemStatus instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Hook into WooCommerce.
*/
public function __construct() {
add_action( 'woocommerce_system_status_report', array( $this, 'system_status_report' ) );
}
/**
* Hooks extra necessary sections into the system status report template
*/
public function system_status_report() {
?>
<table class="wc_status_table widefat" cellspacing="0">
<thead>
<tr>
<th colspan="5" data-export-label="Admin">
<h2>
<?php esc_html_e( 'Admin', 'woocommerce' ); ?><?php echo wc_help_tip( esc_html__( 'This section shows details of WC Admin.', 'woocommerce' ) ); ?>
</h2>
</th>
</tr>
</thead>
<tbody>
<?php
$this->render_features();
$this->render_daily_cron();
$this->render_options();
$this->render_notes();
$this->render_onboarding_state();
?>
</tbody>
</table>
<?php
}
/**
* Render features rows.
*/
public function render_features() {
/**
* Filter the admin feature configs.
*
* @since 6.5.0
*/
$features = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
$enabled_features = array_filter( $features );
$disabled_features = array_filter(
$features,
function( $feature ) {
return empty( $feature );
}
);
?>
<tr>
<td data-export-label="Enabled Features">
<?php esc_html_e( 'Enabled Features', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Which features are enabled?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( implode( ', ', array_keys( $enabled_features ) ) )
?>
</td>
</tr>
<tr>
<td data-export-label="Disabled Features">
<?php esc_html_e( 'Disabled Features', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Which features are disabled?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( implode( ', ', array_keys( $disabled_features ) ) )
?>
</td>
</tr>
<?php
}
/**
* Render daily cron row.
*/
public function render_daily_cron() {
$next_daily_cron = wp_next_scheduled( 'wc_admin_daily' );
?>
<tr>
<td data-export-label="Daily Cron">
<?php esc_html_e( 'Daily Cron', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Is the daily cron job active, when does it next run?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
if ( empty( $next_daily_cron ) ) {
echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not scheduled', 'woocommerce' ) . '</mark>';
} else {
echo '<mark class="yes"><span class="dashicons dashicons-yes"></span> Next scheduled: ' . esc_html( date_i18n( 'Y-m-d H:i:s P', $next_daily_cron ) ) . '</mark>';
}
?>
</td>
</tr>
<?php
}
/**
* Render option row.
*/
public function render_options() {
$woocommerce_admin_install_timestamp = get_option( 'woocommerce_admin_install_timestamp' );
$all_options_expected = is_numeric( $woocommerce_admin_install_timestamp )
&& 0 < (int) $woocommerce_admin_install_timestamp
&& is_array( get_option( 'woocommerce_onboarding_profile', array() ) );
?>
<tr>
<td data-export-label="Options">
<?php esc_html_e( 'Options', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Do the important options return expected values?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
if ( $all_options_expected ) {
echo '<mark class="yes"><span class="dashicons dashicons-yes"></mark>';
} else {
echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not all expected', 'woocommerce' ) . '</mark>';
}
?>
</td>
</tr>
<?php
}
/**
* Render the notes row.
*/
public function render_notes() {
$notes_count = Notes::get_notes_count();
?>
<tr>
<td data-export-label="Notes">
<?php esc_html_e( 'Notes', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'How many notes in the database?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( $notes_count )
?>
</td>
</tr>
<?php
}
/**
* Render the onboarding state row.
*/
public function render_onboarding_state() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
$onboarding_state = '-';
if ( isset( $onboarding_profile['skipped'] ) && $onboarding_profile['skipped'] ) {
$onboarding_state = 'skipped';
}
if ( isset( $onboarding_profile['completed'] ) && $onboarding_profile['completed'] ) {
$onboarding_state = 'completed';
}
?>
<tr>
<td data-export-label="Onboarding">
<?php esc_html_e( 'Onboarding', 'woocommerce' ); ?>:
</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Was onboarding completed or skipped?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
echo esc_html( $onboarding_state )
?>
</td>
</tr>
<?php
}
}
Internal/Admin/Translations.php 0000644 00000027602 15153704477 0012566 0 ustar 00 <?php
/**
* Register the scripts, and handles items needed for managing translations within WooCommerce Admin.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
/**
* Translations Class.
*/
class Translations {
/**
* Class instance.
*
* @var Translations instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'potentially_load_translation_script_file' ), 15 );
// Combine JSON translation files (from chunks) when language packs are updated.
add_action( 'upgrader_process_complete', array( $this, 'combine_translation_chunk_files' ), 10, 2 );
// Handler for WooCommerce and WooCommerce Admin plugin activation.
add_action( 'woocommerce_activated_plugin', array( $this, 'potentially_generate_translation_strings' ) );
add_action( 'activated_plugin', array( $this, 'potentially_generate_translation_strings' ) );
}
/**
* Generate a filename to cache translations from JS chunks.
*
* @param string $domain Text domain.
* @param string $locale Locale being retrieved.
* @return string Filename.
*/
private function get_combined_translation_filename( $domain, $locale ) {
$filename = implode( '-', array( $domain, $locale, WC_ADMIN_APP ) ) . '.json';
return $filename;
}
/**
* Combines data from translation chunk files based on officially downloaded file format.
*
* @param array $json_i18n_filenames List of JSON chunk files.
* @return array Combined translation chunk data.
*/
private function combine_official_translation_chunks( $json_i18n_filenames ) {
// the filesystem object should be hooked up.
global $wp_filesystem;
$combined_translation_data = array();
foreach ( $json_i18n_filenames as $json_filename ) {
if ( ! $wp_filesystem->is_readable( $json_filename ) ) {
continue;
}
$file_contents = $wp_filesystem->get_contents( $json_filename );
$chunk_data = \json_decode( $file_contents, true );
if ( empty( $chunk_data ) ) {
continue;
}
$reference_file = $chunk_data['comment']['reference'];
// Only combine "app" files (not scripts registered with WP).
if (
false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'app/index.js' ) &&
false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'chunks/' )
) {
continue;
}
if ( empty( $combined_translation_data ) ) {
// Use the first translation file as the base structure.
$combined_translation_data = $chunk_data;
} else {
// Combine all messages from all chunk files.
$combined_translation_data['locale_data']['messages'] = array_merge(
$combined_translation_data['locale_data']['messages'],
$chunk_data['locale_data']['messages']
);
}
}
// Remove inaccurate reference comment.
unset( $combined_translation_data['comment'] );
return $combined_translation_data;
}
/**
* Combines data from translation chunk files based on user-generated file formats,
* such as wp-cli tool or Loco Translate plugin.
*
* @param array $json_i18n_filenames List of JSON chunk files.
* @return array Combined translation chunk data.
*/
private function combine_user_translation_chunks( $json_i18n_filenames ) {
// the filesystem object should be hooked up.
global $wp_filesystem;
$combined_translation_data = array();
foreach ( $json_i18n_filenames as $json_filename ) {
if ( ! $wp_filesystem->is_readable( $json_filename ) ) {
continue;
}
$file_contents = $wp_filesystem->get_contents( $json_filename );
$chunk_data = \json_decode( $file_contents, true );
if ( empty( $chunk_data ) ) {
continue;
}
$reference_file = $chunk_data['source'];
// Only combine "app" files (not scripts registered with WP).
if (
false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'app/index.js' ) &&
false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'chunks/' )
) {
continue;
}
if ( empty( $combined_translation_data ) ) {
// Use the first translation file as the base structure.
$combined_translation_data = $chunk_data;
} else {
// Combine all messages from all chunk files.
$combined_translation_data['locale_data']['woocommerce'] = array_merge(
$combined_translation_data['locale_data']['woocommerce'],
$chunk_data['locale_data']['woocommerce']
);
}
}
// Remove inaccurate reference comment.
unset( $combined_translation_data['source'] );
return $combined_translation_data;
}
/**
* Find and combine translation chunk files.
*
* Only targets files that aren't represented by a registered script (e.g. not passed to wp_register_script()).
*
* @param string $lang_dir Path to language files.
* @param string $domain Text domain.
* @param string $locale Locale being retrieved.
* @return array Combined translation chunk data.
*/
private function get_translation_chunk_data( $lang_dir, $domain, $locale ) {
// So long as this function is called during the 'upgrader_process_complete' action,
// the filesystem object should be hooked up.
global $wp_filesystem;
// Grab all JSON files in the current language pack.
$json_i18n_filenames = glob( $lang_dir . $domain . '-' . $locale . '-*.json' );
$combined_translation_data = array();
if ( false === $json_i18n_filenames ) {
return $combined_translation_data;
}
// Use first JSON file to determine file format. This check is required due to
// file format difference between official language files and user translated files.
$format_determine_file = reset( $json_i18n_filenames );
if ( ! $wp_filesystem->is_readable( $format_determine_file ) ) {
return $combined_translation_data;
}
$file_contents = $wp_filesystem->get_contents( $format_determine_file );
$format_determine_data = \json_decode( $file_contents, true );
if ( empty( $format_determine_data ) ) {
return $combined_translation_data;
}
if ( isset( $format_determine_data['comment'] ) ) {
return $this->combine_official_translation_chunks( $json_i18n_filenames );
} elseif ( isset( $format_determine_data['source'] ) ) {
return $this->combine_user_translation_chunks( $json_i18n_filenames );
} else {
return $combined_translation_data;
}
}
/**
* Combine and save translations for a specific locale.
*
* Note that this assumes \WP_Filesystem is already initialized with write access.
*
* @param string $language_dir Path to language files.
* @param string $plugin_domain Text domain.
* @param string $locale Locale being retrieved.
*/
private function build_and_save_translations( $language_dir, $plugin_domain, $locale ) {
global $wp_filesystem;
$translations_from_chunks = $this->get_translation_chunk_data( $language_dir, $plugin_domain, $locale );
if ( empty( $translations_from_chunks ) ) {
return;
}
$cache_filename = $this->get_combined_translation_filename( $plugin_domain, $locale );
$chunk_translations_json = wp_json_encode( $translations_from_chunks );
// Cache combined translations strings to a file.
$wp_filesystem->put_contents( $language_dir . $cache_filename, $chunk_translations_json );
}
/**
* Combine translation chunks when plugin is activated.
*
* This function combines JSON translation data auto-extracted by GlotPress
* from Webpack-generated JS chunks into a single file. This is necessary
* since the JS chunks are not known to WordPress via wp_register_script()
* and wp_set_script_translations().
*/
private function generate_translation_strings() {
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
$locale = determine_locale();
$lang_dir = WP_LANG_DIR . '/plugins/';
// Bail early if not localized.
if ( 'en_US' === $locale ) {
return;
}
if ( ! function_exists( 'get_filesystem_method' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$access_type = get_filesystem_method();
if ( 'direct' === $access_type ) {
\WP_Filesystem();
$this->build_and_save_translations( $lang_dir, $plugin_domain, $locale );
} else {
// I'm reluctant to add support for other filesystems here as it would require
// user's input on activating plugin - which I don't think is common.
return;
}
}
/**
* Loads the required translation scripts on the correct pages.
*/
public function potentially_load_translation_script_file() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
// Grab translation strings from Webpack-generated chunks.
add_filter( 'load_script_translation_file', array( $this, 'load_script_translation_file' ), 10, 3 );
}
/**
* Load translation strings from language packs for dynamic imports.
*
* @param string $file File location for the script being translated.
* @param string $handle Script handle.
* @param string $domain Text domain.
*
* @return string New file location for the script being translated.
*/
public function load_script_translation_file( $file, $handle, $domain ) {
// Make sure the main app script is being loaded.
if ( WC_ADMIN_APP !== $handle ) {
return $file;
}
// Make sure we're handing the correct domain (could be woocommerce or woocommerce-admin).
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
if ( $plugin_domain !== $domain ) {
return $file;
}
$locale = determine_locale();
$cache_filename = $this->get_combined_translation_filename( $domain, $locale );
return WP_LANG_DIR . '/plugins/' . $cache_filename;
}
/**
* Run when plugin is activated (can be WooCommerce or WooCommerce Admin).
*
* @param string $filename Activated plugin filename.
*/
public function potentially_generate_translation_strings( $filename ) {
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
$activated_plugin_domain = explode( '/', $filename )[0];
// Ensure we're only running only on activation hook that originates from our plugin.
if ( $plugin_domain === $activated_plugin_domain ) {
$this->generate_translation_strings();
}
}
/**
* Combine translation chunks when files are updated.
*
* This function combines JSON translation data auto-extracted by GlotPress
* from Webpack-generated JS chunks into a single file that can be used in
* subsequent requests. This is necessary since the JS chunks are not known
* to WordPress via wp_register_script() and wp_set_script_translations().
*
* @param Language_Pack_Upgrader $instance Upgrader instance.
* @param array $hook_extra Info about the upgraded language packs.
*/
public function combine_translation_chunk_files( $instance, $hook_extra ) {
if (
! is_a( $instance, 'Language_Pack_Upgrader' ) ||
! isset( $hook_extra['translations'] ) ||
! is_array( $hook_extra['translations'] )
) {
return;
}
// Make sure we're handing the correct domain (could be woocommerce or woocommerce-admin).
$plugin_domain = explode( '/', plugin_basename( __FILE__ ) )[0];
$locales = array();
$language_dir = WP_LANG_DIR . '/plugins/';
// Gather the locales that were updated in this operation.
foreach ( $hook_extra['translations'] as $translation ) {
if (
'plugin' === $translation['type'] &&
$plugin_domain === $translation['slug']
) {
$locales[] = $translation['language'];
}
}
// Build combined translation files for all updated locales.
foreach ( $locales as $locale ) {
// So long as this function is hooked to the 'upgrader_process_complete' action,
// WP_Filesystem should be hooked up to be able to call build_and_save_translations.
$this->build_and_save_translations( $language_dir, $plugin_domain, $locale );
}
}
}
Internal/Admin/WCAdminAssets.php 0000644 00000032121 15153704477 0012542 0 ustar 00 <?php
/**
* Register the scripts, and styles used within WooCommerce Admin.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use _WP_Dependency;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
/**
* WCAdminAssets Class.
*/
class WCAdminAssets {
/**
* Class instance.
*
* @var WCAdminAssets instance
*/
protected static $instance = null;
/**
* An array of dependencies that have been preloaded (to avoid duplicates).
*
* @var array
*/
protected $preloaded_dependencies;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
Features::get_instance();
add_action( 'admin_enqueue_scripts', array( $this, 'register_scripts' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'inject_wc_settings_dependencies' ), 14 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 15 );
}
/**
* Gets the path for the asset depending on file type.
*
* @param string $ext File extension.
* @return string Folder path of asset.
*/
public static function get_path( $ext ) {
return ( $ext === 'css' ) ? WC_ADMIN_DIST_CSS_FOLDER : WC_ADMIN_DIST_JS_FOLDER;
}
/**
* Determines if a minified JS file should be served.
*
* @param boolean $script_debug Only serve unminified files if script debug is on.
* @return boolean If js asset should use minified version.
*/
public static function should_use_minified_js_file( $script_debug ) {
// minified files are only shipped in non-core versions of wc-admin, return false if minified files are not available.
if ( ! Features::exists( 'minified-js' ) ) {
return false;
}
// Otherwise we will serve un-minified files if SCRIPT_DEBUG is on, or if anything truthy is passed in-lieu of SCRIPT_DEBUG.
return ! $script_debug;
}
/**
* Gets the URL to an asset file.
*
* @param string $file File name (without extension).
* @param string $ext File extension.
* @return string URL to asset.
*/
public static function get_url( $file, $ext ) {
$suffix = '';
// Potentially enqueue minified JavaScript.
if ( $ext === 'js' ) {
$script_debug = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
$suffix = self::should_use_minified_js_file( $script_debug ) ? '.min' : '';
}
return plugins_url( self::get_path( $ext ) . $file . $suffix . '.' . $ext, WC_ADMIN_PLUGIN_FILE );
}
/**
* Gets the file modified time as a cache buster if we're in dev mode, or the plugin version otherwise.
*
* @param string $ext File extension.
* @return string The cache buster value to use for the given file.
*/
public static function get_file_version( $ext ) {
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
return filemtime( WC_ADMIN_ABSPATH . self::get_path( $ext ) );
}
return WC_VERSION;
}
/**
* Gets a script asset registry filename. The asset registry lists dependencies for the given script.
*
* @param string $script_path_name Path to where the script asset registry is contained.
* @param string $file File name (without extension).
* @return string complete asset filename.
*
* @throws \Exception Throws an exception when a readable asset registry file cannot be found.
*/
public static function get_script_asset_filename( $script_path_name, $file ) {
$minification_supported = Features::exists( 'minified-js' );
$script_min_filename = $file . '.min.asset.php';
$script_nonmin_filename = $file . '.asset.php';
$script_asset_path = WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/';
// Check minification is supported first, to avoid multiple is_readable checks when minification is
// not supported.
if ( $minification_supported && is_readable( $script_asset_path . $script_min_filename ) ) {
return $script_min_filename;
} elseif ( is_readable( $script_asset_path . $script_nonmin_filename ) ) {
return $script_nonmin_filename;
} else {
// could not find an asset file, throw an error.
throw new \Exception( 'Could not find asset registry for ' . $script_path_name );
}
}
/**
* Render a preload link tag for a dependency, optionally
* checked against a provided allowlist.
*
* See: https://macarthur.me/posts/preloading-javascript-in-wordpress
*
* @param WP_Dependency $dependency The WP_Dependency being preloaded.
* @param string $type Dependency type - 'script' or 'style'.
* @param array $allowlist Optional. List of allowed dependency handles.
*/
private function maybe_output_preload_link_tag( $dependency, $type, $allowlist = array() ) {
if (
(
! empty( $allowlist ) &&
! in_array( $dependency->handle, $allowlist, true )
) ||
( ! empty( $this->preloaded_dependencies[ $type ] ) &&
in_array( $dependency->handle, $this->preloaded_dependencies[ $type ], true ) )
) {
return;
}
$this->preloaded_dependencies[ $type ][] = $dependency->handle;
$source = $dependency->ver ? add_query_arg( 'ver', $dependency->ver, $dependency->src ) : $dependency->src;
echo '<link rel="preload" href="', esc_url( $source ), '" as="', esc_attr( $type ), '" />', "\n";
}
/**
* Output a preload link tag for dependencies (and their sub dependencies)
* with an optional allowlist.
*
* See: https://macarthur.me/posts/preloading-javascript-in-wordpress
*
* @param string $type Dependency type - 'script' or 'style'.
* @param array $allowlist Optional. List of allowed dependency handles.
*/
private function output_header_preload_tags_for_type( $type, $allowlist = array() ) {
if ( $type === 'script' ) {
$dependencies_of_type = wp_scripts();
} elseif ( $type === 'style' ) {
$dependencies_of_type = wp_styles();
} else {
return;
}
foreach ( $dependencies_of_type->queue as $dependency_handle ) {
$dependency = $dependencies_of_type->query( $dependency_handle, 'registered' );
if ( $dependency === false ) {
continue;
}
// Preload the subdependencies first.
foreach ( $dependency->deps as $sub_dependency_handle ) {
$sub_dependency = $dependencies_of_type->query( $sub_dependency_handle, 'registered' );
if ( $sub_dependency ) {
$this->maybe_output_preload_link_tag( $sub_dependency, $type, $allowlist );
}
}
$this->maybe_output_preload_link_tag( $dependency, $type, $allowlist );
}
}
/**
* Output preload link tags for all enqueued stylesheets and scripts.
*
* See: https://macarthur.me/posts/preloading-javascript-in-wordpress
*/
private function output_header_preload_tags() {
$wc_admin_scripts = array(
WC_ADMIN_APP,
'wc-components',
);
$wc_admin_styles = array(
WC_ADMIN_APP,
'wc-components',
'wc-material-icons',
);
// Preload styles.
$this->output_header_preload_tags_for_type( 'style', $wc_admin_styles );
// Preload scripts.
$this->output_header_preload_tags_for_type( 'script', $wc_admin_scripts );
}
/**
* Loads the required scripts on the correct pages.
*/
public function enqueue_assets() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
wp_enqueue_script( WC_ADMIN_APP );
wp_enqueue_style( WC_ADMIN_APP );
wp_enqueue_style( 'wc-material-icons' );
wp_enqueue_style( 'wc-onboarding' );
// Preload our assets.
$this->output_header_preload_tags();
}
/**
* Registers all the necessary scripts and styles to show the admin experience.
*/
public function register_scripts() {
if ( ! function_exists( 'wp_set_script_translations' ) ) {
return;
}
$js_file_version = self::get_file_version( 'js' );
$css_file_version = self::get_file_version( 'css' );
$scripts = array(
'wc-admin-layout',
'wc-explat',
'wc-experimental',
'wc-customer-effort-score',
// NOTE: This should be removed when Gutenberg is updated and the notices package is removed from WooCommerce Admin.
'wc-notices',
'wc-number',
'wc-tracks',
'wc-date',
'wc-components',
WC_ADMIN_APP,
'wc-csv',
'wc-store-data',
'wc-currency',
'wc-navigation',
'wc-product-editor',
);
$scripts_map = array(
WC_ADMIN_APP => 'app',
'wc-csv' => 'csv-export',
'wc-store-data' => 'data',
);
$translated_scripts = array(
'wc-currency',
'wc-date',
'wc-components',
'wc-customer-effort-score',
'wc-experimental',
WC_ADMIN_APP,
);
foreach ( $scripts as $script ) {
$script_path_name = isset( $scripts_map[ $script ] ) ? $scripts_map[ $script ] : str_replace( 'wc-', '', $script );
try {
$script_assets_filename = self::get_script_asset_filename( $script_path_name, 'index' );
$script_assets = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;
global $wp_version;
if ( 'app' === $script_path_name && version_compare( $wp_version, '6.3', '<' ) ) {
// Remove wp-router dependency for WordPress versions < 6.3 because wp-router is not included in those versions. We only use wp-router in customize store pages and the feature is only available in WordPress 6.3+.
// We can remove this once our minimum support is WP 6.3.
$script_assets['dependencies'] = array_diff( $script_assets['dependencies'], array( 'wp-router' ) );
}
wp_register_script(
$script,
self::get_url( $script_path_name . '/index', 'js' ),
$script_assets ['dependencies'],
$js_file_version,
true
);
if ( in_array( $script, $translated_scripts, true ) ) {
wp_set_script_translations( $script, 'woocommerce' );
}
} catch ( \Exception $e ) {
// Avoid crashing WordPress if an asset file could not be loaded.
wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, $script_path_name );
}
}
wp_register_style(
'wc-admin-layout',
self::get_url( 'admin-layout/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-admin-layout', 'rtl', 'replace' );
wp_register_style(
'wc-components',
self::get_url( 'components/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-components', 'rtl', 'replace' );
wp_register_style(
'wc-product-editor',
self::get_url( 'product-editor/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-product-editor', 'rtl', 'replace' );
wp_register_style(
'wc-customer-effort-score',
self::get_url( 'customer-effort-score/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-customer-effort-score', 'rtl', 'replace' );
wp_register_style(
'wc-experimental',
self::get_url( 'experimental/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-experimental', 'rtl', 'replace' );
wp_localize_script(
WC_ADMIN_APP,
'wcAdminAssets',
array(
'path' => plugins_url( self::get_path( 'js' ), WC_ADMIN_PLUGIN_FILE ),
'version' => $js_file_version,
)
);
wp_register_style(
WC_ADMIN_APP,
self::get_url( 'app/style', 'css' ),
array( 'wc-components', 'wc-admin-layout', 'wc-customer-effort-score', 'wc-product-editor', 'wp-components', 'wc-experimental' ),
$css_file_version
);
wp_style_add_data( WC_ADMIN_APP, 'rtl', 'replace' );
wp_register_style(
'wc-onboarding',
self::get_url( 'onboarding/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-onboarding', 'rtl', 'replace' );
}
/**
* Injects wp-shared-settings as a dependency if it's present.
*/
public function inject_wc_settings_dependencies() {
if ( wp_script_is( 'wc-settings', 'registered' ) ) {
$handles_for_injection = [
'wc-admin-layout',
'wc-csv',
'wc-currency',
'wc-customer-effort-score',
'wc-navigation',
// NOTE: This should be removed when Gutenberg is updated and
// the notices package is removed from WooCommerce Admin.
'wc-notices',
'wc-number',
'wc-date',
'wc-components',
'wc-tracks',
'wc-product-editor',
];
foreach ( $handles_for_injection as $handle ) {
$script = wp_scripts()->query( $handle, 'registered' );
if ( $script instanceof _WP_Dependency ) {
$script->deps[] = 'wc-settings';
}
}
}
}
/**
* Loads a script
*
* @param string $script_path_name The script path name.
* @param string $script_name Filename of the script to load.
* @param bool $need_translation Whether the script need translations.
*/
public static function register_script( $script_path_name, $script_name, $need_translation = false ) {
$script_assets_filename = self::get_script_asset_filename( $script_path_name, $script_name );
$script_assets = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;
wp_enqueue_script(
'wc-admin-' . $script_name,
self::get_url( $script_path_name . '/' . $script_name, 'js' ),
array_merge( array( WC_ADMIN_APP ), $script_assets ['dependencies'] ),
self::get_file_version( 'js' ),
true
);
if ( $need_translation ) {
wp_set_script_translations( 'wc-admin-' . $script_name, 'woocommerce' );
}
}
}
Internal/Admin/WCAdminSharedSettings.php 0000644 00000003006 15153704477 0014227 0 ustar 00 <?php
/**
* Manages the WC Admin settings that need to be pre-loaded.
*/
namespace Automattic\WooCommerce\Internal\Admin;
defined( 'ABSPATH' ) || exit;
/**
* \Automattic\WooCommerce\Internal\Admin\WCAdminSharedSettings class.
*/
class WCAdminSharedSettings {
/**
* Settings prefix used for the window.wcSettings object.
*
* @var string
*/
private $settings_prefix = 'admin';
/**
* Class instance.
*
* @var WCAdminSharedSettings instance
*/
protected static $instance = null;
/**
* Hook into WooCommerce Blocks.
*/
protected function __construct() {
if ( did_action( 'woocommerce_blocks_loaded' ) ) {
$this->on_woocommerce_blocks_loaded();
} else {
add_action( 'woocommerce_blocks_loaded', array( $this, 'on_woocommerce_blocks_loaded' ), 10 );
}
}
/**
* Get class instance.
*
* @return object Instance.
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Adds settings to the Blocks AssetDataRegistry when woocommerce_blocks is loaded.
*
* @return void
*/
public function on_woocommerce_blocks_loaded() {
if ( class_exists( '\Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry' ) ) {
\Automattic\WooCommerce\Blocks\Package::container()->get( \Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry::class )->add(
$this->settings_prefix,
function() {
return apply_filters( 'woocommerce_admin_shared_settings', array() );
},
true
);
}
}
}
Internal/Admin/WCAdminUser.php 0000644 00000007745 15153704477 0012234 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin;
/**
* WCAdminUser Class.
*/
class WCAdminUser {
/**
* Class instance.
*
* @var WCAdminUser instance
*/
protected static $instance = null;
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_user_data' ) );
}
/**
* Get class instance.
*
* @return object Instance.
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Registers WooCommerce specific user data to the WordPress user API.
*/
public function register_user_data() {
register_rest_field(
'user',
'is_super_admin',
array(
'get_callback' => function() {
return is_super_admin();
},
'schema' => null,
)
);
register_rest_field(
'user',
'woocommerce_meta',
array(
'get_callback' => array( $this, 'get_user_data_values' ),
'update_callback' => array( $this, 'update_user_data_values' ),
'schema' => null,
)
);
}
/**
* For all the registered user data fields ( Loader::get_user_data_fields ), fetch the data
* for returning via the REST API.
*
* @param WP_User $user Current user.
*/
public function get_user_data_values( $user ) {
$values = array();
foreach ( $this->get_user_data_fields() as $field ) {
$values[ $field ] = self::get_user_data_field( $user['id'], $field );
}
return $values;
}
/**
* For all the registered user data fields ( Loader::get_user_data_fields ), update the data
* for the REST API.
*
* @param array $values The new values for the meta.
* @param WP_User $user The current user.
* @param string $field_id The field id for the user meta.
*/
public function update_user_data_values( $values, $user, $field_id ) {
if ( empty( $values ) || ! is_array( $values ) || 'woocommerce_meta' !== $field_id ) {
return;
}
$fields = $this->get_user_data_fields();
$updates = array();
foreach ( $values as $field => $value ) {
if ( in_array( $field, $fields, true ) ) {
$updates[ $field ] = $value;
self::update_user_data_field( $user->ID, $field, $value );
}
}
return $updates;
}
/**
* We store some WooCommerce specific user meta attached to users endpoint,
* so that we can track certain preferences or values such as the inbox activity panel last open time.
* Additional fields can be added in the function below, and then used via wc-admin's currentUser data.
*
* @return array Fields to expose over the WP user endpoint.
*/
public function get_user_data_fields() {
/**
* Filter user data fields exposed over the WordPress user endpoint.
*
* @since 4.0.0
* @param array $fields Array of fields to expose over the WP user endpoint.
*/
return apply_filters( 'woocommerce_admin_get_user_data_fields', array( 'variable_product_tour_shown' ) );
}
/**
* Helper to update user data fields.
*
* @param int $user_id User ID.
* @param string $field Field name.
* @param mixed $value Field value.
*/
public static function update_user_data_field( $user_id, $field, $value ) {
update_user_meta( $user_id, 'woocommerce_admin_' . $field, $value );
}
/**
* Helper to retrieve user data fields.
*
* Migrates old key prefixes as well.
*
* @param int $user_id User ID.
* @param string $field Field name.
* @return mixed The user field value.
*/
public static function get_user_data_field( $user_id, $field ) {
$meta_value = get_user_meta( $user_id, 'woocommerce_admin_' . $field, true );
// Migrate old meta values (prefix changed from `wc_admin_` to `woocommerce_admin_`).
if ( '' === $meta_value ) {
$old_meta_value = get_user_meta( $user_id, 'wc_admin_' . $field, true );
if ( '' !== $old_meta_value ) {
self::update_user_data_field( $user_id, $field, $old_meta_value );
delete_user_meta( $user_id, 'wc_admin_' . $field );
$meta_value = $old_meta_value;
}
}
return $meta_value;
}
}
Internal/Admin/WCPayPromotion/Init.php 0000644 00000012223 15153704477 0013673 0 ustar 00 <?php
/**
* Handles wcpay promotion
*/
namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\DataSourcePoller;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\EvaluateSuggestion;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaySuggestionsDataSourcePoller as PaymentGatewaySuggestionsDataSourcePoller;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* WC Pay Promotion engine.
*/
class Init {
const EXPLAT_VARIATION_PREFIX = 'woocommerce_wc_pay_promotion_payment_methods_table_';
/**
* Constructor.
*/
public function __construct() {
include_once __DIR__ . '/WCPaymentGatewayPreInstallWCPayPromotion.php';
$is_payments_page = isset( $_GET['page'] ) && $_GET['page'] === 'wc-settings' && isset( $_GET['tab'] ) && $_GET['tab'] === 'checkout'; // phpcs:ignore WordPress.Security.NonceVerification
if ( ! wp_is_json_request() && ! $is_payments_page ) {
return;
}
add_filter( 'woocommerce_payment_gateways', array( __CLASS__, 'possibly_register_pre_install_wc_pay_promotion_gateway' ) );
add_filter( 'option_woocommerce_gateway_order', [ __CLASS__, 'set_gateway_top_of_list' ] );
add_filter( 'default_option_woocommerce_gateway_order', [ __CLASS__, 'set_gateway_top_of_list' ] );
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style(
'wc-admin-payment-method-promotions',
WCAdminAssets::get_url( "payment-method-promotions/style{$rtl}", 'css' ),
array( 'wp-components' ),
WCAdminAssets::get_file_version( 'css' )
);
WCAdminAssets::register_script( 'wp-admin-scripts', 'payment-method-promotions', true );
}
/**
* Possibly registers the pre install wc pay promoted gateway.
*
* @param array $gateways list of gateway classes.
* @return array list of gateway classes.
*/
public static function possibly_register_pre_install_wc_pay_promotion_gateway( $gateways ) {
if ( self::can_show_promotion() && ! WCPaymentGatewayPreInstallWCPayPromotion::is_dismissed() ) {
$gateways[] = 'Automattic\WooCommerce\Internal\Admin\WCPayPromotion\WCPaymentGatewayPreInstallWCPayPromotion';
}
return $gateways;
}
/**
* Checks if promoted gateway can be registered.
*
* @return boolean if promoted gateway should be registered.
*/
public static function can_show_promotion() {
// Check if WC Pay is enabled.
if ( class_exists( '\WC_Payments' ) ) {
return false;
}
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return false;
}
if ( ! apply_filters( 'woocommerce_allow_marketplace_suggestions', true ) ) {
return false;
}
$wc_pay_spec = self::get_wc_pay_promotion_spec();
if ( ! $wc_pay_spec ) {
return false;
}
return true;
}
/**
* By default, new payment gateways are put at the bottom of the list on the admin "Payments" settings screen.
* For visibility, we want WooCommerce Payments to be at the top of the list.
*
* @param array $ordering Existing ordering of the payment gateways.
*
* @return array Modified ordering.
*/
public static function set_gateway_top_of_list( $ordering ) {
$ordering = (array) $ordering;
$id = WCPaymentGatewayPreInstallWCPayPromotion::GATEWAY_ID;
// Only tweak the ordering if the list hasn't been reordered with WooCommerce Payments in it already.
if ( ! isset( $ordering[ $id ] ) || ! is_numeric( $ordering[ $id ] ) ) {
$is_empty = empty( $ordering ) || ( count( $ordering ) === 1 && $ordering[0] === false );
$ordering[ $id ] = $is_empty ? 0 : ( min( $ordering ) - 1 );
}
return $ordering;
}
/**
* Get WC Pay promotion spec.
*/
public static function get_wc_pay_promotion_spec() {
$promotions = self::get_promotions();
$wc_pay_promotion_spec = array_values(
array_filter(
$promotions,
function( $promotion ) {
return isset( $promotion->plugins ) && in_array( 'woocommerce-payments', $promotion->plugins, true );
}
)
);
return current( $wc_pay_promotion_spec );
}
/**
* Go through the specs and run them.
*/
public static function get_promotions() {
$suggestions = array();
$specs = self::get_specs();
foreach ( $specs as $spec ) {
$suggestion = EvaluateSuggestion::evaluate( $spec );
$suggestions[] = $suggestion;
}
return array_values(
array_filter(
$suggestions,
function( $suggestion ) {
return ! property_exists( $suggestion, 'is_visible' ) || $suggestion->is_visible;
}
)
);
}
/**
* Get merchant WooPay eligibility.
*/
public static function is_woopay_eligible() {
$wcpay_promotion = self::get_wc_pay_promotion_spec();
return $wcpay_promotion && 'woocommerce_payments:woopay' === $wcpay_promotion->id;
}
/**
* Delete the specs transient.
*/
public static function delete_specs_transient() {
WCPayPromotionDataSourcePoller::get_instance()->delete_specs_transient();
}
/**
* Get specs or fetch remotely if they don't exist.
*/
public static function get_specs() {
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return array();
}
return WCPayPromotionDataSourcePoller::get_instance()->get_specs_from_data_sources();
}
}
Internal/Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.php 0000644 00000001425 15153704477 0021015 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;
use Automattic\WooCommerce\Admin\DataSourcePoller;
/**
* Specs data source poller class for WooCommerce Payment Promotion.
*/
class WCPayPromotionDataSourcePoller extends DataSourcePoller {
const ID = 'payment_method_promotion';
/**
* Default data sources array.
*/
const DATA_SOURCES = array(
'https://woocommerce.com/wp-json/wccom/payment-gateway-suggestions/1.0/payment-method/promotions.json',
);
/**
* Class instance.
*
* @var Analytics instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self( self::ID, self::DATA_SOURCES );
}
return self::$instance;
}
}
Internal/Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php 0000644 00000003224 15153704500 0022756 0 ustar 00 <?php
/**
* Class WCPaymentGatewayPreInstallWCPayPromotion
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* A Psuedo WCPay gateway class.
*
* @extends WC_Payment_Gateway
*/
class WCPaymentGatewayPreInstallWCPayPromotion extends \WC_Payment_Gateway {
const GATEWAY_ID = 'pre_install_woocommerce_payments_promotion';
/**
* Constructor
*/
public function __construct() {
$wc_pay_spec = Init::get_wc_pay_promotion_spec();
if ( ! $wc_pay_spec ) {
return;
}
$this->id = static::GATEWAY_ID;
$this->method_title = $wc_pay_spec->title;
if ( property_exists( $wc_pay_spec, 'sub_title' ) ) {
$this->title = sprintf( '<span class="gateway-subtitle" >%s</span>', $wc_pay_spec->sub_title );
}
$this->method_description = $wc_pay_spec->content;
$this->has_fields = false;
// Get setting values.
$this->enabled = false;
// Load the settings.
$this->init_form_fields();
$this->init_settings();
}
/**
* Initialise Gateway Settings Form Fields.
*/
public function init_form_fields() {
$this->form_fields = array(
'is_dismissed' => array(
'title' => __( 'Dismiss', 'woocommerce' ),
'type' => 'checkbox',
'label' => __( 'Dismiss the gateway', 'woocommerce' ),
'default' => 'no',
),
);
}
/**
* Check if the promotional gateaway has been dismissed.
*
* @return bool
*/
public static function is_dismissed() {
$settings = get_option( 'woocommerce_' . self::GATEWAY_ID . '_settings', array() );
return isset( $settings['is_dismissed'] ) && 'yes' === $settings['is_dismissed'];
}
}
Internal/Admin/WcPayWelcomePage.php 0000644 00000033712 15153704500 0013223 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\WooCommercePayments;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Admin\PageController;
/**
* Class WCPayWelcomePage
*
* @package Automattic\WooCommerce\Admin\Features
*/
class WcPayWelcomePage {
const CACHE_TRANSIENT_NAME = 'wcpay_welcome_page_incentive';
const HAD_WCPAY_OPTION_NAME = 'wcpay_was_in_use';
/**
* Plugin instance.
*
* @var WcPayWelcomePage
*/
protected static $instance = null;
/**
* Main Instance.
*/
public static function instance() {
self::$instance = is_null( self::$instance ) ? new self() : self::$instance;
return self::$instance;
}
/**
* Eligible incentive for the store.
*
* @var array|null
*/
private $incentive = null;
/**
* WCPayWelcomePage constructor.
*/
public function __construct() {
add_action( 'admin_menu', [ $this, 'register_payments_welcome_page' ] );
add_filter( 'woocommerce_admin_shared_settings', [ $this, 'shared_settings' ] );
add_filter( 'woocommerce_admin_allowed_promo_notes', [ $this, 'allowed_promo_notes' ] );
add_filter( 'woocommerce_admin_woopayments_onboarding_task_badge', [ $this, 'onboarding_task_badge' ] );
}
/**
* Whether the WooPayments welcome page should be visible.
*
* @return boolean
*/
public function must_be_visible(): bool {
// The WooPayments plugin must not be active.
if ( $this->is_wcpay_active() ) {
return false;
}
// Suggestions not disabled via a setting.
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return false;
}
/**
* Filter allow marketplace suggestions.
*
* User can disable all suggestions via filter.
*
* @since 3.6.0
*/
if ( ! apply_filters( 'woocommerce_allow_marketplace_suggestions', true ) ) {
return false;
}
// An incentive must be available.
if ( empty( $this->get_incentive() ) ) {
return false;
}
// Incentive not manually dismissed.
if ( $this->is_incentive_dismissed() ) {
return false;
}
return true;
}
/**
* Registers the WooPayments welcome page.
*/
public function register_payments_welcome_page() {
global $menu;
if ( ! $this->must_be_visible() ) {
return;
}
$menu_icon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NTIiIGhlaWdodD0iNjg0Ij48cGF0aCBmaWxsPSIjYTJhYWIyIiBkPSJNODIgODZ2NTEyaDY4NFY4NlptMCA1OThjLTQ4IDAtODQtMzgtODQtODZWODZDLTIgMzggMzQgMCA4MiAwaDY4NGM0OCAwIDg0IDM4IDg0IDg2djUxMmMwIDQ4LTM2IDg2LTg0IDg2em0zODQtNTU2djQ0aDg2djg0SDM4MnY0NGgxMjhjMjQgMCA0MiAxOCA0MiA0MnYxMjhjMCAyNC0xOCA0Mi00MiA0MmgtNDR2NDRoLTg0di00NGgtODZ2LTg0aDE3MHYtNDRIMzM4Yy0yNCAwLTQyLTE4LTQyLTQyVjIxNGMwLTI0IDE4LTQyIDQyLTQyaDQ0di00NHoiLz48L3N2Zz4=';
$menu_data = [
'id' => 'wc-calypso-bridge-payments-welcome-page',
'title' => esc_html__( 'Payments', 'woocommerce' ),
'path' => '/wc-pay-welcome-page',
'position' => '56',
'nav_args' => [
'title' => esc_html__( 'WooPayments', 'woocommerce' ),
'is_category' => false,
'menuId' => 'plugins',
'is_top_level' => true,
],
'icon' => $menu_icon,
];
wc_admin_register_page( $menu_data );
// Registering a top level menu via wc_admin_register_page doesn't work when the new
// nav is enabled. The new nav disabled everything, except the 'WooCommerce' menu.
// We need to register this menu via add_menu_page so that it doesn't become a child of
// WooCommerce menu.
if ( get_option( 'woocommerce_navigation_enabled', 'no' ) === 'yes' ) {
$menu_with_nav_data = [
esc_html__( 'Payments', 'woocommerce' ),
esc_html__( 'Payments', 'woocommerce' ),
'view_woocommerce_reports',
'admin.php?page=wc-admin&path=/wc-pay-welcome-page',
null,
$menu_icon,
56,
];
call_user_func_array( 'add_menu_page', $menu_with_nav_data );
}
// Add badge.
$badge = ' <span class="wcpay-menu-badge awaiting-mod count-1"><span class="plugin-count">1</span></span>';
foreach ( $menu as $index => $menu_item ) {
// Only add the badge markup if not already present and the menu item is the WooPayments menu item.
if ( false === strpos( $menu_item[0], $badge )
&& ( 'wc-admin&path=/wc-pay-welcome-page' === $menu_item[2]
|| 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' === $menu_item[2] )
) {
$menu[ $index ][0] .= $badge; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// One menu item with a badge is more than enough.
break;
}
}
}
/**
* Adds shared settings for the WooPayments incentive.
*
* @param array $settings Shared settings.
* @return array
*/
public function shared_settings( $settings ): array {
// Return early if not on a wc-admin powered page.
if ( ! PageController::is_admin_page() ) {
return $settings;
}
// Return early if the incentive must not be visible.
if ( ! $this->must_be_visible() ) {
return $settings;
}
$settings['wcpayWelcomePageIncentive'] = $this->get_incentive();
return $settings;
}
/**
* Adds allowed promo notes from the WooPayments incentive.
*
* @param array $promo_notes Allowed promo notes.
* @return array
*/
public function allowed_promo_notes( $promo_notes = [] ): array {
// Return early if the incentive must not be visible.
if ( ! $this->must_be_visible() ) {
return $promo_notes;
}
// Add our incentive ID to the promo notes.
$promo_notes[] = $this->get_incentive()['id'];
return $promo_notes;
}
/**
* Adds the WooPayments incentive badge to the onboarding task.
*
* @param string $badge Current badge.
*
* @return string
*/
public function onboarding_task_badge( string $badge ): string {
// Return early if the incentive must not be visible.
if ( ! $this->must_be_visible() ) {
return $badge;
}
return $this->get_incentive()['task_badge'] ?? $badge;
}
/**
* Check if the WooPayments payment gateway is active and set up or was at some point,
* or there are orders processed with it, at some moment.
*
* @return boolean
*/
private function has_wcpay(): bool {
// First, get the stored value, if it exists.
// This way we avoid costly DB queries and API calls.
// Basically, we only want to know if WooPayments was in use in the past.
// Since the past can't be changed, neither can this value.
$had_wcpay = get_option( self::HAD_WCPAY_OPTION_NAME );
if ( false !== $had_wcpay ) {
return $had_wcpay === 'yes';
}
// We need to determine the value.
// Start with the assumption that the store didn't have WooPayments in use.
$had_wcpay = false;
// We consider the store to have WooPayments if there is meaningful account data in the WooPayments account cache.
// This implies that WooPayments was active at some point and that it was connected.
// If WooPayments is active right now, we will not get to this point since the plugin is active check is done first.
if ( $this->has_wcpay_account_data() ) {
$had_wcpay = true;
}
// If there is at least one order processed with WooPayments, we consider the store to have WooPayments.
if ( false === $had_wcpay && ! empty(
wc_get_orders(
[
'payment_method' => 'woocommerce_payments',
'return' => 'ids',
'limit' => 1,
]
)
) ) {
$had_wcpay = true;
}
// Store the value for future use.
update_option( self::HAD_WCPAY_OPTION_NAME, $had_wcpay ? 'yes' : 'no' );
return $had_wcpay;
}
/**
* Check if the WooPayments plugin is active.
*
* @return boolean
*/
private function is_wcpay_active(): bool {
return class_exists( '\WC_Payments' );
}
/**
* Check if there is meaningful data in the WooPayments account cache.
*
* @return boolean
*/
private function has_wcpay_account_data(): bool {
$account_data = get_option( 'wcpay_account_data', [] );
if ( ! empty( $account_data['data']['account_id'] ) ) {
return true;
}
return false;
}
/**
* Check if the current incentive has been manually dismissed.
*
* @return boolean
*/
private function is_incentive_dismissed(): bool {
$dismissed_incentives = get_option( 'wcpay_welcome_page_incentives_dismissed', [] );
// If there are no dismissed incentives, return early.
if ( empty( $dismissed_incentives ) ) {
return false;
}
// Return early if there is no eligible incentive.
$incentive = $this->get_incentive();
if ( empty( $incentive ) ) {
return true;
}
// Search the incentive ID in the dismissed incentives list.
if ( in_array( $incentive['id'], $dismissed_incentives, true ) ) {
return true;
}
return false;
}
/**
* Fetches and caches eligible incentive from the WooPayments API.
*
* @return array|null Array of eligible incentive or null.
*/
private function get_incentive(): ?array {
// Return in-memory cached incentive if it is set.
if ( isset( $this->incentive ) ) {
return $this->incentive;
}
// Get the cached data.
$cache = get_transient( self::CACHE_TRANSIENT_NAME );
// If the cached data is not expired and it's a WP_Error,
// it means there was an API error previously and we should not retry just yet.
if ( is_wp_error( $cache ) ) {
// Initialize the in-memory cache and return it.
$this->incentive = [];
return $this->incentive;
}
// Gather the store context data.
$store_context = [
// Store ISO-2 country code, e.g. `US`.
'country' => WC()->countries->get_base_country(),
// Store locale, e.g. `en_US`.
'locale' => get_locale(),
// WooCommerce active for duration in seconds.
'active_for' => WCAdminHelper::get_wcadmin_active_for_in_seconds(),
// Whether the store has paid orders in the last 90 days.
'has_orders' => ! empty(
wc_get_orders(
[
'status' => [ 'wc-completed', 'wc-processing' ],
'date_created' => '>=' . strtotime( '-90 days' ),
'return' => 'ids',
'limit' => 1,
]
)
),
// Whether the store has at least one payment gateway enabled.
'has_payments' => ! empty( WC()->payment_gateways()->get_available_payment_gateways() ),
'has_wcpay' => $this->has_wcpay(),
];
// Fingerprint the store context through a hash of certain entries.
$store_context_hash = $this->generate_context_hash( $store_context );
// Use the transient cached incentive if it exists, it is not expired,
// and the store context hasn't changed since we last requested from the WooPayments API (based on context hash).
if ( false !== $cache
&& ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] )
&& hash_equals( $store_context_hash, $cache['context_hash'] ) ) {
// We have a store context hash and it matches with the current context one.
// We can use the cached incentive data.
// Store the incentive in the in-memory cache and return it.
$this->incentive = $cache['incentive'] ?? [];
return $this->incentive;
}
// By this point, we have an expired transient or the store context has changed.
// Query for incentives by calling the WooPayments API.
$url = add_query_arg(
$store_context,
'https://public-api.wordpress.com/wpcom/v2/wcpay/incentives',
);
$response = wp_remote_get(
$url,
[
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
]
);
// Return early if there is an error, waiting 6 hours before the next attempt.
if ( is_wp_error( $response ) ) {
// Store a trimmed down, lightweight error.
$error = new \WP_Error(
$response->get_error_code(),
$response->get_error_message(),
wp_remote_retrieve_response_code( $response )
);
// Store the error in the transient so we know this is due to an API error.
set_transient( self::CACHE_TRANSIENT_NAME, $error, HOUR_IN_SECONDS * 6 );
// Initialize the in-memory cache and return it.
$this->incentive = [];
return $this->incentive;
}
$cache_for = wp_remote_retrieve_header( $response, 'cache-for' );
// Initialize the in-memory cache.
$this->incentive = [];
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
// Decode the results, falling back to an empty array.
$results = json_decode( wp_remote_retrieve_body( $response ), true ) ?? [];
// Find all `welcome_page` incentives.
$incentives = array_filter(
$results,
function( $incentive ) {
return 'welcome_page' === $incentive['type'];
}
);
// Use the first found matching incentive or empty array if none was found.
// Store incentive in the in-memory cache.
$this->incentive = empty( $incentives ) ? [] : reset( $incentives );
}
// Skip transient cache if `cache-for` header equals zero.
if ( '0' === $cache_for ) {
// If we have a transient cache that is not expired, delete it so there are no leftovers.
if ( false !== $cache ) {
delete_transient( self::CACHE_TRANSIENT_NAME );
}
return $this->incentive;
}
// Store incentive in transient cache (together with the context hash) for the given number of seconds
// or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched.
set_transient(
self::CACHE_TRANSIENT_NAME,
[
'incentive' => $this->incentive,
'context_hash' => $store_context_hash,
'timestamp' => time(),
],
! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS
);
return $this->incentive;
}
/**
* Generate a hash from the store context data.
*
* @param array $context The store context data.
*
* @return string The context hash.
*/
private function generate_context_hash( array $context ): string {
// Include only certain entries in the context hash.
// We need only discrete, user-interaction dependent data.
// Entries like `active_for` have no place in the hash generation since they change automatically.
return md5(
wp_json_encode(
[
'country' => $context['country'] ?? '',
'locale' => $context['locale'] ?? '',
'has_orders' => $context['has_orders'] ?? false,
'has_payments' => $context['has_payments'] ?? false,
'has_wcpay' => $context['has_wcpay'] ?? false,
]
)
);
}
}
Internal/AssignDefaultCategory.php 0000644 00000003631 15153704500 0013263 0 ustar 00 <?php
/**
* AssignDefaultCategory class file.
*/
namespace Automattic\WooCommerce\Internal;
defined( 'ABSPATH' ) || exit;
/**
* Class to assign default category to products.
*/
class AssignDefaultCategory {
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
add_action( 'wc_schedule_update_product_default_cat', array( $this, 'maybe_assign_default_product_cat' ) );
}
/**
* When a product category is deleted, we need to check
* if the product has no categories assigned. Then assign
* it a default category. We delay this with a scheduled
* action job to not block the response.
*
* @return void
*/
public function schedule_action() {
WC()->queue()->schedule_single(
time(),
'wc_schedule_update_product_default_cat',
array(),
'wc_update_product_default_cat'
);
}
/**
* Assigns default product category for products
* that have no categories.
*
* @return void
*/
public function maybe_assign_default_product_cat() {
global $wpdb;
$default_category = get_option( 'default_product_cat', 0 );
if ( $default_category ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->term_relationships} (object_id, term_taxonomy_id)
SELECT DISTINCT posts.ID, %s FROM {$wpdb->posts} posts
LEFT JOIN
(
SELECT object_id FROM {$wpdb->term_relationships} term_relationships
LEFT JOIN {$wpdb->term_taxonomy} term_taxonomy ON term_relationships.term_taxonomy_id = term_taxonomy.term_taxonomy_id
WHERE term_taxonomy.taxonomy = 'product_cat'
) AS tax_query
ON posts.ID = tax_query.object_id
WHERE posts.post_type = 'product'
AND tax_query.object_id IS NULL",
$default_category
)
);
wp_cache_flush();
delete_transient( 'wc_term_counts' );
wp_update_term_count_now( array( $default_category ), 'product_cat' );
}
}
}
Internal/BatchProcessing/BatchProcessingController.php 0000644 00000036204 15153704500 0017236 0 ustar 00 <?php
/**
* This class is a helper intended to handle data processings that need to happen in batches in a deferred way.
* It abstracts away the nuances of (re)scheduling actions and dealing with errors.
*
* Usage:
*
* 1. Create a class that implements BatchProcessorInterface.
* The class must either be registered in the dependency injection container, or have a public parameterless constructor,
* or an instance must be provided via the 'woocommerce_get_batch_processor' filter.
* 2. Whenever there's data to be processed invoke the 'enqueue_processor' method in this class,
* passing the class name of the processor.
*
* That's it, processing will be performed in batches inside scheduled actions; enqueued processors will only
* be dequeued once they notify that no more items are left to process (or when `force_clear_all_processes` is invoked).
* Failed batches will be retried after a while.
*
* There are also a few public methods to get the list of currently enqueued processors
* and to check if a given processor is enqueued/actually scheduled.
*/
namespace Automattic\WooCommerce\Internal\BatchProcessing;
/**
* Class BatchProcessingController
*
* @package Automattic\WooCommerce\Internal\Updates.
*/
class BatchProcessingController {
/*
* Identifier of a "watchdog" action that will schedule a processing action
* for any processor that is enqueued but not yet scheduled
* (because it's been just enqueued or because it threw an error while processing a batch),
* that's one single action that reschedules itself continuously.
*/
const WATCHDOG_ACTION_NAME = 'wc_schedule_pending_batch_processes';
/*
* Identifier of the action that will do the actual batch processing.
* There's one action per enqueued processor that will keep rescheduling itself
* as long as there are still pending items to process
* (except if there's an error that caused no items to be processed at all).
*/
const PROCESS_SINGLE_BATCH_ACTION_NAME = 'wc_run_batch_process';
const ENQUEUED_PROCESSORS_OPTION_NAME = 'wc_pending_batch_processes';
const ACTION_GROUP = 'wc_batch_processes';
/**
* Instance of WC_Logger class.
*
* @var \WC_Logger_Interface
*/
private $logger;
/**
* BatchProcessingController constructor.
*
* Schedules the necessary actions to process batches.
*/
public function __construct() {
add_action(
self::WATCHDOG_ACTION_NAME,
function () {
$this->handle_watchdog_action();
}
);
add_action(
self::PROCESS_SINGLE_BATCH_ACTION_NAME,
function ( $batch_process ) {
$this->process_next_batch_for_single_processor( $batch_process );
},
10,
2
);
$this->logger = wc_get_logger();
}
/**
* Enqueue a processor so that it will get batch processing requests from within scheduled actions.
*
* @param string $processor_class_name Fully qualified class name of the processor, must implement `BatchProcessorInterface`.
*/
public function enqueue_processor( string $processor_class_name ): void {
$pending_updates = $this->get_enqueued_processors();
if ( ! in_array( $processor_class_name, array_keys( $pending_updates ), true ) ) {
$pending_updates[] = $processor_class_name;
$this->set_enqueued_processors( $pending_updates );
}
$this->schedule_watchdog_action( false, true );
}
/**
* Schedule the watchdog action.
*
* @param bool $with_delay Whether to delay the action execution. Should be true when rescheduling, false when enqueueing.
* @param bool $unique Whether to make the action unique.
*/
private function schedule_watchdog_action( bool $with_delay = false, bool $unique = false ): void {
$time = $with_delay ? time() + HOUR_IN_SECONDS : time();
as_schedule_single_action(
$time,
self::WATCHDOG_ACTION_NAME,
array(),
self::ACTION_GROUP,
$unique
);
}
/**
* Schedule a processing action for all the processors that are enqueued but not scheduled
* (because they have just been enqueued, or because the processing for a batch failed).
*/
private function handle_watchdog_action(): void {
$pending_processes = $this->get_enqueued_processors();
if ( empty( $pending_processes ) ) {
return;
}
foreach ( $pending_processes as $process_name ) {
if ( ! $this->is_scheduled( $process_name ) ) {
$this->schedule_batch_processing( $process_name );
}
}
$this->schedule_watchdog_action( true );
}
/**
* Process a batch for a single processor, and handle any required rescheduling or state cleanup.
*
* @param string $processor_class_name Fully qualified class name of the processor.
*
* @throws \Exception If error occurred during batch processing.
*/
private function process_next_batch_for_single_processor( string $processor_class_name ): void {
if ( ! $this->is_enqueued( $processor_class_name ) ) {
return;
}
$batch_processor = $this->get_processor_instance( $processor_class_name );
$error = $this->process_next_batch_for_single_processor_core( $batch_processor );
$still_pending = count( $batch_processor->get_next_batch_to_process( 1 ) ) > 0;
if ( ( $error instanceof \Exception ) ) {
// The batch processing failed and no items were processed:
// reschedule the processing with a delay, and also throw the error
// so Action Scheduler will ignore the rescheduling if this happens repeatedly.
$this->schedule_batch_processing( $processor_class_name, true );
throw $error;
}
if ( $still_pending ) {
$this->schedule_batch_processing( $processor_class_name );
} else {
$this->dequeue_processor( $processor_class_name );
}
}
/**
* Process a batch for a single processor, updating state and logging any error.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
*
* @return null|\Exception Exception if error occurred, null otherwise.
*/
private function process_next_batch_for_single_processor_core( BatchProcessorInterface $batch_processor ): ?\Exception {
$details = $this->get_process_details( $batch_processor );
$time_start = microtime( true );
$batch = $batch_processor->get_next_batch_to_process( $details['current_batch_size'] );
if ( empty( $batch ) ) {
return null;
}
try {
$batch_processor->process_batch( $batch );
$time_taken = microtime( true ) - $time_start;
$this->update_processor_state( $batch_processor, $time_taken );
} catch ( \Exception $exception ) {
$time_taken = microtime( true ) - $time_start;
$this->log_error( $exception, $batch_processor, $batch );
$this->update_processor_state( $batch_processor, $time_taken, $exception );
return $exception;
}
return null;
}
/**
* Get the current state for a given enqueued processor.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
*
* @return array Current state for the processor, or a "blank" state if none exists yet.
*/
private function get_process_details( BatchProcessorInterface $batch_processor ): array {
return get_option(
$this->get_processor_state_option_name( $batch_processor ),
array(
'total_time_spent' => 0,
'current_batch_size' => $batch_processor->get_default_batch_size(),
'last_error' => null,
)
);
}
/**
* Get the name of the option where we will be saving state for a given processor.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
*
* @return string Option name.
*/
private function get_processor_state_option_name( BatchProcessorInterface $batch_processor ): string {
$class_name = get_class( $batch_processor );
$class_md5 = md5( $class_name );
// truncate the class name so we know that it will fit in the option name column along with md5 hash and prefix.
$class_name = substr( $class_name, 0, 140 );
return 'wc_batch_' . $class_name . '_' . $class_md5;
}
/**
* Update the state for a processor after a batch has completed processing.
*
* @param BatchProcessorInterface $batch_processor Batch processor instance.
* @param float $time_taken Time take by the batch to complete processing.
* @param \Exception|null $last_error Exception object in processing the batch, if there was one.
*/
private function update_processor_state( BatchProcessorInterface $batch_processor, float $time_taken, \Exception $last_error = null ): void {
$current_status = $this->get_process_details( $batch_processor );
$current_status['total_time_spent'] += $time_taken;
$current_status['last_error'] = null !== $last_error ? $last_error->getMessage() : null;
update_option( $this->get_processor_state_option_name( $batch_processor ), $current_status, false );
}
/**
* Schedule a processing action for a single processor.
*
* @param string $processor_class_name Fully qualified class name of the processor.
* @param bool $with_delay Whether to schedule the action for immediate execution or for later.
*/
private function schedule_batch_processing( string $processor_class_name, bool $with_delay = false ) : void {
$time = $with_delay ? time() + MINUTE_IN_SECONDS : time();
as_schedule_single_action( $time, self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
}
/**
* Check if a batch processing action is already scheduled for a given processor.
* Differs from `as_has_scheduled_action` in that this excludes actions in progress.
*
* @param string $processor_class_name Fully qualified class name of the batch processor.
*
* @return bool True if a batch processing action is already scheduled for the processor.
*/
public function is_scheduled( string $processor_class_name ): bool {
return as_has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
}
/**
* Get an instance of a processor given its class name.
*
* @param string $processor_class_name Full class name of the batch processor.
*
* @return BatchProcessorInterface Instance of batch processor for the given class.
* @throws \Exception If it's not possible to get an instance of the class.
*/
private function get_processor_instance( string $processor_class_name ) : BatchProcessorInterface {
$processor = wc_get_container()->get( $processor_class_name );
/**
* Filters the instance of a processor for a given class name.
*
* @param object|null $processor The processor instance given by the dependency injection container, or null if none was obtained.
* @param string $processor_class_name The full class name of the processor.
* @return BatchProcessorInterface|null The actual processor instance to use, or null if none could be retrieved.
*
* @since 6.8.0.
*/
$processor = apply_filters( 'woocommerce_get_batch_processor', $processor, $processor_class_name );
if ( ! isset( $processor ) && class_exists( $processor_class_name ) ) {
// This is a fallback for when the batch processor is not registered in the container.
$processor = new $processor_class_name();
}
if ( ! is_a( $processor, BatchProcessorInterface::class ) ) {
throw new \Exception( "Unable to initialize batch processor instance for $processor_class_name" );
}
return $processor;
}
/**
* Helper method to get list of all the enqueued processors.
*
* @return array List (of string) of the class names of the enqueued processors.
*/
public function get_enqueued_processors() : array {
return get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() );
}
/**
* Dequeue a processor once it has no more items pending processing.
*
* @param string $processor_class_name Full processor class name.
*/
private function dequeue_processor( string $processor_class_name ): void {
$pending_processes = $this->get_enqueued_processors();
if ( in_array( $processor_class_name, $pending_processes, true ) ) {
$pending_processes = array_diff( $pending_processes, array( $processor_class_name ) );
$this->set_enqueued_processors( $pending_processes );
}
}
/**
* Helper method to set the enqueued processor class names.
*
* @param array $processors List of full processor class names.
*/
private function set_enqueued_processors( array $processors ): void {
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $processors, false );
}
/**
* Check if a particular processor is enqueued.
*
* @param string $processor_class_name Fully qualified class name of the processor.
*
* @return bool True if the processor is enqueued.
*/
public function is_enqueued( string $processor_class_name ) : bool {
return in_array( $processor_class_name, $this->get_enqueued_processors(), true );
}
/**
* Dequeue and de-schedule a processor instance so that it won't be processed anymore.
*
* @param string $processor_class_name Fully qualified class name of the processor.
* @return bool True if the processor has been dequeued, false if the processor wasn't enqueued (so nothing has been done).
*/
public function remove_processor( string $processor_class_name ): bool {
$enqueued_processors = $this->get_enqueued_processors();
if ( ! in_array( $processor_class_name, $enqueued_processors, true ) ) {
return false;
}
$enqueued_processors = array_diff( $enqueued_processors, array( $processor_class_name ) );
if ( empty( $enqueued_processors ) ) {
$this->force_clear_all_processes();
} else {
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $enqueued_processors, false );
as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
}
return true;
}
/**
* Dequeues and de-schedules all the processors.
*/
public function force_clear_all_processes(): void {
as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME );
as_unschedule_all_actions( self::WATCHDOG_ACTION_NAME );
update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array(), false );
}
/**
* Log an error that happened while processing a batch.
*
* @param \Exception $error Exception object to log.
* @param BatchProcessorInterface $batch_processor Batch processor instance.
* @param array $batch Batch that was being processed.
*/
protected function log_error( \Exception $error, BatchProcessorInterface $batch_processor, array $batch ) : void {
$batch_detail_string = '';
// Log only first and last, as the entire batch may be too big.
if ( count( $batch ) > 0 ) {
$batch_detail_string = "\n" . wp_json_encode(
array(
'batch_start' => $batch[0],
'batch_end' => end( $batch ),
),
JSON_PRETTY_PRINT
);
}
$error_message = "Error processing batch for {$batch_processor->get_name()}: {$error->getMessage()}" . $batch_detail_string;
/**
* Filters the error message for a batch processing.
*
* @param string $error_message The error message that will be logged.
* @param \Exception $error The exception that was thrown by the processor.
* @param BatchProcessorInterface $batch_processor The processor that threw the exception.
* @param array $batch The batch that was being processed.
* @return string The actual error message that will be logged.
*
* @since 6.8.0
*/
$error_message = apply_filters( 'wc_batch_processing_log_message', $error_message, $error, $batch_processor, $batch );
$this->logger->error( $error_message, array( 'exception' => $error ) );
}
}
Internal/BatchProcessing/BatchProcessorInterface.php 0000644 00000005765 15153704500 0016666 0 ustar 00 <?php
/**
* Interface for batch data processors. See the BatchProcessingController class for usage details.
*/
namespace Automattic\WooCommerce\Internal\BatchProcessing;
/**
* Interface BatchProcessorInterface
*
* @package Automattic\WooCommerce\DataBase
*/
interface BatchProcessorInterface {
/**
* Get a user-friendly name for this processor.
*
* @return string Name of the processor.
*/
public function get_name() : string;
/**
* Get a user-friendly description for this processor.
*
* @return string Description of what this processor does.
*/
public function get_description() : string;
/**
* Get the total number of pending items that require processing.
* Once an item is successfully processed by 'process_batch' it shouldn't be included in this count.
*
* Note that the once the processor is enqueued the batch processor controller will keep
* invoking `get_next_batch_to_process` and `process_batch` repeatedly until this method returns zero.
*
* @return int Number of items pending processing.
*/
public function get_total_pending_count() : int;
/**
* Returns the next batch of items that need to be processed.
*
* A batch item can be anything needed to identify the actual processing to be done,
* but whenever possible items should be numbers (e.g. database record ids)
* or at least strings, to ease troubleshooting and logging in case of problems.
*
* The size of the batch returned can be less than $size if there aren't that
* many items pending processing (and it can be zero if there isn't anything to process),
* but the size should always be consistent with what 'get_total_pending_count' returns
* (i.e. the size of the returned batch shouldn't be larger than the pending items count).
*
* @param int $size Maximum size of the batch to be returned.
*
* @return array Batch of items to process, containing $size or less items.
*/
public function get_next_batch_to_process( int $size ) : array;
/**
* Process data for the supplied batch.
*
* This method should be prepared to receive items that don't actually need processing
* (because they have been processed before) and ignore them, but if at least
* one of the batch items that actually need processing can't be processed, an exception should be thrown.
*
* Once an item has been processed it shouldn't be counted in 'get_total_pending_count'
* nor included in 'get_next_batch_to_process' anymore (unless something happens that causes it
* to actually require further processing).
*
* @throw \Exception Something went wrong while processing the batch.
*
* @param array $batch Batch to process, as returned by 'get_next_batch_to_process'.
*/
public function process_batch( array $batch ): void;
/**
* Default (preferred) batch size to pass to 'get_next_batch_to_process'.
* The controller will pass this size unless it's externally configured
* to use a different size.
*
* @return int Default batch size.
*/
public function get_default_batch_size() : int;
}
Internal/DataStores/CustomMetaDataStore.php 0000644 00000013662 15153704500 0015002 0 ustar 00 <?php
/**
* CustomMetaDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores;
/**
* Implements functions similar to WP's add_metadata(), get_metadata(), and friends using a custom table.
*
* @see WC_Data_Store_WP For an implementation using WP's metadata functions and tables.
*/
abstract class CustomMetaDataStore {
/**
* Returns the name of the table used for storage.
*
* @return string
*/
abstract protected function get_table_name();
/**
* Returns the name of the field/column used for identifiying metadata entries.
*
* @return string
*/
protected function get_meta_id_field() {
return 'id';
}
/**
* Returns the name of the field/column used for associating meta with objects.
*
* @return string
*/
protected function get_object_id_field() {
return 'object_id';
}
/**
* Describes the structure of the metadata table.
*
* @return array Array elements: table, object_id_field, meta_id_field.
*/
protected function get_db_info() {
return array(
'table' => $this->get_table_name(),
'meta_id_field' => $this->get_meta_id_field(),
'object_id_field' => $this->get_object_id_field(),
);
}
/**
* Returns an array of meta for an object.
*
* @param WC_Data $object WC_Data object.
* @return array
*/
public function read_meta( &$object ) {
global $wpdb;
$db_info = $this->get_db_info();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw_meta_data = $wpdb->get_results(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']} AS meta_id, meta_key, meta_value FROM {$db_info['table']} WHERE {$db_info['object_id_field']} = %d ORDER BY meta_id",
$object->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $raw_meta_data;
}
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing at least ->id).
*
* @return bool
*/
public function delete_meta( &$object, $meta ) : bool {
global $wpdb;
if ( ! isset( $meta->id ) ) {
return false;
}
$db_info = $this->get_db_info();
$meta_id = absint( $meta->id );
return (bool) $wpdb->delete( $db_info['table'], array( $db_info['meta_id_field'] => $meta_id ) );
}
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->key and ->value).
*
* @return int|false meta ID
*/
public function add_meta( &$object, $meta ) {
global $wpdb;
$db_info = $this->get_db_info();
$object_id = $object->get_id();
$meta_key = wp_unslash( wp_slash( $meta->key ) );
$meta_value = maybe_serialize( is_string( $meta->value ) ? wp_unslash( wp_slash( $meta->value ) ) : $meta->value );
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$result = $wpdb->insert(
$db_info['table'],
array(
$db_info['object_id_field'] => $object_id,
'meta_key' => $meta_key,
'meta_value' => $meta_value,
)
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
return $result ? (int) $wpdb->insert_id : false;
}
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->id, ->key and ->value).
*
* @return bool
*/
public function update_meta( &$object, $meta ) : bool {
global $wpdb;
if ( ! isset( $meta->id ) || empty( $meta->key ) ) {
return false;
}
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$data = array(
'meta_key' => $meta->key,
'meta_value' => maybe_serialize( $meta->value ),
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$db_info = $this->get_db_info();
$result = $wpdb->update(
$db_info['table'],
$data,
array( $db_info['meta_id_field'] => $meta->id ),
'%s',
'%d'
);
return 1 === $result;
}
/**
* Retrieves metadata by meta ID.
*
* @param int $meta_id Meta ID.
* @return object|bool Metadata object or FALSE if not found.
*/
public function get_metadata_by_id( $meta_id ) {
global $wpdb;
if ( ! is_numeric( $meta_id ) || floor( $meta_id ) != $meta_id ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
return false;
}
$db_info = $this->get_db_info();
$meta_id = absint( $meta_id );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$meta = $wpdb->get_row(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE {$db_info['meta_id_field']} = %d",
$meta_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $meta ) ) {
return false;
}
if ( isset( $meta->meta_value ) ) {
$meta->meta_value = maybe_unserialize( $meta->meta_value );
}
return $meta;
}
/**
* Retrieves metadata by meta key.
*
* @param \WC_Abstract_Order $object Object ID.
* @param string $meta_key Meta key.
*
* @return \stdClass|bool Metadata object or FALSE if not found.
*/
public function get_metadata_by_key( &$object, string $meta_key ) {
global $wpdb;
$db_info = $this->get_db_info();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$meta = $wpdb->get_results(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE meta_key = %s AND {$db_info['object_id_field']} = %d",
$meta_key,
$object->get_id(),
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $meta ) ) {
return false;
}
foreach ( $meta as $row ) {
if ( isset( $row->meta_value ) ) {
$row->meta_value = maybe_unserialize( $row->meta_value );
}
}
return $meta;
}
}
Internal/DataStores/Orders/CustomOrdersTableController.php 0000644 00000045117 15153704500 0020015 0 ustar 00 <?php
/**
* CustomOrdersTableController class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\PluginUtil;
defined( 'ABSPATH' ) || exit;
/**
* This is the main class that controls the custom orders tables feature. Its responsibilities are:
*
* - Displaying UI components (entries in the tools page and in settings)
* - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
*
* ...and in general, any functionality that doesn't imply database access.
*/
class CustomOrdersTableController {
use AccessiblePrivateMethods;
/**
* The name of the option for enabling the usage of the custom orders tables
*/
public const CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION = 'woocommerce_custom_orders_table_enabled';
/**
* The name of the option that tells whether database transactions are to be used or not for data synchronization.
*/
public const USE_DB_TRANSACTIONS_OPTION = 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync';
/**
* The name of the option to store the transaction isolation level to use when database transactions are enabled.
*/
public const DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION = 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync';
public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'READ UNCOMMITTED';
/**
* The data store object to use.
*
* @var OrdersTableDataStore
*/
private $data_store;
/**
* Refunds data store object to use.
*
* @var OrdersTableRefundDataStore
*/
private $refund_data_store;
/**
* The data synchronizer object to use.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* The batch processing controller to use.
*
* @var BatchProcessingController
*/
private $batch_processing_controller;
/**
* The features controller to use.
*
* @var FeaturesController
*/
private $features_controller;
/**
* The orders cache object to use.
*
* @var OrderCache
*/
private $order_cache;
/**
* The orders cache controller object to use.
*
* @var OrderCacheController
*/
private $order_cache_controller;
/**
* The plugin util object to use.
*
* @var PluginUtil
*/
private $plugin_util;
/**
* Class constructor.
*/
public function __construct() {
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
self::add_filter( 'woocommerce_order_data_store', array( $this, 'get_orders_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 999, 1 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_data_sync_option_changed' ), 10, 1 );
self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_feature_enabled_changed' ), 10, 2 );
self::add_action( 'woocommerce_feature_setting', array( $this, 'get_hpos_feature_setting' ), 10, 2 );
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param OrdersTableDataStore $data_store The data store to use.
* @param DataSynchronizer $data_synchronizer The data synchronizer to use.
* @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
* @param BatchProcessingController $batch_processing_controller The batch processing controller to use.
* @param FeaturesController $features_controller The features controller instance to use.
* @param OrderCache $order_cache The order cache engine to use.
* @param OrderCacheController $order_cache_controller The order cache controller to use.
* @param PluginUtil $plugin_util The plugin util to use.
*/
final public function init(
OrdersTableDataStore $data_store,
DataSynchronizer $data_synchronizer,
OrdersTableRefundDataStore $refund_data_store,
BatchProcessingController $batch_processing_controller,
FeaturesController $features_controller,
OrderCache $order_cache,
OrderCacheController $order_cache_controller,
PluginUtil $plugin_util
) {
$this->data_store = $data_store;
$this->data_synchronizer = $data_synchronizer;
$this->batch_processing_controller = $batch_processing_controller;
$this->refund_data_store = $refund_data_store;
$this->features_controller = $features_controller;
$this->order_cache = $order_cache;
$this->order_cache_controller = $order_cache_controller;
$this->plugin_util = $plugin_util;
}
/**
* Is the custom orders table usage enabled via settings?
* This can be true only if the feature is enabled and a table regeneration has been completed.
*
* @return bool True if the custom orders table usage is enabled
*/
public function custom_orders_table_usage_is_enabled(): bool {
return get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
}
/**
* Gets the instance of the orders data store to use.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order_data_store hook).
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_orders_data_store( $default_data_store ) {
return $this->get_data_store_instance( $default_data_store, 'order' );
}
/**
* Gets the instance of the refunds data store to use.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order-refund_data_store hook).
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_refunds_data_store( $default_data_store ) {
return $this->get_data_store_instance( $default_data_store, 'order_refund' );
}
/**
* Gets the instance of a given data store.
*
* @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the appropriate hooks).
* @param string $type The type of the data store to get.
*
* @return \WC_Object_Data_Store_Interface|string The actual data store to use.
*/
private function get_data_store_instance( $default_data_store, string $type ) {
if ( $this->custom_orders_table_usage_is_enabled() ) {
switch ( $type ) {
case 'order_refund':
return $this->refund_data_store;
default:
return $this->data_store;
}
} else {
return $default_data_store;
}
}
/**
* Add an entry to Status - Tools to create or regenerate the custom orders table,
* and also an entry to delete the table as appropriate.
*
* @param array $tools_array The array of tools to add the tool to.
* @return array The updated array of tools-
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ): array {
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
return $tools_array;
}
if ( $this->custom_orders_table_usage_is_enabled() || $this->data_synchronizer->data_sync_is_enabled() ) {
$disabled = true;
$message = __( 'This will delete the custom orders tables. The tables can be deleted only if the "High-Performance order storage" is not authoritative and sync is disabled (via Settings > Advanced > Features).', 'woocommerce' );
} else {
$disabled = false;
$message = __( 'This will delete the custom orders tables. To create them again enable the "High-Performance order storage" feature (via Settings > Advanced > Features).', 'woocommerce' );
}
$tools_array['delete_custom_orders_table'] = array(
'name' => __( 'Delete the custom orders tables', 'woocommerce' ),
'desc' => sprintf(
'<strong class="red">%1$s</strong> %2$s',
__( 'Note:', 'woocommerce' ),
$message
),
'requires_refresh' => true,
'callback' => function () {
$this->features_controller->change_feature_enable( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, false );
$this->delete_custom_orders_tables();
return __( 'Custom orders tables have been deleted.', 'woocommerce' );
},
'button' => __( 'Delete', 'woocommerce' ),
'disabled' => $disabled,
);
return $tools_array;
}
/**
* Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
*
* @throws \Exception Can't delete the tables.
*/
private function delete_custom_orders_tables() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
throw new \Exception( "Can't delete the custom orders tables: they are currently in use (via Settings > Advanced > Features)." );
}
delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
$this->data_synchronizer->delete_database_tables();
}
/**
* Handler for the individual setting updated hook.
*
* @param string $option Setting name.
* @param mixed $old_value Old value of the setting.
* @param mixed $value New value of the setting.
*/
private function process_updated_option( $option, $old_value, $value ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) {
$this->data_synchronizer->cleanup_synchronization_state();
}
}
/**
* Handler for the setting pre-update hook.
* We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
*
* @param mixed $value New value of the setting.
* @param string $option Setting name.
* @param mixed $old_value Old value of the setting.
*
* @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
*/
private function process_pre_update_option( $value, $option, $old_value ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && $value !== $old_value ) {
$this->order_cache->flush();
return $value;
}
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) {
return $value;
}
$this->order_cache->flush();
/**
* Re-enable the following code once the COT to posts table sync is implemented (it's currently commented out to ease testing).
$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
if ( $sync_is_pending ) {
throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
}
*/
return $value;
}
/**
* Handler for the all settings updated hook.
*
* @param string $feature_id Feature ID.
*/
private function handle_data_sync_option_changed( string $feature_id ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION !== $feature_id ) {
return;
}
$data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled();
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$this->data_synchronizer->create_database_tables();
}
// Enabling/disabling the sync implies starting/stopping it too, if needed.
// We do this check here, and not in process_pre_update_option, so that if for some reason
// the setting is enabled but no sync is in process, sync will start by just saving the
// settings even without modifying them (and the opposite: sync will be stopped if for
// some reason it was ongoing while it was disabled).
if ( $data_sync_is_enabled ) {
$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
} else {
$this->batch_processing_controller->remove_processor( DataSynchronizer::class );
}
}
/**
* Handle the 'woocommerce_feature_enabled_changed' action,
* if the custom orders table feature is enabled create the database tables if they don't exist.
*
* @param string $feature_id The id of the feature that is being enabled or disabled.
* @param bool $is_enabled True if the feature is being enabled, false if it's being disabled.
*/
private function handle_feature_enabled_changed( $feature_id, $is_enabled ): void {
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $feature_id || ! $is_enabled ) {
return;
}
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$success = $this->data_synchronizer->create_database_tables();
if ( ! $success ) {
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
}
}
}
/**
* Handler for the woocommerce_after_register_post_type post,
* registers the post type for placeholder orders.
*
* @return void
*/
private function register_post_type_for_order_placeholders(): void {
wc_register_order_type(
DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE,
array(
'public' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'show_in_admin_bar' => false,
'show_in_rest' => false,
'rewrite' => false,
'query_var' => false,
'can_export' => false,
'supports' => array(),
'capabilities' => array(),
'exclude_from_order_count' => true,
'exclude_from_order_views' => true,
'exclude_from_order_reports' => true,
'exclude_from_order_sales_reports' => true,
)
);
}
/**
* Returns the HPOS setting for rendering in Features section of the settings page.
*
* @param array $feature_setting HPOS feature value as defined in the feature controller.
* @param string $feature_id ID of the feature.
*
* @return array Feature setting object.
*/
private function get_hpos_feature_setting( array $feature_setting, string $feature_id ) {
if ( ! in_array( $feature_id, array( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'custom_order_tables' ), true ) ) {
return $feature_setting;
}
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return $feature_setting;
}
$sync_status = $this->data_synchronizer->get_sync_status();
switch ( $feature_id ) {
case self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION:
return $this->get_hpos_setting_for_feature( $sync_status );
case DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION:
return $this->get_hpos_setting_for_sync( $sync_status );
case 'custom_order_tables':
return array();
}
}
/**
* Returns the HPOS setting for rendering HPOS vs Post setting block in Features section of the settings page.
*
* @param array $sync_status Details of sync status, includes pending count, and count when sync started.
*
* @return array Feature setting object.
*/
private function get_hpos_setting_for_feature( $sync_status ) {
$hpos_enabled = $this->custom_orders_table_usage_is_enabled();
$plugin_info = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
$plugin_incompat_warning = $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_info );
$sync_complete = 0 === $sync_status['current_pending_count'];
$disabled_option = array();
// Changing something here? might also want to look at `enable|disable` functions in CLIRunner.
if ( count( array_merge( $plugin_info['uncertain'], $plugin_info['incompatible'] ) ) > 0 ) {
$disabled_option = array( 'yes' );
}
if ( ! $sync_complete ) {
$disabled_option = array( 'yes', 'no' );
}
return array(
'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
'title' => __( 'Order data storage', 'woocommerce' ),
'type' => 'radio',
'options' => array(
'no' => __( 'WordPress posts storage (legacy)', 'woocommerce' ),
'yes' => __( 'High-performance order storage (recommended)', 'woocommerce' ),
),
'value' => $hpos_enabled ? 'yes' : 'no',
'disabled' => $disabled_option,
'desc' => $plugin_incompat_warning,
'desc_at_end' => true,
'row_class' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
);
}
/**
* Returns the setting for rendering sync enabling setting block in Features section of the settings page.
*
* @param array $sync_status Details of sync status, includes pending count, and count when sync started.
*
* @return array Feature setting object.
*/
private function get_hpos_setting_for_sync( $sync_status ) {
$sync_in_progress = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
$sync_enabled = get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
$sync_message = '';
if ( $sync_in_progress && $sync_status['current_pending_count'] > 0 ) {
$sync_message = sprintf(
// translators: %d: number of pending orders.
__( 'Currently syncing orders... %d pending', 'woocommerce' ),
$sync_status['current_pending_count']
);
} elseif ( $sync_status['current_pending_count'] > 0 ) {
$sync_message = sprintf(
// translators: %d: number of pending orders.
_n(
'%d order pending to be synchronized. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
'%d orders pending to be synchronized. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
$sync_status['current_pending_count'],
'woocommerce'
),
$sync_status['current_pending_count'],
);
}
return array(
'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
'title' => '',
'type' => 'checkbox',
'desc' => __( 'Enable compatibility mode (synchronizes orders to the posts table).', 'woocommerce' ),
'value' => $sync_enabled,
'desc_tip' => $sync_message,
'row_class' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
);
}
}
Internal/DataStores/Orders/DataSynchronizer.php 0000644 00000067331 15153704500 0015641 0 ustar 00 <?php
/**
* DataSynchronizer class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilites are:
*
* - Providing entry points for creating and deleting the required database tables.
* - Synchronizing changes between the custom orders tables and the posts table whenever changes in orders happen.
*/
class DataSynchronizer implements BatchProcessorInterface {
use AccessiblePrivateMethods;
public const ORDERS_DATA_SYNC_ENABLED_OPTION = 'woocommerce_custom_orders_table_data_sync_enabled';
private const INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION = 'woocommerce_initial_orders_pending_sync_count';
public const PLACEHOLDER_ORDER_POST_TYPE = 'shop_order_placehold';
public const DELETED_RECORD_META_KEY = '_deleted_from';
public const DELETED_FROM_POSTS_META_VALUE = 'posts_table';
public const DELETED_FROM_ORDERS_META_VALUE = 'orders_table';
public const ORDERS_TABLE_CREATED = 'woocommerce_custom_orders_table_created';
private const ORDERS_SYNC_BATCH_SIZE = 250;
// Allowed values for $type in get_ids_of_orders_pending_sync method.
public const ID_TYPE_MISSING_IN_ORDERS_TABLE = 0;
public const ID_TYPE_MISSING_IN_POSTS_TABLE = 1;
public const ID_TYPE_DIFFERENT_UPDATE_DATE = 2;
public const ID_TYPE_DELETED_FROM_ORDERS_TABLE = 3;
public const ID_TYPE_DELETED_FROM_POSTS_TABLE = 4;
/**
* The data store object to use.
*
* @var OrdersTableDataStore
*/
private $data_store;
/**
* The database util object to use.
*
* @var DatabaseUtil
*/
private $database_util;
/**
* The posts to COT migrator to use.
*
* @var PostsToOrdersMigrationController
*/
private $posts_to_cot_migrator;
/**
* Logger object to be used to log events.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* The order cache controller.
*
* @var OrderCacheController
*/
private $order_cache_controller;
/**
* Class constructor.
*/
public function __construct() {
self::add_action( 'deleted_post', array( $this, 'handle_deleted_post' ), 10, 2 );
self::add_action( 'woocommerce_new_order', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'woocommerce_refund_created', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'wp_scheduled_auto_draft_delete', array( $this, 'delete_auto_draft_orders' ), 9 );
self::add_filter( 'woocommerce_feature_description_tip', array( $this, 'handle_feature_description_tip' ), 10, 3 );
}
/**
* Class initialization, invoked by the DI container.
*
* @param OrdersTableDataStore $data_store The data store to use.
* @param DatabaseUtil $database_util The database util class to use.
* @param PostsToOrdersMigrationController $posts_to_cot_migrator The posts to COT migration class to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
* @param OrderCacheController $order_cache_controller The order cache controller instance to use.
* @internal
*/
final public function init(
OrdersTableDataStore $data_store,
DatabaseUtil $database_util,
PostsToOrdersMigrationController $posts_to_cot_migrator,
LegacyProxy $legacy_proxy,
OrderCacheController $order_cache_controller
) {
$this->data_store = $data_store;
$this->database_util = $database_util;
$this->posts_to_cot_migrator = $posts_to_cot_migrator;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->order_cache_controller = $order_cache_controller;
}
/**
* Does the custom orders tables exist in the database?
*
* @return bool True if the custom orders tables exist in the database.
*/
public function check_orders_table_exists(): bool {
$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
if ( count( $missing_tables ) === 0 ) {
update_option( self::ORDERS_TABLE_CREATED, 'yes' );
return true;
} else {
update_option( self::ORDERS_TABLE_CREATED, 'no' );
return false;
}
}
/**
* Returns the value of the orders table created option. If it's not set, then it checks the orders table and set it accordingly.
*
* @return bool Whether orders table exists.
*/
public function get_table_exists(): bool {
$table_exists = get_option( self::ORDERS_TABLE_CREATED );
switch ( $table_exists ) {
case 'no':
case 'yes':
return 'yes' === $table_exists;
default:
return $this->check_orders_table_exists();
}
}
/**
* Create the custom orders database tables.
*/
public function create_database_tables() {
$this->database_util->dbdelta( $this->data_store->get_database_schema() );
$success = $this->check_orders_table_exists();
if ( ! $success ) {
$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
$missing_tables = implode( ', ', $missing_tables );
$this->error_logger->error( "HPOS tables are missing in the database and couldn't be created. The missing tables are: $missing_tables" );
}
return $success;
}
/**
* Delete the custom orders database tables.
*/
public function delete_database_tables() {
$table_names = $this->data_store->get_all_table_names();
foreach ( $table_names as $table_name ) {
$this->database_util->drop_database_table( $table_name );
}
delete_option( self::ORDERS_TABLE_CREATED );
}
/**
* Is the data sync between old and new tables currently enabled?
*
* @return bool
*/
public function data_sync_is_enabled(): bool {
return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
}
/**
* Get the current sync process status.
* The information is meaningful only if pending_data_sync_is_in_progress return true.
*
* @return array
*/
public function get_sync_status() {
return array(
'initial_pending_count' => (int) get_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, 0 ),
'current_pending_count' => $this->get_total_pending_count(),
);
}
/**
* Get the total number of orders pending synchronization.
*
* @return int
*/
public function get_current_orders_pending_sync_count_cached() : int {
return $this->get_current_orders_pending_sync_count( true );
}
/**
* Calculate how many orders need to be synchronized currently.
* A database query is performed to get how many orders match one of the following:
*
* - Existing in the authoritative table but not in the backup table.
* - Existing in both tables, but they have a different update date.
*
* @param bool $use_cache Whether to use the cached value instead of fetching from database.
*/
public function get_current_orders_pending_sync_count( $use_cache = false ): int {
global $wpdb;
if ( $use_cache ) {
$pending_count = wp_cache_get( 'woocommerce_hpos_pending_sync_count' );
if ( false !== $pending_count ) {
return (int) $pending_count;
}
}
$order_post_types = wc_get_order_types( 'cot-migration' );
$order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );
$orders_table = $this->data_store::get_orders_table_name();
if ( empty( $order_post_types ) ) {
$this->error_logger->debug(
sprintf(
/* translators: 1: method name. */
esc_html__( '%1$s was called but no order types were registered: it may have been called too early.', 'woocommerce' ),
__METHOD__
)
);
return 0;
}
if ( ! $this->get_table_exists() ) {
$count = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
$wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts where post_type in ( $order_post_type_placeholder )",
$order_post_types
)
// phpcs:enable
);
return $count;
}
if ( $this->custom_orders_table_is_authoritative() ) {
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
AND orders.type IN ($order_post_type_placeholder)",
$order_post_types
);
$operator = '>';
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.id=orders.id
WHERE
posts.post_type in ($order_post_type_placeholder)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL",
$order_post_types
);
// phpcs:enable
$operator = '<';
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $missing_orders_count_sql is prepared.
$sql = $wpdb->prepare(
"
SELECT(
($missing_orders_count_sql)
+
(SELECT COUNT(1) FROM (
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholder)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
) x)
) count",
$order_post_types
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$pending_count = (int) $wpdb->get_var( $sql );
$deleted_from_table = $this->get_current_deletion_record_meta_value();
$deleted_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT count(1) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s",
array( self::DELETED_RECORD_META_KEY, $deleted_from_table )
)
);
$pending_count += $deleted_count;
wp_cache_set( 'woocommerce_hpos_pending_sync_count', $pending_count );
return $pending_count;
}
/**
* Get the meta value for order deletion records based on which table is currently authoritative.
*
* @return string self::DELETED_FROM_ORDERS_META_VALUE if the orders table is authoritative, self::DELETED_FROM_POSTS_META_VALUE otherwise.
*/
private function get_current_deletion_record_meta_value() {
return $this->custom_orders_table_is_authoritative() ?
self::DELETED_FROM_ORDERS_META_VALUE :
self::DELETED_FROM_POSTS_META_VALUE;
}
/**
* Is the custom orders table the authoritative data source for orders currently?
*
* @return bool Whether the custom orders table the authoritative data source for orders currently.
*/
public function custom_orders_table_is_authoritative(): bool {
return wc_string_to_bool( get_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) );
}
/**
* Get a list of ids of orders than are out of sync.
*
* Valid values for $type are:
*
* ID_TYPE_MISSING_IN_ORDERS_TABLE: orders that exist in posts table but not in orders table.
* ID_TYPE_MISSING_IN_POSTS_TABLE: orders that exist in orders table but not in posts table (the corresponding post entries are placeholders).
* ID_TYPE_DIFFERENT_UPDATE_DATE: orders that exist in both tables but have different last update dates.
* ID_TYPE_DELETED_FROM_ORDERS_TABLE: orders deleted from the orders table but not yet from the posts table.
* ID_TYPE_DELETED_FROM_POSTS_TABLE: orders deleted from the posts table but not yet from the orders table.
*
* @param int $type One of ID_TYPE_MISSING_IN_ORDERS_TABLE, ID_TYPE_MISSING_IN_POSTS_TABLE, ID_TYPE_DIFFERENT_UPDATE_DATE.
* @param int $limit Maximum number of ids to return.
* @return array An array of order ids.
* @throws \Exception Invalid parameter.
*/
public function get_ids_of_orders_pending_sync( int $type, int $limit ) {
global $wpdb;
if ( $limit < 1 ) {
throw new \Exception( '$limit must be at least 1' );
}
$orders_table = $this->data_store::get_orders_table_name();
$order_post_types = wc_get_order_types( 'cot-migration' );
$order_post_type_placeholders = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
switch ( $type ) {
case self::ID_TYPE_MISSING_IN_ORDERS_TABLE:
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL
ORDER BY posts.ID ASC",
$order_post_types
);
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
break;
case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
AND orders.type IN ($order_post_type_placeholders)
ORDER BY posts.id ASC",
$order_post_types
);
break;
case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
$sql = $wpdb->prepare(
"
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
ORDER BY orders.id ASC
",
$order_post_types
);
// phpcs:enable
break;
case self::ID_TYPE_DELETED_FROM_ORDERS_TABLE:
return $this->get_deleted_order_ids( true, $limit );
case self::ID_TYPE_DELETED_FROM_POSTS_TABLE:
return $this->get_deleted_order_ids( false, $limit );
default:
throw new \Exception( 'Invalid $type, must be one of the ID_TYPE_... constants.' );
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:ignore WordPress.DB
return array_map( 'intval', $wpdb->get_col( $sql . " LIMIT $limit" ) );
}
/**
* Get the ids of the orders that are marked as deleted in the orders meta table.
*
* @param bool $deleted_from_orders_table True to get the ids of the orders deleted from the orders table, false o get the ids of the orders deleted from the posts table.
* @param int $limit The maximum count of orders to return.
* @return array An array of order ids.
*/
private function get_deleted_order_ids( bool $deleted_from_orders_table, int $limit ) {
global $wpdb;
$deleted_from_table = $this->get_current_deletion_record_meta_value();
$order_ids = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT DISTINCT(order_id) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s LIMIT {$limit}",
self::DELETED_RECORD_META_KEY,
$deleted_from_table
)
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
return array_map( 'absint', $order_ids );
}
/**
* Cleanup all the synchronization status information,
* because the process has been disabled by the user via settings,
* or because there's nothing left to synchronize.
*/
public function cleanup_synchronization_state() {
delete_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION );
}
/**
* Process data for current batch.
*
* @param array $batch Batch details.
*/
public function process_batch( array $batch ) : void {
if ( empty( $batch ) ) {
return;
}
$batch = array_map( 'absint', $batch );
$this->order_cache_controller->temporarily_disable_orders_cache_usage();
$custom_orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
$deleted_order_ids = $this->process_deleted_orders( $batch, $custom_orders_table_is_authoritative );
$batch = array_diff( $batch, $deleted_order_ids );
if ( ! empty( $batch ) ) {
if ( $custom_orders_table_is_authoritative ) {
foreach ( $batch as $id ) {
$order = wc_get_order( $id );
if ( ! $order ) {
$this->error_logger->error( "Order $id not found during batch process, skipping." );
continue;
}
$data_store = $order->get_data_store();
$data_store->backfill_post_record( $order );
}
} else {
$this->posts_to_cot_migrator->migrate_orders( $batch );
}
}
if ( 0 === $this->get_total_pending_count() ) {
$this->cleanup_synchronization_state();
$this->order_cache_controller->maybe_restore_orders_cache_usage();
}
}
/**
* Take a batch of order ids pending synchronization and process those that were deleted, ignoring the others
* (which will be orders that were created or modified) and returning the ids of the orders actually processed.
*
* @param array $batch Array of ids of order pending synchronization.
* @param bool $custom_orders_table_is_authoritative True if the custom orders table is currently authoritative.
* @return array Order ids that have been actually processed.
*/
private function process_deleted_orders( array $batch, bool $custom_orders_table_is_authoritative ): array {
global $wpdb;
$deleted_from_table_name = $this->get_current_deletion_record_meta_value();
$data_store_for_deletion =
$custom_orders_table_is_authoritative ?
new \WC_Order_Data_Store_CPT() :
wc_get_container()->get( OrdersTableDataStore::class );
$order_ids_as_sql_list = '(' . implode( ',', $batch ) . ')';
$deleted_order_ids = array();
$meta_ids_to_delete = array();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$deletion_data = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, order_id FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s AND order_id IN $order_ids_as_sql_list ORDER BY order_id DESC",
self::DELETED_RECORD_META_KEY,
$deleted_from_table_name
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $deletion_data ) ) {
return array();
}
foreach ( $deletion_data as $item ) {
$meta_id = $item['id'];
$order_id = $item['order_id'];
if ( isset( $deleted_order_ids[ $order_id ] ) ) {
$meta_ids_to_delete[] = $meta_id;
continue;
}
if ( ! $data_store_for_deletion->order_exists( $order_id ) ) {
$this->error_logger->warning( "Order {$order_id} doesn't exist in the backup table, thus it can't be deleted" );
$deleted_order_ids[] = $order_id;
$meta_ids_to_delete[] = $meta_id;
continue;
}
try {
$order = new \WC_Order();
$order->set_id( $order_id );
$data_store_for_deletion->read( $order );
$data_store_for_deletion->delete(
$order,
array(
'force_delete' => true,
'suppress_filters' => true,
)
);
} catch ( \Exception $ex ) {
$this->error_logger->error( "Couldn't delete order {$order_id} from the backup table: {$ex->getMessage()}" );
continue;
}
$deleted_order_ids[] = $order_id;
$meta_ids_to_delete[] = $meta_id;
}
if ( ! empty( $meta_ids_to_delete ) ) {
$order_id_rows_as_sql_list = '(' . implode( ',', $meta_ids_to_delete ) . ')';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_orders_meta WHERE id IN {$order_id_rows_as_sql_list}" );
}
return $deleted_order_ids;
}
/**
* Get total number of pending records that require update.
*
* @return int Number of pending records.
*/
public function get_total_pending_count(): int {
return $this->get_current_orders_pending_sync_count();
}
/**
* Returns the batch with records that needs to be processed for a given size.
*
* @param int $size Size of the batch.
*
* @return array Batch of records.
*/
public function get_next_batch_to_process( int $size ): array {
$orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
$order_ids = $this->get_ids_of_orders_pending_sync(
$orders_table_is_authoritative ? self::ID_TYPE_MISSING_IN_POSTS_TABLE : self::ID_TYPE_MISSING_IN_ORDERS_TABLE,
$size
);
if ( count( $order_ids ) >= $size ) {
return $order_ids;
}
$updated_order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_DIFFERENT_UPDATE_DATE, $size - count( $order_ids ) );
$order_ids = array_merge( $order_ids, $updated_order_ids );
if ( count( $order_ids ) >= $size ) {
return $order_ids;
}
$deleted_order_ids = $this->get_ids_of_orders_pending_sync(
$orders_table_is_authoritative ? self::ID_TYPE_DELETED_FROM_ORDERS_TABLE : self::ID_TYPE_DELETED_FROM_POSTS_TABLE,
$size - count( $order_ids )
);
$order_ids = array_merge( $order_ids, $deleted_order_ids );
return array_map( 'absint', $order_ids );
}
/**
* Default batch size to use.
*
* @return int Default batch size.
*/
public function get_default_batch_size(): int {
$batch_size = self::ORDERS_SYNC_BATCH_SIZE;
if ( $this->custom_orders_table_is_authoritative() ) {
// Back-filling is slower than migration.
$batch_size = absint( self::ORDERS_SYNC_BATCH_SIZE / 10 ) + 1;
}
/**
* Filter to customize the count of orders that will be synchronized in each step of the custom orders table to/from posts table synchronization process.
*
* @since 6.6.0
*
* @param int Default value for the count.
*/
return apply_filters( 'woocommerce_orders_cot_and_posts_sync_step_size', $batch_size );
}
/**
* A user friendly name for this process.
*
* @return string Name of the process.
*/
public function get_name(): string {
return 'Order synchronizer';
}
/**
* A user friendly description for this process.
*
* @return string Description.
*/
public function get_description(): string {
return 'Synchronizes orders between posts and custom order tables.';
}
/**
* Handle the 'deleted_post' action.
*
* When posts is authoritative and sync is enabled, deleting a post also deletes COT data.
*
* @param int $postid The post id.
* @param WP_Post $post The deleted post.
*/
private function handle_deleted_post( $postid, $post ): void {
global $wpdb;
$order_post_types = wc_get_order_types( 'cot-migration' );
if ( ! in_array( $post->post_type, $order_post_types, true ) ) {
return;
}
if ( ! $this->get_table_exists() ) {
return;
}
if ( $this->data_sync_is_enabled() ) {
$this->data_store->delete_order_data_from_custom_order_tables( $postid );
} elseif ( $this->custom_orders_table_is_authoritative() ) {
return;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
if ( $wpdb->get_var(
$wpdb->prepare(
"SELECT EXISTS (SELECT id FROM {$this->data_store::get_orders_table_name()} WHERE ID=%d)
AND NOT EXISTS (SELECT order_id FROM {$this->data_store::get_meta_table_name()} WHERE order_id=%d AND meta_key=%s AND meta_value=%s)",
$postid,
$postid,
self::DELETED_RECORD_META_KEY,
self::DELETED_FROM_POSTS_META_VALUE
)
)
) {
$wpdb->insert(
$this->data_store::get_meta_table_name(),
array(
'order_id' => $postid,
'meta_key' => self::DELETED_RECORD_META_KEY,
'meta_value' => self::DELETED_FROM_POSTS_META_VALUE,
)
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
}
/**
* Handle the 'woocommerce_update_order' action.
*
* When posts is authoritative and sync is enabled, updating a post triggers a corresponding change in the COT table.
*
* @param int $order_id The order id.
*/
private function handle_updated_order( $order_id ): void {
if ( ! $this->custom_orders_table_is_authoritative() && $this->data_sync_is_enabled() ) {
$this->posts_to_cot_migrator->migrate_orders( array( $order_id ) );
}
}
/**
* Handles deletion of auto-draft orders in sync with WP's own auto-draft deletion.
*
* @since 7.7.0
*
* @return void
*/
private function delete_auto_draft_orders() {
if ( ! $this->custom_orders_table_is_authoritative() ) {
return;
}
// Fetch auto-draft orders older than 1 week.
$to_delete = wc_get_orders(
array(
'date_query' => array(
array(
'column' => 'date_created',
'before' => '-1 week',
),
),
'orderby' => 'date',
'order' => 'ASC',
'status' => 'auto-draft',
)
);
foreach ( $to_delete as $order ) {
$order->delete( true );
}
/**
* Fires after schedueld deletion of auto-draft orders has been completed.
*
* @since 7.7.0
*/
do_action( 'woocommerce_scheduled_auto_draft_delete' );
}
/**
* Handle the 'woocommerce_feature_description_tip' filter.
*
* When the COT feature is enabled and there are orders pending sync (in either direction),
* show a "you should ync before disabling" warning under the feature in the features page.
* Skip this if the UI prevents changing the feature enable status.
*
* @param string $desc_tip The original description tip for the feature.
* @param string $feature_id The feature id.
* @param bool $ui_disabled True if the UI doesn't allow to enable or disable the feature.
* @return string The new description tip for the feature.
*/
private function handle_feature_description_tip( $desc_tip, $feature_id, $ui_disabled ): string {
if ( 'custom_order_tables' !== $feature_id || $ui_disabled ) {
return $desc_tip;
}
$features_controller = wc_get_container()->get( FeaturesController::class );
$feature_is_enabled = $features_controller->feature_is_enabled( 'custom_order_tables' );
if ( ! $feature_is_enabled ) {
return $desc_tip;
}
$pending_sync_count = $this->get_current_orders_pending_sync_count();
if ( ! $pending_sync_count ) {
return $desc_tip;
}
if ( $this->custom_orders_table_is_authoritative() ) {
$extra_tip = sprintf(
_n(
"⚠ There's one order pending sync from the orders table to the posts table. The feature shouldn't be disabled until this order is synchronized.",
"⚠ There are %1\$d orders pending sync from the orders table to the posts table. The feature shouldn't be disabled until these orders are synchronized.",
$pending_sync_count,
'woocommerce'
),
$pending_sync_count
);
} else {
$extra_tip = sprintf(
_n(
"⚠ There's one order pending sync from the posts table to the orders table. The feature shouldn't be disabled until this order is synchronized.",
"⚠ There are %1\$d orders pending sync from the posts table to the orders table. The feature shouldn't be disabled until these orders are synchronized.",
$pending_sync_count,
'woocommerce'
),
$pending_sync_count
);
}
$cot_settings_url = add_query_arg(
array(
'page' => 'wc-settings',
'tab' => 'advanced',
'section' => 'custom_data_stores',
),
admin_url( 'admin.php' )
);
/* translators: %s = URL of the custom data stores settings page */
$manage_cot_settings_link = sprintf( __( "<a href='%s'>Manage orders synchronization</a>", 'woocommerce' ), $cot_settings_url );
return $desc_tip ? "{$desc_tip}<br/>{$extra_tip} {$manage_cot_settings_link}" : "{$extra_tip} {$manage_cot_settings_link}";
}
}
Internal/DataStores/Orders/OrdersTableDataStore.php 0000644 00000273414 15153704500 0016370 0 ustar 00 <?php
/**
* OrdersTableDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Exception;
use WC_Abstract_Order;
use WC_Data;
use WC_Order;
defined( 'ABSPATH' ) || exit;
/**
* This class is the standard data store to be used when the custom orders table is in use.
*/
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {
/**
* Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
*
* @var array.
*/
private static $reading_order_ids = array();
/**
* Keep track of order IDs that are actively being backfilled. We use this to prevent further read on sync from add_|update_|delete_postmeta etc hooks. If we allow this, then we would end up syncing the same order multiple times as it is being backfilled.
*
* @var array
*/
private static $backfilling_order_ids = array();
/**
* Data stored in meta keys, but not considered "meta" for an order.
*
* @since 7.0.0
* @var array
*/
protected $internal_meta_keys = array(
'_customer_user',
'_order_key',
'_order_currency',
'_billing_first_name',
'_billing_last_name',
'_billing_company',
'_billing_address_1',
'_billing_address_2',
'_billing_city',
'_billing_state',
'_billing_postcode',
'_billing_country',
'_billing_email',
'_billing_phone',
'_shipping_first_name',
'_shipping_last_name',
'_shipping_company',
'_shipping_address_1',
'_shipping_address_2',
'_shipping_city',
'_shipping_state',
'_shipping_postcode',
'_shipping_country',
'_shipping_phone',
'_completed_date',
'_paid_date',
'_edit_last',
'_cart_discount',
'_cart_discount_tax',
'_order_shipping',
'_order_shipping_tax',
'_order_tax',
'_order_total',
'_payment_method',
'_payment_method_title',
'_transaction_id',
'_customer_ip_address',
'_customer_user_agent',
'_created_via',
'_order_version',
'_prices_include_tax',
'_date_completed',
'_date_paid',
'_payment_tokens',
'_billing_address_index',
'_shipping_address_index',
'_recorded_sales',
'_recorded_coupon_usage_counts',
'_download_permissions_granted',
'_order_stock_reduced',
'_new_order_email_sent',
);
/**
* Handles custom metadata in the wc_orders_meta table.
*
* @var OrdersTableDataStoreMeta
*/
protected $data_store_meta;
/**
* The database util object to use.
*
* @var DatabaseUtil
*/
protected $database_util;
/**
* The posts data store object to use.
*
* @var \WC_Order_Data_Store_CPT
*/
private $cpt_data_store;
/**
* Logger object to be used to log events.
*
* @var \WC_Logger
*/
private $error_logger;
/**
* The name of the main orders table.
*
* @var string
*/
private $orders_table_name;
/**
* The instance of the LegacyProxy object to use.
*
* @var LegacyProxy
*/
private $legacy_proxy;
/**
* Initialize the object.
*
* @internal
* @param OrdersTableDataStoreMeta $data_store_meta Metadata instance.
* @param DatabaseUtil $database_util The database util instance to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
*
* @return void
*/
final public function init( OrdersTableDataStoreMeta $data_store_meta, DatabaseUtil $database_util, LegacyProxy $legacy_proxy ) {
$this->data_store_meta = $data_store_meta;
$this->database_util = $database_util;
$this->legacy_proxy = $legacy_proxy;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->internal_meta_keys = $this->get_internal_meta_keys();
$this->orders_table_name = self::get_orders_table_name();
}
/**
* Get the custom orders table name.
*
* @return string The custom orders table name.
*/
public static function get_orders_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders';
}
/**
* Get the order addresses table name.
*
* @return string The order addresses table name.
*/
public static function get_addresses_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_addresses';
}
/**
* Get the orders operational data table name.
*
* @return string The orders operational data table name.
*/
public static function get_operational_data_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_operational_data';
}
/**
* Get the orders meta data table name.
*
* @return string Name of order meta data table.
*/
public static function get_meta_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders_meta';
}
/**
* Get the names of all the tables involved in the custom orders table feature.
*
* See also : get_all_table_names_with_id.
*
* @return string[]
*/
public function get_all_table_names() {
return array(
$this->get_orders_table_name(),
$this->get_addresses_table_name(),
$this->get_operational_data_table_name(),
$this->get_meta_table_name(),
);
}
/**
* Similar to get_all_table_names, but also returns the table name along with the items table.
*
* @return array Names of the tables.
*/
public static function get_all_table_names_with_id() {
global $wpdb;
return array(
'orders' => self::get_orders_table_name(),
'addresses' => self::get_addresses_table_name(),
'operational_data' => self::get_operational_data_table_name(),
'meta' => self::get_meta_table_name(),
'items' => $wpdb->prefix . 'woocommerce_order_items',
);
}
/**
* Table column to WC_Order mapping for wc_orders table.
*
* @var \string[][]
*/
protected $order_column_mapping = array(
'id' => array(
'type' => 'int',
'name' => 'id',
),
'status' => array(
'type' => 'string',
'name' => 'status',
),
'type' => array(
'type' => 'string',
'name' => 'type',
),
'currency' => array(
'type' => 'string',
'name' => 'currency',
),
'tax_amount' => array(
'type' => 'decimal',
'name' => 'cart_tax',
),
'total_amount' => array(
'type' => 'decimal',
'name' => 'total',
),
'customer_id' => array(
'type' => 'int',
'name' => 'customer_id',
),
'billing_email' => array(
'type' => 'string',
'name' => 'billing_email',
),
'date_created_gmt' => array(
'type' => 'date',
'name' => 'date_created',
),
'date_updated_gmt' => array(
'type' => 'date',
'name' => 'date_modified',
),
'parent_order_id' => array(
'type' => 'int',
'name' => 'parent_id',
),
'payment_method' => array(
'type' => 'string',
'name' => 'payment_method',
),
'payment_method_title' => array(
'type' => 'string',
'name' => 'payment_method_title',
),
'ip_address' => array(
'type' => 'string',
'name' => 'customer_ip_address',
),
'transaction_id' => array(
'type' => 'string',
'name' => 'transaction_id',
),
'user_agent' => array(
'type' => 'string',
'name' => 'customer_user_agent',
),
'customer_note' => array(
'type' => 'string',
'name' => 'customer_note',
),
);
/**
* Table column to WC_Order mapping for billing addresses in wc_address table.
*
* @var \string[][]
*/
protected $billing_address_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'address_type' => array( 'type' => 'string' ),
'first_name' => array(
'type' => 'string',
'name' => 'billing_first_name',
),
'last_name' => array(
'type' => 'string',
'name' => 'billing_last_name',
),
'company' => array(
'type' => 'string',
'name' => 'billing_company',
),
'address_1' => array(
'type' => 'string',
'name' => 'billing_address_1',
),
'address_2' => array(
'type' => 'string',
'name' => 'billing_address_2',
),
'city' => array(
'type' => 'string',
'name' => 'billing_city',
),
'state' => array(
'type' => 'string',
'name' => 'billing_state',
),
'postcode' => array(
'type' => 'string',
'name' => 'billing_postcode',
),
'country' => array(
'type' => 'string',
'name' => 'billing_country',
),
'email' => array(
'type' => 'string',
'name' => 'billing_email',
),
'phone' => array(
'type' => 'string',
'name' => 'billing_phone',
),
);
/**
* Table column to WC_Order mapping for shipping addresses in wc_address table.
*
* @var \string[][]
*/
protected $shipping_address_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'address_type' => array( 'type' => 'string' ),
'first_name' => array(
'type' => 'string',
'name' => 'shipping_first_name',
),
'last_name' => array(
'type' => 'string',
'name' => 'shipping_last_name',
),
'company' => array(
'type' => 'string',
'name' => 'shipping_company',
),
'address_1' => array(
'type' => 'string',
'name' => 'shipping_address_1',
),
'address_2' => array(
'type' => 'string',
'name' => 'shipping_address_2',
),
'city' => array(
'type' => 'string',
'name' => 'shipping_city',
),
'state' => array(
'type' => 'string',
'name' => 'shipping_state',
),
'postcode' => array(
'type' => 'string',
'name' => 'shipping_postcode',
),
'country' => array(
'type' => 'string',
'name' => 'shipping_country',
),
'email' => array( 'type' => 'string' ),
'phone' => array(
'type' => 'string',
'name' => 'shipping_phone',
),
);
/**
* Table column to WC_Order mapping for wc_operational_data table.
*
* @var \string[][]
*/
protected $operational_data_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'created_via' => array(
'type' => 'string',
'name' => 'created_via',
),
'woocommerce_version' => array(
'type' => 'string',
'name' => 'version',
),
'prices_include_tax' => array(
'type' => 'bool',
'name' => 'prices_include_tax',
),
'coupon_usages_are_counted' => array(
'type' => 'bool',
'name' => 'recorded_coupon_usage_counts',
),
'download_permission_granted' => array(
'type' => 'bool',
'name' => 'download_permissions_granted',
),
'cart_hash' => array(
'type' => 'string',
'name' => 'cart_hash',
),
'new_order_email_sent' => array(
'type' => 'bool',
'name' => 'new_order_email_sent',
),
'order_key' => array(
'type' => 'string',
'name' => 'order_key',
),
'order_stock_reduced' => array(
'type' => 'bool',
'name' => 'order_stock_reduced',
),
'date_paid_gmt' => array(
'type' => 'date',
'name' => 'date_paid',
),
'date_completed_gmt' => array(
'type' => 'date',
'name' => 'date_completed',
),
'shipping_tax_amount' => array(
'type' => 'decimal',
'name' => 'shipping_tax',
),
'shipping_total_amount' => array(
'type' => 'decimal',
'name' => 'shipping_total',
),
'discount_tax_amount' => array(
'type' => 'decimal',
'name' => 'discount_tax',
),
'discount_total_amount' => array(
'type' => 'decimal',
'name' => 'discount_total',
),
'recorded_sales' => array(
'type' => 'bool',
'name' => 'recorded_sales',
),
);
/**
* Cache variable to store combined mapping.
*
* @var array[][][]
*/
private $all_order_column_mapping;
/**
* Return combined mappings for all order tables.
*
* @return array|\array[][][] Return combined mapping.
*/
public function get_all_order_column_mappings() {
if ( ! isset( $this->all_order_column_mapping ) ) {
$this->all_order_column_mapping = array(
'orders' => $this->order_column_mapping,
'billing_address' => $this->billing_address_column_mapping,
'shipping_address' => $this->shipping_address_column_mapping,
'operational_data' => $this->operational_data_column_mapping,
);
}
return $this->all_order_column_mapping;
}
/**
* Helper function to get alias for order table, this is used in select query.
*
* @return string Alias.
*/
private function get_order_table_alias() : string {
return 'o';
}
/**
* Helper function to get alias for op table, this is used in select query.
*
* @return string Alias.
*/
private function get_op_table_alias() : string {
return 'p';
}
/**
* Helper function to get alias for address table, this is used in select query.
*
* @param string $type Type of address; 'billing' or 'shipping'.
*
* @return string Alias.
*/
private function get_address_table_alias( string $type ) : string {
return 'billing' === $type ? 'b' : 's';
}
/**
* Helper method to get a CPT data store instance to use.
*
* @return \WC_Order_Data_Store_CPT Data store instance.
*/
public function get_cpt_data_store_instance() {
if ( ! isset( $this->cpt_data_store ) ) {
$this->cpt_data_store = $this->get_post_data_store_for_backfill();
}
return $this->cpt_data_store;
}
/**
* Returns data store object to use backfilling.
*
* @return \Abstract_WC_Order_Data_Store_CPT
*/
protected function get_post_data_store_for_backfill() {
return new \WC_Order_Data_Store_CPT();
}
/**
* Backfills order details in to WP_Post DB. Uses WC_Order_Data_store_CPT.
*
* @param \WC_Abstract_Order $order Order object to backfill.
*/
public function backfill_post_record( $order ) {
$cpt_data_store = $this->get_post_data_store_for_backfill();
if ( is_null( $cpt_data_store ) || ! method_exists( $cpt_data_store, 'update_order_from_object' ) ) {
return;
}
self::$backfilling_order_ids[] = $order->get_id();
$this->update_order_meta_from_object( $order );
$order_class = get_class( $order );
$post_order = new $order_class();
$post_order->set_id( $order->get_id() );
$cpt_data_store->read( $post_order );
// This compares the order data to the post data and set changes array for props that are changed.
$post_order->set_props( $order->get_data() );
$cpt_data_store->update_order_from_object( $post_order );
foreach ( $cpt_data_store->get_internal_data_store_key_getters() as $key => $getter_name ) {
if (
is_callable( array( $cpt_data_store, "set_$getter_name" ) ) &&
is_callable( array( $this, "get_$getter_name" ) )
) {
call_user_func_array(
array(
$cpt_data_store,
"set_$getter_name",
),
array(
$order,
$this->{"get_$getter_name"}( $order ),
)
);
}
}
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
/**
* Get information about whether permissions are granted yet.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether permissions are granted.
*/
public function get_download_permissions_granted( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_download_permissions_granted();
}
/**
* Stores information about whether permissions were generated yet.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set True or false.
*/
public function set_download_permissions_granted( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_download_permissions_granted( $set );
$order->save();
}
/**
* Gets information about whether sales were recorded.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether sales are recorded.
*/
public function get_recorded_sales( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_recorded_sales();
}
/**
* Stores information about whether sales were recorded.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_recorded_sales( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_recorded_sales( $set );
$order->save();
}
/**
* Gets information about whether coupon counts were updated.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether coupon counts were updated.
*/
public function get_recorded_coupon_usage_counts( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_recorded_coupon_usage_counts();
}
/**
* Stores information about whether coupon counts were updated.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_recorded_coupon_usage_counts( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_recorded_coupon_usage_counts( $set );
$order->save();
}
/**
* Whether email have been sent for this order.
*
* @param \WC_Order|int $order Order object.
*
* @return bool Whether email is sent.
*/
public function get_email_sent( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_new_order_email_sent();
}
/**
* Stores information about whether email was sent.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_email_sent( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_new_order_email_sent( $set );
$order->save();
}
/**
* Helper setter for email_sent.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether email was sent.
*/
public function get_new_order_email_sent( $order ) {
return $this->get_email_sent( $order );
}
/**
* Helper setter for new order email sent.
*
* @param \WC_Order $order Order object.
* @param bool $set True or false.
*/
public function set_new_order_email_sent( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_new_order_email_sent( $set );
$order->save();
}
/**
* Gets information about whether stock was reduced.
*
* @param \WC_Order $order Order object.
*
* @return bool Whether stock was reduced.
*/
public function get_stock_reduced( $order ) {
$order_id = is_int( $order ) ? $order : $order->get_id();
$order = wc_get_order( $order_id );
return $order->get_order_stock_reduced();
}
/**
* Stores information about whether stock was reduced.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set True or false.
*/
public function set_stock_reduced( $order, $set ) {
if ( is_int( $order ) ) {
$order = wc_get_order( $order );
}
$order->set_order_stock_reduced( $set );
$order->save();
}
/**
* Helper getter for `order_stock_reduced`.
*
* @param \WC_Order $order Order object.
* @return bool Whether stock was reduced.
*/
public function get_order_stock_reduced( $order ) {
return $this->get_stock_reduced( $order );
}
/**
* Helper setter for `order_stock_reduced`.
*
* @param \WC_Order $order Order ID or order object.
* @param bool $set Whether stock was reduced.
*/
public function set_order_stock_reduced( $order, $set ) {
$this->set_stock_reduced( $order, $set );
}
/**
* Get token ids for an order.
*
* @param WC_Order $order Order object.
* @return array
*/
public function get_payment_token_ids( $order ) {
/**
* We don't store _payment_tokens in props to preserve backward compatibility. In CPT data store, `_payment_tokens` is always fetched directly from DB instead of from prop.
*/
$payment_tokens = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
if ( $payment_tokens ) {
$payment_tokens = $payment_tokens[0]->meta_value;
}
if ( ! $payment_tokens && version_compare( $order->get_version(), '8.0.0', '<' ) ) {
// Before 8.0 we were incorrectly storing payment_tokens in the order meta. So we need to check there too.
$payment_tokens = get_post_meta( $order->get_id(), '_payment_tokens', true );
}
return array_filter( (array) $payment_tokens );
}
/**
* Update token ids for an order.
*
* @param WC_Order $order Order object.
* @param array $token_ids Payment token ids.
*/
public function update_payment_token_ids( $order, $token_ids ) {
$meta = new \WC_Meta_Data();
$meta->key = '_payment_tokens';
$meta->value = $token_ids;
$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
if ( $existing_meta ) {
$existing_meta = $existing_meta[0];
$meta->id = $existing_meta->id;
$this->data_store_meta->update_meta( $order, $meta );
} else {
$this->data_store_meta->add_meta( $order, $meta );
}
}
/**
* Get amount already refunded.
*
* @param \WC_Order $order Order object.
*
* @return float Refunded amount.
*/
public function get_total_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
"
SELECT SUM( total_amount ) FROM $order_table
WHERE
type = %s AND
parent_order_id = %d
;
",
// phpcs:enable
'shop_order_refund',
$order->get_id()
)
);
return -1 * ( isset( $total ) ? $total : 0 );
}
/**
* Get the total tax refunded.
*
* @param WC_Order $order Order object.
* @return float
*/
public function get_total_tax_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
$order->get_id()
)
) ?? 0;
// phpcs:enable
return abs( $total );
}
/**
* Get the total shipping refunded.
*
* @param WC_Order $order Order object.
* @return float
*/
public function get_total_shipping_refunded( $order ) {
global $wpdb;
$order_table = self::get_orders_table_name();
$total = $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'shipping' )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
AND order_itemmeta.meta_key IN ('cost')",
$order->get_id()
)
) ?? 0;
// phpcs:enable
return abs( $total );
}
/**
* Finds an Order ID based on an order key.
*
* @param string $order_key An order key has generated by.
* @return int The ID of an order, or 0 if the order could not be found
*/
public function get_order_id_by_order_key( $order_key ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
$op_table = self::get_operational_data_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT {$orders_table}.id FROM {$orders_table}
INNER JOIN {$op_table} ON {$op_table}.order_id = {$orders_table}.id
WHERE {$op_table}.order_key = %s AND {$op_table}.order_key != ''",
$order_key
)
);
// phpcs:enable
}
/**
* Return count of orders with a specific status.
*
* @param string $status Order status. Function wc_get_order_statuses() returns a list of valid statuses.
* @return int
*/
public function get_order_count( $status ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
return absint( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$orders_table} WHERE type = %s AND status = %s", 'shop_order', $status ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get all orders matching the passed in args.
*
* @deprecated 3.1.0 - Use {@see wc_get_orders} instead.
* @param array $args List of args passed to wc_get_orders().
* @return array|object
*/
public function get_orders( $args = array() ) {
wc_deprecated_function( __METHOD__, '3.1.0', 'Use wc_get_orders instead.' );
return wc_get_orders( $args );
}
/**
* Get unpaid orders last updated before the specified date.
*
* @param int $date Timestamp.
* @return array
*/
public function get_unpaid_orders( $date ) {
global $wpdb;
$orders_table = self::get_orders_table_name();
$order_types_sql = "('" . implode( "','", wc_get_order_types() ) . "')";
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM {$orders_table} WHERE
{$orders_table}.type IN {$order_types_sql}
AND {$orders_table}.status = %s
AND {$orders_table}.date_updated_gmt < %s",
'wc-pending',
gmdate( 'Y-m-d H:i:s', absint( $date ) )
)
);
// phpcs:enable
}
/**
* Search order data for a term and return matching order IDs.
*
* @param string $term Search term.
*
* @return int[] Array of order IDs.
*/
public function search_orders( $term ) {
$order_ids = wc_get_orders(
array(
's' => $term,
'return' => 'ids',
)
);
/**
* Provides an opportunity to modify the list of order IDs obtained during an order search.
*
* This hook is used for Custom Order Table queries. For Custom Post Type order searches, the corresponding hook
* is `woocommerce_shop_order_search_results`.
*
* @since 7.0.0
*
* @param int[] $order_ids Search results as an array of order IDs.
* @param string $term The search term.
*/
return array_map( 'intval', (array) apply_filters( 'woocommerce_cot_shop_order_search_results', $order_ids, $term ) );
}
/**
* Fetch order type for orders in bulk.
*
* @param array $order_ids Order IDs.
*
* @return array array( $order_id1 => $type1, ... ) Array for all orders.
*/
public function get_orders_type( $order_ids ) {
global $wpdb;
if ( empty( $order_ids ) ) {
return array();
}
$orders_table = self::get_orders_table_name();
$order_ids_placeholder = implode( ', ', array_fill( 0, count( $order_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, type FROM {$orders_table} WHERE id IN ( $order_ids_placeholder )",
$order_ids
)
);
// phpcs:enable
$order_types = array();
foreach ( $results as $row ) {
$order_types[ $row->id ] = $row->type;
}
return $order_types;
}
/**
* Get order type from DB.
*
* @param int $order_id Order ID.
*
* @return string Order type.
*/
public function get_order_type( $order_id ) {
$type = $this->get_orders_type( array( $order_id ) );
return $type[ $order_id ] ?? '';
}
/**
* Check if an order exists by id.
*
* @since 8.0.0
*
* @param int $order_id The order id to check.
* @return bool True if an order exists with the given name.
*/
public function order_exists( $order_id ) : bool {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT EXISTS (SELECT id FROM {$this->orders_table_name} WHERE id=%d)",
$order_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $exists;
}
/**
* Method to read an order from custom tables.
*
* @param \WC_Order $order Order object.
*
* @throws \Exception If passed order is invalid.
*/
public function read( &$order ) {
$orders_array = array( $order->get_id() => $order );
$this->read_multiple( $orders_array );
}
/**
* Reads multiple orders from custom tables in one pass.
*
* @since 6.9.0
* @param array[\WC_Order] $orders Order objects.
* @throws \Exception If passed an invalid order.
*/
public function read_multiple( &$orders ) {
$order_ids = array_keys( $orders );
$data = $this->get_order_data_for_ids( $order_ids );
if ( count( $data ) !== count( $order_ids ) ) {
throw new \Exception( __( 'Invalid order IDs in call to read_multiple()', 'woocommerce' ) );
}
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( ! $data_synchronizer instanceof DataSynchronizer ) {
return;
}
$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
if ( $data_sync_enabled ) {
/**
* Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
* This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
*
* @param bool $read_on_sync_enabled Whether to sync on read.
*
* @since 8.1.0
*/
$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $data_sync_enabled );
}
$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
$post_orders = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();
foreach ( $data as $order_data ) {
$order_id = absint( $order_data->id );
$order = $orders[ $order_id ];
$this->init_order_record( $order, $order_id, $order_data );
if ( $data_sync_enabled && $this->should_sync_order( $order ) && isset( $post_orders[ $order_id ] ) ) {
self::$reading_order_ids[] = $order_id;
$this->maybe_sync_order( $order, $post_orders[ $order->get_id() ] );
}
}
}
/**
* Helper method to check whether to sync the order.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return bool Whether the order should be synced.
*/
private function should_sync_order( \WC_Abstract_Order $order ) : bool {
$draft_order = in_array( $order->get_status(), array( 'draft', 'auto-draft' ), true );
$already_synced = in_array( $order->get_id(), self::$reading_order_ids, true );
return ! $draft_order && ! $already_synced;
}
/**
* Helper method to initialize order object from DB data.
*
* @param \WC_Abstract_Order $order Order object.
* @param int $order_id Order ID.
* @param \stdClass $order_data Order data fetched from DB.
*
* @return void
*/
protected function init_order_record( \WC_Abstract_Order &$order, int $order_id, \stdClass $order_data ) {
$order->set_defaults();
$order->set_id( $order_id );
$filtered_meta_data = $this->filter_raw_meta_data( $order, $order_data->meta_data );
$order->init_meta_data( $filtered_meta_data );
$this->set_order_props_from_data( $order, $order_data );
$order->set_object_read( true );
}
/**
* For post based data stores, this was used to filter internal meta data. For custom tables, technically there is no internal meta data,
* (i.e. we store all core data as properties for the order, and not in meta data). So this method is a no-op.
*
* Except that some meta such as billing_address_index and shipping_address_index are infact stored in meta data, so we need to filter those out.
*
* However, declaring $internal_meta_keys is still required so that our backfill and other comparison checks works as expected.
*
* @param \WC_Data $object Object to filter meta data for.
* @param array $raw_meta_data Raw meta data.
*
* @return array Filtered meta data.
*/
public function filter_raw_meta_data( &$object, $raw_meta_data ) {
$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
$allowed_keys = array(
'_billing_address_index',
'_shipping_address_index',
);
$allowed_meta = array_filter(
$raw_meta_data,
function( $meta ) use ( $allowed_keys ) {
return in_array( $meta->meta_key, $allowed_keys, true );
}
);
return array_merge( $allowed_meta, $filtered_meta_data );
}
/**
* Sync order to/from posts tables if we are able to detect difference between order and posts but the sync is enabled.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object initialized from post.
*
* @return void
* @throws \Exception If passed an invalid order.
*/
private function maybe_sync_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
if ( ! $this->is_post_different_from_order( $order, $post_order ) ) {
return;
}
// Modified dates can be empty when the order is created but never updated again. Fallback to created date in those cases.
$order_modified_date = $order->get_date_modified() ?? $order->get_date_created();
$order_modified_date = is_null( $order_modified_date ) ? 0 : $order_modified_date->getTimestamp();
$post_order_modified_date = $post_order->get_date_modified() ?? $post_order->get_date_created();
$post_order_modified_date = is_null( $post_order_modified_date ) ? 0 : $post_order_modified_date->getTimestamp();
/**
* We are here because there was difference in posts and order data, although the sync is enabled.
* When order modified date is more recent than post modified date, it can only mean that COT definitely has more updated version of the order.
*
* In a case where post meta was updated (without updating post_modified date), post_modified would be equal to order_modified date.
*
* So we write back to the order table when order modified date is more recent than post modified date. Otherwise, we write to the post table.
*/
if ( $post_order_modified_date >= $order_modified_date ) {
$this->migrate_post_record( $order, $post_order );
}
}
/**
* Get the post type order representation.
*
* @param \WP_Post $post Post object.
*
* @return \WC_Order Order object.
*/
private function get_cpt_order( $post ) {
$cpt_order = new \WC_Order();
$cpt_order->set_id( $post->ID );
$cpt_data_store = $this->get_cpt_data_store_instance();
$cpt_data_store->read( $cpt_order );
return $cpt_order;
}
/**
* Helper function to get posts data for an order in bullk. We use to this to compute posts object in bulk so that we can compare it with COT data.
*
* @param array $orders List of orders mapped by $order_id.
*
* @return array List of posts.
*/
private function get_post_orders_for_ids( array $orders ): array {
$order_ids = array_keys( $orders );
// We have to bust meta cache, otherwise we will just get the meta cached by OrderTableDataStore.
foreach ( $order_ids as $order_id ) {
wp_cache_delete( WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
}
$cpt_stores = array();
$cpt_store_orders = array();
foreach ( $orders as $order_id => $order ) {
$table_data_store = $order->get_data_store();
$cpt_data_store = $table_data_store->get_cpt_data_store_instance();
$cpt_store_class_name = get_class( $cpt_data_store );
if ( ! isset( $cpt_stores[ $cpt_store_class_name ] ) ) {
$cpt_stores[ $cpt_store_class_name ] = $cpt_data_store;
$cpt_store_orders[ $cpt_store_class_name ] = array();
}
$cpt_store_orders[ $cpt_store_class_name ][ $order_id ] = $order;
}
$cpt_orders = array();
foreach ( $cpt_stores as $cpt_store_name => $cpt_store ) {
// Prime caches if we can.
if ( method_exists( $cpt_store, 'prime_caches_for_orders' ) ) {
$cpt_store->prime_caches_for_orders( array_keys( $cpt_store_orders[ $cpt_store_name ] ), array() );
}
foreach ( $cpt_store_orders[ $cpt_store_name ] as $order_id => $order ) {
$cpt_order_class_name = wc_get_order_type( $order->get_type() )['class_name'];
$cpt_order = new $cpt_order_class_name();
try {
$cpt_order->set_id( $order_id );
$cpt_store->read( $cpt_order );
$cpt_orders[ $order_id ] = $cpt_order;
} catch ( Exception $e ) {
// If the post record has been deleted (for instance, by direct query) then an exception may be thrown.
$this->error_logger->warning(
sprintf(
/* translators: %1$d order ID. */
__( 'Unable to load the post record for order %1$d', 'woocommerce' ),
$order_id
),
array(
'exception_code' => $e->getCode(),
'exception_msg' => $e->getMessage(),
'origin' => __METHOD__,
)
);
}
}
}
return $cpt_orders;
}
/**
* Computes whether post has been updated after last order. Tries to do it as efficiently as possible.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts table.
*
* @return bool True if post is different than order.
*/
private function is_post_different_from_order( $order, $post_order ): bool {
if ( ArrayUtil::deep_compare_array_diff( $order->get_base_data(), $post_order->get_base_data(), false ) ) {
return true;
}
$meta_diff = $this->get_diff_meta_data_between_orders( $order, $post_order );
if ( ! empty( $meta_diff ) ) {
return true;
}
return false;
}
/**
* Migrate meta data from post to order.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts table.
*
* @return array List of meta data that was migrated.
*/
private function migrate_meta_data_from_post_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
$diff = $this->get_diff_meta_data_between_orders( $order, $post_order, true );
$order->save_meta_data();
return $diff;
}
/**
* Helper function to compute diff between metadata of post and cot data for an order.
*
* Also provides an option to sync the metadata as well, since we are already computing the diff.
*
* @param \WC_Abstract_Order $order1 Order object read from posts.
* @param \WC_Abstract_Order $order2 Order object read from COT.
* @param bool $sync Whether to also sync the meta data.
*
* @return array Difference between post and COT meta data.
*/
private function get_diff_meta_data_between_orders( \WC_Abstract_Order &$order1, \WC_Abstract_Order $order2, $sync = false ): array {
$order1_meta = ArrayUtil::select( $order1->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$order2_meta = ArrayUtil::select( $order2->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$order1_meta_by_key = ArrayUtil::select_as_assoc( $order1_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
$order2_meta_by_key = ArrayUtil::select_as_assoc( $order2_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
$diff = array();
foreach ( $order1_meta_by_key as $key => $value ) {
if ( in_array( $key, $this->internal_meta_keys, true ) ) {
// These should have already been verified in the base data comparison.
continue;
}
$order1_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
if ( ! array_key_exists( $key, $order2_meta_by_key ) ) {
$sync && $order1->delete_meta_data( $key );
$diff[ $key ] = $order1_values;
unset( $order2_meta_by_key[ $key ] );
continue;
}
$order2_values = ArrayUtil::select( $order2_meta_by_key[ $key ], 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
$new_diff = ArrayUtil::deep_assoc_array_diff( $order1_values, $order2_values );
if ( ! empty( $new_diff ) && $sync ) {
if ( count( $order2_values ) > 1 ) {
$sync && $order1->delete_meta_data( $key );
foreach ( $order2_values as $post_order_value ) {
$sync && $order1->add_meta_data( $key, $post_order_value, false );
}
} else {
$sync && $order1->update_meta_data( $key, $order2_values[0] );
}
$diff[ $key ] = $new_diff;
unset( $order2_meta_by_key[ $key ] );
}
}
foreach ( $order2_meta_by_key as $key => $value ) {
if ( array_key_exists( $key, $order1_meta_by_key ) || in_array( $key, $this->internal_meta_keys, true ) ) {
continue;
}
$order2_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
foreach ( $order2_values as $meta_value ) {
$sync && $order1->add_meta_data( $key, $meta_value );
}
$diff[ $key ] = $order2_values;
}
return $diff;
}
/**
* Log difference between post and COT data for an order.
*
* @param array $diff Difference between post and COT data.
*
* @return void
*/
private function log_diff( array $diff ): void {
$this->error_logger->notice( 'Diff found: ' . wp_json_encode( $diff, JSON_PRETTY_PRINT ) );
}
/**
* Migrate post record from a given order object.
*
* @param \WC_Abstract_Order $order Order object.
* @param \WC_Abstract_Order $post_order Order object read from posts.
*
* @return void
*/
private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
$this->migrate_meta_data_from_post_order( $order, $post_order );
$post_order_base_data = $post_order->get_base_data();
foreach ( $post_order_base_data as $key => $value ) {
$this->set_order_prop( $order, $key, $value );
}
$this->persist_updates( $order, false );
}
/**
* Sets order properties based on a row from the database.
*
* @param \WC_Abstract_Order $order The order object.
* @param object $order_data A row of order data from the database.
*/
protected function set_order_props_from_data( &$order, $order_data ) {
foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mapping ) {
foreach ( $column_mapping as $column_name => $prop_details ) {
if ( ! isset( $prop_details['name'] ) ) {
continue;
}
$prop_value = $order_data->{$prop_details['name']};
if ( is_null( $prop_value ) ) {
continue;
}
try {
if ( 'date' === $prop_details['type'] ) {
$prop_value = $this->string_to_timestamp( $prop_value );
}
$this->set_order_prop( $order, $prop_details['name'], $prop_value );
} catch ( \Exception $e ) {
$order_id = $order->get_id();
$this->error_logger->warning(
sprintf(
/* translators: %1$d = peoperty name, %2$d = order ID, %3$s = error message. */
__( 'Error when setting property \'%1$s\' for order %2$d: %3$s', 'woocommerce' ),
$prop_details['name'],
$order_id,
$e->getMessage()
),
array(
'exception_code' => $e->getCode(),
'exception_msg' => $e->getMessage(),
'origin' => __METHOD__,
'order_id' => $order_id,
'property_name' => $prop_details['name'],
)
);
}
}
}
}
/**
* Set order prop if a setter exists in either the order object or in the data store.
*
* @param \WC_Abstract_Order $order Order object.
* @param string $prop_name Property name.
* @param mixed $prop_value Property value.
*
* @return bool True if the property was set, false otherwise.
*/
private function set_order_prop( \WC_Abstract_Order $order, string $prop_name, $prop_value ) {
$prop_setter_function_name = "set_{$prop_name}";
if ( is_callable( array( $order, $prop_setter_function_name ) ) ) {
return $order->{$prop_setter_function_name}( $prop_value );
} elseif ( is_callable( array( $this, $prop_setter_function_name ) ) ) {
return $this->{$prop_setter_function_name}( $order, $prop_value, false );
}
return false;
}
/**
* Return order data for a single order ID.
*
* @param int $id Order ID.
*
* @return object|\WP_Error DB order object or WP_Error.
*/
private function get_order_data_for_id( $id ) {
$results = $this->get_order_data_for_ids( array( $id ) );
return is_array( $results ) && count( $results ) > 0 ? $results[ $id ] : $results;
}
/**
* Return order data for multiple IDs.
*
* @param array $ids List of order IDs.
*
* @return \stdClass[]|object|null DB Order objects or error.
*/
protected function get_order_data_for_ids( $ids ) {
global $wpdb;
if ( ! $ids || empty( $ids ) ) {
return array();
}
$table_aliases = array(
'orders' => $this->get_order_table_alias(),
'billing_address' => $this->get_address_table_alias( 'billing' ),
'shipping_address' => $this->get_address_table_alias( 'shipping' ),
'operational_data' => $this->get_op_table_alias(),
);
$order_table_alias = $table_aliases['orders'];
$order_table_query = $this->get_order_table_select_statement();
$id_placeholder = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$order_meta_table = self::get_meta_table_name();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_table_query is autogenerated and should already be prepared.
$table_data = $wpdb->get_results(
$wpdb->prepare(
"$order_table_query WHERE $order_table_alias.id in ( $id_placeholder )",
$ids
)
);
// phpcs:enable
$meta_data_query = $this->get_order_meta_select_statement();
$order_data = array();
$meta_data = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $meta_data_query and $order_meta_table is autogenerated and should already be prepared. $id_placeholder is already prepared.
"$meta_data_query WHERE $order_meta_table.order_id in ( $id_placeholder )",
$ids
)
);
foreach ( $table_data as $table_datum ) {
$id = $table_datum->{"{$order_table_alias}_id"};
$order_data[ $id ] = new \stdClass();
foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mappings ) {
$table_alias = $table_aliases[ $table_name ];
// This remapping is required to keep the query length small enough to be supported by implementations such as HyperDB (i.e. fetching some tables in join via alias.*, while others via full name). We can revert this commit if HyperDB starts supporting SRTM for query length more than 3076 characters.
foreach ( $column_mappings as $field => $map ) {
$field_name = $map['name'] ?? "{$table_name}_$field";
if ( property_exists( $table_datum, $field_name ) ) {
$field_value = $table_datum->{ $field_name }; // Unique column, field name is different prop name.
} elseif ( property_exists( $table_datum, "{$table_alias}_$field" ) ) {
$field_value = $table_datum->{"{$table_alias}_$field"}; // Non-unique column (billing, shipping etc).
} else {
$field_value = $table_datum->{ $field }; // Unique column, field name is same as prop name.
}
$order_data[ $id ]->{$field_name} = $field_value;
}
}
$order_data[ $id ]->id = $id;
$order_data[ $id ]->meta_data = array();
}
foreach ( $meta_data as $meta_datum ) {
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Not a meta query.
$order_data[ $meta_datum->order_id ]->meta_data[] = (object) array(
'meta_id' => $meta_datum->id,
'meta_key' => $meta_datum->meta_key,
'meta_value' => $meta_datum->meta_value,
);
// phpcs:enable
}
return $order_data;
}
/**
* Helper method to generate combined select statement.
*
* @return string Select SQL statement to fetch order.
*/
private function get_order_table_select_statement() {
$order_table = $this::get_orders_table_name();
$order_table_alias = $this->get_order_table_alias();
$billing_address_table_alias = $this->get_address_table_alias( 'billing' );
$shipping_address_table_alias = $this->get_address_table_alias( 'shipping' );
$op_data_table_alias = $this->get_op_table_alias();
$billing_address_clauses = $this->join_billing_address_table_to_order_query( $order_table_alias, $billing_address_table_alias );
$shipping_address_clauses = $this->join_shipping_address_table_to_order_query( $order_table_alias, $shipping_address_table_alias );
$operational_data_clauses = $this->join_operational_data_table_to_order_query( $order_table_alias, $op_data_table_alias );
/**
* We fully spell out address table columns because they have duplicate columns for billing and shipping and would be overwritten if we don't spell them out. There is not such duplication in the operational data table and orders table, so select with `alias`.* is fine.
* We do spell ID columns manually, as they are duplicate.
*/
return "
SELECT $order_table_alias.id as o_id, $op_data_table_alias.id as p_id, $order_table_alias.*, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, $op_data_table_alias.*
FROM $order_table $order_table_alias
LEFT JOIN {$billing_address_clauses['join']}
LEFT JOIN {$shipping_address_clauses['join']}
LEFT JOIN {$operational_data_clauses['join']}
";
}
/**
* Helper function to generate select statement for fetching metadata in bulk.
*
* @return string Select SQL statement to fetch order metadata.
*/
private function get_order_meta_select_statement() {
$order_meta_table = self::get_meta_table_name();
return "
SELECT $order_meta_table.id, $order_meta_table.order_id, $order_meta_table.meta_key, $order_meta_table.meta_value
FROM $order_meta_table
";
}
/**
* Helper method to generate join query for billing addresses in wc_address table.
*
* @param string $order_table_alias Alias for order table to use in join.
* @param string $address_table_alias Alias for address table to use in join.
*
* @return array Select and join statements for billing address table.
*/
private function join_billing_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
return $this->join_address_table_order_query( 'billing', $order_table_alias, $address_table_alias );
}
/**
* Helper method to generate join query for shipping addresses in wc_address table.
*
* @param string $order_table_alias Alias for order table to use in join.
* @param string $address_table_alias Alias for address table to use in join.
*
* @return array Select and join statements for shipping address table.
*/
private function join_shipping_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
return $this->join_address_table_order_query( 'shipping', $order_table_alias, $address_table_alias );
}
/**
* Helper method to generate join and select query for address table.
*
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param string $order_table_alias Alias of order table to use.
* @param string $address_table_alias Alias for address table to use.
*
* @return array Select and join statements for address table.
*/
private function join_address_table_order_query( $address_type, $order_table_alias, $address_table_alias ) {
global $wpdb;
$address_table = $this::get_addresses_table_name();
$column_props_map = 'billing' === $address_type ? $this->billing_address_column_mapping : $this->shipping_address_column_mapping;
$clauses = $this->generate_select_and_join_clauses( $order_table_alias, $address_table, $address_table_alias, $column_props_map );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $clauses['join'] and $address_table_alias are hardcoded.
$clauses['join'] = $wpdb->prepare(
"{$clauses['join']} AND $address_table_alias.address_type = %s",
$address_type
);
// phpcs:enable
return array(
'select' => $clauses['select'],
'join' => $clauses['join'],
);
}
/**
* Helper method to join order operational data table.
*
* @param string $order_table_alias Alias to use for order table.
* @param string $operational_table_alias Alias to use for operational data table.
*
* @return array Select and join queries for operational data table.
*/
private function join_operational_data_table_to_order_query( $order_table_alias, $operational_table_alias ) {
$operational_data_table = $this::get_operational_data_table_name();
return $this->generate_select_and_join_clauses(
$order_table_alias,
$operational_data_table,
$operational_table_alias,
$this->operational_data_column_mapping
);
}
/**
* Helper method to generate join and select clauses.
*
* @param string $order_table_alias Alias for order table.
* @param string $table Table to join.
* @param string $table_alias Alias for table to join.
* @param array[] $column_props_map Column to prop map for table to join.
*
* @return array Select and join queries.
*/
private function generate_select_and_join_clauses( $order_table_alias, $table, $table_alias, $column_props_map ) {
// Add aliases to column names so they will be unique when fetching.
$select_clause = $this->generate_select_clause_for_props( $table_alias, $column_props_map );
$join_clause = "$table $table_alias ON $table_alias.order_id = $order_table_alias.id";
return array(
'select' => $select_clause,
'join' => $join_clause,
);
}
/**
* Helper method to generate select clause for props.
*
* @param string $table_alias Alias for table.
* @param array[] $props Props to column mapping for table.
*
* @return string Select clause.
*/
private function generate_select_clause_for_props( $table_alias, $props ) {
$select_clauses = array();
foreach ( $props as $column_name => $prop_details ) {
$select_clauses[] = isset( $prop_details['name'] ) ? "$table_alias.$column_name as {$prop_details['name']}" : "$table_alias.$column_name as {$table_alias}_$column_name";
}
return implode( ', ', $select_clauses );
}
/**
* Persists order changes to the database.
*
* @param \WC_Abstract_Order $order The order.
* @param bool $force_all_fields Force saving all fields to DB and just changed.
*
* @throws \Exception If order data is not valid.
*
* @since 6.8.0
*/
protected function persist_order_to_db( &$order, bool $force_all_fields = false ) {
$context = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update';
$data_sync = wc_get_container()->get( DataSynchronizer::class );
if ( 'create' === $context ) {
$post_id = wp_insert_post(
array(
'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
'post_status' => 'draft',
'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
)
);
if ( ! $post_id ) {
throw new \Exception( __( 'Could not create order in posts table.', 'woocommerce' ) );
}
$order->set_id( $post_id );
}
$only_changes = ! $force_all_fields && 'update' === $context;
// Figure out what needs to be updated in the database.
$db_updates = $this->get_db_rows_for_order( $order, $context, $only_changes );
// Persist changes.
foreach ( $db_updates as $update ) {
// Make sure 'data' and 'format' entries match before passing to $wpdb.
ksort( $update['data'] );
ksort( $update['format'] );
$result = $this->database_util->insert_on_duplicate_key_update(
$update['table'],
$update['data'],
array_values( $update['format'] )
);
if ( false === $result ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Could not persist order to database table "%s".', 'woocommerce' ), $update['table'] ) );
}
}
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
$default_taxonomies = $this->init_default_taxonomies( $order, array() );
$this->set_custom_taxonomies( $order, $default_taxonomies );
}
/**
* Set default taxonomies for the order.
*
* Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set default taxonomies is not filterable, we have to re-implement it.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $sanitized_tax_input Sanitized taxonomy input.
*
* @return array Sanitized tax input with default taxonomies.
*/
public function init_default_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
if ( 'auto-draft' === $order->get_status() ) {
return $sanitized_tax_input;
}
foreach ( get_object_taxonomies( $order->get_type(), 'object' ) as $taxonomy => $tax_object ) {
if ( empty( $tax_object->default_term ) ) {
return $sanitized_tax_input;
}
// Filter out empty terms.
if ( isset( $sanitized_tax_input[ $taxonomy ] ) && is_array( $sanitized_tax_input[ $taxonomy ] ) ) {
$sanitized_tax_input[ $taxonomy ] = array_filter( $sanitized_tax_input[ $taxonomy ] );
}
// Passed custom taxonomy list overwrites the existing list if not empty.
$terms = wp_get_object_terms( $order->get_id(), $taxonomy, array( 'fields' => 'ids' ) );
if ( ! empty( $terms ) && empty( $sanitized_tax_input[ $taxonomy ] ) ) {
$sanitized_tax_input[ $taxonomy ] = $terms;
}
if ( empty( $sanitized_tax_input[ $taxonomy ] ) ) {
$default_term_id = get_option( 'default_term_' . $taxonomy );
if ( ! empty( $default_term_id ) ) {
$sanitized_tax_input[ $taxonomy ] = array( (int) $default_term_id );
}
}
}
return $sanitized_tax_input;
}
/**
* Set custom taxonomies for the order.
*
* Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set custom taxonomies is not filterable, we have to re-implement it.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $sanitized_tax_input Sanitized taxonomy input.
*
* @return void
*/
public function set_custom_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
if ( empty( $sanitized_tax_input ) ) {
return;
}
foreach ( $sanitized_tax_input as $taxonomy => $tags ) {
$taxonomy_obj = get_taxonomy( $taxonomy );
if ( ! $taxonomy_obj ) {
/* translators: %s: Taxonomy name. */
_doing_it_wrong( __FUNCTION__, esc_html( sprintf( __( 'Invalid taxonomy: %s.', 'woocommerce' ), $taxonomy ) ), '7.9.0' );
continue;
}
// array = hierarchical, string = non-hierarchical.
if ( is_array( $tags ) ) {
$tags = array_filter( $tags );
}
if ( current_user_can( $taxonomy_obj->cap->assign_terms ) ) {
wp_set_post_terms( $order->get_id(), $tags, $taxonomy );
}
}
}
/**
* Generates an array of rows with all the details required to insert or update an order in the database.
*
* @param \WC_Abstract_Order $order The order.
* @param string $context The context: 'create' or 'update'.
* @param boolean $only_changes Whether to consider only changes in the order for generating the rows.
*
* @return array
* @throws \Exception When invalid data is found for the given context.
*
* @since 6.8.0
*/
protected function get_db_rows_for_order( \WC_Abstract_Order $order, string $context = 'create', bool $only_changes = false ): array {
$result = array();
$row = $this->get_db_row_from_order( $order, $this->order_column_mapping, $only_changes );
if ( 'create' === $context && ! $row ) {
throw new \Exception( 'No data for new record.' ); // This shouldn't occur.
}
if ( $row ) {
$result[] = array(
'table' => self::get_orders_table_name(),
'data' => array_merge(
$row['data'],
array(
'id' => $order->get_id(),
'type' => $order->get_type(),
)
),
'format' => array_merge(
$row['format'],
array(
'id' => '%d',
'type' => '%s',
)
),
);
}
// wc_order_operational_data.
$row = $this->get_db_row_from_order( $order, $this->operational_data_column_mapping, $only_changes );
if ( $row ) {
$result[] = array(
'table' => self::get_operational_data_table_name(),
'data' => array_merge( $row['data'], array( 'order_id' => $order->get_id() ) ),
'format' => array_merge( $row['format'], array( 'order_id' => '%d' ) ),
);
}
// wc_order_addresses.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
$row = $this->get_db_row_from_order( $order, $this->{$address_type . '_address_column_mapping'}, $only_changes );
if ( $row ) {
$result[] = array(
'table' => self::get_addresses_table_name(),
'data' => array_merge(
$row['data'],
array(
'order_id' => $order->get_id(),
'address_type' => $address_type,
)
),
'format' => array_merge(
$row['format'],
array(
'order_id' => '%d',
'address_type' => '%s',
)
),
);
}
}
/**
* Allow third parties to include rows that need to be inserted/updated in custom tables when persisting an order.
*
* @since 6.8.0
*
* @param array Array of rows to be inserted/updated when persisting an order. Each entry should be an array with
* keys 'table', 'data' (the row), 'format' (row format), 'where' and 'where_format'.
* @param \WC_Order The order object.
* @param string The context of the operation: 'create' or 'update'.
*/
$ext_rows = apply_filters( 'woocommerce_orders_table_datastore_extra_db_rows_for_order', array(), $order, $context );
return array_merge( $result, $ext_rows );
}
/**
* Produces an array with keys 'row' and 'format' that can be passed to `$wpdb->update()` as the `$data` and
* `$format` parameters. Values are taken from the order changes array and properly formatted for inclusion in the
* database.
*
* @param \WC_Abstract_Order $order Order.
* @param array $column_mapping Table column mapping.
* @param bool $only_changes Whether to consider only changes in the order object or all fields.
* @return array
*
* @since 6.8.0
*/
protected function get_db_row_from_order( $order, $column_mapping, $only_changes = false ) {
$changes = $only_changes ? $order->get_changes() : array_merge( $order->get_data(), $order->get_changes() );
// Make sure 'status' is correctly prefixed.
if ( array_key_exists( 'status', $column_mapping ) && array_key_exists( 'status', $changes ) ) {
$changes['status'] = $this->get_post_status( $order );
}
$row = array();
$row_format = array();
foreach ( $column_mapping as $column => $details ) {
if ( ! isset( $details['name'] ) || ! array_key_exists( $details['name'], $changes ) ) {
continue;
}
$row[ $column ] = $this->database_util->format_object_value_for_db( $changes[ $details['name'] ], $details['type'] );
$row_format[ $column ] = $this->database_util->get_wpdb_format_for_type( $details['type'] );
}
if ( ! $row ) {
return false;
}
return array(
'data' => $row,
'format' => $row_format,
);
}
/**
* Method to delete an order from the database.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $args Array of args to pass to the delete method.
*
* @return void
*/
public function delete( &$order, $args = array() ) {
$order_id = $order->get_id();
if ( ! $order_id ) {
return;
}
$args = wp_parse_args(
$args,
array(
'force_delete' => false,
'suppress_filters' => false,
)
);
$do_filters = ! $args['suppress_filters'];
if ( $args['force_delete'] ) {
if ( $do_filters ) {
/**
* Fires immediately before an order is deleted from the database.
*
* @since 7.1.0
*
* @param int $order_id ID of the order about to be deleted.
* @param WC_Order $order Instance of the order that is about to be deleted.
*/
do_action( 'woocommerce_before_delete_order', $order_id, $order );
}
$this->upshift_or_delete_child_orders( $order );
$this->delete_order_data_from_custom_order_tables( $order_id );
$this->delete_items( $order );
$order->set_id( 0 );
/** We can delete the post data if:
* 1. The HPOS table is authoritative and synchronization is enabled.
* 2. The post record is of type `shop_order_placehold`, since this is created by the HPOS in the first place.
*
* In other words, we do not delete the post record when HPOS table is authoritative and synchronization is disabled but post record is a full record and not just a placeholder, because it implies that the order was created before HPOS was enabled.
*/
$orders_table_is_authoritative = $order->get_data_store()->get_current_class_name() === self::class;
if ( $orders_table_is_authoritative ) {
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
// Once we stop creating posts for orders, we should do the cleanup here instead.
wp_delete_post( $order_id );
} else {
$this->handle_order_deletion_with_sync_disabled( $order_id );
}
}
if ( $do_filters ) {
/**
* Fires immediately after an order is deleted.
*
* @since
*
* @param int $order_id ID of the order that has been deleted.
*/
do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
} else {
if ( $do_filters ) {
/**
* Fires immediately before an order is trashed.
*
* @since 7.1.0
*
* @param int $order_id ID of the order about to be trashed.
* @param WC_Order $order Instance of the order that is about to be trashed.
*/
do_action( 'woocommerce_before_trash_order', $order_id, $order );
}
$this->trash_order( $order );
if ( $do_filters ) {
/**
* Fires immediately after an order is trashed.
*
* @since
*
* @param int $order_id ID of the order that has been trashed.
*/
do_action( 'woocommerce_trash_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
}
}
/**
* Handles the deletion of an order from the orders table when sync is disabled:
*
* If the corresponding row in the posts table is of placeholder type,
* it's just deleted; otherwise a "deleted_from" record is created in the meta table
* and the sync process will detect these and take care of deleting the appropriate post records.
*
* @param int $order_id Th id of the order that has been deleted from the orders table.
* @return void
*/
protected function handle_order_deletion_with_sync_disabled( $order_id ): void {
global $wpdb;
$post_type = $wpdb->get_var(
$wpdb->prepare( "SELECT post_type FROM {$wpdb->posts} WHERE ID=%d", $order_id )
);
if ( DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post_type ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->posts} WHERE ID=%d OR post_parent=%d",
$order_id,
$order_id
)
);
} else {
// phpcs:disable WordPress.DB.SlowDBQuery
$wpdb->insert(
self::get_meta_table_name(),
array(
'order_id' => $order_id,
'meta_key' => DataSynchronizer::DELETED_RECORD_META_KEY,
'meta_value' => DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE,
)
);
// phpcs:enable WordPress.DB.SlowDBQuery
// Note that at this point upshift_or_delete_child_orders will already have been invoked,
// thus all the child orders either still exist but have a different parent id,
// or have been deleted and got their own deletion record already.
// So there's no need to do anything about them.
}
}
/**
* Set the parent id of child orders to the parent order's parent if the post type
* for the order is hierarchical, just delete the child orders otherwise.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return void
*/
private function upshift_or_delete_child_orders( $order ) : void {
global $wpdb;
$order_table = self::get_orders_table_name();
$order_parent_id = $order->get_parent_id();
if ( $this->legacy_proxy->call_function( 'is_post_type_hierarchical', $order->get_type() ) ) {
$wpdb->update(
$order_table,
array( 'parent_order_id' => $order_parent_id ),
array( 'parent_order_id' => $order->get_id() ),
array( '%d' ),
array( '%d' )
);
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$child_order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM $order_table WHERE parent_order_id=%d",
$order->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
foreach ( $child_order_ids as $child_order_id ) {
$child_order = wc_get_order( $child_order_id );
if ( $child_order ) {
$child_order->delete( true );
}
}
}
}
/**
* Trashes an order.
*
* @param WC_Order $order The order object.
*
* @return void
*/
public function trash_order( $order ) {
global $wpdb;
if ( 'trash' === $order->get_status( 'edit' ) ) {
return;
}
$trash_metadata = array(
'_wp_trash_meta_status' => 'wc-' . $order->get_status( 'edit' ),
'_wp_trash_meta_time' => time(),
);
$wpdb->update(
self::get_orders_table_name(),
array(
'status' => 'trash',
'date_updated_gmt' => current_time( 'Y-m-d H:i:s', true ),
),
array( 'id' => $order->get_id() ),
array( '%s', '%s' ),
array( '%d' )
);
$order->set_status( 'trash' );
foreach ( $trash_metadata as $meta_key => $meta_value ) {
$this->add_meta(
$order,
(object) array(
'key' => $meta_key,
'value' => $meta_value,
)
);
}
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
wp_trash_post( $order->get_id() );
}
}
/**
* Attempts to restore the specified order back to its original status (after having been trashed).
*
* @param WC_Order $order The order to be untrashed.
*
* @return bool If the operation was successful.
*/
public function untrash_order( WC_Order $order ): bool {
$id = $order->get_id();
$status = $order->get_status();
if ( 'trash' !== $status ) {
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'Order %1$d cannot be restored from the trash: it has already been restored to status "%2$s".', 'woocommerce' ),
$id,
$status
)
);
return false;
}
$previous_status = $order->get_meta( '_wp_trash_meta_status' );
$valid_statuses = wc_get_order_statuses();
$previous_state_is_invalid = ! array_key_exists( $previous_status, $valid_statuses );
$pending_is_valid_status = array_key_exists( 'wc-pending', $valid_statuses );
if ( $previous_state_is_invalid && $pending_is_valid_status ) {
// If the previous status is no longer valid, let's try to restore it to "pending" instead.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'The previous status of order %1$d ("%2$s") is invalid. It has been restored to "pending" status instead.', 'woocommerce' ),
$id,
$previous_status
)
);
$previous_status = 'pending';
} elseif ( $previous_state_is_invalid ) {
// If we cannot restore to pending, we should probably stand back and let the merchant intervene some other way.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'The previous status of order %1$d ("%2$s") is invalid. It could not be restored.', 'woocommerce' ),
$id,
$previous_status
)
);
return false;
}
/**
* Fires before an order is restored from the trash.
*
* @since 7.2.0
*
* @param int $order_id Order ID.
* @param string $previous_status The status of the order before it was trashed.
*/
do_action( 'woocommerce_untrash_order', $order->get_id(), $previous_status );
$order->set_status( $previous_status );
$order->save();
// Was the status successfully restored? Let's clean up the meta and indicate success...
if ( 'wc-' . $order->get_status() === $previous_status ) {
$order->delete_meta_data( '_wp_trash_meta_status' );
$order->delete_meta_data( '_wp_trash_meta_time' );
$order->delete_meta_data( '_wp_trash_meta_comments_status' );
$order->save_meta_data();
return true;
}
// ...Or log a warning and bail.
wc_get_logger()->warning(
sprintf(
/* translators: 1: order ID, 2: order status */
__( 'Something went wrong when trying to restore order %d from the trash. It could not be restored.', 'woocommerce' ),
$id
)
);
return false;
}
/**
* Deletes order data from custom order tables.
*
* @param int $order_id The order ID.
* @return void
*/
public function delete_order_data_from_custom_order_tables( $order_id ) {
global $wpdb;
$order_cache = wc_get_container()->get( OrderCache::class );
// Delete COT-specific data.
foreach ( $this->get_all_table_names() as $table ) {
$wpdb->delete(
$table,
( self::get_orders_table_name() === $table )
? array( 'id' => $order_id )
: array( 'order_id' => $order_id ),
array( '%d' )
);
$order_cache->remove( $order_id );
}
}
/**
* Method to create an order in the database.
*
* @param \WC_Order $order Order object.
*/
public function create( &$order ) {
if ( '' === $order->get_order_key() ) {
$order->set_order_key( wc_generate_order_key() );
}
$this->persist_save( $order );
// Do not fire 'woocommerce_new_order' for draft statuses for backwards compatibility.
if ( 'auto-draft' === $order->get_status( 'edit' ) ) {
return;
}
/**
* Fires when a new order is created.
*
* @since 2.7.0
*
* @param int Order ID.
* @param \WC_Order Order object.
*/
do_action( 'woocommerce_new_order', $order->get_id(), $order );
}
/**
* Helper method responsible for persisting new data to order table.
*
* This should not contain and specific meta or actions, so that it can be used other order types safely.
*
* @param \WC_Order $order Order object.
* @param bool $force_all_fields Force update all fields, instead of calculating and updating only changed fields.
* @param bool $backfill Whether to backfill data to post datastore.
*
* @return void
*
* @throws \Exception When unable to save data.
*/
protected function persist_save( &$order, bool $force_all_fields = false, $backfill = true ) {
$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
$order->set_currency( $order->get_currency() ? $order->get_currency() : get_woocommerce_currency() );
if ( ! $order->get_date_created( 'edit' ) ) {
$order->set_date_created( time() );
}
if ( ! $order->get_date_modified( 'edit' ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
}
$this->persist_order_to_db( $order, $force_all_fields );
$this->update_order_meta( $order );
$order->save_meta_data();
$order->apply_changes();
if ( $backfill ) {
self::$backfilling_order_ids[] = $order->get_id();
$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
$this->maybe_backfill_post_record( $r_order );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
$this->clear_caches( $order );
}
/**
* Method to update an order in the database.
*
* @param \WC_Order $order Order object.
*/
public function update( &$order ) {
$previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status' );
$changes = $order->get_changes();
// Before updating, ensure date paid is set if missing.
if (
! $order->get_date_paid( 'edit' )
&& version_compare( $order->get_version( 'edit' ), '3.0', '<' )
&& $order->has_status( apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? 'processing' : 'completed', $order->get_id(), $order ) ) // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
) {
$order->set_date_paid( $order->get_date_created( 'edit' ) );
}
if ( null === $order->get_date_created( 'edit' ) ) {
$order->set_date_created( time() );
}
$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
// Fetch changes.
$changes = $order->get_changes();
$this->persist_updates( $order );
// Update download permissions if necessary.
if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) {
$data_store = \WC_Data_Store::load( 'customer-download' );
$data_store->update_user_by_order_id( $order->get_id(), $order->get_customer_id(), $order->get_billing_email() );
}
// Mark user account as active.
if ( array_key_exists( 'customer_id', $changes ) ) {
wc_update_user_last_active( $order->get_customer_id() );
}
$order->apply_changes();
$this->clear_caches( $order );
// For backwards compatibility, moving an auto-draft order to a valid status triggers the 'woocommerce_new_order' hook.
if ( ! empty( $changes['status'] ) && 'auto-draft' === $previous_status ) {
do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
return;
}
// For backwards compat with CPT, trashing/untrashing and changing previously datastore-level props does not trigger the update hook.
if ( ( ! empty( $changes['status'] ) && in_array( 'trash', array( $changes['status'], $previous_status ), true ) )
|| ! array_diff_key( $changes, array_flip( $this->get_post_data_store_for_backfill()->get_internal_data_store_key_getters() ) ) ) {
return;
}
do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
/**
* Proxy to udpating order meta. Here for backward compatibility reasons.
*
* @param \WC_Order $order Order object.
*
* @return void
*/
protected function update_post_meta( &$order ) {
$this->update_order_meta( $order );
}
/**
* Helper method that is responsible for persisting order updates to the database.
*
* This is expected to be reused by other order types, and should not contain any specific metadata updates or actions.
*
* @param \WC_Order $order Order object.
* @param bool $backfill Whether to backfill data to post tables.
*
* @return array $changes Array of changes.
*
* @throws \Exception When unable to persist order.
*/
protected function persist_updates( &$order, $backfill = true ) {
// Fetch changes.
$changes = $order->get_changes();
if ( ! isset( $changes['date_modified'] ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
}
$this->persist_order_to_db( $order );
$order->save_meta_data();
if ( $backfill ) {
self::$backfilling_order_ids[] = $order->get_id();
$this->clear_caches( $order );
$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
$this->maybe_backfill_post_record( $r_order );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
return $changes;
}
/**
* Helper method to check whether to backfill post record.
*
* @return bool
*/
private function should_backfill_post_record() {
$data_sync = wc_get_container()->get( DataSynchronizer::class );
return $data_sync->data_sync_is_enabled();
}
/**
* Helper function to decide whether to backfill post record.
*
* @param \WC_Abstract_Order $order Order object.
*
* @return void
*/
private function maybe_backfill_post_record( $order ) {
if ( $this->should_backfill_post_record() ) {
$this->backfill_post_record( $order );
}
}
/**
* Helper method that updates post meta based on an order object.
* Mostly used for backwards compatibility purposes in this datastore.
*
* @param \WC_Order $order Order object.
*
* @since 7.0.0
*/
public function update_order_meta( &$order ) {
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
}
/**
* Helper function to update billing and shipping address metadata.
*
* @param \WC_Abstract_Order $order Order Object.
* @param array $changes Array of changes.
*
* @return void
*/
private function update_address_index_meta( $order, $changes ) {
// If address changed, store concatenated version to make searches faster.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
if ( isset( $changes[ $address_type ] ) ) {
$order->update_meta_data( "_{$address_type}_address_index", implode( ' ', $order->get_address( $address_type ) ) );
}
}
}
/**
* Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys.
* Pass $coupon_id if key for only one of the coupon is needed.
*
* @param WC_Order $order Order object.
* @param int $coupon_id If passed, will return held key for that coupon.
*
* @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon.
*/
public function get_coupon_held_keys( $order, $coupon_id = null ) {
$held_keys = $order->get_meta( '_coupon_held_keys' );
if ( $coupon_id ) {
return isset( $held_keys[ $coupon_id ] ) ? $held_keys[ $coupon_id ] : null;
}
return $held_keys;
}
/**
* Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys.
*
* @param WC_Order $order Order object.
* @param int $coupon_id If passed, will return held key for that coupon.
*
* @return mixed
*/
public function get_coupon_held_keys_for_users( $order, $coupon_id = null ) {
$held_keys_for_user = $order->get_meta( '_coupon_held_keys_for_users' );
if ( $coupon_id ) {
return isset( $held_keys_for_user[ $coupon_id ] ) ? $held_keys_for_user[ $coupon_id ] : null;
}
return $held_keys_for_user;
}
/**
* Add/Update list of meta keys that are currently being used by this order to hold a coupon.
* This is used to figure out what all meta entries we should delete when order is cancelled/completed.
*
* @param WC_Order $order Order object.
* @param array $held_keys Array of coupon_code => meta_key.
* @param array $held_keys_for_user Array of coupon_code => meta_key for held coupon for user.
*
* @return mixed
*/
public function set_coupon_held_keys( $order, $held_keys, $held_keys_for_user ) {
if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) {
$order->update_meta_data( '_coupon_held_keys', $held_keys );
}
if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) {
$order->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user );
}
}
/**
* Release all coupons held by this order.
*
* @param WC_Order $order Current order object.
* @param bool $save Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request.
*/
public function release_held_coupons( $order, $save = true ) {
$coupon_held_keys = $this->get_coupon_held_keys( $order );
if ( is_array( $coupon_held_keys ) ) {
foreach ( $coupon_held_keys as $coupon_id => $meta_key ) {
$coupon = new \WC_Coupon( $coupon_id );
$coupon->delete_meta_data( $meta_key );
$coupon->save_meta_data();
}
}
$order->delete_meta_data( '_coupon_held_keys' );
$coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users( $order );
if ( is_array( $coupon_held_keys_for_users ) ) {
foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) {
$coupon = new \WC_Coupon( $coupon_id );
$coupon->delete_meta_data( $meta_key );
$coupon->save_meta_data();
}
}
$order->delete_meta_data( '_coupon_held_keys_for_users' );
if ( $save ) {
$order->save_meta_data();
}
}
/**
* Performs actual query to get orders. Uses `OrdersTableQuery` to build and generate the query.
*
* @param array $query_vars Query variables.
*
* @return array|object List of orders and count of orders.
*/
public function query( $query_vars ) {
if ( ! isset( $query_vars['paginate'] ) || ! $query_vars['paginate'] ) {
$query_vars['no_found_rows'] = true;
}
if ( isset( $query_vars['anonymized'] ) ) {
$query_vars['meta_query'] = $query_vars['meta_query'] ?? array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
if ( $query_vars['anonymized'] ) {
$query_vars['meta_query'][] = array(
'key' => '_anonymized',
'value' => 'yes',
);
} else {
$query_vars['meta_query'][] = array(
'key' => '_anonymized',
'compare' => 'NOT EXISTS',
);
}
}
try {
$query = new OrdersTableQuery( $query_vars );
} catch ( \Exception $e ) {
$query = (object) array(
'orders' => array(),
'found_orders' => 0,
'max_num_pages' => 0,
);
}
if ( isset( $query_vars['return'] ) && 'ids' === $query_vars['return'] ) {
$orders = $query->orders;
} else {
$orders = WC()->order_factory->get_orders( $query->orders );
}
if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) {
return (object) array(
'orders' => $orders,
'total' => $query->found_orders,
'max_num_pages' => $query->max_num_pages,
);
}
return $orders;
}
//phpcs:enable Squiz.Commenting, Generic.Commenting
/**
* Get the SQL needed to create all the tables needed for the custom orders table feature.
*
* @return string
*/
public function get_database_schema() {
global $wpdb;
$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
$orders_table_name = $this->get_orders_table_name();
$addresses_table_name = $this->get_addresses_table_name();
$operational_data_table_name = $this->get_operational_data_table_name();
$meta_table = $this->get_meta_table_name();
$max_index_length = $this->database_util->get_max_index_length();
$composite_meta_value_index_length = max( $max_index_length - 8 - 100 - 1, 20 ); // 8 for order_id, 100 for meta_key, 10 minimum for meta_value.
$composite_customer_id_email_length = max( $max_index_length - 20, 20 ); // 8 for customer_id, 20 minimum for email.
$sql = "
CREATE TABLE $orders_table_name (
id bigint(20) unsigned,
status varchar(20) null,
currency varchar(10) null,
type varchar(20) null,
tax_amount decimal(26,8) null,
total_amount decimal(26,8) null,
customer_id bigint(20) unsigned null,
billing_email varchar(320) null,
date_created_gmt datetime null,
date_updated_gmt datetime null,
parent_order_id bigint(20) unsigned null,
payment_method varchar(100) null,
payment_method_title text null,
transaction_id varchar(100) null,
ip_address varchar(100) null,
user_agent text null,
customer_note text null,
PRIMARY KEY (id),
KEY status (status),
KEY date_created (date_created_gmt),
KEY customer_id_billing_email (customer_id, billing_email({$composite_customer_id_email_length})),
KEY billing_email (billing_email($max_index_length)),
KEY type_status_date (type, status, date_created_gmt),
KEY parent_order_id (parent_order_id),
KEY date_updated (date_updated_gmt)
) $collate;
CREATE TABLE $addresses_table_name (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned NOT NULL,
address_type varchar(20) null,
first_name text null,
last_name text null,
company text null,
address_1 text null,
address_2 text null,
city text null,
state text null,
postcode text null,
country text null,
email varchar(320) null,
phone varchar(100) null,
KEY order_id (order_id),
UNIQUE KEY address_type_order_id (address_type, order_id),
KEY email (email($max_index_length)),
KEY phone (phone)
) $collate;
CREATE TABLE $operational_data_table_name (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned NULL,
created_via varchar(100) NULL,
woocommerce_version varchar(20) NULL,
prices_include_tax tinyint(1) NULL,
coupon_usages_are_counted tinyint(1) NULL,
download_permission_granted tinyint(1) NULL,
cart_hash varchar(100) NULL,
new_order_email_sent tinyint(1) NULL,
order_key varchar(100) NULL,
order_stock_reduced tinyint(1) NULL,
date_paid_gmt datetime NULL,
date_completed_gmt datetime NULL,
shipping_tax_amount decimal(26,8) NULL,
shipping_total_amount decimal(26,8) NULL,
discount_tax_amount decimal(26,8) NULL,
discount_total_amount decimal(26,8) NULL,
recorded_sales tinyint(1) NULL,
UNIQUE KEY order_id (order_id),
KEY order_key (order_key)
) $collate;
CREATE TABLE $meta_table (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned null,
meta_key varchar(255),
meta_value text null,
KEY meta_key_value (meta_key(100), meta_value($composite_meta_value_index_length)),
KEY order_id_meta_key_meta_value (order_id, meta_key(100), meta_value($composite_meta_value_index_length))
) $collate;
";
return $sql;
}
/**
* Returns an array of meta for an object.
*
* @param WC_Data $object WC_Data object.
* @return array
*/
public function read_meta( &$object ) {
$raw_meta_data = $this->data_store_meta->read_meta( $object );
return $this->filter_raw_meta_data( $object, $raw_meta_data );
}
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing at least ->id).
*
* @return bool
*/
public function delete_meta( &$object, $meta ) {
if ( $this->should_backfill_post_record() && isset( $meta->id ) ) {
// Let's get the actual meta key before its deleted for backfilling. We cannot delete just by ID because meta IDs are different in HPOS and posts tables.
$db_meta = $this->data_store_meta->get_metadata_by_id( $meta->id );
if ( $db_meta ) {
$meta->key = $db_meta->meta_key;
$meta->value = $db_meta->meta_value;
}
}
$delete_meta = $this->data_store_meta->delete_meta( $object, $meta );
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() && isset( $meta->key ) ) {
self::$backfilling_order_ids[] = $object->get_id();
delete_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $delete_meta;
}
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing ->key and ->value).
*
* @return int|bool meta ID or false on failure
*/
public function add_meta( &$object, $meta ) {
$add_meta = $this->data_store_meta->add_meta( $object, $meta );
$meta->id = $add_meta;
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
self::$backfilling_order_ids[] = $object->get_id();
add_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $add_meta;
}
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing ->id, ->key and ->value).
*
* @return bool The number of rows updated, or false on error.
*/
public function update_meta( &$object, $meta ) {
$update_meta = $this->data_store_meta->update_meta( $object, $meta );
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
self::$backfilling_order_ids[] = $object->get_id();
update_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $update_meta;
}
/**
* Perform after meta change operations, including updating the date_modified field, clearing caches and applying changes.
*
* @param WC_Abstract_Order $order Order object.
* @param \WC_Meta_Data $meta Metadata object.
*
* @return bool True if changes were applied, false otherwise.
*/
protected function after_meta_change( &$order, $meta ) {
method_exists( $meta, 'apply_changes' ) && $meta->apply_changes();
// Prevent this happening multiple time in same request.
if ( $this->should_save_after_meta_change( $order ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
$order->save();
return true;
} else {
$order_cache = wc_get_container()->get( OrderCache::class );
$order_cache->remove( $order->get_id() );
}
return false;
}
/**
* Helper function to check whether the modified date needs to be updated after a meta save.
*
* This method prevents order->save() call multiple times in the same request after any meta update by checking if:
* 1. Order modified date is already the current date, no updates needed in this case.
* 2. If there are changes already queued for order object, then we don't need to update the modified date as it will be updated ina subsequent save() call.
*
* @param WC_Order $order Order object.
*
* @return bool Whether the modified date needs to be updated.
*/
private function should_save_after_meta_change( $order ) {
$current_date_time = new \WC_DateTime( current_time( 'mysql', 1 ), new \DateTimeZone( 'GMT' ) );
return $order->get_date_modified() < $current_date_time && empty( $order->get_changes() );
}
}
Internal/DataStores/Orders/OrdersTableDataStoreMeta.php 0000644 00000001336 15153704500 0017167 0 ustar 00 <?php
/**
* OrdersTableDataStoreMeta class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;
/**
* Mimics a WP metadata (i.e. add_metadata(), get_metadata() and friends) implementation using a custom table.
*/
class OrdersTableDataStoreMeta extends CustomMetaDataStore {
/**
* Returns the name of the table used for storage.
*
* @return string
*/
protected function get_table_name() {
return OrdersTableDataStore::get_meta_table_name();
}
/**
* Returns the name of the field/column used for associating meta with objects.
*
* @return string
*/
protected function get_object_id_field() {
return 'order_id';
}
}
Internal/DataStores/Orders/OrdersTableFieldQuery.php 0000644 00000020740 15153704500 0016543 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Provides the implementation for `field_query` in {@see OrdersTableQuery} used to build
* complex queries against order fields in the database.
*
* @internal
*/
class OrdersTableFieldQuery {
/**
* List of valid SQL operators to use as field_query 'compare' values.
*
* @var array
*/
private const VALID_COMPARISON_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* The original query object.
*
* @var OrdersTableQuery
*/
private $query = null;
/**
* Determines whether the field query should produce no results due to an invalid argument.
*
* @var boolean
*/
private $force_no_results = false;
/**
* Holds a sanitized version of the `field_query`.
*
* @var array
*/
private $queries = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the field query. Used to keep track of JOINs and optimize when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$field_query = $q->get( 'field_query' );
if ( ! $field_query || ! is_array( $field_query ) ) {
return;
}
$this->query = $q;
$this->queries = $this->sanitize_query( $field_query );
$this->where = ( ! $this->force_no_results ) ? $this->process( $this->queries ) : '1=0';
}
/**
* Sanitizes the field_query argument.
*
* @param array $q A field_query array.
* @return array A sanitized field query array.
* @throws \Exception When field table info is missing.
*/
private function sanitize_query( array $q ) {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
continue;
}
// Sanitize 'compare'.
$arg['compare'] = strtoupper( $arg['compare'] ?? '=' );
$arg['compare'] = in_array( $arg['compare'], self::VALID_COMPARISON_OPERATORS, true ) ? $arg['compare'] : '=';
if ( '=' === $arg['compare'] && isset( $arg['value'] ) && is_array( $arg['value'] ) ) {
$arg['compare'] = 'IN';
}
// Sanitize 'cast'.
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$field_info = $this->query->get_field_mapping_info( $arg['field'] );
if ( ! $field_info ) {
$this->force_no_results = true;
continue;
}
$arg = array_merge( $arg, $field_info );
$sanitized[ $key ] = $arg;
} else {
$sanitized_arg = $this->sanitize_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Processes field_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $q A field query.
* @return string An SQL WHERE statement.
*/
private function process( array $q ) {
$where = '';
if ( empty( $q ) ) {
return $where;
}
if ( $this->is_atomic( $q ) ) {
$q['alias'] = $this->find_or_create_table_alias_for_clause( $q );
$where = $this->generate_where_for_clause( $q );
} else {
$relation = $q['relation'];
unset( $q['relation'] );
foreach ( $q as $query ) {
$chunks[] = $this->process( $query );
}
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = '(' . implode( " {$relation} ", $chunks ) . ')';
}
}
return $where;
}
/**
* Checks whether a given field_query clause is atomic or not (i.e. not nested).
*
* @param array $q The field_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( $q ) {
return isset( $q['field'] );
}
/**
* Finds a common table alias that the field_query clause can use, or creates one.
*
* @param array $q An atomic field_query clause.
* @return string A table alias for use in an SQL JOIN clause.
* @throws \Exception When table info for clause is missing.
*/
private function find_or_create_table_alias_for_clause( $q ) {
global $wpdb;
if ( ! empty( $q['alias'] ) ) {
return $q['alias'];
}
if ( empty( $q['table'] ) || empty( $q['column'] ) ) {
throw new \Exception( __( 'Missing table info for query arg.', 'woocommerce' ) );
}
$join = '';
if ( isset( $q['mapping_id'] ) ) {
// Re-use JOINs and aliases from OrdersTableQuery for core tables.
$alias = $this->query->get_core_mapping_alias( $q['mapping_id'] );
$join = $this->query->get_core_mapping_join( $q['mapping_id'] );
} else {
$alias = $q['table'];
$join = '';
}
if ( in_array( $alias, $this->table_aliases, true ) ) {
return $alias;
}
$this->table_aliases[] = $alias;
if ( $join ) {
$this->join[ $alias ] = $join;
}
return $alias;
}
/**
* Returns the correct type for a given clause 'type'.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( $type ) {
$clause_type = strtoupper( $type );
if ( ! $clause_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $clause_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $clause_type ) {
$clause_type = 'SIGNED';
}
return $clause_type;
}
/**
* Generates an SQL WHERE clause for a given field_query atomic clause.
*
* @param array $clause An atomic field_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause( $clause ): string {
global $wpdb;
$clause_value = $clause['value'] ?? '';
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $clause_value ) ) {
$clause_value = preg_split( '/[,\s]+/', $clause_value );
}
} elseif ( is_string( $clause_value ) ) {
$clause_value = trim( $clause_value );
}
$clause_compare = $clause['compare'];
switch ( $clause_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $clause_value[0], $clause_value[1] ?? $clause_value[0] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $clause_value ) . '%' );
break;
case 'EXISTS':
// EXISTS with a value is interpreted as '='.
if ( $clause_value ) {
$clause_compare = '=';
$where = $wpdb->prepare( '%s', $clause_value );
} else {
$clause_compare = 'IS NOT';
$where = 'NULL';
}
break;
case 'NOT EXISTS':
// 'value' is ignored for NOT EXISTS.
$clause_compare = 'IS';
$where = 'NULL';
break;
default:
$where = $wpdb->prepare( '%s', $clause_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
} else {
return "CAST(`{$clause['alias']}`.`{$clause['column']}` AS {$clause['cast']}) {$clause_compare} {$where}";
}
}
return '';
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses() {
return array(
'join' => $this->join,
'where' => $this->where ? array( $this->where ) : array(),
);
}
}
Internal/DataStores/Orders/OrdersTableMetaQuery.php 0000644 00000045036 15153704500 0016413 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Class used to implement meta queries for the orders table datastore via {@see OrdersTableQuery}.
* Heavily inspired by WordPress' own `WP_Meta_Query` for backwards compatibility reasons.
*
* Parts of the implementation have been adapted from {@link https://core.trac.wordpress.org/browser/tags/6.0.1/src/wp-includes/class-wp-meta-query.php}.
*/
class OrdersTableMetaQuery {
/**
* List of non-numeric SQL operators used for comparisons in meta queries.
*
* @var array
*/
private const NON_NUMERIC_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
);
/**
* List of numeric SQL operators used for comparisons in meta queries.
*
* @var array
*/
private const NUMERIC_OPERATORS = array(
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* Prefix used when generating aliases for the metadata table.
*
* @var string
*/
private const ALIAS_PREFIX = 'meta';
/**
* Name of the main orders table.
*
* @var string
*/
private $meta_table = '';
/**
* Name of the metadata table.
*
* @var string
*/
private $orders_table = '';
/**
* Sanitized `meta_query`.
*
* @var array
*/
private $queries = array();
/**
* Flat list of clauses by name.
*
* @var array
*/
private $flattened_clauses = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the meta query. Used to optimize JOINs when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$meta_query = $q->get( 'meta_query' );
if ( ! $meta_query ) {
return;
}
$this->queries = $this->sanitize_meta_query( $meta_query );
$this->meta_table = $q->get_table_name( 'meta' );
$this->orders_table = $q->get_table_name( 'orders' );
$this->build_query();
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses(): array {
return array(
'join' => $this->sanitize_join( $this->join ),
'where' => $this->flatten_where_clauses( $this->where ),
);
}
/**
* Returns a list of names (corresponding to meta_query clauses) that can be used as an 'orderby' arg.
*
* @since 7.4
*
* @return array
*/
public function get_orderby_keys(): array {
if ( ! $this->flattened_clauses ) {
return array();
}
$keys = array();
$keys[] = 'meta_value';
$keys[] = 'meta_value_num';
$first_clause = reset( $this->flattened_clauses );
if ( $first_clause && ! empty( $first_clause['key'] ) ) {
$keys[] = $first_clause['key'];
}
$keys = array_merge(
$keys,
array_keys( $this->flattened_clauses )
);
return $keys;
}
/**
* Returns an SQL fragment for the given meta_query key that can be used in an ORDER BY clause.
* Call {@see 'get_orderby_keys'} to obtain a list of valid keys.
*
* @since 7.4
*
* @param string $key The key name.
* @return string
*
* @throws \Exception When an invalid key is passed.
*/
public function get_orderby_clause_for_key( string $key ): string {
$clause = false;
if ( isset( $this->flattened_clauses[ $key ] ) ) {
$clause = $this->flattened_clauses[ $key ];
} else {
$first_clause = reset( $this->flattened_clauses );
if ( $first_clause && ! empty( $first_clause['key'] ) ) {
if ( 'meta_value_num' === $key ) {
return "{$first_clause['alias']}.meta_value+0";
}
if ( 'meta_value' === $key || $first_clause['key'] === $key ) {
$clause = $first_clause;
}
}
}
if ( ! $clause ) {
// translators: %s is a meta_query key.
throw new \Exception( sprintf( __( 'Invalid meta_query clause key: %s.', 'woocommerce' ), $key ) );
}
return "CAST({$clause['alias']}.meta_value AS {$clause['cast']})";
}
/**
* Checks whether a given meta_query clause is atomic or not (i.e. not nested).
*
* @param array $arg The meta_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( array $arg ): bool {
return isset( $arg['key'] ) || isset( $arg['value'] );
}
/**
* Sanitizes the meta_query argument.
*
* @param array $q A meta_query array.
* @return array A sanitized meta query array.
*/
private function sanitize_meta_query( array $q ): array {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
unset( $arg['value'] );
}
$arg['compare'] = isset( $arg['compare'] ) ? strtoupper( $arg['compare'] ) : ( isset( $arg['value'] ) && is_array( $arg['value'] ) ? 'IN' : '=' );
$arg['compare_key'] = isset( $arg['compare_key'] ) ? strtoupper( $arg['compare_key'] ) : ( isset( $arg['key'] ) && is_array( $arg['key'] ) ? 'IN' : '=' );
if ( ! in_array( $arg['compare'], self::NON_NUMERIC_OPERATORS, true ) && ! in_array( $arg['compare'], self::NUMERIC_OPERATORS, true ) ) {
$arg['compare'] = '=';
}
if ( ! in_array( $arg['compare_key'], self::NON_NUMERIC_OPERATORS, true ) ) {
$arg['compare_key'] = '=';
}
$sanitized[ $key ] = $arg;
$sanitized[ $key ]['index'] = $key;
} else {
$sanitized_arg = $this->sanitize_meta_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Returns the correct type for a given meta type.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( string $type = '' ): string {
$meta_type = strtoupper( $type );
if ( ! $meta_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $meta_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $meta_type ) {
$meta_type = 'SIGNED';
}
return $meta_type;
}
/**
* Makes sure a JOIN array does not have duplicates.
*
* @param array $join A JOIN array.
* @return array A sanitized JOIN array.
*/
private function sanitize_join( array $join ): array {
return array_filter( array_unique( array_map( 'trim', $join ) ) );
}
/**
* Flattens a nested WHERE array.
*
* @param array $where A possibly nested WHERE array with AND/OR operators.
* @return string An SQL WHERE clause.
*/
private function flatten_where_clauses( $where ): string {
if ( is_string( $where ) ) {
return trim( $where );
}
$chunks = array();
$operator = $this->sanitize_relation( $where['operator'] ?? '' );
foreach ( $where as $key => $w ) {
if ( 'operator' === $key ) {
continue;
}
$flattened = $this->flatten_where_clauses( $w );
if ( $flattened ) {
$chunks[] = $flattened;
}
}
if ( $chunks ) {
return '(' . implode( " {$operator} ", $chunks ) . ')';
} else {
return '';
}
}
/**
* Builds all the required internal bits for this meta query.
*
* @return void
*/
private function build_query(): void {
if ( ! $this->queries ) {
return;
}
$queries = $this->queries;
$sql_where = $this->process( $queries );
$this->where = $sql_where;
}
/**
* Processes meta_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $arg A meta query.
* @param null|array $parent The parent of the element being processed.
* @return array A nested array of WHERE conditions.
*/
private function process( array &$arg, &$parent = null ): array {
$where = array();
if ( $this->is_atomic( $arg ) ) {
$arg['alias'] = $this->find_or_create_table_alias_for_clause( $arg, $parent );
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$where = array_filter(
array(
$this->generate_where_for_clause_key( $arg ),
$this->generate_where_for_clause_value( $arg ),
)
);
// Store clauses by their key for ORDER BY purposes.
$flat_clause_key = is_int( $arg['index'] ) ? $arg['alias'] : $arg['index'];
$unique_flat_key = $flat_clause_key;
$i = 1;
while ( isset( $this->flattened_clauses[ $unique_flat_key ] ) ) {
$unique_flat_key = $flat_clause_key . '-' . $i;
$i++;
}
$this->flattened_clauses[ $unique_flat_key ] =& $arg;
} else {
// Nested.
$relation = $arg['relation'];
unset( $arg['relation'] );
foreach ( $arg as $index => &$clause ) {
$chunks[] = $this->process( $clause, $arg );
}
// Merge chunks of the form OR(m) with the surrounding clause.
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = array_merge(
array(
'operator' => $relation,
),
$chunks
);
}
}
return $where;
}
/**
* Generates a JOIN clause to handle an atomic meta_query clause.
*
* @param array $clause An atomic meta_query clause.
* @param string $alias Metadata table alias to use.
* @return string An SQL JOIN clause.
*/
private function generate_join_for_clause( array $clause, string $alias ): string {
global $wpdb;
if ( 'NOT EXISTS' === $clause['compare'] ) {
if ( 'LIKE' === $clause['compare_key'] ) {
return $wpdb->prepare(
"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key LIKE %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'%' . $wpdb->esc_like( $clause['key'] ) . '%'
);
} else {
return $wpdb->prepare(
"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$clause['key']
);
}
}
return "INNER JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id )";
}
/**
* Finds a common table alias that the meta_query clause can use, or creates one.
*
* @param array $clause An atomic meta_query clause.
* @param array $parent_query The parent query this clause is in.
* @return string A table alias for use in an SQL JOIN clause.
*/
private function find_or_create_table_alias_for_clause( array $clause, array $parent_query ): string {
if ( ! empty( $clause['alias'] ) ) {
return $clause['alias'];
}
$alias = false;
$siblings = array_filter(
$parent_query,
array( __CLASS__, 'is_atomic' )
);
foreach ( $siblings as $sibling ) {
if ( empty( $sibling['alias'] ) ) {
continue;
}
if ( $this->is_operator_compatible_with_shared_join( $clause, $sibling, $parent_query['relation'] ?? 'AND' ) ) {
$alias = $sibling['alias'];
break;
}
}
if ( ! $alias ) {
$alias = self::ALIAS_PREFIX . count( $this->table_aliases );
$this->join[] = $this->generate_join_for_clause( $clause, $alias );
$this->table_aliases[] = $alias;
}
return $alias;
}
/**
* Checks whether two meta_query clauses can share a JOIN.
*
* @param array $clause An atomic meta_query clause.
* @param array $sibling An atomic meta_query clause.
* @param string $relation The relation involving both clauses.
* @return boolean TRUE if the clauses can share a table alias, FALSE otherwise.
*/
private function is_operator_compatible_with_shared_join( array $clause, array $sibling, string $relation = 'AND' ): bool {
if ( ! $this->is_atomic( $clause ) || ! $this->is_atomic( $sibling ) ) {
return false;
}
$valid_operators = array();
if ( 'OR' === $relation ) {
$valid_operators = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
} elseif ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
$valid_operators = array( '!=', 'NOT IN', 'NOT LIKE' );
}
return in_array( strtoupper( $clause['compare'] ), $valid_operators, true ) && in_array( strtoupper( $sibling['compare'] ), $valid_operators, true );
}
/**
* Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta key.
* Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
*
* @param array $clause An atomic meta_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause_key( array $clause ): string {
global $wpdb;
if ( ! array_key_exists( 'key', $clause ) ) {
return '';
}
if ( 'NOT EXISTS' === $clause['compare'] ) {
return "{$clause['alias']}.order_id IS NULL";
}
$alias = $clause['alias'];
if ( in_array( $clause['compare_key'], array( '!=', 'NOT IN', 'NOT LIKE', 'NOT EXISTS', 'NOT REGEXP' ), true ) ) {
$i = count( $this->table_aliases );
$subquery_alias = self::ALIAS_PREFIX . $i;
$this->table_aliases[] = $subquery_alias;
$meta_compare_string_start = 'NOT EXISTS (';
$meta_compare_string_start .= "SELECT 1 FROM {$this->meta_table} {$subquery_alias} ";
$meta_compare_string_start .= "WHERE {$subquery_alias}.order_id = {$alias}.order_id ";
$meta_compare_string_end = 'LIMIT 1';
$meta_compare_string_end .= ')';
}
switch ( $clause['compare_key'] ) {
case '=':
case 'EXISTS':
$where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case 'LIKE':
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
$where = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case 'IN':
$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')';
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'RLIKE':
case 'REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
} else {
$cast = '';
}
$where = $wpdb->prepare( "$alias.meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
break;
case '!=':
case 'NOT EXISTS':
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key = %s " . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT LIKE':
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key LIKE %s " . $meta_compare_string_end;
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
$where = $wpdb->prepare( $meta_compare_string, $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT IN':
$array_subclause = '(' . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ') ';
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key IN " . $array_subclause . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'NOT REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
} else {
$cast = '';
}
$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key REGEXP $cast %s " . $meta_compare_string_end;
$where = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
default:
$where = '';
break;
}
return $where;
}
/**
* Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta value.
* Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
*
* @param array $clause An atomic meta_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause_value( $clause ): string {
global $wpdb;
if ( ! array_key_exists( 'value', $clause ) ) {
return '';
}
$meta_value = $clause['value'];
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $meta_value ) ) {
$meta_value = preg_split( '/[,\s]+/', $meta_value );
}
} elseif ( is_string( $meta_value ) ) {
$meta_value = trim( $meta_value );
}
$meta_compare = $clause['compare'];
switch ( $meta_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')', $meta_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $meta_value[0], $meta_value[1] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $meta_value ) . '%' );
break;
// EXISTS with a value is interpreted as '='.
case 'EXISTS':
$meta_compare = '=';
$where = $wpdb->prepare( '%s', $meta_value );
break;
// 'value' is ignored for NOT EXISTS.
case 'NOT EXISTS':
$where = '';
break;
default:
$where = $wpdb->prepare( '%s', $meta_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "{$clause['alias']}.meta_value {$meta_compare} {$where}";
} else {
return "CAST({$clause['alias']}.meta_value AS {$clause['cast']}) {$meta_compare} {$where}";
}
}
}
}
Internal/DataStores/Orders/OrdersTableQuery.php 0000644 00000127575 15153704500 0015615 0 ustar 00 <?php
// phpcs:disable Generic.Commenting.Todo.TaskFound
/**
* OrdersTableQuery class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
defined( 'ABSPATH' ) || exit;
/**
* This class provides a `WP_Query`-like interface to custom order tables.
*
* @property-read int $found_orders Number of found orders.
* @property-read int $found_posts Alias of the `$found_orders` property.
* @property-read int $max_num_pages Max number of pages matching the current query.
* @property-read array $orders Order objects, or order IDs.
* @property-read array $posts Alias of the $orders property.
*/
class OrdersTableQuery {
/**
* Values to ignore when parsing query arguments.
*/
public const SKIPPED_VALUES = array( '', array(), null );
/**
* Regex used to catch "shorthand" comparisons in date-related query args.
*/
public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';
/**
* Highest possible unsigned bigint value (unsigned bigints being the type of the `id` column).
*
* This is deliberately held as a string, rather than a numeric type, for inclusion within queries.
*/
private const MYSQL_MAX_UNSIGNED_BIGINT = '18446744073709551615';
/**
* Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
*
* @var array
*/
private $tables = array();
/**
* Column mappings for all COT tables.
*
* @var array
*/
private $mappings = array();
/**
* Query vars after processing and sanitization.
*
* @var array
*/
private $args = array();
/**
* Columns to be selected in the SELECT clause.
*
* @var array
*/
private $fields = array();
/**
* Array of table aliases and conditions used to compute the JOIN clause of the query.
*
* @var array
*/
private $join = array();
/**
* Array of fields and conditions used to compute the WHERE clause of the query.
*
* @var array
*/
private $where = array();
/**
* Field to be used in the GROUP BY clause of the query.
*
* @var array
*/
private $groupby = array();
/**
* Array of fields used to compute the ORDER BY clause of the query.
*
* @var array
*/
private $orderby = array();
/**
* Limits used to compute the LIMIT clause of the query.
*
* @var array
*/
private $limits = array();
/**
* Results (order IDs) for the current query.
*
* @var array
*/
private $orders = array();
/**
* Final SQL query to run after processing of args.
*
* @var string
*/
private $sql = '';
/**
* Final SQL query to count results after processing of args.
*
* @var string
*/
private $count_sql = '';
/**
* The number of pages (when pagination is enabled).
*
* @var int
*/
private $max_num_pages = 0;
/**
* The number of orders found.
*
* @var int
*/
private $found_orders = 0;
/**
* Field query parser.
*
* @var OrdersTableFieldQuery
*/
private $field_query = null;
/**
* Meta query parser.
*
* @var OrdersTableMetaQuery
*/
private $meta_query = null;
/**
* Search query parser.
*
* @var OrdersTableSearchQuery?
*/
private $search_query = null;
/**
* Date query parser.
*
* @var WP_Date_Query
*/
private $date_query = null;
/**
* Instance of the OrdersTableDataStore class.
*
* @var OrdersTableDataStore
*/
private $order_datastore = null;
/**
* Whether to run filters to modify the query or not.
*
* @var boolean
*/
private $suppress_filters = false;
/**
* Sets up and runs the query after processing arguments.
*
* @param array $args Array of query vars.
*/
public function __construct( $args = array() ) {
// Note that ideally we would inject this dependency via constructor, but that's not possible since this class needs to be backward compatible with WC_Order_Query class.
$this->order_datastore = wc_get_container()->get( OrdersTableDataStore::class );
$this->tables = $this->order_datastore::get_all_table_names_with_id();
$this->mappings = $this->order_datastore->get_all_order_column_mappings();
$this->suppress_filters = array_key_exists( 'suppress_filters', $args ) ? (bool) $args['suppress_filters'] : false;
unset( $args['suppress_filters'] );
$this->args = $args;
// TODO: args to be implemented.
unset( $this->args['customer_note'], $this->args['name'] );
$this->build_query();
if ( ! $this->maybe_override_query() ) {
$this->run_query();
}
}
/**
* Lets the `woocommerce_hpos_pre_query` filter override the query.
*
* @return boolean Whether the query was overridden or not.
*/
private function maybe_override_query(): bool {
/**
* Filters the orders array before the query takes place.
*
* Return a non-null value to bypass the HPOS default order queries.
*
* If the query includes limits via the `limit`, `page`, or `offset` arguments, we
* encourage the `found_orders` and `max_num_pages` properties to also be set.
*
* @since 8.2.0
*
* @param array|null $order_data {
* An array of order data.
* @type int[] $orders Return an array of order IDs data to short-circuit the HPOS query,
* or null to allow HPOS to run its normal query.
* @type int $found_orders The number of orders found.
* @type int $max_num_pages The number of pages.
* }
* @param OrdersTableQuery $query The OrdersTableQuery instance.
* @param string $sql The OrdersTableQuery instance.
*/
$pre_query = apply_filters( 'woocommerce_hpos_pre_query', null, $this, $this->sql );
if ( ! $pre_query || ! isset( $pre_query[0] ) || ! is_array( $pre_query[0] ) ) {
return false;
}
// If the filter set the orders, make sure the others values are set as well and skip running the query.
list( $this->orders, $this->found_orders, $this->max_num_pages ) = $pre_query;
if ( ! is_int( $this->found_orders ) || $this->found_orders < 1 ) {
$this->found_orders = count( $this->orders );
}
if ( ! is_int( $this->max_num_pages ) || $this->max_num_pages < 1 ) {
if ( ! $this->arg_isset( 'limit' ) || ! is_int( $this->args['limit'] ) || $this->args['limit'] < 1 ) {
$this->args['limit'] = 10;
}
$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
}
return true;
}
/**
* Remaps some legacy and `WP_Query` specific query vars to vars available in the customer order table scheme.
*
* @return void
*/
private function maybe_remap_args(): void {
$mapping = array(
// WP_Query legacy.
'post_date' => 'date_created',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_updated',
'post_modified_gmt' => 'date_updated_gmt',
'post_status' => 'status',
'_date_completed' => 'date_completed',
'_date_paid' => 'date_paid',
'paged' => 'page',
'post_parent' => 'parent_order_id',
'post_parent__in' => 'parent_order_id',
'post_parent__not_in' => 'parent_exclude',
'post__not_in' => 'exclude',
'posts_per_page' => 'limit',
'p' => 'id',
'post__in' => 'id',
'post_type' => 'type',
'fields' => 'return',
'customer_user' => 'customer_id',
'order_currency' => 'currency',
'order_version' => 'woocommerce_version',
'cart_discount' => 'discount_total_amount',
'cart_discount_tax' => 'discount_tax_amount',
'order_shipping' => 'shipping_total_amount',
'order_shipping_tax' => 'shipping_tax_amount',
'order_tax' => 'tax_amount',
// Translate from WC_Order_Query to table structure.
'version' => 'woocommerce_version',
'date_modified' => 'date_updated',
'date_modified_gmt' => 'date_updated_gmt',
'discount_total' => 'discount_total_amount',
'discount_tax' => 'discount_tax_amount',
'shipping_total' => 'shipping_total_amount',
'shipping_tax' => 'shipping_tax_amount',
'cart_tax' => 'tax_amount',
'total' => 'total_amount',
'customer_ip_address' => 'ip_address',
'customer_user_agent' => 'user_agent',
'parent' => 'parent_order_id',
);
foreach ( $mapping as $query_key => $table_field ) {
if ( isset( $this->args[ $query_key ] ) && '' !== $this->args[ $query_key ] ) {
$this->args[ $table_field ] = $this->args[ $query_key ];
unset( $this->args[ $query_key ] );
}
}
// meta_query.
$this->args['meta_query'] = ( $this->arg_isset( 'meta_query' ) && is_array( $this->args['meta_query'] ) ) ? $this->args['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$shortcut_meta_query = array();
foreach ( array( 'key', 'value', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
if ( $this->arg_isset( "meta_{$key}" ) ) {
$shortcut_meta_query[ $key ] = $this->args[ "meta_{$key}" ];
}
}
if ( ! empty( $shortcut_meta_query ) ) {
if ( ! empty( $this->args['meta_query'] ) ) {
$this->args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'relation' => 'AND',
$shortcut_meta_query,
$this->args['meta_query'],
);
} else {
$this->args['meta_query'] = array( $shortcut_meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
}
}
/**
* Generates a `WP_Date_Query` compatible query from a given date.
* YYYY-MM-DD queries have 'day' precision for backwards compatibility.
*
* @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
* @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
*/
private function date_to_date_query_arg( $date ): array {
$result = array(
'year' => '',
'month' => '',
'day' => '',
);
if ( is_numeric( $date ) ) {
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
$precision = 'second';
} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
// For backwards compat (see https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date)
// only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
$date = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
$precision = 'day';
}
$result['year'] = $date->date( 'Y' );
$result['month'] = $date->date( 'm' );
$result['day'] = $date->date( 'd' );
if ( 'second' === $precision ) {
$result['hour'] = $date->date( 'H' );
$result['minute'] = $date->date( 'i' );
$result['second'] = $date->date( 's' );
}
return $result;
}
/**
* Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
*
* @param array $dates_raw Array of dates (in local time) to use in combination with the operator.
* @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
* @return array Partial date query arg with relevant dates now UTC-based.
*
* @since 8.2.0
*/
private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
$result = array();
// Convert YYYY-MM-DD to UTC timestamp. Per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date only date is relevant (time is ignored).
foreach ( $dates_raw as &$raw_date ) {
$raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
}
$date1 = end( $dates_raw );
switch ( $operator ) {
case '>':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => true,
);
break;
case '>=':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
);
break;
case '=':
$result = array(
'relation' => 'AND',
array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
),
array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
)
);
break;
case '<=':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
);
break;
case '<':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => false,
);
break;
case '...':
$result = array(
'relation' => 'AND',
$this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
$this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
);
break;
}
if ( ! $result ) {
throw new \Exception( 'Please specify a valid date shorthand operator.' );
}
return $result;
}
/**
* Processes date-related query args and merges the result into 'date_query'.
*
* @return void
* @throws \Exception When date args are invalid.
*/
private function process_date_args(): void {
if ( $this->arg_isset( 'date_query' ) ) {
// Process already passed date queries args.
$this->args['date_query'] = $this->map_gmt_and_post_keys_to_hpos_keys( $this->args['date_query'] );
}
$valid_operators = array( '>', '>=', '=', '<=', '<', '...' );
$date_queries = array();
$local_to_gmt_date_keys = array(
'date_created' => 'date_created_gmt',
'date_updated' => 'date_updated_gmt',
'date_paid' => 'date_paid_gmt',
'date_completed' => 'date_completed_gmt',
);
$gmt_date_keys = array_values( $local_to_gmt_date_keys );
$local_date_keys = array_keys( $local_to_gmt_date_keys );
$valid_date_keys = array_merge( $gmt_date_keys, $local_date_keys );
$date_keys = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );
foreach ( $date_keys as $date_key ) {
$is_local = in_array( $date_key, $local_date_keys, true );
$date_value = $this->args[ $date_key ];
$operator = '=';
$dates_raw = array();
$dates = array();
if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';
if ( ! empty( $matches[1] ) ) {
$dates_raw[] = $matches[1];
}
$dates_raw[] = $matches[3];
} else {
$dates_raw[] = $date_value;
}
if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
throw new \Exception( 'Invalid date_query' );
}
if ( $is_local ) {
$date_key = $local_to_gmt_date_keys[ $date_key ];
if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
// Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date.
$date_queries[] = array_merge(
array(
'column' => $date_key,
),
$this->local_time_to_gmt_date_query( $dates_raw, $operator )
);
continue;
}
}
$operator_to_keys = array();
if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
$operator_to_keys[] = 'after';
}
if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
$operator_to_keys[] = 'before';
}
$dates = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
$date_queries[] = array_merge(
array(
'column' => $date_key,
'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
),
'=' === $operator
? end( $dates )
: array_combine( $operator_to_keys, $dates )
);
}
// Add top-level date parameters to the date_query.
$tl_query = array();
foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day', 'year' ) as $tl_key ) {
if ( $this->arg_isset( $tl_key ) ) {
$tl_query[ $tl_key ] = $this->args[ $tl_key ];
unset( $this->args[ $tl_key ] );
}
}
if ( $tl_query ) {
$tl_query['column'] = 'date_created_gmt';
$date_queries[] = $tl_query;
}
if ( $date_queries ) {
if ( ! $this->arg_isset( 'date_query' ) ) {
$this->args['date_query'] = array();
}
$this->args['date_query'] = array_merge(
array( 'relation' => 'AND' ),
$date_queries,
$this->args['date_query']
);
}
$this->process_date_query_columns();
}
/**
* Helper function to map posts and gmt based keys to HPOS keys.
*
* @param array $query Date query argument.
*
* @return array|mixed Date query argument with modified keys.
*/
private function map_gmt_and_post_keys_to_hpos_keys( $query ) {
if ( ! is_array( $query ) ) {
return $query;
}
$post_to_hpos_mappings = array(
'post_date' => 'date_created',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_updated',
'post_modified_gmt' => 'date_updated_gmt',
'_date_completed' => 'date_completed',
'_date_paid' => 'date_paid',
'date_modified' => 'date_updated',
'date_modified_gmt' => 'date_updated_gmt',
);
$local_to_gmt_date_keys = array(
'date_created' => 'date_created_gmt',
'date_updated' => 'date_updated_gmt',
'date_paid' => 'date_paid_gmt',
'date_completed' => 'date_completed_gmt',
);
array_walk(
$query,
function ( &$sub_query ) {
$sub_query = $this->map_gmt_and_post_keys_to_hpos_keys( $sub_query );
}
);
if ( ! isset( $query['column'] ) ) {
return $query;
}
if ( isset( $post_to_hpos_mappings[ $query['column'] ] ) ) {
$query['column'] = $post_to_hpos_mappings[ $query['column'] ];
}
// Convert any local dates to GMT.
if ( isset( $local_to_gmt_date_keys[ $query['column'] ] ) ) {
$query['column'] = $local_to_gmt_date_keys[ $query['column'] ];
$op = isset( $query['after'] ) ? 'after' : 'before';
$date_value_local = $query[ $op ];
$date_value_gmt = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
$query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt );
}
return $query;
}
/**
* Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
*
* @return void
*/
private function process_date_query_columns() {
global $wpdb;
$legacy_columns = array(
'post_date' => 'date_created_gmt',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_modified_gmt',
'post_modified_gmt' => 'date_updated_gmt',
);
$table_mapping = array(
'date_created_gmt' => $this->tables['orders'],
'date_updated_gmt' => $this->tables['orders'],
'date_paid_gmt' => $this->tables['operational_data'],
'date_completed_gmt' => $this->tables['operational_data'],
);
if ( empty( $this->args['date_query'] ) ) {
return;
}
array_walk_recursive(
$this->args['date_query'],
function( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
if ( 'column' !== $key ) {
return;
}
// Translate legacy columns from wp_posts if necessary.
$value =
( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
? $legacy_columns[ $value ]
: $value;
$table = $table_mapping[ $value ] ?? null;
if ( ! $table ) {
return;
}
$value = "{$table}.{$value}";
if ( $table !== $this->tables['orders'] ) {
$this->join( $table, '', '', 'inner', true );
}
}
);
}
/**
* Sanitizes the 'status' query var.
*
* @return void
*/
private function sanitize_status(): void {
// Sanitize status.
$valid_statuses = array_keys( wc_get_order_statuses() );
if ( empty( $this->args['status'] ) || 'any' === $this->args['status'] ) {
$this->args['status'] = $valid_statuses;
} elseif ( 'all' === $this->args['status'] ) {
$this->args['status'] = array();
} else {
$this->args['status'] = is_array( $this->args['status'] ) ? $this->args['status'] : array( $this->args['status'] );
foreach ( $this->args['status'] as &$status ) {
$status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
}
$this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
}
}
/**
* Parses and sanitizes the 'orderby' query var.
*
* @return void
*/
private function sanitize_order_orderby(): void {
// Allowed keys.
// TODO: rand, meta keys, etc.
$allowed_keys = array( 'ID', 'id', 'type', 'date', 'modified', 'parent' );
// Translate $orderby to a valid field.
$mapping = array(
'ID' => "{$this->tables['orders']}.id",
'id' => "{$this->tables['orders']}.id",
'type' => "{$this->tables['orders']}.type",
'date' => "{$this->tables['orders']}.date_created_gmt",
'date_created' => "{$this->tables['orders']}.date_created_gmt",
'modified' => "{$this->tables['orders']}.date_updated_gmt",
'date_modified' => "{$this->tables['orders']}.date_updated_gmt",
'parent' => "{$this->tables['orders']}.parent_order_id",
'total' => "{$this->tables['orders']}.total_amount",
'order_total' => "{$this->tables['orders']}.total_amount",
);
$order = $this->args['order'] ?? '';
$orderby = $this->args['orderby'] ?? '';
if ( 'none' === $orderby ) {
return;
}
// No need to sanitize, will be processed in calling function.
if ( 'include' === $orderby || 'post__in' === $orderby ) {
return;
}
if ( is_string( $orderby ) ) {
$orderby_fields = array_map( 'trim', explode( ' ', $orderby ) );
$orderby = array();
foreach ( $orderby_fields as $field ) {
$orderby[ $field ] = $order;
}
}
$allowed_orderby = array_merge(
array_keys( $mapping ),
array_values( $mapping ),
$this->meta_query ? $this->meta_query->get_orderby_keys() : array()
);
$this->args['orderby'] = array();
foreach ( $orderby as $order_key => $order ) {
if ( ! in_array( $order_key, $allowed_orderby, true ) ) {
continue;
}
if ( isset( $mapping[ $order_key ] ) ) {
$order_key = $mapping[ $order_key ];
}
$this->args['orderby'][ $order_key ] = $this->sanitize_order( $order );
}
}
/**
* Makes sure the order in an ORDER BY statement is either 'ASC' o 'DESC'.
*
* @param string $order The unsanitized order.
* @return string The sanitized order.
*/
private function sanitize_order( string $order ): string {
$order = strtoupper( $order );
return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
}
/**
* Builds the final SQL query to be run.
*
* @return void
*/
private function build_query(): void {
$this->maybe_remap_args();
// Field queries.
if ( ! empty( $this->args['field_query'] ) ) {
$this->field_query = new OrdersTableFieldQuery( $this );
$sql = $this->field_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Build query.
$this->process_date_args();
$this->process_orders_table_query_args();
$this->process_operational_data_table_query_args();
$this->process_addresses_table_query_args();
// Search queries.
if ( ! empty( $this->args['s'] ) ) {
$this->search_query = new OrdersTableSearchQuery( $this );
$sql = $this->search_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Meta queries.
if ( ! empty( $this->args['meta_query'] ) ) {
$this->meta_query = new OrdersTableMetaQuery( $this );
$sql = $this->meta_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, array( $sql['where'] ) ) : $this->where;
}
// Date queries.
if ( ! empty( $this->args['date_query'] ) ) {
$this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
$this->where[] = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
}
$this->process_orderby();
$this->process_limit();
$orders_table = $this->tables['orders'];
// Group by is a faster substitute for DISTINCT, as long as we are only selecting IDs. MySQL don't like it when we join tables and use DISTINCT.
$this->groupby[] = "{$this->tables['orders']}.id";
$this->fields = "{$orders_table}.id";
$fields = $this->fields;
// JOIN.
$join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );
// WHERE.
$where = '1=1';
foreach ( $this->where as $_where ) {
$where .= " AND ({$_where})";
}
// ORDER BY.
$orderby = $this->orderby ? implode( ', ', $this->orderby ) : '';
// LIMITS.
$limits = '';
if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
list( $offset, $row_count ) = $this->limits;
$row_count = -1 === $row_count ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
$limits = 'LIMIT ' . (int) $offset . ', ' . $row_count;
}
// GROUP BY.
$groupby = $this->groupby ? implode( ', ', (array) $this->groupby ) : '';
$pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );
if ( ! $this->suppress_filters ) {
/**
* Filters all query clauses at once.
* Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
*
* @since 7.9.0
*
* @param string[] $clauses {
* Associative array of the clauses for the query.
*
* @type string $fields The SELECT clause of the query.
* @type string $join The JOIN clause of the query.
* @type string $where The WHERE clause of the query.
* @type string $groupby The GROUP BY clause of the query.
* @type string $orderby The ORDER BY clause of the query.
* @type string $limits The LIMIT clause of the query.
* }
* @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
* @param array $args Query args.
*/
$clauses = (array) apply_filters_ref_array( 'woocommerce_orders_table_query_clauses', array( $pieces, &$this, $this->args ) );
$fields = $clauses['fields'] ?? '';
$join = $clauses['join'] ?? '';
$where = $clauses['where'] ?? '';
$groupby = $clauses['groupby'] ?? '';
$orderby = $clauses['orderby'] ?? '';
$limits = $clauses['limits'] ?? '';
}
$groupby = $groupby ? ( 'GROUP BY ' . $groupby ) : '';
$orderby = $orderby ? ( 'ORDER BY ' . $orderby ) : '';
$this->sql = "SELECT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";
if ( ! $this->suppress_filters ) {
/**
* Filters the completed SQL query.
*
* @since 7.9.0
*
* @param string $sql The complete SQL query.
* @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
* @param array $args Query args.
*/
$this->sql = apply_filters_ref_array( 'woocommerce_orders_table_query_sql', array( $this->sql, &$this, $this->args ) );
}
$this->build_count_query( $fields, $join, $where, $groupby );
}
/**
* Build SQL query for counting total number of results.
*
* @param string $fields Prepared fields for SELECT clause.
* @param string $join Prepared JOIN clause.
* @param string $where Prepared WHERE clause.
* @param string $groupby Prepared GROUP BY clause.
*/
private function build_count_query( $fields, $join, $where, $groupby ) {
if ( ! isset( $this->sql ) || '' === $this->sql ) {
wc_doing_it_wrong( __FUNCTION__, 'Count query can only be build after main query is built.', '7.3.0' );
}
$orders_table = $this->tables['orders'];
$this->count_sql = "SELECT COUNT(DISTINCT $fields) FROM $orders_table $join WHERE $where";
}
/**
* Returns the table alias for a given table mapping.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string Table alias.
*
* @since 7.0.0
*/
public function get_core_mapping_alias( string $mapping_id ): string {
return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
? $mapping_id
: $this->tables[ $mapping_id ];
}
/**
* Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string The JOIN clause.
*
* @since 7.0.0
*/
public function get_core_mapping_join( string $mapping_id ): string {
global $wpdb;
if ( 'orders' === $mapping_id ) {
return '';
}
$is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );
$alias = $this->get_core_mapping_alias( $mapping_id );
$table = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
$join = '';
$join_on = '';
$join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );
if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
$join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
if ( $is_address_mapping ) {
$join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
}
/**
* JOINs the main orders table with another table.
*
* @param string $table Table name (including prefix).
* @param string $alias Table alias to use. Defaults to $table.
* @param string $on ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
* @param string $join_type JOIN type: LEFT, RIGHT or INNER.
* @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
* @return void
* @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
*/
private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
$alias = empty( $alias ) ? $table : $alias;
$join_type = strtoupper( trim( $join_type ) );
if ( $this->tables['orders'] === $alias ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
}
if ( empty( $on ) ) {
if ( $this->tables['orders'] === $table ) {
$on = "`{$this->tables['orders']}`.id = `{$alias}`.id";
} else {
$on = "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
}
if ( isset( $this->join[ $alias ] ) ) {
if ( ! $alias_once ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
}
return;
}
if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
$join_type = 'INNER';
}
$sql_join = '';
$sql_join .= "{$join_type} JOIN `{$table}` ";
$sql_join .= ( $alias !== $table ) ? "AS `{$alias}` " : '';
$sql_join .= "ON ( {$on} )";
$this->join[ $alias ] = $sql_join;
}
/**
* Generates a properly escaped and sanitized WHERE condition for a given field.
*
* @param string $table The table the field belongs to.
* @param string $field The field or column name.
* @param string $operator The operator to use in the condition. Defaults to '=' or 'IN' depending on $value.
* @param mixed $value The value.
* @param string $type The column type as specified in {@see OrdersTableDataStore} column mappings.
* @return string The resulting WHERE condition.
*/
public function where( string $table, string $field, string $operator, $value, string $type ): string {
global $wpdb;
$db_util = wc_get_container()->get( DatabaseUtil::class );
$operator = strtoupper( '' !== $operator ? $operator : '=' );
try {
$format = $db_util->get_wpdb_format_for_type( $type );
} catch ( \Exception $e ) {
$format = '%s';
}
// = and != can be shorthands for IN and NOT in for array values.
if ( is_array( $value ) && '=' === $operator ) {
$operator = 'IN';
} elseif ( is_array( $value ) && '!=' === $operator ) {
$operator = 'NOT IN';
}
if ( ! in_array( $operator, array( '=', '!=', 'IN', 'NOT IN' ), true ) ) {
return false;
}
if ( is_array( $value ) ) {
$value = array_map( array( $db_util, 'format_object_value_for_db' ), $value, array_fill( 0, count( $value ), $type ) );
} else {
$value = $db_util->format_object_value_for_db( $value, $type );
}
if ( is_array( $value ) ) {
$placeholder = array_fill( 0, count( $value ), $format );
$placeholder = '(' . implode( ',', $placeholder ) . ')';
} else {
$placeholder = $format;
}
$sql = $wpdb->prepare( "{$table}.{$field} {$operator} {$placeholder}", $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $sql;
}
/**
* Processes fields related to the orders table.
*
* @return void
*/
private function process_orders_table_query_args(): void {
$this->sanitize_status();
$fields = array_filter(
array(
'id',
'status',
'type',
'currency',
'tax_amount',
'customer_id',
'billing_email',
'total_amount',
'parent_order_id',
'payment_method',
'payment_method_title',
'transaction_id',
'ip_address',
'user_agent',
),
array( $this, 'arg_isset' )
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['orders'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['orders'][ $arg_key ]['type'] );
}
if ( $this->arg_isset( 'parent_exclude' ) ) {
$this->where[] = $this->where( $this->tables['orders'], 'parent_order_id', '!=', $this->args['parent_exclude'], 'int' );
}
if ( $this->arg_isset( 'exclude' ) ) {
$this->where[] = $this->where( $this->tables['orders'], 'id', '!=', $this->args['exclude'], 'int' );
}
// 'customer' is a very special field.
if ( $this->arg_isset( 'customer' ) ) {
$customer_query = $this->generate_customer_query( $this->args['customer'] );
if ( $customer_query ) {
$this->where[] = $customer_query;
}
}
}
/**
* Generate SQL conditions for the 'customer' query.
*
* @param array $values List of customer ids or emails.
* @param string $relation 'OR' or 'AND' relation used to build the customer query.
* @return string SQL to be used in a WHERE clause.
*/
private function generate_customer_query( $values, string $relation = 'OR' ): string {
$values = is_array( $values ) ? $values : array( $values );
$ids = array();
$emails = array();
foreach ( $values as $value ) {
if ( is_array( $value ) ) {
$sql = $this->generate_customer_query( $value, 'AND' );
$pieces[] = $sql ? '(' . $sql . ')' : '';
} elseif ( is_numeric( $value ) ) {
$ids[] = absint( $value );
} elseif ( is_string( $value ) && is_email( $value ) ) {
$emails[] = sanitize_email( $value );
} else {
// Invalid query.
$pieces[] = '1=0';
}
}
if ( $ids ) {
$pieces[] = $this->where( $this->tables['orders'], 'customer_id', '=', $ids, 'int' );
}
if ( $emails ) {
$pieces[] = $this->where( $this->tables['orders'], 'billing_email', '=', $emails, 'string' );
}
return $pieces ? implode( " $relation ", $pieces ) : '';
}
/**
* Processes fields related to the operational data table.
*
* @return void
*/
private function process_operational_data_table_query_args(): void {
$fields = array_filter(
array(
'created_via',
'woocommerce_version',
'prices_include_tax',
'order_key',
'discount_total_amount',
'discount_tax_amount',
'shipping_total_amount',
'shipping_tax_amount',
),
array( $this, 'arg_isset' )
);
if ( ! $fields ) {
return;
}
$this->join(
$this->tables['operational_data'],
'',
'',
'inner',
true
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
}
}
/**
* Processes fields related to the addresses table.
*
* @return void
*/
private function process_addresses_table_query_args(): void {
global $wpdb;
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
$fields = array_filter(
array(
$address_type . '_first_name',
$address_type . '_last_name',
$address_type . '_company',
$address_type . '_address_1',
$address_type . '_address_2',
$address_type . '_city',
$address_type . '_state',
$address_type . '_postcode',
$address_type . '_country',
$address_type . '_phone',
),
array( $this, 'arg_isset' )
);
if ( ! $fields ) {
continue;
}
$this->join(
$this->tables['addresses'],
$address_type,
$wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'inner',
false
);
foreach ( $fields as $arg_key ) {
$column_name = str_replace( "{$address_type}_", '', $arg_key );
$this->where[] = $this->where(
$address_type,
$column_name,
'=',
$this->args[ $arg_key ],
$this->mappings[ "{$address_type}_address" ][ $column_name ]['type']
);
}
}
}
/**
* Generates the ORDER BY clause.
*
* @return void
*/
private function process_orderby(): void {
// 'order' and 'orderby' vars.
$this->args['order'] = $this->sanitize_order( $this->args['order'] ?? '' );
$this->sanitize_order_orderby();
$orderby = $this->args['orderby'];
if ( 'none' === $orderby ) {
$this->orderby = '';
return;
}
if ( 'include' === $orderby || 'post__in' === $orderby ) {
$ids = $this->args['id'] ?? $this->args['includes'];
if ( empty( $ids ) ) {
return;
}
$ids = array_map( 'absint', $ids );
$this->orderby = array( "FIELD( {$this->tables['orders']}.id, " . implode( ',', $ids ) . ' )' );
return;
}
$meta_orderby_keys = $this->meta_query ? $this->meta_query->get_orderby_keys() : array();
$orderby_array = array();
foreach ( $this->args['orderby'] as $_orderby => $order ) {
if ( in_array( $_orderby, $meta_orderby_keys, true ) ) {
$_orderby = $this->meta_query->get_orderby_clause_for_key( $_orderby );
}
$orderby_array[] = "{$_orderby} {$order}";
}
$this->orderby = $orderby_array;
}
/**
* Generates the limits to be used in the LIMIT clause.
*
* @return void
*/
private function process_limit(): void {
$row_count = ( $this->arg_isset( 'limit' ) ? (int) $this->args['limit'] : false );
$page = ( $this->arg_isset( 'page' ) ? absint( $this->args['page'] ) : 1 );
$offset = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );
// Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
if ( false === $row_count || $row_count < -1 ) {
return;
}
if ( false === $offset && $row_count > -1 ) {
$offset = (int) ( ( $page - 1 ) * $row_count );
}
$this->limits = array( $offset, $row_count );
}
/**
* Checks if a query var is set (i.e. not one of the "skipped values").
*
* @param string $arg_key Query var.
* @return bool TRUE if query var is set.
*/
public function arg_isset( string $arg_key ): bool {
return ( isset( $this->args[ $arg_key ] ) && ! in_array( $this->args[ $arg_key ], self::SKIPPED_VALUES, true ) );
}
/**
* Runs the SQL query.
*
* @return void
*/
private function run_query(): void {
global $wpdb;
// Run query.
$this->orders = array_map( 'absint', $wpdb->get_col( $this->sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// Set max_num_pages and found_orders if necessary.
if ( ( $this->arg_isset( 'no_found_rows' ) && ! $this->args['no_found_rows'] ) || empty( $this->orders ) ) {
return;
}
if ( $this->limits ) {
$this->found_orders = absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
} else {
$this->found_orders = count( $this->orders );
}
}
/**
* Make some private available for backwards compatibility.
*
* @param string $name Property to get.
* @return mixed
*/
public function __get( string $name ) {
switch ( $name ) {
case 'found_orders':
case 'found_posts':
return $this->found_orders;
case 'max_num_pages':
return $this->max_num_pages;
case 'posts':
case 'orders':
return $this->orders;
case 'request':
return $this->sql;
default:
break;
}
}
/**
* Returns the value of one of the query arguments.
*
* @param string $arg_name Query var.
* @return mixed
*/
public function get( string $arg_name ) {
return $this->args[ $arg_name ] ?? null;
}
/**
* Returns the name of one of the OrdersTableDatastore tables.
*
* @param string $table_id Table identifier. One of 'orders', 'operational_data', 'addresses', 'meta'.
* @return string The prefixed table name.
* @throws \Exception When table ID is not found.
*/
public function get_table_name( string $table_id = '' ): string {
if ( ! isset( $this->tables[ $table_id ] ) ) {
// Translators: %s is a table identifier.
throw new \Exception( sprintf( __( 'Invalid table id: %s.', 'woocommerce' ), $table_id ) );
}
return $this->tables[ $table_id ];
}
/**
* Finds table and mapping information about a field or column.
*
* @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
* @return false|array {
* @type string $table Full table name where the field is located.
* @type string $mapping_id Unprefixed table or mapping name.
* @type string $field_name Name of the corresponding order field.
* @type string $column Column in $table that corresponds to the field.
* @type string $type Field type.
* }
*/
public function get_field_mapping_info( $field ) {
global $wpdb;
$result = array(
'table' => '',
'mapping_id' => '',
'field_name' => '',
'column' => '',
'column_type' => '',
);
$mappings_to_search = array();
if ( false !== strstr( $field, '.' ) ) {
list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );
$mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
$mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;
if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
$result['mapping_id'] = $mapping_or_table;
$result['column'] = $field_name_or_col;
} else {
$mappings_to_search = array( $mapping_or_table );
}
}
} else {
$field_name_or_col = $field;
$mappings_to_search = array_keys( $this->mappings );
}
foreach ( $mappings_to_search as $mapping_id ) {
foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
$result['mapping_id'] = $mapping_id;
$result['column'] = $column_name;
break 2;
}
}
}
if ( ! $result['mapping_id'] || ! $result['column'] ) {
return false;
}
$field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];
$result['field_name'] = $field_info['name'];
$result['column_type'] = $field_info['type'];
$result['table'] = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
? $this->tables['addresses']
: $this->tables[ $result['mapping_id'] ];
return $result;
}
}
Internal/DataStores/Orders/OrdersTableRefundDataStore.php 0000644 00000013767 15153704500 0017537 0 ustar 00 <?php
/**
* Order refund data store. Refunds are based on orders (essentially negative orders) but there is slight difference in how we save them.
* For example, order save hooks etc can't be fired when saving refund, so we need to do it a separate datastore.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use \WC_Cache_Helper;
use \WC_Meta_Data;
/**
* Class OrdersTableRefundDataStore.
*/
class OrdersTableRefundDataStore extends OrdersTableDataStore {
/**
* Data stored in meta keys, but not considered "meta" for refund.
*
* @var string[]
*/
protected $internal_meta_keys = array(
'_refund_amount',
'_refund_reason',
'_refunded_by',
'_refunded_payment',
);
/**
* We do not have and use all the getters and setters from OrderTableDataStore, so we only select the props we actually need.
*
* @var \string[][]
*/
protected $operational_data_column_mapping = array(
'id' => array( 'type' => 'int' ),
'order_id' => array( 'type' => 'int' ),
'woocommerce_version' => array(
'type' => 'string',
'name' => 'version',
),
'prices_include_tax' => array(
'type' => 'bool',
'name' => 'prices_include_tax',
),
'coupon_usages_are_counted' => array(
'type' => 'bool',
'name' => 'recorded_coupon_usage_counts',
),
'shipping_tax_amount' => array(
'type' => 'decimal',
'name' => 'shipping_tax',
),
'shipping_total_amount' => array(
'type' => 'decimal',
'name' => 'shipping_total',
),
'discount_tax_amount' => array(
'type' => 'decimal',
'name' => 'discount_tax',
),
'discount_total_amount' => array(
'type' => 'decimal',
'name' => 'discount_total',
),
);
/**
* Delete a refund order from database.
*
* @param \WC_Order $refund Refund object to delete.
* @param array $args Array of args to pass to the delete method.
*
* @return void
*/
public function delete( &$refund, $args = array() ) {
$refund_id = $refund->get_id();
if ( ! $refund_id ) {
return;
}
$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $refund->get_parent_id();
wp_cache_delete( $refund_cache_key, 'orders' );
$this->delete_order_data_from_custom_order_tables( $refund_id );
$refund->set_id( 0 );
$orders_table_is_authoritative = $refund->get_data_store()->get_current_class_name() === self::class;
if ( $orders_table_is_authoritative ) {
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
// Once we stop creating posts for orders, we should do the cleanup here instead.
wp_delete_post( $refund_id );
} else {
$this->handle_order_deletion_with_sync_disabled( $refund_id );
}
}
}
/**
* Helper method to set refund props.
*
* @param \WC_Order_Refund $refund Refund object.
* @param object $data DB data object.
*
* @since 8.0.0
*/
protected function set_order_props_from_data( &$refund, $data ) {
parent::set_order_props_from_data( $refund, $data );
foreach ( $data->meta_data as $meta ) {
switch ( $meta->meta_key ) {
case '_refund_amount':
$refund->set_amount( $meta->meta_value );
break;
case '_refunded_by':
$refund->set_refunded_by( $meta->meta_value );
break;
case '_refunded_payment':
$refund->set_refunded_payment( wc_string_to_bool( $meta->meta_value ) );
break;
case '_refund_reason':
$refund->set_reason( $meta->meta_value );
break;
}
}
}
/**
* Method to create a refund in the database.
*
* @param \WC_Abstract_Order $refund Refund object.
*/
public function create( &$refund ) {
$refund->set_status( 'completed' ); // Refund are always marked completed.
$this->persist_save( $refund );
}
/**
* Update refund in database.
*
* @param \WC_Order $refund Refund object.
*/
public function update( &$refund ) {
$this->persist_updates( $refund );
}
/**
* Helper method that updates post meta based on an refund object.
* Mostly used for backwards compatibility purposes in this datastore.
*
* @param \WC_Order $refund Refund object.
*/
public function update_order_meta( &$refund ) {
parent::update_order_meta( $refund );
// Update additional props.
$updated_props = array();
$meta_key_to_props = array(
'_refund_amount' => 'amount',
'_refunded_by' => 'refunded_by',
'_refunded_payment' => 'refunded_payment',
'_refund_reason' => 'reason',
);
$props_to_update = $this->get_props_to_update( $refund, $meta_key_to_props );
foreach ( $props_to_update as $meta_key => $prop ) {
$meta_object = new WC_Meta_Data();
$meta_object->key = $meta_key;
$meta_object->value = $refund->{"get_$prop"}( 'edit' );
$existing_meta = $this->data_store_meta->get_metadata_by_key( $refund, $meta_key );
if ( $existing_meta ) {
$existing_meta = $existing_meta[0];
$meta_object->id = $existing_meta->id;
$this->update_meta( $refund, $meta_object );
} else {
$this->add_meta( $refund, $meta_object );
}
$updated_props[] = $prop;
}
/**
* Fires after updating meta for a order refund.
*
* @since 2.7.0
*/
do_action( 'woocommerce_order_refund_object_updated_props', $refund, $updated_props );
}
/**
* Get a title for the new post type.
*
* @return string
*/
protected function get_post_title() {
return sprintf(
/* translators: %s: Order date */
__( 'Refund – %s', 'woocommerce' ),
( new \DateTime( 'now' ) )->format( _x( 'M d, Y @ h:i A', 'Order date parsed by DateTime::format', 'woocommerce' ) ) // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText
);
}
/**
* Returns data store object to use backfilling.
*
* @return \WC_Order_Refund_Data_Store_CPT
*/
protected function get_post_data_store_for_backfill() {
return new \WC_Order_Refund_Data_Store_CPT();
}
}
Internal/DataStores/Orders/OrdersTableSearchQuery.php 0000644 00000011022 15153704500 0016716 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Exception;
/**
* Creates the join and where clauses needed to perform an order search using Custom Order Tables.
*
* @internal
*/
class OrdersTableSearchQuery {
/**
* Holds the Orders Table Query object.
*
* @var OrdersTableQuery
*/
private $query;
/**
* Holds the search term to be used in the WHERE clauses.
*
* @var string
*/
private $search_term;
/**
* Creates the JOIN and WHERE clauses needed to execute a search of orders.
*
* @internal
*
* @param OrdersTableQuery $query The order query object.
*/
public function __construct( OrdersTableQuery $query ) {
global $wpdb;
$this->query = $query;
$this->search_term = esc_sql( '%' . $wpdb->esc_like( urldecode( $query->get( 's' ) ) ) . '%' );
}
/**
* Supplies an array of clauses to be used in an order query.
*
* @internal
* @throws Exception If unable to generate either the JOIN or WHERE SQL fragments.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses(): array {
return array(
'join' => array( $this->generate_join() ),
'where' => array( $this->generate_where() ),
);
}
/**
* Generates the necessary JOIN clauses for the order search to be performed.
*
* @throws Exception May be triggered if a table name cannot be determined.
*
* @return string
*/
private function generate_join(): string {
$orders_table = $this->query->get_table_name( 'orders' );
$items_table = $this->query->get_table_name( 'items' );
return "
LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
";
}
/**
* Generates the necessary WHERE clauses for the order search to be performed.
*
* @throws Exception May be triggered if a table name cannot be determined.
*
* @return string
*/
private function generate_where(): string {
global $wpdb;
$where = '';
$possible_order_id = (string) absint( $this->query->get( 's' ) );
$order_table = $this->query->get_table_name( 'orders' );
// Support the passing of an order ID as the search term.
if ( (string) $this->query->get( 's' ) === $possible_order_id ) {
$where = "`$order_table`.id = $possible_order_id OR ";
}
$meta_sub_query = $this->generate_where_for_meta_table();
$where .= $wpdb->prepare(
"
search_query_items.order_item_name LIKE %s
OR `$order_table`.id IN ( $meta_sub_query )
",
$this->search_term
);
return " ( $where ) ";
}
/**
* Generates where clause for meta table.
*
* Note we generate the where clause as a subquery to be used by calling function inside the IN clause. This is against the general wisdom for performance, but in this particular case, a subquery is able to use the order_id-meta_key-meta_value index, which is not possible with a join.
*
* Since it can use the index, which otherwise would not be possible, it is much faster than both LEFT JOIN or SQL_CALC approach that could have been used.
*
* @return string The where clause for meta table.
*/
private function generate_where_for_meta_table(): string {
global $wpdb;
$meta_table = $this->query->get_table_name( 'meta' );
$meta_fields = $this->get_meta_fields_to_be_searched();
return $wpdb->prepare(
"
SELECT search_query_meta.order_id
FROM $meta_table as search_query_meta
WHERE search_query_meta.meta_key IN ( $meta_fields )
AND search_query_meta.meta_value LIKE %s
GROUP BY search_query_meta.order_id
",
$this->search_term
);
}
/**
* Returns the order meta field keys to be searched.
*
* These will be returned as a single string, where the meta keys have been escaped, quoted and are
* comma-separated (ie, "'abc', 'foo'" - ready for inclusion in a SQL IN() clause).
*
* @return string
*/
private function get_meta_fields_to_be_searched(): string {
/**
* Controls the order meta keys to be included in search queries.
*
* This hook is used when Custom Order Tables are in use: the corresponding hook when CPT-orders are in use
* is 'woocommerce_shop_order_search_fields'.
*
* @since 7.0.0
*
* @param array
*/
$meta_keys = apply_filters(
'woocommerce_order_table_search_query_meta_keys',
array(
'_billing_address_index',
'_shipping_address_index',
)
);
$meta_keys = (array) array_map(
function ( string $meta_key ): string {
return "'" . esc_sql( wc_clean( $meta_key ) ) . "'";
},
$meta_keys
);
return implode( ',', $meta_keys );
}
}
Internal/DependencyManagement/AbstractServiceProvider.php 0000644 00000007741 15153704500 0017714 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\DefinitionInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\ServiceProvider\AbstractServiceProvider as LeagueProvider;
/**
* Class AbstractServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
abstract class AbstractServiceProvider extends LeagueProvider {
/**
* Array of classes provided by this container.
*
* Keys should be the class name, and the value can be anything (like `true`).
*
* @var array
*/
protected $provides = [];
/**
* Returns a boolean if checking whether this provider provides a specific
* service or returns an array of provided services if no argument passed.
*
* @param string $service
*
* @return boolean
*/
public function provides( string $service ): bool {
return array_key_exists( $service, $this->provides );
}
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
foreach ( $this->provides as $class => $provided ) {
$this->share( $class );
}
}
/**
* Add an interface to the container.
*
* @param string $interface_name The interface to add.
* @param string|null $concrete (Optional) The concrete class.
*
* @return DefinitionInterface
*/
protected function share_concrete( string $interface_name, $concrete = null ): DefinitionInterface {
return $this->getContainer()->addShared( $interface_name, $concrete );
}
/**
* Share a class and add interfaces as tags.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @return DefinitionInterface
*/
protected function share_with_tags( string $class_name, ...$arguments ): DefinitionInterface {
$definition = $this->share( $class_name, ...$arguments );
foreach ( class_implements( $class_name ) as $interface_name ) {
$definition->addTag( $interface_name );
}
return $definition;
}
/**
* Share a class.
*
* Shared classes will always return the same instance of the class when the class is requested
* from the container.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @return DefinitionInterface
*/
protected function share( string $class_name, ...$arguments ): DefinitionInterface {
return $this->getContainer()->addShared( $class_name )->addArguments( $arguments );
}
/**
* Add a class.
*
* Classes will return a new instance of the class when the class is requested from the container.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @return DefinitionInterface
*/
protected function add( string $class_name, ...$arguments ): DefinitionInterface {
return $this->getContainer()->add( $class_name )->addArguments( $arguments );
}
/**
* Maybe share a class and add interfaces as tags.
*
* This will also check any classes that implement the Conditional interface and only add them if
* they are needed.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*/
protected function conditionally_share_with_tags( string $class_name, ...$arguments ) {
$implements = class_implements( $class_name );
if ( array_key_exists( Conditional::class, $implements ) ) {
/** @var Conditional $class */
if ( ! $class_name::is_needed() ) {
return;
}
}
$this->provides[ $class_name ] = true;
$this->share_with_tags( $class_name, ...$arguments );
}
}
Internal/DependencyManagement/ContainerException.php 0000644 00000001256 15153704500 0016711 0 ustar 00 <?php
/**
* ExtendedContainer class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
/**
* Class ContainerException.
* Used to signal error conditions related to the dependency injection container.
*/
class ContainerException extends \Exception {
/**
* Create a new instance of the class.
*
* @param null $message The exception message to throw.
* @param int $code The error code.
* @param \Exception|null $previous The previous throwable used for exception chaining.
*/
public function __construct( $message = null, $code = 0, \Exception $previous = null ) {
parent::__construct( $message, $code, $previous );
}
}
Internal/DependencyManagement/Definition.php 0000644 00000003476 15153704500 0015206 0 ustar 00 <?php
/**
* An extension to the Definition class to prevent constructor injection from being possible.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Vendor\League\Container\Definition\Definition as BaseDefinition;
/**
* An extension of the definition class that replaces constructor injection with method injection.
*/
class Definition extends BaseDefinition {
/**
* The standard method that we use for dependency injection.
*/
public const INJECTION_METHOD = 'init';
/**
* Resolve a class using method injection instead of constructor injection.
*
* @param string $concrete The concrete to instantiate.
*
* @return object
*/
protected function resolveClass( string $concrete ) {
$instance = new $concrete();
$this->invokeInit( $instance );
return $instance;
}
/**
* Invoke methods on resolved instance, including 'init'.
*
* @param object $instance The concrete to invoke methods on.
*
* @return object
*/
protected function invokeMethods( $instance ) {
$this->invokeInit( $instance );
parent::invokeMethods( $instance );
return $instance;
}
/**
* Invoke the 'init' method on a resolved object.
*
* Constructor injection causes backwards compatibility problems
* so we will rely on method injection via an internal method.
*
* @param object $instance The resolved object.
* @return void
*/
private function invokeInit( $instance ) {
$resolved = $this->resolveArguments( $this->arguments );
if ( method_exists( $instance, static::INJECTION_METHOD ) ) {
call_user_func_array( array( $instance, static::INJECTION_METHOD ), $resolved );
}
}
/**
* Forget the cached resolved object, so the next time it's requested
* it will be resolved again.
*/
public function forgetResolved() {
$this->resolved = null;
}
}
Internal/DependencyManagement/ExtendedContainer.php 0000644 00000017025 15153704500 0016514 0 ustar 00 <?php
/**
* ExtendedContainer class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Container;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Testing\Tools\DependencyManagement\MockableLegacyProxy;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Vendor\League\Container\Container as BaseContainer;
use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterface;
/**
* This class extends the original League's Container object by adding some functionality
* that we need for WooCommerce.
*/
class ExtendedContainer extends BaseContainer {
/**
* The root namespace of all WooCommerce classes in the `src` directory.
*
* @var string
*/
private $woocommerce_namespace = 'Automattic\\WooCommerce\\';
/**
* Holds the original registrations so that 'reset_replacement' can work, keys are class names and values are the original concretes.
*
* @var array
*/
private $original_concretes = array();
/**
* Whitelist of classes that we can register using the container
* despite not belonging to the WooCommerce root namespace.
*
* In general we allow only the registration of classes in the
* WooCommerce root namespace to prevent registering 3rd party code
* (which doesn't really belong to this container) or old classes
* (which may be eventually deprecated, also the LegacyProxy
* should be used for those).
*
* @var string[]
*/
private $registration_whitelist = array(
Container::class,
);
/**
* Register a class in the container.
*
* @param string $class_name Class name.
* @param mixed $concrete How to resolve the class with `get`: a factory callback, a concrete instance, another class name, or null to just create an instance of the class.
* @param bool|null $shared Whether the resolution should be performed only once and cached.
*
* @return DefinitionInterface The generated definition for the container.
* @throws ContainerException Invalid parameters.
*/
public function add( string $class_name, $concrete = null, bool $shared = null ) : DefinitionInterface {
if ( ! $this->is_class_allowed( $class_name ) ) {
throw new ContainerException( "You cannot add '$class_name', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) ) {
throw new ContainerException( "You cannot add concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
// We want to use a definition class that does not support constructor injection to avoid accidental usage.
if ( ! $concrete instanceof DefinitionInterface ) {
$concrete = new Definition( $class_name, $concrete );
}
return parent::add( $class_name, $concrete, $shared );
}
/**
* Replace an existing registration with a different concrete. See also 'reset_replacement' and 'reset_all_replacements'.
*
* @param string $class_name The class name whose definition will be replaced.
* @param mixed $concrete The new concrete (same as "add").
*
* @return DefinitionInterface The modified definition.
* @throws ContainerException Invalid parameters.
*/
public function replace( string $class_name, $concrete ) : DefinitionInterface {
if ( ! $this->has( $class_name ) ) {
throw new ContainerException( "The container doesn't have '$class_name' registered, please use 'add' instead of 'replace'." );
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) && ! $this->is_anonymous_class( $concrete_class ) ) {
throw new ContainerException( "You cannot use concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
if ( ! array_key_exists( $class_name, $this->original_concretes ) ) {
// LegacyProxy is a special case: we replace it with MockableLegacyProxy at unit testing bootstrap time.
$original_concrete = LegacyProxy::class === $class_name ? MockableLegacyProxy::class : $this->extend( $class_name )->getConcrete( $concrete );
$this->original_concretes[ $class_name ] = $original_concrete;
}
return $this->extend( $class_name )->setConcrete( $concrete );
}
/**
* Reset a replaced registration back to its original concrete.
*
* @param string $class_name The class name whose definition had been replaced.
* @return bool True if the registration has been reset, false if no replacement had been made for the specified class name.
*/
public function reset_replacement( string $class_name ) : bool {
if ( ! array_key_exists( $class_name, $this->original_concretes ) ) {
return false;
}
$this->extend( $class_name )->setConcrete( $this->original_concretes[ $class_name ] );
unset( $this->original_concretes[ $class_name ] );
return true;
}
/**
* Reset all the replaced registrations back to their original concretes.
*/
public function reset_all_replacements() {
foreach ( $this->original_concretes as $class_name => $concrete ) {
$this->extend( $class_name )->setConcrete( $concrete );
}
$this->original_concretes = array();
}
/**
* Reset all the cached resolutions, so any further "get" for shared definitions will generate the instance again.
*/
public function reset_all_resolved() {
foreach ( $this->definitions->getIterator() as $definition ) {
$definition->forgetResolved();
}
}
/**
* Get an instance of a registered class.
*
* @param string $id The class name.
* @param bool $new True to generate a new instance even if the class was registered as shared.
*
* @return object An instance of the requested class.
* @throws ContainerException Attempt to get an instance of a non-namespaced class.
*/
public function get( $id, bool $new = false ) {
if ( false === strpos( $id, '\\' ) ) {
throw new ContainerException( "Attempt to get an instance of the non-namespaced class '$id' from the container, did you forget to add a namespace import?" );
}
return parent::get( $id, $new );
}
/**
* Gets the class from the concrete regardless of type.
*
* @param mixed $concrete The concrete that we want the class from..
*
* @return string|null The class from the concrete if one is available, null otherwise.
*/
protected function get_class_from_concrete( $concrete ) {
if ( is_object( $concrete ) && ! is_callable( $concrete ) ) {
if ( $concrete instanceof DefinitionInterface ) {
return $this->get_class_from_concrete( $concrete->getConcrete() );
}
return get_class( $concrete );
}
if ( is_string( $concrete ) && class_exists( $concrete ) ) {
return $concrete;
}
return null;
}
/**
* Checks to see whether or not a class is allowed to be registered.
*
* @param string $class_name The class to check.
*
* @return bool True if the class is allowed to be registered, false otherwise.
*/
protected function is_class_allowed( string $class_name ): bool {
return StringUtil::starts_with( $class_name, $this->woocommerce_namespace, false ) || in_array( $class_name, $this->registration_whitelist, true );
}
/**
* Check if a class name corresponds to an anonymous class.
*
* @param string $class_name The class name to check.
* @return bool True if the name corresponds to an anonymous class.
*/
protected function is_anonymous_class( string $class_name ): bool {
return StringUtil::starts_with( $class_name, 'class@anonymous' );
}
}
Internal/DependencyManagement/ServiceProviders/AssignDefaultCategoryServiceProvider.php 0000644 00000001320 15153704500 0025661 0 ustar 00 <?php
/**
* AssignDefaultCategoryServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
/**
* Service provider for the AssignDefaultCategory class.
*/
class AssignDefaultCategoryServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
AssignDefaultCategory::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( AssignDefaultCategory::class );
}
}
Internal/DependencyManagement/ServiceProviders/BatchProcessingServiceProvider.php 0000644 00000001750 15153704500 0024517 0 ustar 00 <?php
/**
* Service provider for ActionUpdateController class.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
/**
* Class BatchProcessingServiceProvider
*
* @package Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders
*/
class BatchProcessingServiceProvider extends AbstractServiceProvider {
/**
* Services provided by this provider.
*
* @var string[]
*/
protected $provides = array(
BatchProcessingController::class,
);
/**
* Use the register method to register items with the container via the
* protected $this->leagueContainer property or the `getLeagueContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register() {
$this->share( BatchProcessingController::class, new BatchProcessingController() );
}
}
Internal/DependencyManagement/ServiceProviders/BlockTemplatesServiceProvider.php 0000644 00000002363 15153704500 0024353 0 ustar 00 <?php
/**
* BlockTemplatesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplatesController;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplateRegistry;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\TemplateTransformer;
/**
* Service provider for the block templates controller classes in the Automattic\WooCommerce\Internal\BlockTemplateRegistry namespace.
*/
class BlockTemplatesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
BlockTemplateRegistry::class,
BlockTemplatesController::class,
TemplateTransformer::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( TemplateTransformer::class );
$this->share( BlockTemplateRegistry::class );
$this->share( BlockTemplatesController::class )->addArguments(
array(
BlockTemplateRegistry::class,
TemplateTransformer::class,
)
);
}
}
Internal/DependencyManagement/ServiceProviders/COTMigrationServiceProvider.php 0000644 00000002125 15153704500 0023735 0 ustar 00 <?php
/**
* Service provider for COTMigration.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable\CLIRunner;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Class COTMigrationServiceProvider
*
* @package Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders
*/
class COTMigrationServiceProvider extends AbstractServiceProvider {
/**
* Services provided by this provider.
*
* @var string[]
*/
protected $provides = array(
PostsToOrdersMigrationController::class,
CLIRunner::class,
);
/**
* Use the register method to register items with the container via the
* protected $this->leagueContainer property or the `getLeagueContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register() {
$this->share( PostsToOrdersMigrationController::class );
$this->share( CLIRunner::class );
}
}
Internal/DependencyManagement/ServiceProviders/DownloadPermissionsAdjusterServiceProvider.php 0000644 00000001364 15153704500 0027147 0 ustar 00 <?php
/**
* DownloadPermissionsAdjusterServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
/**
* Service provider for the DownloadPermissionsAdjuster class.
*/
class DownloadPermissionsAdjusterServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DownloadPermissionsAdjuster::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DownloadPermissionsAdjuster::class );
}
}
Internal/DependencyManagement/ServiceProviders/FeaturesServiceProvider.php 0000644 00000001501 15153704500 0023211 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\PluginUtil;
/**
* Service provider for the features enabling/disabling/compatibility engine.
*/
class FeaturesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
FeaturesController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( FeaturesController::class )
->addArguments( array( LegacyProxy::class, PluginUtil::class ) );
}
}
Internal/DependencyManagement/ServiceProviders/MarketingServiceProvider.php 0000644 00000002164 15153704500 0023362 0 ustar 00 <?php
/**
* MarketingServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannels;
use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
// Indicates that the multichannel marketing classes exist.
// This constant will be checked by third-party extensions before utilizing any of the classes defined for this feature.
if ( ! defined( 'WC_MCM_EXISTS' ) ) {
define( 'WC_MCM_EXISTS', true );
}
/**
* Service provider for the non-static utils classes in the Automattic\WooCommerce\src namespace.
*
* @since x.x.x
*/
class MarketingServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
MarketingSpecs::class,
MarketingChannels::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( MarketingSpecs::class );
$this->share( MarketingChannels::class );
}
}
Internal/DependencyManagement/ServiceProviders/MarketplaceServiceProvider.php 0000644 00000001236 15153704500 0023670 0 ustar 00 <?php
/**
* MarketplaceServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
/**
* Service provider for the Marketplace namespace.
*/
class MarketplaceServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Marketplace::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Marketplace::class );
}
}
Internal/DependencyManagement/ServiceProviders/ObjectCacheServiceProvider.php 0000644 00000001154 15153704500 0023571 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Caching\WPCacheEngine;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Service provider for the object cache mechanism.
*/
class ObjectCacheServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
WPCacheEngine::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( WPCacheEngine::class );
}
}
Internal/DependencyManagement/ServiceProviders/OptionSanitizerServiceProvider.php 0000644 00000001266 15153704500 0024604 0 ustar 00 <?php
/**
* OptionSanitizerServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
/**
* Service provider for the OptionSanitizer class.
*/
class OptionSanitizerServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
OptionSanitizer::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( OptionSanitizer::class );
}
}
Internal/DependencyManagement/ServiceProviders/OrderAdminServiceProvider.php 0000644 00000002776 15153704500 0023476 0 ustar 00 <?php
/**
* Service provider for various order admin classes.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
use Automattic\WooCommerce\Internal\Admin\Orders\Edit;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\Admin\Orders\ListTable;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* OrderAdminServiceProvider class.
*/
class OrderAdminServiceProvider extends AbstractServiceProvider {
/**
* List services provided by this class.
*
* @var string[]
*/
protected $provides = array(
COTRedirectionController::class,
PageController::class,
Edit::class,
ListTable::class,
EditLock::class,
TaxonomiesMetaBox::class,
);
/**
* Registers services provided by this class.
*
* @return void
*/
public function register() {
$this->share( COTRedirectionController::class );
$this->share( PageController::class );
$this->share( Edit::class )->addArgument( PageController::class );
$this->share( ListTable::class )->addArgument( PageController::class );
$this->share( EditLock::class );
$this->share( TaxonomiesMetaBox::class )->addArgument( OrdersTableDataStore::class );
}
}
Internal/DependencyManagement/ServiceProviders/OrderMetaBoxServiceProvider.php 0000644 00000001252 15153704500 0023771 0 ustar 00 <?php
/**
* Service provider for order meta boxes.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* OrderMetaBoxServiceProvider class.
*/
class OrderMetaBoxServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
CustomMetaBox::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( CustomMetaBox::class );
}
}
Internal/DependencyManagement/ServiceProviders/OrdersControllersServiceProvider.php 0000644 00000001571 15153704500 0025127 0 ustar 00 <?php
/**
* OrdersControllersServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
/**
* Service provider for the orders controller classes in the Automattic\WooCommerce\Internal\Orders namespace.
*/
class OrdersControllersServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
CouponsController::class,
TaxesController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( CouponsController::class );
$this->share( TaxesController::class );
}
}
Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php 0000644 00000006122 15153704500 0024504 0 ustar 00 <?php
/**
* OrdersDataStoreServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Caching\TransientsEngine;
use Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable\CLIRunner;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableRefundDataStore;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\PluginUtil;
/**
* Service provider for the classes in the Internal\DataStores\Orders namespace.
*/
class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DataSynchronizer::class,
CustomOrdersTableController::class,
OrdersTableDataStore::class,
CLIRunner::class,
OrdersTableDataStoreMeta::class,
OrdersTableRefundDataStore::class,
OrderCache::class,
OrderCacheController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( OrdersTableDataStoreMeta::class );
$this->share( OrdersTableDataStore::class )->addArguments( array( OrdersTableDataStoreMeta::class, DatabaseUtil::class, LegacyProxy::class ) );
$this->share( DataSynchronizer::class )->addArguments(
array(
OrdersTableDataStore::class,
DatabaseUtil::class,
PostsToOrdersMigrationController::class,
LegacyProxy::class,
OrderCacheController::class,
)
);
$this->share( OrdersTableRefundDataStore::class )->addArguments( array( OrdersTableDataStoreMeta::class, DatabaseUtil::class, LegacyProxy::class ) );
$this->share( CustomOrdersTableController::class )->addArguments(
array(
OrdersTableDataStore::class,
DataSynchronizer::class,
OrdersTableRefundDataStore::class,
BatchProcessingController::class,
FeaturesController::class,
OrderCache::class,
OrderCacheController::class,
PluginUtil::class,
)
);
$this->share( OrderCache::class );
$this->share( OrderCacheController::class )->addArgument( OrderCache::class );
if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) {
$this->share( CLIRunner::class )->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class, PostsToOrdersMigrationController::class ) );
}
}
}
Internal/DependencyManagement/ServiceProviders/ProductAttributesLookupServiceProvider.php 0000644 00000002112 15153704500 0026313 0 ustar 00 <?php
/**
* ProductAttributesLookupServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
/**
* Service provider for the ProductAttributesLookupServiceProvider namespace.
*/
class ProductAttributesLookupServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DataRegenerator::class,
Filterer::class,
LookupDataStore::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DataRegenerator::class )->addArgument( LookupDataStore::class );
$this->share( Filterer::class )->addArgument( LookupDataStore::class );
$this->share( LookupDataStore::class );
}
}
Internal/DependencyManagement/ServiceProviders/ProductDownloadsServiceProvider.php 0000644 00000002213 15153704500 0024727 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
/**
* Service provider for the Product Downloads-related services.
*/
class ProductDownloadsServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Register::class,
Synchronize::class,
SyncUI::class,
UI::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Register::class );
$this->share( Synchronize::class )->addArgument( Register::class );
$this->share( SyncUI::class )->addArgument( Register::class );
$this->share( UI::class )->addArgument( Register::class );
}
}
Internal/DependencyManagement/ServiceProviders/ProductReviewsServiceProvider.php 0000644 00000001562 15153704500 0024427 0 ustar 00 <?php
/**
* OrdersDataStoreServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Service provider for the classes in the Internal\Admin\ProductReviews namespace.
*/
class ProductReviewsServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Reviews::class,
ReviewsCommentsOverrides::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Reviews::class );
$this->share( ReviewsCommentsOverrides::class );
}
}
Internal/DependencyManagement/ServiceProviders/ProxiesServiceProvider.php 0000644 00000001464 15153704500 0023074 0 ustar 00 <?php
/**
* ProxiesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Proxies\ActionsProxy;
/**
* Service provider for the classes in the Automattic\WooCommerce\Proxies namespace.
*/
class ProxiesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
LegacyProxy::class,
ActionsProxy::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( ActionsProxy::class );
$this->share_with_auto_arguments( LegacyProxy::class );
}
}
Internal/DependencyManagement/ServiceProviders/RestockRefundedItemsAdjusterServiceProvider.php 0000644 00000001372 15153704500 0027234 0 ustar 00 <?php
/**
* RestockRefundedItemsAdjusterServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
/**
* Service provider for the RestockRefundedItemsAdjuster class.
*/
class RestockRefundedItemsAdjusterServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
RestockRefundedItemsAdjuster::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( RestockRefundedItemsAdjuster::class );
}
}
Internal/DependencyManagement/ServiceProviders/UtilsClassesServiceProvider.php 0000644 00000003157 15153704500 0024062 0 ustar 00 <?php
/**
* UtilsClassesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Utilities\COTMigrationUtil;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\Utilities\HtmlSanitizer;
use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\PluginUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Service provider for the non-static utils classes in the Automattic\WooCommerce\src namespace.
*/
class UtilsClassesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DatabaseUtil::class,
HtmlSanitizer::class,
OrderUtil::class,
PluginUtil::class,
COTMigrationUtil::class,
WebhookUtil::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DatabaseUtil::class );
$this->share( HtmlSanitizer::class );
$this->share( OrderUtil::class );
$this->share( PluginUtil::class )
->addArgument( LegacyProxy::class );
$this->share( COTMigrationUtil::class )
->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class ) );
$this->share( WebhookUtil::class );
}
}
Internal/DownloadPermissionsAdjuster.php 0000644 00000015026 15153704500 0014542 0 ustar 00 <?php
/**
* DownloadPermissionsAdjuster class file.
*/
namespace Automattic\WooCommerce\Internal;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class to adjust download permissions on product save.
*/
class DownloadPermissionsAdjuster {
/**
* The downloads data store to use.
*
* @var WC_Data_Store
*/
private $downloads_data_store;
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
$this->downloads_data_store = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Data_Store::class, 'customer-download' );
add_action( 'adjust_download_permissions', array( $this, 'adjust_download_permissions' ), 10, 1 );
}
/**
* Schedule a download permissions adjustment for a product if necessary.
* This should be executed whenever a product is saved.
*
* @param \WC_Product $product The product to schedule a download permission adjustments for.
*/
public function maybe_schedule_adjust_download_permissions( \WC_Product $product ) {
$children_ids = $product->get_children();
if ( ! $children_ids ) {
return;
}
$are_any_children_downloadable = false;
foreach ( $children_ids as $child_id ) {
$child = wc_get_product( $child_id );
if ( $child && $child->is_downloadable() ) {
$are_any_children_downloadable = true;
break;
}
}
if ( ! $product->is_downloadable() && ! $are_any_children_downloadable ) {
return;
}
$scheduled_action_args = array( $product->get_id() );
$already_scheduled_actions =
WC()->call_function(
'as_get_scheduled_actions',
array(
'hook' => 'adjust_download_permissions',
'args' => $scheduled_action_args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled_actions ) ) {
WC()->call_function(
'as_schedule_single_action',
WC()->call_function( 'time' ) + 1,
'adjust_download_permissions',
$scheduled_action_args
);
}
}
/**
* Create additional download permissions for variations if necessary.
*
* When a simple downloadable product is converted to a variable product,
* existing download permissions are still present in the database but they don't apply anymore.
* This method creates additional download permissions for the variations based on
* the old existing ones for the main product.
*
* The procedure is as follows. For each existing download permission for the parent product,
* check if there's any variation offering the same file for download (the file URL, not name, is checked).
* If that is found, check if an equivalent permission exists (equivalent means for the same file and with
* the same order id and customer id). If no equivalent permission exists, create it.
*
* @param int $product_id The id of the product to check permissions for.
*/
public function adjust_download_permissions( int $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return;
}
$children_ids = $product->get_children();
if ( ! $children_ids ) {
return;
}
$parent_downloads = $this->get_download_files_and_permissions( $product );
if ( ! $parent_downloads ) {
return;
}
$children_with_downloads = array();
foreach ( $children_ids as $child_id ) {
$child = wc_get_product( $child_id );
// Ensure we have a valid child product.
if ( ! $child instanceof WC_Product ) {
wc_get_logger()->warning(
sprintf(
/* translators: 1: child product ID 2: parent product ID. */
__( 'Unable to load child product %1$d while adjusting download permissions for product %2$d.', 'woocommerce' ),
$child_id,
$product_id
)
);
continue;
}
$children_with_downloads[ $child_id ] = $this->get_download_files_and_permissions( $child );
}
foreach ( $parent_downloads['permission_data_by_file_order_user'] as $parent_file_order_and_user => $parent_download_data ) {
foreach ( $children_with_downloads as $child_id => $child_download_data ) {
$file_url = $parent_download_data['file'];
$must_create_permission =
// The variation offers the same file as the parent for download...
in_array( $file_url, array_keys( $child_download_data['download_ids_by_file_url'] ), true ) &&
// ...but no equivalent download permission (same file URL, order id and user id) exists.
! array_key_exists( $parent_file_order_and_user, $child_download_data['permission_data_by_file_order_user'] );
if ( $must_create_permission ) {
// The new child download permission is a copy of the parent's,
// but with the product and download ids changed to match those of the variation.
$new_download_data = $parent_download_data['data'];
$new_download_data['product_id'] = $child_id;
$new_download_data['download_id'] = $child_download_data['download_ids_by_file_url'][ $file_url ];
$this->downloads_data_store->create_from_data( $new_download_data );
}
}
}
}
/**
* Get the existing downloadable files and download permissions for a given product.
* The returned value is an array with two keys:
*
* - download_ids_by_file_url: an associative array of file url => download_id.
* - permission_data_by_file_order_user: an associative array where key is "file_url:customer_id:order_id" and value is the full permission data set.
*
* @param \WC_Product $product The product to get the downloadable files and permissions for.
* @return array[] Information about the downloadable files and permissions for the product.
*/
private function get_download_files_and_permissions( \WC_Product $product ) {
$result = array(
'permission_data_by_file_order_user' => array(),
'download_ids_by_file_url' => array(),
);
$downloads = $product->get_downloads();
foreach ( $downloads as $download ) {
$result['download_ids_by_file_url'][ $download->get_file() ] = $download->get_id();
}
$permissions = $this->downloads_data_store->get_downloads( array( 'product_id' => $product->get_id() ) );
foreach ( $permissions as $permission ) {
$permission_data = (array) $permission->data;
if ( array_key_exists( $permission_data['download_id'], $downloads ) ) {
$file = $downloads[ $permission_data['download_id'] ]->get_file();
$data = array(
'file' => $file,
'data' => (array) $permission->data,
);
$result['permission_data_by_file_order_user'][ "{$file}:{$permission_data['user_id']}:{$permission_data['order_id']}" ] = $data;
}
}
return $result;
}
}
Internal/Features/FeaturesController.php 0000644 00000133367 15153704500 0014446 0 ustar 00 <?php
/**
* FeaturesController class file
*/
namespace Automattic\WooCommerce\Internal\Features;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Admin\Features\Navigation\Init;
use Automattic\WooCommerce\Admin\Features\NewProductManagementExperience;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\PluginUtil;
defined( 'ABSPATH' ) || exit;
/**
* Class to define the WooCommerce features that can be enabled and disabled by admin users,
* provides also a mechanism for WooCommerce plugins to declare that they are compatible
* (or incompatible) with a given feature.
*/
class FeaturesController {
use AccessiblePrivateMethods;
public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';
/**
* The existing feature definitions.
*
* @var array[]
*/
private $features;
/**
* The registered compatibility info for WooCommerce plugins, with plugin names as keys.
*
* @var array
*/
private $compatibility_info_by_plugin;
/**
* Ids of the legacy features (they existed before the features engine was implemented).
*
* @var array
*/
private $legacy_feature_ids;
/**
* The registered compatibility info for WooCommerce plugins, with feature ids as keys.
*
* @var array
*/
private $compatibility_info_by_feature;
/**
* The LegacyProxy instance to use.
*
* @var LegacyProxy
*/
private $proxy;
/**
* The PluginUtil instance to use.
*
* @var PluginUtil
*/
private $plugin_util;
/**
* Flag indicating that features will be enableable from the settings page
* even when they are incompatible with active plugins.
*
* @var bool
*/
private $force_allow_enabling_features = false;
/**
* Flag indicating that plugins will be activable from the plugins page
* even when they are incompatible with enabled features.
*
* @var bool
*/
private $force_allow_enabling_plugins = false;
/**
* Creates a new instance of the class.
*/
public function __construct() {
$hpos_enable_sync = DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION;
$hpos_authoritative = CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION;
$features = array(
'analytics' => array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enable WooCommerce Analytics', 'woocommerce' ),
'is_experimental' => false,
'enabled_by_default' => true,
'disable_ui' => false,
),
'new_navigation' => array(
'name' => __( 'Navigation', 'woocommerce' ),
'description' => __( 'Add the new WooCommerce navigation experience to the dashboard', 'woocommerce' ),
'is_experimental' => false,
'disable_ui' => false,
),
'product_block_editor' => array(
'name' => __( 'New product editor', 'woocommerce' ),
'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ),
'is_experimental' => true,
'disable_ui' => false,
),
// Options for HPOS features are added in CustomOrdersTableController to keep the logic in same place.
'custom_order_tables' => array( // This exists for back-compat only, otherwise it's value is superseded by $hpos_authoritative option.
'name' => __( 'High-Performance Order Storage (HPOS)', 'woocommerce' ),
'enabled_by_default' => false,
),
$hpos_authoritative => array(
'name' => __( 'High-Performance Order Storage', 'woocommerce' ),
'order' => 10,
),
$hpos_enable_sync => array(
'name' => '',
'order' => 9,
),
'cart_checkout_blocks' => array(
'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
'is_experimental' => false,
'disable_ui' => true,
),
'marketplace' => array(
'name' => __( 'Marketplace', 'woocommerce' ),
'description' => __(
'New, faster way to find extensions and themes for your WooCommerce store',
'woocommerce'
),
'is_experimental' => false,
'enabled_by_default' => true,
'disable_ui' => false,
),
);
$this->legacy_feature_ids = array(
'analytics',
'new_navigation',
'product_block_editor',
'marketplace',
// Compatibility for COT is determined by `custom_order_tables'.
CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
);
$this->init_features( $features );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
self::add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
self::add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
self::add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 );
self::add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 );
self::add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 );
self::add_action( 'load-plugins.php', array( $this, 'maybe_invalidate_cached_plugin_data' ) );
self::add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
self::add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 );
self::add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 );
self::add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 );
self::add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 );
}
/**
* Initialize the class according to the existing features.
*
* @param array $features Information about the existing features.
*/
private function init_features( array $features ) {
$this->compatibility_info_by_plugin = array();
$this->compatibility_info_by_feature = array();
$this->features = $features;
foreach ( array_keys( $this->features ) as $feature_id ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
'compatible' => array(),
'incompatible' => array(),
);
}
}
/**
* Initialize the class instance.
*
* @internal
*
* @param LegacyProxy $proxy The instance of LegacyProxy to use.
* @param PluginUtil $plugin_util The instance of PluginUtil to use.
*/
final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
$this->proxy = $proxy;
$this->plugin_util = $plugin_util;
}
/**
* Get all the existing WooCommerce features.
*
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
*
* - name (string)
* - description (string)
* - is_experimental (bool)
* - is_enabled (bool) (only if $include_enabled_info is passed as true)
*
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
*/
public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
$features = $this->features;
if ( ! $include_experimental ) {
$features = array_filter(
$features,
function( $feature ) {
return ! $feature['is_experimental'];
}
);
}
if ( $include_enabled_info ) {
foreach ( array_keys( $features ) as $feature_id ) {
$is_enabled = $this->feature_is_enabled( $feature_id );
$features[ $feature_id ]['is_enabled'] = $is_enabled;
}
}
return $features;
}
/**
* Check if a given feature is currently enabled.
*
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
*/
public function feature_is_enabled( string $feature_id ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
$default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
$value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
return $value;
}
/**
* Check if a given feature is enabled by default.
*
* @param string $feature_id Unique feature id.
* @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
*/
private function feature_is_enabled_by_default( string $feature_id ): bool {
return ! empty( $this->features[ $feature_id ]['enabled_by_default'] );
}
/**
* Change the enabled/disabled status of a feature.
*
* @param string $feature_id Unique feature id.
* @param bool $enable True to enable the feature, false to disable it.
* @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
*/
public function change_feature_enable( string $feature_id, bool $enable ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no' );
}
/**
* Declare (in)compatibility with a given feature for a given plugin.
*
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
*
* The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
* of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
* FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
*
* @param string $feature_id Unique feature id.
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
* @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
*/
public function declare_compatibility( string $feature_id, string $plugin_name, bool $positive_compatibility = true ): bool {
if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
/* translators: 1: class::method 2: before_woocommerce_init */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' );
return false;
}
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
$plugin_name = str_replace( '\\', '/', $plugin_name );
// Register compatibility by plugin.
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_name );
$key = $positive_compatibility ? 'compatible' : 'incompatible';
$opposite_key = $positive_compatibility ? 'incompatible' : 'compatible';
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $key );
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $opposite_key );
if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $opposite_key ], true ) ) {
throw new \Exception( "Plugin $plugin_name is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" );
}
if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $key ], true ) ) {
$this->compatibility_info_by_plugin[ $plugin_name ][ $key ][] = $feature_id;
}
// Register compatibility by feature.
$key = $positive_compatibility ? 'compatible' : 'incompatible';
if ( ! in_array( $plugin_name, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
$this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_name;
}
return true;
}
/**
* Check whether a feature exists with a given id.
*
* @param string $feature_id The feature id to check.
* @return bool True if the feature exists.
*/
private function feature_exists( string $feature_id ): bool {
return isset( $this->features[ $feature_id ] );
}
/**
* Get the ids of the features that a certain plugin has declared compatibility for.
*
* This method can't be called before the 'woocommerce_init' hook is fired.
*
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @param bool $enabled_features_only True to return only names of enabled plugins.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
*/
public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
$features = $this->features;
if ( $enabled_features_only ) {
$features = array_filter(
$features,
array( $this, 'feature_is_enabled' ),
ARRAY_FILTER_USE_KEY
);
}
if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
return array(
'compatible' => array(),
'incompatible' => array(),
'uncertain' => array_keys( $features ),
);
}
$info = $this->compatibility_info_by_plugin[ $plugin_name ];
$info['compatible'] = array_values( array_intersect( array_keys( $features ), $info['compatible'] ) );
$info['incompatible'] = array_values( array_intersect( array_keys( $features ), $info['incompatible'] ) );
$info['uncertain'] = array_values( array_diff( array_keys( $features ), $info['compatible'], $info['incompatible'] ) );
return $info;
}
/**
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
*
* @param string $feature_id Feature id.
* @param bool $active_only True to return only active plugins.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
*/
public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
$woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
if ( ! $this->feature_exists( $feature_id ) ) {
return array(
'compatible' => array(),
'incompatible' => array(),
'uncertain' => $woo_aware_plugins,
);
}
$info = $this->compatibility_info_by_feature[ $feature_id ];
$info['uncertain'] = array_values( array_diff( $woo_aware_plugins, $info['compatible'], $info['incompatible'] ) );
return $info;
}
/**
* Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
*
* @param string|null $function Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
* @return bool True if 'woocommerce_init' has run or is running, false otherwise.
*/
private function verify_did_woocommerce_init( string $function = null ): bool {
if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
if ( ! is_null( $function ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function;
/* translators: 1: class::method 2: plugins_loaded */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' );
}
return false;
}
return true;
}
/**
* Get the name of the option that enables/disables a given feature.
* Note that it doesn't check if the feature actually exists.
*
* @param string $feature_id The id of the feature.
* @return string The option that enables or disables the feature.
*/
public function feature_enable_option_name( string $feature_id ): string {
switch ( $feature_id ) {
case 'analytics':
return Analytics::TOGGLE_OPTION_NAME;
case 'new_navigation':
return Init::TOGGLE_OPTION_NAME;
case 'custom_order_tables':
case CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION:
return CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION;
case DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION:
return DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION;
default:
return "woocommerce_feature_{$feature_id}_enabled";
}
}
/**
* Checks whether a feature id corresponds to a legacy feature
* (a feature that existed prior to the implementation of the features engine).
*
* @param string $feature_id The feature id to check.
* @return bool True if the id corresponds to a legacy feature.
*/
public function is_legacy_feature( string $feature_id ): bool {
return in_array( $feature_id, $this->legacy_feature_ids, true );
}
/**
* Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
* from the WooCommerce feature settings page.
*/
public function allow_enabling_features_with_incompatible_plugins(): void {
$this->force_allow_enabling_features = true;
}
/**
* Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
* from the WordPress plugins page.
*/
public function allow_activating_plugins_with_incompatible_features(): void {
$this->force_allow_enabling_plugins = true;
}
/**
* Handler for the 'added_option' hook.
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been created.
* @param mixed $value The value of the option.
*/
private function process_added_option( string $option, $value ) {
$this->process_updated_option( $option, false, $value );
}
/**
* Handler for the 'updated_option' hook.
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been modified.
* @param mixed $old_value The old value of the option.
* @param mixed $value The new value of the option.
*/
private function process_updated_option( string $option, $old_value, $value ) {
$matches = array();
$success = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
$known_features = array(
Analytics::TOGGLE_OPTION_NAME,
Init::TOGGLE_OPTION_NAME,
NewProductManagementExperience::TOGGLE_OPTION_NAME,
DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
);
if ( ! $success && ! in_array( $option, $known_features, true ) ) {
return;
}
if ( $value === $old_value ) {
return;
}
if ( Analytics::TOGGLE_OPTION_NAME === $option ) {
$feature_id = 'analytics';
} elseif ( Init::TOGGLE_OPTION_NAME === $option ) {
$feature_id = 'new_navigation';
} elseif ( in_array( $option, $known_features, true ) ) {
$feature_id = $option;
} else {
$feature_id = $matches[1];
}
/**
* Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
*
* @param string $feature_id The id of the feature.
* @param bool $enabled True if the feature has been enabled, false if it has been disabled.
*
* @since 7.0.0
*/
do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
}
/**
* Handler for the 'woocommerce_get_sections_advanced' hook,
* it adds the "Features" section to the advanced settings page.
*
* @param array $sections The original sections array.
* @return array The updated sections array.
*/
private function add_features_section( $sections ) {
if ( ! isset( $sections['features'] ) ) {
$sections['features'] = __( 'Features', 'woocommerce' );
}
return $sections;
}
/**
* Handler for the 'woocommerce_get_settings_advanced' hook,
* it adds the settings UI for all the existing features.
*
* Note that the settings added via the 'woocommerce_settings_features' hook will be
* displayed in the non-experimental features section.
*
* @param array $settings The existing settings for the corresponding settings section.
* @param string $current_section The section to get the settings for.
* @return array The updated settings array.
*/
private function add_feature_settings( $settings, $current_section ): array {
if ( 'features' !== $current_section ) {
return $settings;
}
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing WooCommerce Admin to be disabled.
*
* @param bool $disabled False.
*/
$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$feature_settings =
array(
array(
'title' => __( 'Features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
'id' => 'features_options',
),
);
$features = $this->get_features( true );
$feature_ids = array_keys( $features );
usort( $feature_ids, function( $feature_id_a, $feature_id_b ) use ( $features ) {
return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
} );
$experimental_feature_ids = array_filter(
$feature_ids,
function( $feature_id ) use ( $features ) {
return $features[ $feature_id ]['is_experimental'] ?? false;
}
);
$mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
$feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
foreach ( $feature_ids as $id ) {
if ( 'mature_features_end' === $id ) {
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
*
* @param bool $disabled False.
*/
$feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
if ( ! empty( $experimental_feature_ids ) ) {
$feature_settings[] = array(
'type' => 'sectionend',
'id' => 'features_options',
);
$feature_settings[] = array(
'title' => __( 'Experimental features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
'id' => 'experimental_features_options',
);
}
continue;
}
if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
continue;
}
$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ], $admin_features_disabled );
}
$feature_settings[] = array(
'type' => 'sectionend',
'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
);
return $feature_settings;
}
/**
* Get the parameters to display the setting enable/disable UI for a given feature.
*
* @param string $feature_id The feature id.
* @param array $feature The feature parameters, as returned by get_features.
* @param bool $admin_features_disabled True if admin features have been disabled via 'woocommerce_admin_disabled' filter.
* @return array The parameters to add to the settings array.
*/
private function get_setting_for_feature( string $feature_id, array $feature, bool $admin_features_disabled ): array {
$description = $feature['description'] ?? '';
$disabled = false;
$desc_tip = '';
$tooltip = $feature['tooltip'] ?? '';
$type = $feature['type'] ?? 'checkbox';
if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
$disabled = true;
$desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
} elseif ( 'new_navigation' === $feature_id ) {
$disabled = ! $this->feature_is_enabled( $feature_id );
if ( $disabled ) {
$update_text = sprintf(
// translators: 1: line break tag.
__( '%1$s The development of this feature is currently on hold.', 'woocommerce' ),
'<br/>'
);
} else {
$update_text = sprintf(
// translators: 1: line break tag.
__(
'%1$s This navigation will soon become unavailable while we make necessary improvements.
If you turn it off now, you will not be able to turn it back on.',
'woocommerce'
),
'<br/>'
);
}
$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
$update_text = sprintf(
// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
'<br/>',
'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
'</a>'
);
$disabled = true;
}
if ( ! empty( $update_text ) ) {
$description .= $update_text;
}
}
if ( 'product_block_editor' === $feature_id ) {
$disabled = version_compare( get_bloginfo( 'version' ), '6.2', '<' );
if ( $disabled ) {
$desc_tip = __( '⚠ This feature is compatible with WordPress version 6.2 or higher.', 'woocommerce' );
}
}
if ( ! $this->is_legacy_feature( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
$disabled = ! $this->feature_is_enabled( $feature_id );
$plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
$desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
}
/**
* Filter to customize the description tip that appears under the description of each feature in the features settings page.
*
* @since 7.1.0
*
* @param string $desc_tip The original description tip.
* @param string $feature_id The id of the feature for which the description tip is being customized.
* @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
* @return string The new description tip to use.
*/
$desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
$feature_setting = array(
'title' => $feature['name'],
'desc' => $description,
'type' => $type,
'id' => $this->feature_enable_option_name( $feature_id ),
'disabled' => $disabled && ! $this->force_allow_enabling_features,
'desc_tip' => $desc_tip,
'tooltip' => $tooltip,
'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
);
/**
* Allows to modify feature setting that will be used to render in the feature page.
*
* @param array $feature_setting The feature setting. Describes the feature:
* - title: The title of the feature.
* - desc: The description of the feature. Will be displayed under the title.
* - type: The type of the feature. Could be any of supported settings types from `WC_Admin_Settings::output_fields`, but if it's anything other than checkbox or radio, it will need custom handling.
* - id: The id of the feature. Will be used as the name of the setting.
* - disabled: Whether the feature is disabled or not.
* - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
* - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
* - default: The default value of the feature.
* @param string $feature_id The id of the feature.
* @since 8.0.0
*/
return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
}
/**
* Handle the plugin deactivation hook.
*
* @param string $plugin_name Name of the plugin that has been deactivated.
*/
private function handle_plugin_deactivation( $plugin_name ): void {
unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
$compatibles = $this->compatibility_info_by_feature[ $feature ]['compatible'];
$this->compatibility_info_by_feature[ $feature ]['compatible'] = array_diff( $compatibles, array( $plugin_name ) );
$incompatibles = $this->compatibility_info_by_feature[ $feature ]['incompatible'];
$this->compatibility_info_by_feature[ $feature ]['incompatible'] = array_diff( $incompatibles, array( $plugin_name ) );
}
}
/**
* Handler for the all_plugins filter.
*
* Returns the list of plugins incompatible with a given plugin
* if we are in the plugins page and the query string of the current request
* looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
*
* @param array $list The original list of plugins.
*/
private function filter_plugins_list( $list ): array {
if ( ! $this->verify_did_woocommerce_init() ) {
return $list;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
if ( ! function_exists( 'get_current_screen' ) || get_current_screen() && 'plugins' !== get_current_screen()->id || 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
return $list;
}
$feature_id = $_GET['feature_id'] ?? 'all';
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return $list;
}
return $this->get_incompatible_plugins( $feature_id, $list );
}
/**
* Returns the list of plugins incompatible with a given feature.
*
* @param string $feature_id ID of the feature. Can also be `all` to denote all features.
* @param array $list List of plugins to filter.
*
* @return array List of plugins incompatible with the given feature.
*/
private function get_incompatible_plugins( $feature_id, $list ) {
$incompatibles = array();
// phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
foreach ( array_keys( $list ) as $plugin_name ) {
if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
continue;
}
$compatibility = $this->get_compatible_features_for_plugin( $plugin_name );
$incompatible_with = array_diff(
array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ),
$this->legacy_feature_ids
);
if ( ( 'all' === $feature_id && ! empty( $incompatible_with ) ) || in_array( $feature_id, $incompatible_with, true ) ) {
$incompatibles[] = $plugin_name;
}
}
return array_intersect_key( $list, array_flip( $incompatibles ) );
}
/**
* Handler for the admin_notices action.
*/
private function display_notices_in_plugins_page(): void {
if ( ! $this->verify_did_woocommerce_init() ) {
return;
}
$feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
if ( ! $feature_filter_description_shown ) {
$this->maybe_display_feature_incompatibility_warning();
}
}
/**
* Shows a warning when there are any incompatibility between active plugins and enabled features.
* The warning is shown in on any admin screen except the plugins screen itself, since
* there's already a "You are viewing
*/
private function maybe_display_feature_incompatibility_warning(): void {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
$incompatible_plugins = false;
foreach ( $this->plugin_util->get_woocommerce_aware_plugins( true ) as $plugin ) {
$compatibility = $this->get_compatible_features_for_plugin( $plugin, true );
$incompatible_with = array_diff(
array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ),
$this->legacy_feature_ids
);
if ( $incompatible_with ) {
$incompatible_plugins = true;
break;
}
}
if ( ! $incompatible_plugins ) {
return;
}
$message = str_replace(
'<a>',
'<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
__( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
);
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<div class="notice notice-error">
<p><?php echo $message; ?></p>
</div>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Shows a "You are viewing the plugins that are incompatible with the X feature"
* if we are in the plugins page and the query string of the current request
* looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
*/
private function maybe_display_current_feature_filter_description(): bool {
if ( 'plugins' !== get_current_screen()->id ) {
return false;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
$plugin_status = $_GET['plugin_status'] ?? '';
$feature_id = $_GET['feature_id'] ?? '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
if ( 'incompatible_with_feature' !== $plugin_status ) {
return false;
}
$feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return false;
}
// phpcs:enable WordPress.Security.NonceVerification
$plugins_page_url = admin_url( 'plugins.php' );
$features_page_url = $this->get_features_page_url();
$message =
'all' === $feature_id
? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
: sprintf(
/* translators: %s is a feature name. */
__( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
$this->features[ $feature_id ]['name']
);
$message .= '<br />';
$message .= sprintf(
__( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
$plugins_page_url,
$features_page_url
);
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<div class="notice notice-info">
<p><?php echo $message; ?></p>
</div>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
return true;
}
/**
* If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data.
*
* This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request
* (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list
* to inject useful information of its own.
*
* @see https://github.com/woocommerce/woocommerce/issues/37343
*
* @return void
*/
private function maybe_invalidate_cached_plugin_data(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) {
wp_cache_delete( 'plugins', 'plugins' );
}
}
/**
* Handler for the 'after_plugin_row' action.
* Displays a "This plugin is incompatible with X features" notice if necessary.
*
* @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
* @param array $plugin_data Plugin data, as returned by 'get_plugins'.
*/
private function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
global $wp_list_table;
if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
return;
}
if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
return;
}
if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
return;
}
$feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true );
$incompatible_features = array_merge( $feature_compatibility_info['incompatible'], $feature_compatibility_info['uncertain'] );
$incompatible_features = array_values(
array_filter(
$incompatible_features,
function( $feature_id ) {
return ! $this->is_legacy_feature( $feature_id );
}
)
);
$incompatible_features_count = count( $incompatible_features );
if ( $incompatible_features_count > 0 ) {
$columns_count = $wp_list_table->get_column_count();
$is_active = true; // For now we are showing active plugins in the "Incompatible with..." view.
$is_active_class = $is_active ? 'active' : 'inactive';
$is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';
if ( 1 === $incompatible_features_count ) {
$message = sprintf(
/* translators: %s = printable plugin name */
__( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name']
);
} elseif ( 2 === $incompatible_features_count ) {
/* translators: %1\$s, %2\$s = printable plugin names */
$message = sprintf(
__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name'],
$this->features[ $incompatible_features[1] ]['name']
);
} else {
/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
$message = sprintf(
__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name'],
$this->features[ $incompatible_features[1] ]['name'],
$incompatible_features_count - 2
);
}
$features_page_url = $this->get_features_page_url();
$manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
<td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
<div class='notice inline notice-warning notice-alt'>
<p>
<?php echo $message; ?>
<a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
</p>
</div>
</td>
</tr>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* Get the URL of the features settings page.
*
* @return string
*/
private function get_features_page_url(): string {
return admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' );
}
/**
* Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
*
* WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
*
* - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
* border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
* it will have a "plugin-update-tr" class which will draw the missing lower border line.
* - Otherwise, the <tr> will be already drawn with the lower border line.
*
* This is a problem for our rendering of the "plugin is incompatible with X features" warning:
*
* - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
* our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
* - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
*
* This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
* (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
* (and then some extra manual tweaking of margins is needed).
*
* @param string $current_screen The current screen object.
*/
private function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
if ( 'plugins' !== $current_screen->id ) {
return;
}
wc_enqueue_js(
"
const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
for(const warningRow of warningRows) {
const pluginName = warningRow.getAttribute('data-plugin');
const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
if(pluginInfoRow.classList.contains('update')) {
warningRow.classList.remove('plugin-update-tr');
warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
}
else {
pluginInfoRow.classList.add('update');
}
}
"
);
}
/**
* Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
* If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
* "All" (so that it's easy to go back to a known state) and "Incompatible with X".
* We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
* the plugins list via the 'all_plugins' filter.
*
* @param array $views An array of view ids => view links.
* @return string[] The actual views array to use.
*/
private function handle_plugins_page_views_list( $views ): array {
// phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
return $views;
}
$feature_id = $_GET['feature_id'] ?? 'all';
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return $views;
}
// phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
$all_items = get_plugins();
$incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
$incompatible_text =
'all' === $feature_id
? __( 'Incompatible with WooCommerce features', 'woocommerce' )
/* translators: %s = name of a WooCommerce feature */
: sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $this->features[ $feature_id ]['name'] );
$incompatible_link = "<a href='plugins.php?plugin_status=incompatible_with_feature&feature_id={$feature_id}' class='current' aria-current='page'>{$incompatible_text} <span class='count'>({$incompatible_plugins_count})</span></a>";
$all_plugins_count = count( $all_items );
$all_text = __( 'All', 'woocommerce' );
$all_link = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";
return array(
'all' => $all_link,
'incompatible_with_feature' => $incompatible_link,
);
}
/**
* Set the feature nonce to be sent from client side.
*
* @param array $settings Component settings.
*
* @return array
*/
public function set_change_feature_enable_nonce( $settings ) {
$settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' );
return $settings;
}
/**
* Changes the feature given it's id, a toggle value and nonce as a query param.
*
* `/wp-admin/post.php?product_block_editor=1&_feature_nonce=1234`, 1 for on
* `/wp-admin/post.php?product_block_editor=0&_feature_nonce=1234`, 0 for off
*/
private function change_feature_enable_from_query_params(): void {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) );
$query_params_to_remove = array( '_feature_nonce' );
foreach ( array_keys( $this->features ) as $feature_id ) {
if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
$value = absint( $_GET[ $feature_id ] );
if ( $is_feature_nonce_invalid ) {
wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) );
return;
}
if ( 1 === $value ) {
$this->change_feature_enable( $feature_id, true );
} elseif ( 0 === $value ) {
$this->change_feature_enable( $feature_id, false );
}
$query_params_to_remove[] = $feature_id;
}
}
if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) {
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) );
}
}
}
Internal/Orders/CouponsController.php 0000644 00000007022 15153704500 0013762 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Exception;
/**
* Class with methods for handling order coupons.
*/
class CouponsController {
/**
* Add order discount via Ajax.
*
* @throws Exception If order or coupon is invalid.
*/
public function add_coupon_discount_via_ajax(): void {
check_ajax_referer( 'order-item', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) ) {
wp_die( -1 );
}
$response = array();
try {
$order = $this->add_coupon_discount( $_POST );
ob_start();
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
$response['html'] = ob_get_clean();
ob_start();
$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-notes.php';
$response['notes_html'] = ob_get_clean();
} catch ( Exception $e ) {
wp_send_json_error( array( 'error' => $e->getMessage() ) );
}
// wp_send_json_success must be outside the try block not to break phpunit tests.
wp_send_json_success( $response );
}
/**
* Add order discount programmatically.
*
* @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
* @return object The retrieved order object.
* @throws \Exception Invalid order or coupon.
*/
public function add_coupon_discount( array $post_variables ): object {
$order_id = isset( $post_variables['order_id'] ) ? absint( $post_variables['order_id'] ) : 0;
$order = wc_get_order( $order_id );
$calculate_tax_args = array(
'country' => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
'state' => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
'city' => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
);
if ( ! $order ) {
throw new Exception( __( 'Invalid order', 'woocommerce' ) );
}
$coupon = ArrayUtil::get_value_or_default( $post_variables, 'coupon' );
if ( StringUtil::is_null_or_whitespace( $coupon ) ) {
throw new Exception( __( 'Invalid coupon', 'woocommerce' ) );
}
// Add user ID and/or email so validation for coupon limits works.
$user_id_arg = isset( $post_variables['user_id'] ) ? absint( $post_variables['user_id'] ) : 0;
$user_email_arg = isset( $post_variables['user_email'] ) ? sanitize_email( wp_unslash( $post_variables['user_email'] ) ) : '';
if ( $user_id_arg ) {
$order->set_customer_id( $user_id_arg );
}
if ( $user_email_arg ) {
$order->set_billing_email( $user_email_arg );
}
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
$code = wc_format_coupon_code( wp_unslash( $coupon ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$result = $order->apply_coupon( $code );
if ( is_wp_error( $result ) ) {
throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
}
// translators: %s coupon code.
$order->add_order_note( esc_html( sprintf( __( 'Coupon applied: "%s".', 'woocommerce' ), $code ) ), 0, true );
return $order;
}
}
Internal/Orders/IppFunctions.php 0000644 00000004243 15153704500 0012713 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
use WC_Order;
/**
* Class with methods for handling order In-Person Payments.
*/
class IppFunctions {
/**
* Returns if order is eligible to accept In-Person Payments.
*
* @param WC_Order $order order that the conditions are checked for.
*
* @return bool true if order is eligible, false otherwise
*/
public static function is_order_in_person_payment_eligible( WC_Order $order ): bool {
$has_status = in_array( $order->get_status(), array( 'pending', 'on-hold', 'processing' ), true );
$has_payment_method = in_array( $order->get_payment_method(), array( 'cod', 'woocommerce_payments', 'none' ), true );
$order_is_not_paid = null === $order->get_date_paid();
$order_is_not_refunded = empty( $order->get_refunds() );
$order_has_no_subscription_products = true;
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
if ( is_object( $product ) && $product->is_type( 'subscription' ) ) {
$order_has_no_subscription_products = false;
break;
}
}
return $has_status && $has_payment_method && $order_is_not_paid && $order_is_not_refunded && $order_has_no_subscription_products;
}
/**
* Returns if store is eligible to accept In-Person Payments.
*
* @return bool true if store is eligible, false otherwise
*/
public static function is_store_in_person_payment_eligible(): bool {
$is_store_usa_based = self::has_store_specified_country_currency( 'US', 'USD' );
$is_store_canada_based = self::has_store_specified_country_currency( 'CA', 'CAD' );
return $is_store_usa_based || $is_store_canada_based;
}
/**
* Checks if the store has specified country location and currency used.
*
* @param string $country country to compare store's country with.
* @param string $currency currency to compare store's currency with.
*
* @return bool true if specified country and currency match the store's ones. false otherwise
*/
public static function has_store_specified_country_currency( string $country, string $currency ): bool {
return ( WC()->countries->get_base_country() === $country && get_woocommerce_currency() === $currency );
}
}
Internal/Orders/MobileMessagingHandler.php 0000644 00000013002 15153704500 0014626 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
use DateTime;
use Exception;
use WC_Order;
use WC_Tracker;
/**
* Prepares formatted mobile deep link navigation link for order mails.
*/
class MobileMessagingHandler {
private const OPEN_ORDER_INTERVAL_DAYS = 30;
/**
* Prepares mobile messaging with a deep link.
*
* @param WC_Order $order order that mobile message is created for.
* @param ?int $blog_id of blog to make a deep link for (will be null if Jetpack is not enabled).
* @param DateTime $now current DateTime.
* @param string $domain URL of the current site.
*
* @return ?string
*/
public static function prepare_mobile_message(
WC_Order $order,
?int $blog_id,
DateTime $now,
string $domain
): ?string {
try {
$last_mobile_used = self::get_closer_mobile_usage_date();
$used_app_in_last_month = null !== $last_mobile_used && $last_mobile_used->diff( $now )->days <= self::OPEN_ORDER_INTERVAL_DAYS;
$has_jetpack = null !== $blog_id;
if ( IppFunctions::is_store_in_person_payment_eligible() && IppFunctions::is_order_in_person_payment_eligible( $order ) ) {
return self::accept_payment_message( $blog_id, $domain );
} else {
if ( $used_app_in_last_month && $has_jetpack ) {
return self::manage_order_message( $blog_id, $order->get_id(), $domain );
} else {
return self::no_app_message( $blog_id, $domain );
}
}
} catch ( Exception $e ) {
return null;
}
}
/**
* Returns the closest date of last usage of any mobile app platform.
*
* @return ?DateTime
*/
private static function get_closer_mobile_usage_date(): ?DateTime {
$mobile_usage = WC_Tracker::get_woocommerce_mobile_usage();
if ( ! $mobile_usage ) {
return null;
}
$last_ios_used = self::get_last_used_or_null( 'ios', $mobile_usage );
$last_android_used = self::get_last_used_or_null( 'android', $mobile_usage );
return max( $last_android_used, $last_ios_used );
}
/**
* Returns last used date of specified mobile app platform.
*
* @param string $platform mobile platform to check.
* @param array $mobile_usage mobile apps usage data.
*
* @return ?DateTime last used date of specified mobile app
*/
private static function get_last_used_or_null(
string $platform, array $mobile_usage
): ?DateTime {
try {
if ( array_key_exists( $platform, $mobile_usage ) ) {
return new DateTime( $mobile_usage[ $platform ]['last_used'] );
} else {
return null;
}
} catch ( Exception $e ) {
return null;
}
}
/**
* Prepares message with a deep link to mobile payment.
*
* @param ?int $blog_id blog id to deep link to.
* @param string $domain URL of the current site.
*
* @return string formatted message
*/
private static function accept_payment_message( ?int $blog_id, $domain ): string {
$deep_link_url = add_query_arg(
array_merge(
array(
'blog_id' => absint( $blog_id ),
),
self::prepare_utm_parameters( 'deeplinks_payments', $blog_id, $domain )
),
'https://woocommerce.com/mobile/payments'
);
return sprintf(
/* translators: 1: opening link tag 2: closing link tag. */
esc_html__(
'%1$sCollect payments easily%2$s from your customers anywhere with our mobile app.',
'woocommerce'
),
'<a href="' . esc_url( $deep_link_url ) . '">',
'</a>'
);
}
/**
* Prepares message with a deep link to manage order details.
*
* @param int $blog_id blog id to deep link to.
* @param int $order_id order id to deep link to.
* @param string $domain URL of the current site.
*
* @return string formatted message
*/
private static function manage_order_message( int $blog_id, int $order_id, string $domain ): string {
$deep_link_url = add_query_arg(
array_merge(
array(
'blog_id' => absint( $blog_id ),
'order_id' => absint( $order_id ),
),
self::prepare_utm_parameters( 'deeplinks_orders_details', $blog_id, $domain )
),
'https://woocommerce.com/mobile/orders/details'
);
return sprintf(
/* translators: 1: opening link tag 2: closing link tag. */
esc_html__(
'%1$sManage the order%2$s with the app.',
'woocommerce'
),
'<a href="' . esc_url( $deep_link_url ) . '">',
'</a>'
);
}
/**
* Prepares message with a deep link to learn more about mobile app.
*
* @param ?int $blog_id blog id used for tracking.
* @param string $domain URL of the current site.
*
* @return string formatted message
*/
private static function no_app_message( ?int $blog_id, string $domain ): string {
$deep_link_url = add_query_arg(
array_merge(
array(
'blog_id' => absint( $blog_id ),
),
self::prepare_utm_parameters( 'deeplinks_promote_app', $blog_id, $domain )
),
'https://woocommerce.com/mobile'
);
return sprintf(
/* translators: 1: opening link tag 2: closing link tag. */
esc_html__(
'Process your orders on the go. %1$sGet the app%2$s.',
'woocommerce'
),
'<a href="' . esc_url( $deep_link_url ) . '">',
'</a>'
);
}
/**
* Prepares array of parameters used by WooCommerce.com for tracking.
*
* @param string $campaign name of the deep link campaign.
* @param int|null $blog_id blog id of the current site.
* @param string $domain URL of the current site.
*
* @return array
*/
private static function prepare_utm_parameters(
string $campaign,
?int $blog_id,
string $domain
): array {
return array(
'utm_campaign' => $campaign,
'utm_medium' => 'email',
'utm_source' => $domain,
'utm_term' => absint( $blog_id ),
);
}
}
Internal/Orders/TaxesController.php 0000644 00000003462 15153704500 0013424 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Orders;
/**
* Class with methods for handling order taxes.
*/
class TaxesController {
/**
* Calculate line taxes via Ajax call.
*/
public function calc_line_taxes_via_ajax(): void {
check_ajax_referer( 'calc-totals', 'security' );
if ( ! current_user_can( 'edit_shop_orders' ) || ! isset( $_POST['order_id'], $_POST['items'] ) ) {
wp_die( -1 );
}
$order = $this->calc_line_taxes( $_POST );
include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
wp_die();
}
/**
* Calculate line taxes programmatically.
*
* @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
* @return object The retrieved order object.
*/
public function calc_line_taxes( array $post_variables ): object {
$order_id = absint( $post_variables['order_id'] );
$calculate_tax_args = array(
'country' => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
'state' => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
'city' => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
);
// Parse the jQuery serialized items.
$items = array();
parse_str( wp_unslash( $post_variables['items'] ), $items );
// Save order items first.
wc_save_order_items( $order_id, $items );
// Grab the order and recalculate taxes.
$order = wc_get_order( $order_id );
$order->calculate_taxes( $calculate_tax_args );
$order->calculate_totals( false );
return $order;
}
}
Internal/ProductAttributesLookup/DataRegenerator.php 0000644 00000046550 15153704500 0016773 0 ustar 00 <?php
/**
* DataRegenerator class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the (re)generation of the product attributes lookup table.
* It schedules the regeneration in small product batches by itself, so it can be used outside the
* regular WooCommerce data regenerations mechanism.
*
* After the regeneration is completed a wp_wc_product_attributes_lookup table will exist with entries for
* all the products that existed when initiate_regeneration was invoked; entries for products created after that
* are supposed to be created/updated by the appropriate data store classes (or by the code that uses
* the data store classes) whenever a product is created/updated.
*
* Additionally, after the regeneration is completed a 'woocommerce_attribute_lookup_enabled' option
* with a value of 'yes' will have been created, thus effectively enabling the table usage
* (with an exception: if the regeneration was manually aborted via deleting the
* 'woocommerce_attribute_lookup_regeneration_in_progress' option) the option will be set to 'no'.
*
* This class also adds two entries to the Status - Tools menu: one for manually regenerating the table contents,
* and another one for enabling or disabling the actual lookup table usage.
*/
class DataRegenerator {
use AccessiblePrivateMethods;
public const PRODUCTS_PER_GENERATION_STEP = 10;
/**
* The data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* DataRegenerator constructor.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 1, 999 );
self::add_action( 'woocommerce_run_product_attribute_lookup_regeneration_callback', array( $this, 'run_regeneration_step_callback' ) );
self::add_action( 'woocommerce_installed', array( $this, 'run_woocommerce_installed_callback' ) );
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
}
/**
* Initialize the regeneration procedure:
* deletes the lookup table and related options if they exist,
* then it creates the table and runs the first step of the regeneration process.
*
* This method is intended ONLY to be used as a callback for a db update in wc-update-functions,
* regeneration triggered from the tools page will use initiate_regeneration_from_tools_page instead.
*/
public function initiate_regeneration() {
$this->data_store->unset_regeneration_aborted_flag();
$this->enable_or_disable_lookup_table_usage( false );
$this->delete_all_attributes_lookup_data();
$products_exist = $this->initialize_table_and_data();
if ( $products_exist ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration( true );
}
}
/**
* Delete all the existing data related to the lookup table, including the table itself.
*/
private function delete_all_attributes_lookup_data() {
global $wpdb;
delete_option( 'woocommerce_attribute_lookup_enabled' );
delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup_processed_count' );
$this->data_store->unset_regeneration_in_progress_flag();
if ( $this->data_store->check_lookup_table_exists() ) {
$wpdb->query( "TRUNCATE TABLE {$this->lookup_table_name}" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
/**
* Create the lookup table and initialize the options that will be temporarily used
* while the regeneration is in progress.
*
* @return bool True if there's any product at all in the database, false otherwise.
*/
private function initialize_table_and_data() {
$database_util = wc_get_container()->get( DatabaseUtil::class );
$database_util->dbdelta( $this->get_table_creation_sql() );
$last_existing_product_id = $this->get_last_existing_product_id();
if ( ! $last_existing_product_id ) {
// No products exist, nothing to (re)generate.
return false;
}
$this->data_store->set_regeneration_in_progress_flag();
update_option( 'woocommerce_attribute_lookup_last_product_id_to_process', $last_existing_product_id );
update_option( 'woocommerce_attribute_lookup_processed_count', 0 );
return true;
}
/**
* Get the highest existing product id.
*
* @return int|null Highest existing product id, or null if no products exist at all.
*/
private function get_last_existing_product_id(): ?int {
$last_existing_product_id_array =
WC()->call_function(
'wc_get_products',
array(
'return' => 'ids',
'limit' => 1,
'orderby' => array(
'ID' => 'DESC',
),
)
);
return empty( $last_existing_product_id_array ) ? null : current( $last_existing_product_id_array );
}
/**
* Action scheduler callback, performs one regeneration step and then
* schedules the next step if necessary.
*/
private function run_regeneration_step_callback() {
if ( ! $this->data_store->regeneration_is_in_progress() ) {
// No regeneration in progress at this point means that the regeneration process
// was manually aborted via deleting the 'woocommerce_attribute_lookup_regeneration_in_progress' option.
$this->data_store->set_regeneration_aborted_flag();
$this->finalize_regeneration( false );
return;
}
$result = $this->do_regeneration_step();
if ( $result ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration( true );
}
}
/**
* Enqueue one regeneration step in action scheduler.
*/
private function enqueue_regeneration_step_run() {
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_regeneration_callback',
array(),
'woocommerce-db-updates'
);
}
/**
* Perform one regeneration step: grabs a chunk of products and creates
* the appropriate entries for them in the lookup table.
*
* @return bool True if more steps need to be run, false otherwise.
*/
private function do_regeneration_step() {
/**
* Filter to alter the count of products that will be processed in each step of the product attributes lookup table regeneration process.
*
* @since 6.3
* @param int $count Default processing step size.
*/
$products_per_generation_step = apply_filters( 'woocommerce_attribute_lookup_regeneration_step_size', self::PRODUCTS_PER_GENERATION_STEP );
$products_already_processed = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
$product_ids = WC()->call_function(
'wc_get_products',
array(
'limit' => $products_per_generation_step,
'offset' => $products_already_processed,
'orderby' => array(
'ID' => 'ASC',
),
'return' => 'ids',
)
);
if ( ! $product_ids ) {
return false;
}
foreach ( $product_ids as $id ) {
$this->data_store->create_data_for_product( $id );
}
$products_already_processed += count( $product_ids );
update_option( 'woocommerce_attribute_lookup_processed_count', $products_already_processed );
$last_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', PHP_INT_MAX );
return end( $product_ids ) < $last_product_id_to_process;
}
/**
* Cleanup/final option setup after the regeneration has been completed.
*
* @param bool $enable_usage Whether the table usage should be enabled or not.
*/
private function finalize_regeneration( bool $enable_usage ) {
delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup_processed_count' );
update_option( 'woocommerce_attribute_lookup_enabled', $enable_usage ? 'yes' : 'no' );
$this->data_store->unset_regeneration_in_progress_flag();
}
/**
* Add a 'Regenerate product attributes lookup table' entry to the Status - Tools page.
*
* @param array $tools_array The tool definitions array that is passed ro the woocommerce_debug_tools filter.
* @return array The tools array with the entry added.
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ) {
if ( ! $this->data_store->check_lookup_table_exists() ) {
return $tools_array;
}
$generation_is_in_progress = $this->data_store->regeneration_is_in_progress();
$generation_was_aborted = $this->data_store->regeneration_was_aborted();
$entry = array(
'name' => __( 'Regenerate the product attributes lookup table', 'woocommerce' ),
'desc' => __( 'This tool will regenerate the product attributes lookup table data from existing product(s) data. This process may take a while.', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function() {
$this->initiate_regeneration_from_tools_page();
return __( 'Product attributes lookup table data is regenerating', 'woocommerce' );
},
'selector' => array(
'description' => __( 'Select a product to regenerate the data for, or leave empty for a full table regeneration:', 'woocommerce' ),
'class' => 'wc-product-search',
'search_action' => 'woocommerce_json_search_products',
'name' => 'regenerate_product_attribute_lookup_data_product_id',
'placeholder' => esc_attr__( 'Search for a product…', 'woocommerce' ),
),
);
if ( $generation_is_in_progress ) {
$entry['button'] = sprintf(
/* translators: %d: How many products have been processed so far. */
__( 'Filling in progress (%d)', 'woocommerce' ),
get_option( 'woocommerce_attribute_lookup_processed_count', 0 )
);
$entry['disabled'] = true;
} else {
$entry['button'] = __( 'Regenerate', 'woocommerce' );
}
$tools_array['regenerate_product_attributes_lookup_table'] = $entry;
if ( $generation_is_in_progress ) {
$entry = array(
'name' => __( 'Abort the product attributes lookup table regeneration', 'woocommerce' ),
'desc' => __( 'This tool will abort the regenerate product attributes lookup table regeneration. After this is done the process can be either started over, or resumed to continue where it stopped.', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function() {
$this->abort_regeneration_from_tools_page();
return __( 'Product attributes lookup table regeneration process has been aborted.', 'woocommerce' );
},
'button' => __( 'Abort', 'woocommerce' ),
);
$tools_array['abort_product_attributes_lookup_table_regeneration'] = $entry;
} elseif ( $generation_was_aborted ) {
$processed_count = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
$entry = array(
'name' => __( 'Resume the product attributes lookup table regeneration', 'woocommerce' ),
'desc' =>
sprintf(
/* translators: %1$s = count of products already processed. */
__( 'This tool will resume the product attributes lookup table regeneration at the point in which it was aborted (%1$s products were already processed).', 'woocommerce' ),
$processed_count
),
'requires_refresh' => true,
'callback' => function() {
$this->resume_regeneration_from_tools_page();
return __( 'Product attributes lookup table regeneration process has been resumed.', 'woocommerce' );
},
'button' => __( 'Resume', 'woocommerce' ),
);
$tools_array['resume_product_attributes_lookup_table_regeneration'] = $entry;
}
return $tools_array;
}
/**
* Callback to initiate the regeneration process from the Status - Tools page.
*
* @throws \Exception The regeneration is already in progress.
*/
private function initiate_regeneration_from_tools_page() {
$this->verify_tool_execution_nonce();
//phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['regenerate_product_attribute_lookup_data_product_id'] ) ) {
$product_id = (int) $_REQUEST['regenerate_product_attribute_lookup_data_product_id'];
$this->check_can_do_lookup_table_regeneration( $product_id );
$this->data_store->create_data_for_product( $product_id );
} else {
$this->check_can_do_lookup_table_regeneration();
$this->initiate_regeneration();
}
//phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Enable or disable the actual lookup table usage.
*
* @param bool $enable True to enable, false to disable.
* @throws \Exception A lookup table regeneration is currently in progress.
*/
private function enable_or_disable_lookup_table_usage( $enable ) {
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't enable or disable the attributes lookup table usage while it's regenerating." );
}
update_option( 'woocommerce_attribute_lookup_enabled', $enable ? 'yes' : 'no' );
}
/**
* Check if everything is good to go to perform a complete or per product lookup table data regeneration
* and throw an exception if not.
*
* @param mixed $product_id The product id to check the regeneration viability for, or null to check if a complete regeneration is possible.
* @throws \Exception Something prevents the regeneration from starting.
*/
private function check_can_do_lookup_table_regeneration( $product_id = null ) {
if ( $product_id && ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: lookup table doesn't exist" );
}
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: regeneration is already in progress" );
}
if ( $product_id && ! wc_get_product( $product_id ) ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: product doesn't exist" );
}
}
/**
* Callback to abort the regeneration process from the Status - Tools page.
*
* @throws \Exception The lookup table doesn't exist, or there's no regeneration process in progress to abort.
*/
private function abort_regeneration_from_tools_page() {
$this->verify_tool_execution_nonce();
if ( ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't abort the product attribute lookup data regeneration process: lookup table doesn't exist" );
}
if ( ! $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't abort the product attribute lookup data regeneration process since it's not currently in progress" );
}
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->cancel_all( 'woocommerce_run_product_attribute_lookup_regeneration_callback' );
$this->data_store->unset_regeneration_in_progress_flag();
$this->data_store->set_regeneration_aborted_flag();
$this->enable_or_disable_lookup_table_usage( false );
// Note that we are NOT deleting the options that track the regeneration progress (processed count, last product id to process).
// This is on purpose so that the regeneration can be resumed where it stopped.
}
/**
* Callback to resume the regeneration process from the Status - Tools page.
*
* @throws \Exception The lookup table doesn't exist, or a regeneration process is already in place.
*/
private function resume_regeneration_from_tools_page() {
$this->verify_tool_execution_nonce();
if ( ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't resume the product attribute lookup data regeneration process: lookup table doesn't exist" );
}
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't resume the product attribute lookup data regeneration process: regeneration is already in progress" );
}
$this->data_store->unset_regeneration_aborted_flag();
$this->data_store->set_regeneration_in_progress_flag();
$this->enqueue_regeneration_step_run();
}
/**
* Verify the validity of the nonce received when executing a tool from the Status - Tools page.
*
* @throws \Exception Missing or invalid nonce received.
*/
private function verify_tool_execution_nonce() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! isset( $_REQUEST['_wpnonce'] ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) === false ) {
throw new \Exception( 'Invalid nonce' );
}
}
/**
* Get the name of the product attributes lookup table.
*
* @return string
*/
public function get_lookup_table_name() {
return $this->lookup_table_name;
}
/**
* Get the SQL statement that creates the product attributes lookup table, including the indices.
*
* @return string
*/
public function get_table_creation_sql() {
global $wpdb;
$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
return "CREATE TABLE {$this->lookup_table_name} (
product_id bigint(20) NOT NULL,
product_or_parent_id bigint(20) NOT NULL,
taxonomy varchar(32) NOT NULL,
term_id bigint(20) NOT NULL,
is_variation_attribute tinyint(1) NOT NULL,
in_stock tinyint(1) NOT NULL,
INDEX is_variation_attribute_term_id (is_variation_attribute, term_id),
PRIMARY KEY ( `product_or_parent_id`, `term_id`, `product_id`, `taxonomy` )
) $collate;";
}
/**
* Create the primary key for the table if it doesn't exist already.
* It also deletes the product_or_parent_id_term_id index if it exists, since it's now redundant.
*
* @return void
*/
public function create_table_primary_index() {
$database_util = wc_get_container()->get( DatabaseUtil::class );
$database_util->create_primary_key( $this->lookup_table_name, array( 'product_or_parent_id', 'term_id', 'product_id', 'taxonomy' ) );
$database_util->drop_table_index( $this->lookup_table_name, 'product_or_parent_id_term_id' );
if ( empty( $database_util->get_index_columns( $this->lookup_table_name ) ) ) {
wc_get_logger()->error( "The creation of the primary key for the {$this->lookup_table_name} table failed" );
}
if ( ! empty( $database_util->get_index_columns( $this->lookup_table_name, 'product_or_parent_id_term_id' ) ) ) {
wc_get_logger()->error( "Dropping the product_or_parent_id_term_id index from the {$this->lookup_table_name} table failed" );
}
}
/**
* Run additional setup needed after a WooCommerce install or update finishes.
*/
private function run_woocommerce_installed_callback() {
// The table must exist at this point (created via dbDelta), but we check just in case.
if ( ! $this->data_store->check_lookup_table_exists() ) {
return;
}
// If a table regeneration is in progress, leave it alone.
if ( $this->data_store->regeneration_is_in_progress() ) {
return;
}
// If the lookup table has data, or if it's empty because there are no products yet, we're good.
// Otherwise (lookup table is empty but products exist) we need to initiate a regeneration if one isn't already in progress.
if ( $this->data_store->lookup_table_has_data() || ! $this->get_last_existing_product_id() ) {
$must_enable = get_option( 'woocommerce_attribute_lookup_enabled' ) !== 'no';
$this->finalize_regeneration( $must_enable );
} else {
$this->initiate_regeneration();
}
}
}
Internal/ProductAttributesLookup/Filterer.php 0000644 00000030014 15153704500 0015464 0 ustar 00 <?php
/**
* Filterer class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
defined( 'ABSPATH' ) || exit;
/**
* Helper class for filtering products using the product attributes lookup table.
*/
class Filterer {
/**
* The product attributes lookup data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The name of the product attributes lookup table.
*
* @var string
*/
private $lookup_table_name;
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
$this->lookup_table_name = $data_store->get_lookup_table_name();
}
/**
* Checks if the product attribute filtering via lookup table feature is enabled.
*
* @return bool
*/
public function filtering_via_lookup_table_is_active() {
return 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );
}
/**
* Adds post clauses for filtering via lookup table.
* This method should be invoked within a 'posts_clauses' filter.
*
* @param array $args Product query clauses as supplied to the 'posts_clauses' filter.
* @param \WP_Query $wp_query Current product query as supplied to the 'posts_clauses' filter.
* @param array $attributes_to_filter_by Attribute filtering data as generated by WC_Query::get_layered_nav_chosen_attributes.
* @return array The updated product query clauses.
*/
public function filter_by_attribute_post_clauses( array $args, \WP_Query $wp_query, array $attributes_to_filter_by ) {
global $wpdb;
if ( ! $wp_query->is_main_query() || ! $this->filtering_via_lookup_table_is_active() ) {
return $args;
}
// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
// (causes the filtering subquery to be executed only once).
$clause_root = " {$wpdb->posts}.ID IN ( SELECT product_or_parent_id FROM (";
if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$in_stock_clause = ' AND in_stock = 1';
} else {
$in_stock_clause = '';
}
$attribute_ids_for_and_filtering = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$term_ids_to_filter_by = array_map( 'absint', $term_ids_to_filter_by );
$term_ids_to_filter_by_list = '(' . join( ',', $term_ids_to_filter_by ) . ')';
$is_and_query = 'and' === $data['query_type'];
$count = count( $term_ids_to_filter_by );
if ( 0 !== $count ) {
if ( $is_and_query && $count > 1 ) {
$attribute_ids_for_and_filtering = array_merge( $attribute_ids_for_and_filtering, $term_ids_to_filter_by );
} else {
$clauses[] = "
{$clause_root}
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE term_id in {$term_ids_to_filter_by_list}
{$in_stock_clause}
)";
}
}
}
if ( ! empty( $attribute_ids_for_and_filtering ) ) {
$count = count( $attribute_ids_for_and_filtering );
$term_ids_to_filter_by_list = '(' . join( ',', $attribute_ids_for_and_filtering ) . ')';
$clauses[] = "
{$clause_root}
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
GROUP BY product_id
HAVING COUNT(product_id)={$count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
)";
}
if ( ! empty( $clauses ) ) {
// "temp" is needed because the extra derived tables require an alias.
$args['where'] .= ' AND (' . join( ' temp ) AND ', $clauses ) . ' temp ))';
} elseif ( ! empty( $attributes_to_filter_by ) ) {
$args['where'] .= ' AND 1=0';
}
return $args;
}
/**
* Count products within certain terms, taking the main WP query into consideration,
* for the WC_Widget_Layered_Nav widget.
*
* This query allows counts to be generated based on the viewed products, not all products.
*
* @param array $term_ids Term IDs.
* @param string $taxonomy Taxonomy.
* @param string $query_type Query Type.
* @return array
*/
public function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type ) {
global $wpdb;
$use_lookup_table = $this->filtering_via_lookup_table_is_active();
$tax_query = \WC_Query::get_main_tax_query();
$meta_query = \WC_Query::get_main_meta_query();
if ( 'or' === $query_type ) {
foreach ( $tax_query as $key => $query ) {
if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
unset( $tax_query[ $key ] );
}
}
}
$meta_query = new \WP_Meta_Query( $meta_query );
$tax_query = new \WP_Tax_Query( $tax_query );
if ( $use_lookup_table ) {
$query = $this->get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids );
} else {
$query = $this->get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids );
}
$query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query );
$query_sql = implode( ' ', $query );
// We have a query - let's see if cached results of this query already exist.
$query_hash = md5( $query_sql );
// Maybe store a transient of the count values.
$cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true );
if ( true === $cache ) {
$cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) );
} else {
$cached_counts = array();
}
if ( ! isset( $cached_counts[ $query_hash ] ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$results = $wpdb->get_results( $query_sql, ARRAY_A );
$counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
$cached_counts[ $query_hash ] = $counts;
if ( true === $cache ) {
set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, DAY_IN_SECONDS );
}
}
return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
}
/**
* Get the query for counting products by terms using the product attributes lookup table.
*
* @param \WP_Tax_Query $tax_query The current main tax query.
* @param \WP_Meta_Query $meta_query The current main meta query.
* @param string $taxonomy The attribute name to get the term counts for.
* @param string $term_ids The term ids to include in the search.
* @return array An array of SQL query parts.
*/
private function get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids ) {
global $wpdb;
$meta_query_sql = $meta_query->get_sql( 'post', $this->lookup_table_name, 'product_or_parent_id' );
$tax_query_sql = $tax_query->get_sql( $this->lookup_table_name, 'product_or_parent_id' );
$hide_out_of_stock = 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' );
$in_stock_clause = $hide_out_of_stock ? ' AND in_stock = 1' : '';
$query['select'] = 'SELECT COUNT(DISTINCT product_or_parent_id) as term_count, term_id as term_count_id';
$query['from'] = "FROM {$this->lookup_table_name}";
$query['join'] = "
{$tax_query_sql['join']} {$meta_query_sql['join']}
INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$this->lookup_table_name}.product_or_parent_id";
$encoded_taxonomy = sanitize_title( $taxonomy );
$term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'
{$tax_query_sql['where']} {$meta_query_sql['where']}
AND {$this->lookup_table_name}.taxonomy='{$encoded_taxonomy}'
AND {$this->lookup_table_name}.term_id IN $term_ids_sql
{$in_stock_clause}";
if ( ! empty( $term_ids ) ) {
$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();
if ( ! empty( $attributes_to_filter_by ) ) {
$and_term_ids = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
if ( 'and' !== $data['query_type'] ) {
continue;
}
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$and_term_ids = array_merge( $and_term_ids, $term_ids_to_filter_by );
}
if ( ! empty( $and_term_ids ) ) {
$terms_count = count( $and_term_ids );
$term_ids_list = '(' . join( ',', $and_term_ids ) . ')';
// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
// (causes the filtering subquery to be executed only once).
$query['where'] .= "
AND product_or_parent_id IN ( SELECT product_or_parent_id FROM (
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_list}
GROUP BY product_id
HAVING COUNT(product_id)={$terms_count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_list}
) temp )";
}
} else {
$query['where'] .= $in_stock_clause;
}
} elseif ( $hide_out_of_stock ) {
$query['where'] .= " AND {$this->lookup_table_name}.in_stock=1";
}
$search_query_sql = \WC_Query::get_main_search_query_sql();
if ( $search_query_sql ) {
$query['where'] .= ' AND ' . $search_query_sql;
}
$query['group_by'] = 'GROUP BY terms.term_id';
$query['group_by'] = "GROUP BY {$this->lookup_table_name}.term_id";
return $query;
}
/**
* Get the query for counting products by terms NOT using the product attributes lookup table.
*
* @param \WP_Tax_Query $tax_query The current main tax query.
* @param \WP_Meta_Query $meta_query The current main meta query.
* @param string $term_ids The term ids to include in the search.
* @return array An array of SQL query parts.
*/
private function get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids ) {
global $wpdb;
$meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
$tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' );
// Generate query.
$query = array();
$query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) AS term_count, terms.term_id AS term_count_id";
$query['from'] = "FROM {$wpdb->posts}";
$query['join'] = "
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id
INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
INNER JOIN {$wpdb->terms} AS terms USING( term_id )
" . $tax_query_sql['join'] . $meta_query_sql['join'];
$term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'
{$tax_query_sql['where']} {$meta_query_sql['where']}
AND terms.term_id IN $term_ids_sql";
$search_query_sql = \WC_Query::get_main_search_query_sql();
if ( $search_query_sql ) {
$query['where'] .= ' AND ' . $search_query_sql;
}
$query['group_by'] = 'GROUP BY terms.term_id';
return $query;
}
/**
* Formats a list of term ids as "(id,id,id)".
*
* @param array $term_ids The list of terms to format.
* @return string The formatted list.
*/
private function get_term_ids_sql( $term_ids ) {
return '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
}
}
Internal/ProductAttributesLookup/LookupDataStore.php 0000644 00000057725 15153704500 0017012 0 ustar 00 <?php
/**
* LookupDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
defined( 'ABSPATH' ) || exit;
/**
* Data store class for the product attributes lookup table.
*/
class LookupDataStore {
use AccessiblePrivateMethods;
/**
* Types of updates to perform depending on the current changest
*/
public const ACTION_NONE = 0;
public const ACTION_INSERT = 1;
public const ACTION_UPDATE_STOCK = 2;
public const ACTION_DELETE = 3;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* LookupDataStore constructor. Makes the feature hidden by default.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
self::add_action( 'woocommerce_run_product_attribute_lookup_update_callback', array( $this, 'run_update_callback' ), 10, 2 );
self::add_filter( 'woocommerce_get_sections_products', array( $this, 'add_advanced_section_to_product_settings' ), 100, 1 );
self::add_action( 'woocommerce_rest_insert_product', array( $this, 'on_product_created_or_updated_via_rest_api' ), 100, 2 );
self::add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_attributes_lookup_table_settings' ), 100, 2 );
}
/**
* Check if the lookup table exists in the database.
*
* @return bool
*/
public function check_lookup_table_exists() {
global $wpdb;
$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->lookup_table_name ) );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $this->lookup_table_name === $wpdb->get_var( $query );
}
/**
* Get the name of the lookup table.
*
* @return string
*/
public function get_lookup_table_name() {
return $this->lookup_table_name;
}
/**
* Insert/update the appropriate lookup table entries for a new or modified product or variation.
* This must be invoked after a product or a variation is created (including untrashing and duplication)
* or modified.
*
* @param int|\WC_Product $product Product object or product id.
* @param null|array $changeset Changes as provided by 'get_changes' method in the product object, null if it's being created.
*/
public function on_product_changed( $product, $changeset = null ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
$action = $this->get_update_action( $changeset );
if ( $action !== self::ACTION_NONE ) {
$this->maybe_schedule_update( $product->get_id(), $action );
}
}
/**
* Schedule an update of the product attributes lookup table for a given product.
* If an update for the same action is already scheduled, nothing is done.
*
* If the 'woocommerce_attribute_lookup_direct_update' option is set to 'yes',
* the update is done directly, without scheduling.
*
* @param int $product_id The product id to schedule the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function maybe_schedule_update( int $product_id, int $action ) {
if ( get_option( 'woocommerce_attribute_lookup_direct_updates' ) === 'yes' ) {
$this->run_update_callback( $product_id, $action );
return;
}
$args = array( $product_id, $action );
$queue = WC()->get_instance_of( \WC_Queue::class );
$already_scheduled = $queue->search(
array(
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
'args' => $args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled ) ) {
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_update_callback',
$args,
'woocommerce-db-updates'
);
}
}
/**
* Perform an update of the lookup table for a specific product.
*
* @param int $product_id The product id to perform the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function run_update_callback( int $product_id, int $action ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
$product = WC()->call_function( 'wc_get_product', $product_id );
if ( ! $product ) {
$action = self::ACTION_DELETE;
}
switch ( $action ) {
case self::ACTION_INSERT:
$this->delete_data_for( $product_id );
$this->create_data_for( $product );
break;
case self::ACTION_UPDATE_STOCK:
$this->update_stock_status_for( $product );
break;
case self::ACTION_DELETE:
$this->delete_data_for( $product_id );
break;
}
}
/**
* Determine the type of action to perform depending on the received changeset.
*
* @param array|null $changeset The changeset received by on_product_changed.
* @return int One of the ACTION_ constants.
*/
private function get_update_action( $changeset ) {
if ( is_null( $changeset ) ) {
// No changeset at all means that the product is new.
return self::ACTION_INSERT;
}
$keys = array_keys( $changeset );
// Order matters:
// - The change with the most precedence is a change in catalog visibility
// (which will result in all data being regenerated or deleted).
// - Then a change in attributes (all data will be regenerated).
// - And finally a change in stock status (existing data will be updated).
// Thus these conditions must be checked in that same order.
if ( in_array( 'catalog_visibility', $keys, true ) ) {
$new_visibility = $changeset['catalog_visibility'];
if ( $new_visibility === 'visible' || $new_visibility === 'catalog' ) {
return self::ACTION_INSERT;
} else {
return self::ACTION_DELETE;
}
}
if ( in_array( 'attributes', $keys, true ) ) {
return self::ACTION_INSERT;
}
if ( array_intersect( $keys, array( 'stock_quantity', 'stock_status', 'manage_stock' ) ) ) {
return self::ACTION_UPDATE_STOCK;
}
return self::ACTION_NONE;
}
/**
* Update the stock status of the lookup table entries for a given product.
*
* @param \WC_Product $product The product to update the entries for.
*/
private function update_stock_status_for( \WC_Product $product ) {
global $wpdb;
$in_stock = $product->is_in_stock();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . $this->lookup_table_name . ' SET in_stock = %d WHERE product_id = %d',
$in_stock ? 1 : 0,
$product->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Delete the lookup table contents related to a given product or variation,
* if it's a variable product it deletes the information for variations too.
* This must be invoked after a product or a variation is trashed or deleted.
*
* @param int|\WC_Product $product Product object or product id.
*/
public function on_product_deleted( $product ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( is_a( $product, \WC_Product::class ) ) {
$product_id = $product->get_id();
} else {
$product_id = $product;
}
$this->maybe_schedule_update( $product_id, self::ACTION_DELETE );
}
/**
* Create the lookup data for a given product, if a variable product is passed
* the information is created for all of its variations.
* This method is intended to be called from the data regenerator.
*
* @param int|WC_Product $product Product object or id.
* @throws \Exception A variation object is passed.
*/
public function create_data_for_product( $product ) {
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
if ( $this->is_variation( $product ) ) {
throw new \Exception( "LookupDataStore::create_data_for_product can't be called for variations." );
}
$this->delete_data_for( $product->get_id() );
$this->create_data_for( $product );
}
/**
* Create lookup table data for a given product.
*
* @param \WC_Product $product The product to create the data for.
*/
private function create_data_for( \WC_Product $product ) {
if ( $this->is_variation( $product ) ) {
$this->create_data_for_variation( $product );
} elseif ( $this->is_variable_product( $product ) ) {
$this->create_data_for_variable_product( $product );
} else {
$this->create_data_for_simple_product( $product );
}
}
/**
* Delete all the lookup table entries for a given product,
* if it's a variable product information for variations is deleted too.
*
* @param int $product_id Simple product id, or main/parent product id for variable products.
*/
private function delete_data_for( int $product_id ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_id = %d OR product_or_parent_id = %d',
$product_id,
$product_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Create lookup table entries for a simple (non variable) product.
* Assumes that no entries exist yet.
*
* @param \WC_Product $product The product to create the entries for.
*/
private function create_data_for_simple_product( \WC_Product $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$has_stock = $product->is_in_stock();
$product_id = $product->get_id();
foreach ( $product_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $product_id, $product_id, $taxonomy, $term_id, false, $has_stock );
}
}
}
/**
* Create lookup table entries for a variable product.
* Assumes that no entries exist yet.
*
* @param \WC_Product_Variable $product The product to create the entries for.
*/
private function create_data_for_variable_product( \WC_Product_Variable $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$non_variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return ! $item['used_for_variations'];
}
);
$main_product_has_stock = $product->is_in_stock();
$main_product_id = $product->get_id();
foreach ( $non_variation_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $main_product_id, $main_product_id, $taxonomy, $term_id, false, $main_product_has_stock );
}
}
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
$variations = $this->get_variations_of( $product );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
foreach ( $variations as $variation ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product_id, $data['term_ids'], $term_ids_by_slug_cache );
}
}
}
/**
* Create all the necessary lookup data for a given variation.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
*/
private function create_data_for_variation( \WC_Product_Variation $variation ) {
$main_product = WC()->call_function( 'wc_get_product', $variation->get_parent_id() );
$product_attributes_data = $this->get_attribute_taxonomies( $main_product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product->get_id(), $data['term_ids'], $term_ids_by_slug_cache );
}
}
/**
* Create lookup table entries for a given variation, corresponding to a given taxonomy and a set of term ids.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
* @param string $taxonomy The taxonomy to create the entries for.
* @param int $main_product_id The parent product id.
* @param array $term_ids The term ids to create entries for.
* @param array $term_ids_by_slug_cache A dictionary of term ids by term slug, as returned by 'get_term_ids_by_slug_cache'.
*/
private function insert_lookup_table_data_for_variation( \WC_Product_Variation $variation, string $taxonomy, int $main_product_id, array $term_ids, array $term_ids_by_slug_cache ) {
$variation_id = $variation->get_id();
$variation_has_stock = $variation->is_in_stock();
$variation_definition_term_id = $this->get_variation_definition_term_id( $variation, $taxonomy, $term_ids_by_slug_cache );
if ( $variation_definition_term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $variation_definition_term_id, true, $variation_has_stock );
} else {
$term_ids_for_taxonomy = $term_ids;
foreach ( $term_ids_for_taxonomy as $term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $term_id, true, $variation_has_stock );
}
}
}
/**
* Get a cache of term ids by slug for a set of taxonomies, with this format:
*
* [
* 'taxonomy' => [
* 'slug_1' => id_1,
* 'slug_2' => id_2,
* ...
* ], ...
* ]
*
* @param array $taxonomies List of taxonomies to build the cache for.
* @return array A dictionary of taxonomies => dictionary of term slug => term id.
*/
private function get_term_ids_by_slug_cache( $taxonomies ) {
$result = array();
foreach ( $taxonomies as $taxonomy ) {
$terms = WC()->call_function(
'get_terms',
array(
'taxonomy' => wc_sanitize_taxonomy_name( $taxonomy ),
'hide_empty' => false,
'fields' => 'id=>slug',
)
);
$result[ $taxonomy ] = array_flip( $terms );
}
return $result;
}
/**
* Get the id of the term that defines a variation for a given taxonomy,
* or null if there's no such defining id (for variations having "Any <taxonomy>" as the definition)
*
* @param \WC_Product_Variation $variation The variation to get the defining term id for.
* @param string $taxonomy The taxonomy to get the defining term id for.
* @param array $term_ids_by_slug_cache A term ids by slug as generated by get_term_ids_by_slug_cache.
* @return int|null The term id, or null if there's no defining id for that taxonomy in that variation.
*/
private function get_variation_definition_term_id( \WC_Product_Variation $variation, string $taxonomy, array $term_ids_by_slug_cache ) {
$variation_attributes = $variation->get_attributes();
$term_slug = ArrayUtil::get_value_or_default( $variation_attributes, $taxonomy );
if ( $term_slug ) {
return $term_ids_by_slug_cache[ $taxonomy ][ $term_slug ];
} else {
return null;
}
}
/**
* Get the variations of a given variable product.
*
* @param \WC_Product_Variable $product The product to get the variations for.
* @return array An array of WC_Product_Variation objects.
*/
private function get_variations_of( \WC_Product_Variable $product ) {
$variation_ids = $product->get_children();
return array_map(
function( $id ) {
return WC()->call_function( 'wc_get_product', $id );
},
$variation_ids
);
}
/**
* Check if a given product is a variable product.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variable product, false otherwise.
*/
private function is_variable_product( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variable::class );
}
/**
* Check if a given product is a variation.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variation, false otherwise.
*/
private function is_variation( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variation::class );
}
/**
* Return the list of taxonomies used for variations on a product together with
* the associated term ids, with the following format:
*
* [
* 'taxonomy_name' =>
* [
* 'term_ids' => [id, id, ...],
* 'used_for_variations' => true|false
* ], ...
* ]
*
* @param \WC_Product $product The product to get the attribute taxonomies for.
* @return array Information about the attribute taxonomies of the product.
*/
private function get_attribute_taxonomies( \WC_Product $product ) {
$product_attributes = $product->get_attributes();
$result = array();
foreach ( $product_attributes as $taxonomy_name => $attribute_data ) {
if ( ! $attribute_data->get_id() ) {
// Custom product attribute, not suitable for attribute-based filtering.
continue;
}
$result[ $taxonomy_name ] = array(
'term_ids' => $attribute_data->get_options(),
'used_for_variations' => $attribute_data->get_variation(),
);
}
return $result;
}
/**
* Insert one entry in the lookup table.
*
* @param int $product_id The product id.
* @param int $product_or_parent_id The product id for non-variable products, the main/parent product id for variations.
* @param string $taxonomy Taxonomy name.
* @param int $term_id Term id.
* @param bool $is_variation_attribute True if the taxonomy corresponds to an attribute used to define variations.
* @param bool $has_stock True if the product is in stock.
*/
private function insert_lookup_table_data( int $product_id, int $product_or_parent_id, string $taxonomy, int $term_id, bool $is_variation_attribute, bool $has_stock ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'INSERT INTO ' . $this->lookup_table_name . ' (
product_id,
product_or_parent_id,
taxonomy,
term_id,
is_variation_attribute,
in_stock)
VALUES
( %d, %d, %s, %d, %d, %d )',
$product_id,
$product_or_parent_id,
$taxonomy,
$term_id,
$is_variation_attribute ? 1 : 0,
$has_stock ? 1 : 0
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Handler for the woocommerce_rest_insert_product hook.
* Needed to update the lookup table when the REST API batch insert/update endpoints are used.
*
* @param \WP_Post $product The post representing the created or updated product.
* @param \WP_REST_Request $request The REST request that caused the hook to be fired.
* @return void
*/
private function on_product_created_or_updated_via_rest_api( \WP_Post $product, \WP_REST_Request $request ): void {
if ( StringUtil::ends_with( $request->get_route(), '/batch' ) ) {
$this->on_product_changed( $product->ID );
}
}
/**
* Tells if a lookup table regeneration is currently in progress.
*
* @return bool True if a lookup table regeneration is already in progress.
*/
public function regeneration_is_in_progress() {
return get_option( 'woocommerce_attribute_lookup_regeneration_in_progress', null ) === 'yes';
}
/**
* Set a permanent flag (via option) indicating that the lookup table regeneration is in process.
*/
public function set_regeneration_in_progress_flag() {
update_option( 'woocommerce_attribute_lookup_regeneration_in_progress', 'yes' );
}
/**
* Remove the flag indicating that the lookup table regeneration is in process.
*/
public function unset_regeneration_in_progress_flag() {
delete_option( 'woocommerce_attribute_lookup_regeneration_in_progress' );
}
/**
* Set a flag indicating that the last lookup table regeneration process started was aborted.
*/
public function set_regeneration_aborted_flag() {
update_option( 'woocommerce_attribute_lookup_regeneration_aborted', 'yes' );
}
/**
* Remove the flag indicating that the last lookup table regeneration process started was aborted.
*/
public function unset_regeneration_aborted_flag() {
delete_option( 'woocommerce_attribute_lookup_regeneration_aborted' );
}
/**
* Tells if the last lookup table regeneration process started was aborted
* (via deleting the 'woocommerce_attribute_lookup_regeneration_in_progress' option).
*
* @return bool True if the last lookup table regeneration process was aborted.
*/
public function regeneration_was_aborted(): bool {
return get_option( 'woocommerce_attribute_lookup_regeneration_aborted' ) === 'yes';
}
/**
* Check if the lookup table contains any entry at all.
*
* @return bool True if the table contains entries, false if the table is empty.
*/
public function lookup_table_has_data(): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return ( (int) $wpdb->get_var( "SELECT EXISTS (SELECT 1 FROM {$this->lookup_table_name})" ) ) !== 0;
}
/**
* Handler for 'woocommerce_get_sections_products', adds the "Advanced" section to the product settings.
*
* @param array $products Original array of settings sections.
* @return array New array of settings sections.
*/
private function add_advanced_section_to_product_settings( array $products ): array {
if ( $this->check_lookup_table_exists() ) {
$products['advanced'] = __( 'Advanced', 'woocommerce' );
}
return $products;
}
/**
* Handler for 'woocommerce_get_settings_products', adds the settings related to the product attributes lookup table.
*
* @param array $settings Original settings configuration array.
* @param string $section_id Settings section identifier.
* @return array New settings configuration array.
*/
private function add_product_attributes_lookup_table_settings( array $settings, string $section_id ): array {
if ( $section_id === 'advanced' && $this->check_lookup_table_exists() ) {
$title_item = array(
'title' => __( 'Product attributes lookup table', 'woocommerce' ),
'type' => 'title',
);
$regeneration_is_in_progress = $this->regeneration_is_in_progress();
if ( $regeneration_is_in_progress ) {
$title_item['desc'] = __( 'These settings are not available while the lookup table regeneration is in progress.', 'woocommerce' );
}
$settings[] = $title_item;
if ( ! $regeneration_is_in_progress ) {
$regeneration_aborted_warning =
$this->regeneration_was_aborted() ?
sprintf(
"<p><strong style='color: #E00000'>%s</strong></p><p>%s</p>",
__( 'WARNING: The product attributes lookup table regeneration process was aborted.', 'woocommerce' ),
__( 'This means that the table is probably in an inconsistent state. It\'s recommended to run a new regeneration process or to resume the aborted process (Status - Tools - Regenerate the product attributes lookup table/Resume the product attributes lookup table regeneration) before enabling the table usage.', 'woocommerce' )
) : null;
$settings[] = array(
'title' => __( 'Enable table usage', 'woocommerce' ),
'desc' => __( 'Use the product attributes lookup table for catalog filtering.', 'woocommerce' ),
'desc_tip' => $regeneration_aborted_warning,
'id' => 'woocommerce_attribute_lookup_enabled',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
$settings[] = array(
'title' => __( 'Direct updates', 'woocommerce' ),
'desc' => __( 'Update the table directly upon product changes, instead of scheduling a deferred update.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup_direct_updates',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
}
$settings[] = array( 'type' => 'sectionend' );
}
return $settings;
}
}
Internal/ProductDownloads/ApprovedDirectories/Admin/SyncUI.php 0000644 00000010246 15153704500 0020506 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
use Automattic\WooCommerce\Internal\Utilities\Users;
/**
* Adds tools to the Status > Tools page that can be used to (re-)initiate or stop a synchronization process
* for Approved Download Directories.
*/
class SyncUI {
/**
* The active register of approved directories.
*
* @var Register
*/
private $register;
/**
* Sets up UI controls for product download URLs.
*
* @internal
*
* @param Register $register Register of approved directories.
*/
final public function init( Register $register ) {
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system,
* except in the case where the current user is not a site administrator, no hooks will
* be initialized.
*/
final public function init_hooks() {
if ( ! Users::is_site_administrator() ) {
return;
}
add_filter( 'woocommerce_debug_tools', array( $this, 'add_tools' ) );
}
/**
* Adds Approved Directory list-related entries to the tools page.
*
* @param array $tools Admin tool definitions.
*
* @return array
*/
public function add_tools( array $tools ): array {
$sync = wc_get_container()->get( Synchronize::class );
if ( ! $sync->in_progress() ) {
// Provide tools to trigger a fresh scan (migration) and to clear the Approved Directories list.
$tools['approved_directories_sync'] = array(
'name' => __( 'Synchronize approved download directories', 'woocommerce' ),
'desc' => __( 'Updates the list of Approved Product Download Directories. Note that triggering this tool does not impact whether the Approved Download Directories list is enabled or not.', 'woocommerce' ),
'button' => __( 'Update', 'woocommerce' ),
'callback' => array( $this, 'trigger_sync' ),
'requires_refresh' => true,
);
$tools['approved_directories_clear'] = array(
'name' => __( 'Empty the approved download directories list', 'woocommerce' ),
'desc' => __( 'Removes all existing entries from the Approved Product Download Directories list.', 'woocommerce' ),
'button' => __( 'Clear', 'woocommerce' ),
'callback' => array( $this, 'clear_existing_entries' ),
'requires_refresh' => true,
);
} else {
// Or if a scan (migration) is already in progress, offer a means of cancelling it.
$tools['cancel_directories_scan'] = array(
'name' => __( 'Cancel synchronization of approved directories', 'woocommerce' ),
'desc' => sprintf(
/* translators: %d is an integer between 0-100 representing the percentage complete of the current scan. */
__( 'The Approved Product Download Directories list is currently being synchronized with the product catalog (%d%% complete). If you need to, you can cancel it.', 'woocommerce' ),
$sync->get_progress()
),
'button' => __( 'Cancel', 'woocommerce' ),
'callback' => array( $this, 'cancel_sync' ),
);
}
return $tools;
}
/**
* Triggers a new migration.
*/
public function trigger_sync() {
$this->security_check();
wc_get_container()->get( Synchronize::class )->start();
}
/**
* Clears all existing rules from the Approved Directories list.
*/
public function clear_existing_entries() {
$this->security_check();
$this->register->delete_all();
}
/**
* If a migration is in progress, this will attempt to cancel it.
*/
public function cancel_sync() {
$this->security_check();
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan has been cancelled.', 'woocommerce' ) );
wc_get_container()->get( Synchronize::class )->stop();
}
/**
* Makes sure the user has appropriate permissions and that we have a valid nonce.
*/
private function security_check() {
if ( ! Users::is_site_administrator() ) {
wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
}
}
}
Internal/ProductDownloads/ApprovedDirectories/Admin/Table.php 0000644 00000023766 15153704500 0020376 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\StoredUrl;
use WP_List_Table;
use WP_Screen;
/**
* Admin list table used to render our current list of approved directories.
*/
class Table extends WP_List_Table {
/**
* Initialize the webhook table list.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'url',
'plural' => 'urls',
'ajax' => false,
)
);
add_filter( 'manage_woocommerce_page_wc-settings_columns', array( $this, 'get_columns' ) );
$this->items_per_page();
set_screen_options();
}
/**
* Sets up an items-per-page control.
*/
private function items_per_page() {
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => 'edit_approved_directories_per_page',
)
);
add_filter( 'set_screen_option_edit_approved_directories_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
}
/**
* Saves the items-per-page setting.
*
* @param mixed $default The default value.
* @param string $option The option being configured.
* @param int $value The submitted option value.
*
* @return mixed
*/
public function set_items_per_page( $default, string $option, int $value ) {
return 'edit_approved_directories_per_page' === $option ? absint( $value ) : $default;
}
/**
* No items found text.
*/
public function no_items() {
esc_html_e( 'No approved directory URLs found.', 'woocommerce' );
}
/**
* Displays the list of views available on this table.
*/
public function render_views() {
$register = wc_get_container()->get( Register::class );
$enabled_count = $register->count( true );
$disabled_count = $register->count( false );
$all_count = $enabled_count + $disabled_count;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$selected_view = isset( $_REQUEST['view'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['view'] ) ) : 'all';
$all_url = esc_url( add_query_arg( 'view', 'all', $this->get_base_url() ) );
$all_class = 'all' === $selected_view ? 'class="current"' : '';
$all_text = sprintf(
/* translators: %s is the count of approved directory list entries. */
_nx(
'All <span class="count">(%s)</span>',
'All <span class="count">(%s)</span>',
$all_count,
'Approved product download directory views',
'woocommerce'
),
$all_count
);
$enabled_url = esc_url( add_query_arg( 'view', 'enabled', $this->get_base_url() ) );
$enabled_class = 'enabled' === $selected_view ? 'class="current"' : '';
$enabled_text = sprintf(
/* translators: %s is the count of enabled approved directory list entries. */
_nx(
'Enabled <span class="count">(%s)</span>',
'Enabled <span class="count">(%s)</span>',
$enabled_count,
'Approved product download directory views',
'woocommerce'
),
$enabled_count
);
$disabled_url = esc_url( add_query_arg( 'view', 'disabled', $this->get_base_url() ) );
$disabled_class = 'disabled' === $selected_view ? 'class="current"' : '';
$disabled_text = sprintf(
/* translators: %s is the count of disabled directory list entries. */
_nx(
'Disabled <span class="count">(%s)</span>',
'Disabled <span class="count">(%s)</span>',
$disabled_count,
'Approved product download directory views',
'woocommerce'
),
$disabled_count
);
$views = array(
'all' => "<a href='{$all_url}' {$all_class}>{$all_text}</a>",
'enabled' => "<a href='{$enabled_url}' {$enabled_class}>{$enabled_text}</a>",
'disabled' => "<a href='{$disabled_url}' {$disabled_class}>{$disabled_text}</a>",
);
$this->screen->render_screen_reader_content( 'heading_views' );
echo '<ul class="subsubsub list-table-filters">';
foreach ( $views as $slug => $view ) {
$views[ $slug ] = "<li class='{$slug}'>{$view}";
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo implode( ' | </li>', $views ) . "</li>\n";
echo '</ul>';
}
/**
* Get list columns.
*
* @return array
*/
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
'title' => _x( 'URL', 'Approved product download directories', 'woocommerce' ),
'enabled' => _x( 'Enabled', 'Approved product download directories', 'woocommerce' ),
);
}
/**
* Checklist column, used for selecting items for processing by a bulk action.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="%1$s[]" value="%2$s" />', esc_attr( $this->_args['singular'] ), esc_attr( $item->get_id() ) );
}
/**
* URL column.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_title( $item ) {
$id = (int) $item->get_id();
$url = esc_html( $item->get_url() );
$enabled = $item->is_enabled();
$edit_url = esc_url( $this->get_action_url( 'edit', $id ) );
$enable_disable_url = esc_url( $enabled ? $this->get_action_url( 'disable', $id ) : $this->get_action_url( 'enable', $id ) );
$enable_disable_text = esc_html( $enabled ? __( 'Disable', 'woocommerce' ) : __( 'Enable', 'woocommerce' ) );
$delete_url = esc_url( $this->get_action_url( 'delete', $id ) );
$edit_link = "<a href='{$edit_url}'>" . esc_html_x( 'Edit', 'Product downloads list', 'woocommerce' ) . '</a>';
$enable_disable_link = "<a href='{$enable_disable_url}'>{$enable_disable_text}</a>";
$delete_link = "<a href='{$delete_url}' class='submitdelete wc-confirm-delete'>" . esc_html_x( 'Delete permanently', 'Product downloads list', 'woocommerce' ) . '</a>';
$url_link = "<a href='{$edit_url}'>{$url}</a>";
return "
<strong>{$url_link}</strong>
<div class='row-actions'>
<span class='id'>ID: {$id}</span> |
<span class='edit'>{$edit_link}</span> |
<span class='enable-disable'>{$enable_disable_link}</span> |
<span class='delete'><a class='submitdelete'>{$delete_link}</a></span>
</div>
";
}
/**
* Rule-is-enabled column.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_enabled( StoredUrl $item ): string {
return $item->is_enabled()
? '<mark class="yes" title="' . esc_html__( 'Enabled', 'woocommerce' ) . '"><span class="dashicons dashicons-yes"></span></mark>'
: '<mark class="no" title="' . esc_html__( 'Disabled', 'woocommerce' ) . '">–</mark>';
}
/**
* Get bulk actions.
*
* @return array
*/
protected function get_bulk_actions() {
return array(
'enable' => __( 'Enable rule', 'woocommerce' ),
'disable' => __( 'Disable rule', 'woocommerce' ),
'delete' => __( 'Delete permanently', 'woocommerce' ),
);
}
/**
* Builds an action URL (ie, to edit or delete a row).
*
* @param string $action The action to be created.
* @param int $id The ID that is the subject of the action.
* @param string $nonce_action Action used to add a nonce to the URL.
*
* @return string
*/
public function get_action_url( string $action, int $id, string $nonce_action = 'modify_approved_directories' ): string {
return add_query_arg(
array(
'check' => wp_create_nonce( $nonce_action ),
'action' => $action,
'url' => $id,
),
$this->get_base_url()
);
}
/**
* Supplies the 'base' admin URL for this admin table.
*
* @return string
*/
public function get_base_url(): string {
return add_query_arg(
array(
'page' => 'wc-settings',
'tab' => 'products',
'section' => 'download_urls',
),
admin_url( 'admin.php' )
);
}
/**
* Generate the table navigation above or below the table.
* Included to remove extra nonce input.
*
* @param string $which The location of the extra table nav markup: 'top' or 'bottom'.
*/
protected function display_tablenav( $which ) {
$directories = wc_get_container()->get( Register::class );
echo '<div class="tablenav ' . esc_attr( $which ) . '">';
if ( $this->has_items() ) {
echo '<div class="alignleft actions bulkactions">';
$this->bulk_actions( $which );
if ( $directories->count( false ) > 0 ) {
echo '<a href="' . esc_url( $this->get_action_url( 'enable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Enable All', 'Approved product download directories', 'woocommerce' ) . '</a> ';
}
if ( $directories->count( true ) > 0 ) {
echo '<a href="' . esc_url( $this->get_action_url( 'disable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Disable All', 'Approved product download directories', 'woocommerce' ) . '</a>';
}
echo '</div>';
}
$this->pagination( $which );
echo '<br class="clear" />';
echo '</div>';
}
/**
* Prepare table list items.
*/
public function prepare_items() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.NonceVerification.Missing
$current_page = $this->get_pagenum();
$per_page = $this->get_items_per_page( 'edit_approved_directories_per_page' );
$search = sanitize_text_field( wp_unslash( $_REQUEST['s'] ?? '' ) );
switch ( $_REQUEST['view'] ?? '' ) {
case 'enabled':
$enabled = true;
break;
case 'disabled':
$enabled = false;
break;
default:
$enabled = null;
break;
}
// phpcs:enable
$approved_directories = wc_get_container()->get( Register::class )->list(
array(
'page' => $current_page,
'per_page' => $per_page,
'search' => $search,
'enabled' => $enabled,
)
);
$this->items = $approved_directories['approved_directories'];
// Set the pagination.
$this->set_pagination_args(
array(
'total_items' => $approved_directories['total_urls'],
'total_pages' => $approved_directories['total_pages'],
'per_page' => $per_page,
)
);
}
}
Internal/ProductDownloads/ApprovedDirectories/Admin/UI.php 0000644 00000035040 15153704500 0017650 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Exception;
use WC_Admin_Settings;
/**
* Manages user interactions for product download URL safety.
*/
class UI {
/**
* The active register of approved directories.
*
* @var Register
*/
private $register;
/**
* The WP_List_Table instance used to display approved directories.
*
* @var Table
*/
private $table;
/**
* Sets up UI controls for product download URLs.
*
* @internal
*
* @param Register $register Register of approved directories.
*/
final public function init( Register $register ) {
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system,
* except in the case where the current user is not a site administrator, no hooks will
* be initialized.
*/
final public function init_hooks() {
if ( ! Users::is_site_administrator() ) {
return;
}
add_filter( 'woocommerce_get_sections_products', array( $this, 'add_section' ) );
add_action( 'load-woocommerce_page_wc-settings', array( $this, 'setup' ) );
add_action( 'woocommerce_settings_products', array( $this, 'render' ) );
}
/**
* Injects our new settings section (when approved directory rules are disabled, it will not show).
*
* @param array $sections Other admin settings sections.
*
* @return array
*/
public function add_section( array $sections ): array {
$sections['download_urls'] = __( 'Approved download directories', 'woocommerce' );
return $sections;
}
/**
* Sets up the table, renders any notices and processes actions as needed.
*/
public function setup() {
if ( ! $this->is_download_urls_screen() ) {
return;
}
$this->table = new Table();
$this->admin_notices();
$this->handle_search();
$this->process_actions();
}
/**
* Renders the UI.
*/
public function render() {
if ( null === $this->table || ! $this->is_download_urls_screen() ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['action'] ) && 'edit' === $_REQUEST['action'] && isset( $_REQUEST['url'] ) ) {
$this->edit_screen( (int) $_REQUEST['url'] );
return;
}
// phpcs:enable
// Show list table.
$this->table->prepare_items();
wp_nonce_field( 'modify_approved_directories', 'check' );
$this->display_title();
$this->table->render_views();
$this->table->search_box( _x( 'Search', 'Approved Directory URLs', 'woocommerce' ), 'download_url_search' );
$this->table->display();
}
/**
* Indicates if we are currently on the download URLs admin screen.
*
* @return bool
*/
private function is_download_urls_screen(): bool {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
return isset( $_GET['tab'] )
&& 'products' === $_GET['tab']
&& isset( $_GET['section'] )
&& 'download_urls' === $_GET['section'];
// phpcs:enable
}
/**
* Process bulk and single-row actions.
*/
private function process_actions() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$ids = isset( $_REQUEST['url'] ) ? array_map( 'absint', (array) $_REQUEST['url'] ) : array();
if ( empty( $ids ) || empty( $_REQUEST['action'] ) ) {
return;
}
$this->security_check();
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
switch ( $action ) {
case 'edit':
$this->process_edits( current( $ids ) );
break;
case 'delete':
case 'enable':
case 'disable':
$this->process_bulk_actions( $ids, $action );
break;
case 'enable-all':
case 'disable-all':
$this->process_all_actions( $action );
break;
case 'turn-on':
case 'turn-off':
$this->process_on_off( $action );
break;
}
// phpcs:enable
}
/**
* Support pagination across search results.
*
* In the context of the WC settings screen, form data is submitted by the post method: that poses
* a problem for the default WP_List_Table pagination logic which expects the search value to live
* as part of the URL query. This method is a simple shim to bridge the resulting gap.
*/
private function handle_search() {
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// If a search value has not been POSTed, or if it was POSTed but is already equal to the
// same value in the URL query, we need take no further action.
if ( empty( $_POST['s'] ) || sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) ) === $_POST['s'] ) {
return;
}
wp_safe_redirect(
add_query_arg(
array(
'paged' => absint( $_GET['paged'] ?? 1 ),
's' => sanitize_text_field( wp_unslash( $_POST['s'] ) ),
),
$this->table->get_base_url()
)
);
// phpcs:enable
exit;
}
/**
* Handles updating or adding a new URL to the list of approved directories.
*
* @param int $url_id The ID of the rule to be edited/created. Zero if we are creating a new entry.
*/
private function process_edits( int $url_id ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$url = esc_url_raw( wp_unslash( $_POST['approved_directory_url'] ?? '' ) );
$enabled = (bool) sanitize_text_field( wp_unslash( $_POST['approved_directory_enabled'] ?? '' ) );
if ( empty( $url ) ) {
return;
}
$redirect_url = add_query_arg( 'id', $url_id, $this->table->get_action_url( 'edit', $url_id ) );
try {
$upserted = 0 === $url_id
? $this->register->add_approved_directory( $url, $enabled )
: $this->register->update_approved_directory( $url_id, $url, $enabled );
if ( is_integer( $upserted ) ) {
$redirect_url = add_query_arg( 'url', $upserted, $redirect_url );
}
$redirect_url = add_query_arg( 'edit-status', 0 === $url_id ? 'added' : 'updated', $redirect_url );
} catch ( Exception $e ) {
$redirect_url = add_query_arg(
array(
'edit-status' => 'failure',
'submitted-url' => $url,
),
$redirect_url
);
}
wp_safe_redirect( $redirect_url );
exit;
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Processes actions that can be applied in bulk (requests to delete, enable
* or disable).
*
* @param int[] $ids The ID(s) to be updates.
* @param string $action The action to be applied.
*/
private function process_bulk_actions( array $ids, string $action ) {
$deletes = 0;
$enabled = 0;
$disabled = 0;
$register = wc_get_container()->get( Register::class );
foreach ( $ids as $id ) {
if ( 'delete' === $action && $register->delete_by_id( $id ) ) {
$deletes++;
} elseif ( 'enable' === $action && $register->enable_by_id( $id ) ) {
$enabled++;
} elseif ( 'disable' === $action && $register->disable_by_id( $id ) ) {
$disabled ++;
}
}
$fails = count( $ids ) - $deletes - $enabled - $disabled;
$redirect = $this->table->get_base_url();
if ( $deletes ) {
$redirect = add_query_arg( 'deleted-ids', $deletes, $redirect );
} elseif ( $enabled ) {
$redirect = add_query_arg( 'enabled-ids', $enabled, $redirect );
} elseif ( $disabled ) {
$redirect = add_query_arg( 'disabled-ids', $disabled, $redirect );
}
if ( $fails ) {
$redirect = add_query_arg( 'bulk-fails', $fails, $redirect );
}
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles the enable/disable-all actions.
*
* @param string $action The action to be applied.
*/
private function process_all_actions( string $action ) {
$register = wc_get_container()->get( Register::class );
$redirect = $this->table->get_base_url();
switch ( $action ) {
case 'enable-all':
$redirect = add_query_arg( 'enabled-all', (int) $register->enable_all(), $redirect );
break;
case 'disable-all':
$redirect = add_query_arg( 'disabled-all', (int) $register->disable_all(), $redirect );
break;
}
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles turning on/off the entire approved download directory system (vs enabling
* and disabling of individual rules).
*
* @param string $action Whether the feature should be turned on or off.
*/
private function process_on_off( string $action ) {
switch ( $action ) {
case 'turn-on':
$this->register->set_mode( Register::MODE_ENABLED );
break;
case 'turn-off':
$this->register->set_mode( Register::MODE_DISABLED );
break;
}
}
/**
* Displays the screen title, etc.
*/
private function display_title() {
$turn_on_off = $this->register->get_mode() === Register::MODE_ENABLED
? '<a href="' . esc_url( $this->table->get_action_url( 'turn-off', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Stop Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>'
: '<a href="' . esc_url( $this->table->get_action_url( 'turn-on', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Start Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>';
?>
<h2 class='wc-table-list-header'>
<?php esc_html_e( 'Approved Download Directories', 'woocommerce' ); ?>
<a href='<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>' class='page-title-action'><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
<?php echo $turn_on_off; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</h2>
<?php
}
/**
* Renders the editor screen for approved directory URLs.
*
* @param int $url_id The ID of the rule to be edited (may be zero for new rules).
*/
private function edit_screen( int $url_id ) {
$this->security_check();
$existing = $this->register->get_by_id( $url_id );
if ( 0 !== $url_id && ! $existing ) {
WC_Admin_Settings::add_error( _x( 'The provided ID was invalid.', 'Approved product download directories', 'woocommerce' ) );
WC_Admin_Settings::show_messages();
return;
}
$title = $existing
? __( 'Edit Approved Directory', 'woocommerce' )
: __( 'Add New Approved Directory', 'woocommerce' );
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$submitted = sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) );
$existing_url = $existing ? $existing->get_url() : '';
$enabled = $existing ? $existing->is_enabled() : true;
// phpcs:enable
?>
<h2 class='wc-table-list-header'>
<?php echo esc_html( $title ); ?>
<?php if ( $existing ) : ?>
<a href="<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
<?php endif; ?>
<a href="<?php echo esc_url( $this->table->get_base_url() ); ?> " class="page-title-action"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></a>
</h2>
<table class='form-table'>
<tbody>
<tr valign='top'>
<th scope='row' class='titledesc'>
<label for='approved_directory_url'> <?php echo esc_html_x( 'Directory URL', 'Approved product download directories', 'woocommerce' ); ?> </label>
</th>
<td class='forminp'>
<input name='approved_directory_url' id='approved_directory_url' type='text' class='input-text regular-input' value='<?php echo esc_attr( empty( $submitted ) ? $existing_url : $submitted ); ?>'>
</td>
</tr>
<tr valign='top'>
<th scope='row' class='titledesc'>
<label for='approved_directory_enabled'> <?php echo esc_html_x( 'Enabled', 'Approved product download directories', 'woocommerce' ); ?> </label>
</th>
<td class='forminp'>
<input name='approved_directory_enabled' id='approved_directory_enabled' type='checkbox' value='1' <?php checked( true, $enabled ); ?>'>
</td>
</tr>
</tbody>
</table>
<input name='id' id='approved_directory_id' type='hidden' value='{$url_id}'>
<?php
}
/**
* Displays any admin notices that might be needed.
*/
private function admin_notices() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$successfully_deleted = isset( $_GET['deleted-ids'] ) ? (int) $_GET['deleted-ids'] : 0;
$successfully_enabled = isset( $_GET['enabled-ids'] ) ? (int) $_GET['enabled-ids'] : 0;
$successfully_disabled = isset( $_GET['disabled-ids'] ) ? (int) $_GET['disabled-ids'] : 0;
$failed_updates = isset( $_GET['bulk-fails'] ) ? (int) $_GET['bulk-fails'] : 0;
$edit_status = sanitize_text_field( wp_unslash( $_GET['edit-status'] ?? '' ) );
$edit_url = esc_attr( sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) ) );
// phpcs:enable
if ( $successfully_deleted ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL deleted.', '%d approved directory URLs deleted.', $successfully_deleted, 'woocommerce' ),
$successfully_deleted
)
);
} elseif ( $successfully_enabled ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL enabled.', '%d approved directory URLs enabled.', $successfully_enabled, 'woocommerce' ),
$successfully_enabled
)
);
} elseif ( $successfully_disabled ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL disabled.', '%d approved directory URLs disabled.', $successfully_disabled, 'woocommerce' ),
$successfully_disabled
)
);
}
if ( $failed_updates ) {
WC_Admin_Settings::add_error(
sprintf(
/* translators: %d: count */
_n( '%d URL could not be updated.', '%d URLs could not be updated.', $failed_updates, 'woocommerce' ),
$failed_updates
)
);
}
if ( 'added' === $edit_status ) {
WC_Admin_Settings::add_message( __( 'URL was successfully added.', 'woocommerce' ) );
}
if ( 'updated' === $edit_status ) {
WC_Admin_Settings::add_message( __( 'URL was successfully updated.', 'woocommerce' ) );
}
if ( 'failure' === $edit_status && ! empty( $edit_url ) ) {
WC_Admin_Settings::add_error(
sprintf(
/* translators: %s is the submitted URL. */
__( '"%s" could not be saved. Please review, ensure it is a valid URL and try again.', 'woocommerce' ),
$edit_url
)
);
}
}
/**
* Makes sure the user has appropriate permissions and that we have a valid nonce.
*/
private function security_check() {
if ( ! Users::is_site_administrator() || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['check'] ?? '' ) ), 'modify_approved_directories' ) ) {
wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
}
}
}
Internal/ProductDownloads/ApprovedDirectories/ApprovedDirectoriesException.php 0000644 00000000523 15153704500 0024135 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Exception;
/**
* Encapsulates a problem encountered while an operation relating to approved directories
* was performed.
*/
class ApprovedDirectoriesException extends Exception {
public const INVALID_URL = 1;
public const DB_ERROR = 2;
}
Internal/ProductDownloads/ApprovedDirectories/Register.php 0000644 00000033654 15153704500 0020100 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\Utilities\URL;
use Automattic\WooCommerce\Internal\Utilities\URLException;
/**
* Maintains and manages the list of approved directories, within which product downloads can
* be stored.
*/
class Register {
/**
* Used to indicate the current mode.
*/
private const MODES = array(
self::MODE_DISABLED,
self::MODE_ENABLED,
);
public const MODE_DISABLED = 'disabled';
public const MODE_ENABLED = 'enabled';
/**
* Name of the option used to store the current mode. See self::MODES for a
* list of acceptable values for the actual option.
*
* @var string
*/
private $mode_option = 'wc_downloads_approved_directories_mode';
/**
* Sets up the approved directories sub-system.
*
* @internal
*/
final public function init() {
add_action(
'admin_init',
function () {
wc_get_container()->get( SyncUI::class )->init_hooks();
wc_get_container()->get( UI::class )->init_hooks();
}
);
add_action(
'before_woocommerce_init',
function() {
if ( get_option( Synchronize::SYNC_TASK_PAGE ) > 0 ) {
wc_get_container()->get( Synchronize::class )->init_hooks();
}
}
);
}
/**
* Supplies the name of the database table used to store approved directories.
*
* @return string
*/
public function get_table(): string {
global $wpdb;
return $wpdb->prefix . 'wc_product_download_directories';
}
/**
* Returns a string indicating the current mode.
*
* May be one of: 'disabled', 'enabled', 'migrating'.
*
* @return string
*/
public function get_mode(): string {
$current_mode = get_option( $this->mode_option, self::MODE_DISABLED );
return in_array( $current_mode, self::MODES, true ) ? $current_mode : self::MODE_DISABLED;
}
/**
* Sets the mode. This effectively controls if approved directories are enforced or not.
*
* May be one of: 'disabled', 'enabled', 'migrating'.
*
* @param string $mode One of the values contained within self::MODES.
*
* @return bool
*/
public function set_mode( string $mode ): bool {
if ( ! in_array( $mode, self::MODES, true ) ) {
return false;
}
update_option( $this->mode_option, $mode );
return get_option( $this->mode_option ) === $mode;
}
/**
* Adds a new URL path.
*
* On success (or if the URL was already added) returns the URL ID, or else
* returns boolean false.
*
* @throws URLException If the URL was invalid.
* @throws ApprovedDirectoriesException If the operation could not be performed.
*
* @param string $url The URL of the approved directory.
* @param bool $enabled If the rule is enabled.
*
* @return int
*/
public function add_approved_directory( string $url, bool $enabled = true ): int {
$url = $this->prepare_url_for_upsert( $url );
$existing = $this->get_by_url( $url );
if ( $existing ) {
return $existing->get_id();
}
global $wpdb;
$insert_fields = array(
'url' => $url,
'enabled' => (int) $enabled,
);
if ( false !== $wpdb->insert( $this->get_table(), $insert_fields ) ) {
return $wpdb->insert_id;
}
throw new ApprovedDirectoriesException( __( 'URL could not be added (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
}
/**
* Updates an existing approved directory.
*
* On success or if there is an existing entry for the same URL, returns true.
*
* @throws ApprovedDirectoriesException If the operation could not be performed.
* @throws URLException If the URL was invalid.
*
* @param int $id The ID of the approved directory to be updated.
* @param string $url The new URL for the specified option.
* @param bool $enabled If the rule is enabled.
*
* @return bool
*/
public function update_approved_directory( int $id, string $url, bool $enabled = true ): bool {
$url = $this->prepare_url_for_upsert( $url );
$existing_path = $this->get_by_url( $url );
// No need to go any further if the URL is already listed and nothing has changed.
if ( $existing_path && $existing_path->get_url() === $url && $enabled === $existing_path->is_enabled() ) {
return true;
}
global $wpdb;
$fields = array(
'url' => $url,
'enabled' => (int) $enabled,
);
if ( false === $wpdb->update( $this->get_table(), $fields, array( 'url_id' => $id ) ) ) {
throw new ApprovedDirectoriesException( __( 'URL could not be updated (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
}
return true;
}
/**
* Indicates if the specified URL is already an approved directory.
*
* @param string $url The URL to check.
*
* @return bool
*/
public function approved_directory_exists( string $url ): bool {
return (bool) $this->get_by_url( $url );
}
/**
* Returns the path identified by $id, or false if it does not exist.
*
* @param int $id The ID of the rule we are looking for.
*
* @return StoredUrl|false
*/
public function get_by_id( int $id ) {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url_id = %d", array( $id ) ) );
if ( ! $result ) {
return false;
}
return new StoredUrl( $result->url_id, $result->url, $result->enabled );
}
/**
* Returns the path identified by $url, or false if it does not exist.
*
* @param string $url The URL of the rule we are looking for.
*
* @return StoredUrl|false
*/
public function get_by_url( string $url ) {
global $wpdb;
$table = $this->get_table();
$url = trailingslashit( $url );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url = %s", array( $url ) ) );
if ( ! $result ) {
return false;
}
return new StoredUrl( $result->url_id, $result->url, $result->enabled );
}
/**
* Indicates if the URL is within an approved directory. The approved directory must be enabled
* (it is possible for individual approved directories to be disabled).
*
* For instance, for 'https://storage.king/12345/ebook.pdf' to be valid then 'https://storage.king/12345'
* would need to be within our register.
*
* If the provided URL is a filepath it can be passed in without the 'file://' scheme.
*
* @throws URLException If the provided URL is badly formed.
*
* @param string $download_url The URL to check.
*
* @return bool
*/
public function is_valid_path( string $download_url ): bool {
global $wpdb;
$parent_directories = array();
foreach ( ( new URL( $this->normalize_url( $download_url ) ) )->get_all_parent_urls() as $parent ) {
$parent_directories[] = "'" . esc_sql( $parent ) . "'";
}
if ( empty( $parent_directories ) ) {
return false;
}
$parent_directories = join( ',', $parent_directories );
$table = $this->get_table();
// Look for a rule that matches the start of the download URL being tested. Since rules describe parent
// directories, we also ensure it ends with a trailing slash.
//
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$matches = (int) $wpdb->get_var(
"
SELECT COUNT(*)
FROM {$table}
WHERE enabled = 1
AND url IN ( {$parent_directories} )
"
);
// phpcs:enable
return $matches > 0;
}
/**
* Used when a URL string is prepared before potentially adding it to the database.
*
* It will be normalized and trailing-slashed; a length check will also be performed.
*
* @throws ApprovedDirectoriesException If the operation could not be performed.
* @throws URLException If the URL was invalid.
*
* @param string $url The string URL to be normalized and trailing-slashed.
*
* @return string
*/
private function prepare_url_for_upsert( string $url ): string {
$url = trailingslashit( $this->normalize_url( $url ) );
if ( mb_strlen( $url ) > 256 ) {
throw new ApprovedDirectoriesException( __( 'Approved directory URLs cannot be longer than 256 characters.', 'woocommerce' ), ApprovedDirectoriesException::INVALID_URL );
}
return $url;
}
/**
* Normalizes the provided URL, by trimming whitespace per normal PHP conventions
* and removing any trailing slashes. If it lacks a scheme, the file scheme is
* assumed and prepended.
*
* @throws URLException If the URL is badly formed.
*
* @param string $url The URL to be normalized.
*
* @return string
*/
private function normalize_url( string $url ): string {
$url = untrailingslashit( trim( $url ) );
return ( new URL( $url ) )->get_url();
}
/**
* Lists currently approved directories.
*
* Returned array will have the following structure:
*
* [
* 'total_urls' => 12345,
* 'total_pages' => 123,
* 'urls' => [], # StoredUrl[]
* ]
*
* @param array $args {
* Controls pagination and ordering.
*
* @type null|bool $enabled Controls if only enabled (true), disabled (false) or all rules (null) should be listed.
* @type string $order Ordering ('ASC' for ascending, 'DESC' for descending).
* @type string $order_by Field to order by (one of 'url_id' or 'url').
* @type int $page The page of results to retrieve.
* @type int $per_page The number of results to retrieve per page.
* @type string $search Term to search for.
* }
*
* @return array
*/
public function list( array $args ): array {
global $wpdb;
$args = array_merge(
array(
'enabled' => null,
'order' => 'ASC',
'order_by' => 'url',
'page' => 1,
'per_page' => 20,
'search' => '',
),
$args
);
$table = $this->get_table();
$paths = array();
$order = in_array( $args['order'], array( 'ASC', 'DESC' ), true ) ? $args['order'] : 'ASC';
$order_by = in_array( $args['order_by'], array( 'url_id', 'url' ), true ) ? $args['order_by'] : 'url';
$page = absint( $args['page'] );
$per_page = absint( $args['per_page'] );
$enabled = is_bool( $args['enabled'] ) ? $args['enabled'] : null;
$search = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%';
if ( $page < 1 ) {
$page = 1;
}
if ( $per_page < 1 ) {
$per_page = 1;
}
$where = array();
$where_sql = '';
if ( ! empty( $search ) ) {
$where[] = $wpdb->prepare( 'url LIKE %s', $search );
}
if ( is_bool( $enabled ) ) {
$where[] = 'enabled = ' . (int) $enabled;
}
if ( ! empty( $where ) ) {
$where_sql = 'WHERE ' . join( ' AND ', $where );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
$wpdb->prepare(
"
SELECT url_id, url, enabled
FROM {$table}
{$where_sql}
ORDER BY {$order_by} {$order}
LIMIT %d, %d
",
( $page - 1 ) * $per_page,
$per_page
)
);
$total_rows = (int) $wpdb->get_var( "SELECT COUNT( * ) FROM {$table} {$where_sql}" );
// phpcs:enable
foreach ( $results as $single_result ) {
$paths[] = new StoredUrl( $single_result->url_id, $single_result->url, $single_result->enabled );
}
return array(
'total_urls' => $total_rows,
'total_pages' => (int) ceil( $total_rows / $per_page ),
'approved_directories' => $paths,
);
}
/**
* Delete the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function delete_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->delete( $table, array( 'url_id' => $id ) );
}
/**
* Delete the entirev approved directory list.
*
* @return bool
*/
public function delete_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "DELETE FROM $table" );
}
/**
* Enable the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function enable_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->update( $table, array( 'enabled' => 1 ), array( 'url_id' => $id ) );
}
/**
* Disable the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function disable_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->update( $table, array( 'enabled' => 0 ), array( 'url_id' => $id ) );
}
/**
* Enables all Approved Download Directory rules in a single operation.
*
* @return bool
*/
public function enable_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 1" );
}
/**
* Disables all Approved Download Directory rules in a single operation.
*
* @return bool
*/
public function disable_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 0" );
}
/**
* Indicates the number of approved directories that are enabled (or disabled, if optional
* param $enabled is set to false).
*
* @param bool $enabled Controls whether enabled or disabled directory rules are counted.
*
* @return int
*/
public function count( bool $enabled = true ): int {
global $wpdb;
$table = $this->get_table();
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COUNT(*) FROM {$table} WHERE enabled = %d",
$enabled ? 1 : 0
)
);
}
}
Internal/ProductDownloads/ApprovedDirectories/StoredUrl.php 0000644 00000002427 15153704500 0020231 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
/**
* Representation of an approved directory URL, bundling the ID and URL in a single entity.
*/
class StoredUrl {
/**
* The approved directory ID.
*
* @var int
*/
private $id;
/**
* The approved directory URL.
*
* @var string
*/
private $url;
/**
* If the individual rule is enabled or disabled.
*
* @var bool
*/
private $enabled;
/**
* Sets up the approved directory rule.
*
* @param int $id The approved directory ID.
* @param string $url The approved directory URL.
* @param bool $enabled Indicates if the approved directory rule is enabled.
*/
public function __construct( int $id, string $url, bool $enabled ) {
$this->id = $id;
$this->url = $url;
$this->enabled = $enabled;
}
/**
* Supplies the ID of the approved directory.
*
* @return int
*/
public function get_id(): int {
return $this->id;
}
/**
* Supplies the approved directory URL.
*
* @return string
*/
public function get_url(): string {
return $this->url;
}
/**
* Indicates if this rule is enabled or not (rules can be temporarily disabled).
*
* @return bool
*/
public function is_enabled(): bool {
return $this->enabled;
}
}
Internal/ProductDownloads/ApprovedDirectories/Synchronize.php 0000644 00000020374 15153704500 0020622 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Exception;
use Automattic\WooCommerce\Internal\Utilities\URL;
use WC_Admin_Notices;
use WC_Product;
use WC_Queue_Interface;
/**
* Ensures that any downloadable files have a corresponding entry in the Approved Product
* Download Directories list.
*/
class Synchronize {
/**
* Scheduled action hook used to facilitate scanning the product catalog for downloadable products.
*/
public const SYNC_TASK = 'woocommerce_download_dir_sync';
/**
* The group under which synchronization tasks run (our standard 'woocommerce-db-updates' group).
*/
public const SYNC_TASK_GROUP = 'woocommerce-db-updates';
/**
* Used to track progress throughout the sync process.
*/
public const SYNC_TASK_PAGE = 'wc_product_download_dir_sync_page';
/**
* Used to record an estimation of progress on the current synchronization process. 0 means 0%,
* 100 means 100%.
*
* @param int
*/
public const SYNC_TASK_PROGRESS = 'wc_product_download_dir_sync_progress';
/**
* Number of downloadable products to be processed in each atomic sync task.
*/
public const SYNC_TASK_BATCH_SIZE = 20;
/**
* WC Queue.
*
* @var WC_Queue_Interface
*/
private $queue;
/**
* Register of approved directories.
*
* @var Register
*/
private $register;
/**
* Sets up our checks and controls for downloadable asset URLs, as appropriate for
* the current approved download directory mode.
*
* @internal
* @throws Exception If the WC_Queue instance cannot be obtained.
*
* @param Register $register The active approved download directories instance in use.
*/
final public function init( Register $register ) {
$this->queue = WC()->get_instance_of( WC_Queue_Interface::class );
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system.
*/
final public function init_hooks() {
add_action( self::SYNC_TASK, array( $this, 'run' ) );
}
/**
* Initializes the Approved Download Directories feature, typically following an update or
* during initial installation.
*
* @param bool $synchronize Synchronize with existing product downloads. Not needed in a fresh installation.
* @param bool $enable_feature Enable (default) or disable the feature.
*/
public function init_feature( bool $synchronize = true, bool $enable_feature = true ) {
try {
$this->add_default_directories();
if ( $synchronize ) {
$this->start();
}
} catch ( Exception $e ) {
wc_get_logger()->log( 'warning', __( 'It was not possible to synchronize download directories following the most recent update.', 'woocommerce' ) );
}
$this->register->set_mode(
$enable_feature ? Register::MODE_ENABLED : Register::MODE_DISABLED
);
}
/**
* By default we add the woocommerce_uploads directory (file path plus web URL) to the list
* of approved download directories.
*
* @throws Exception If the default directories cannot be added to the Approved List.
*/
public function add_default_directories() {
$upload_dir = wp_get_upload_dir();
$this->register->add_approved_directory( $upload_dir['basedir'] . '/woocommerce_uploads' );
$this->register->add_approved_directory( $upload_dir['baseurl'] . '/woocommerce_uploads' );
}
/**
* Starts the synchronization process.
*
* @return bool
*/
public function start(): bool {
if ( null !== $this->queue->get_next( self::SYNC_TASK ) ) {
wc_get_logger()->log( 'warning', __( 'Synchronization of approved product download directories is already in progress.', 'woocommerce' ) );
return false;
}
update_option( self::SYNC_TASK_PAGE, 1 );
$this->queue->schedule_single( time(), self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: new scan scheduled.', 'woocommerce' ) );
return true;
}
/**
* Runs the syncronization task.
*/
public function run() {
$products = $this->get_next_set_of_downloadable_products();
foreach ( $products as $product ) {
$this->process_product( $product );
}
// Detect if we have reached the end of the task.
if ( count( $products ) < self::SYNC_TASK_BATCH_SIZE ) {
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan is complete!', 'woocommerce' ) );
$this->stop();
} else {
wc_get_logger()->log(
'info',
sprintf(
/* translators: %1$d is the current batch in the synchronization task, %2$d is the percent complete. */
__( 'Approved Download Directories sync: completed batch %1$d (%2$d%% complete).', 'woocommerce' ),
(int) get_option( self::SYNC_TASK_PAGE, 2 ) - 1,
$this->get_progress()
)
);
$this->queue->schedule_single( time() + 1, self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
}
}
/**
* Stops/cancels the current synchronization task.
*/
public function stop() {
WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
delete_option( self::SYNC_TASK_PAGE );
delete_option( self::SYNC_TASK_PROGRESS );
$this->queue->cancel( self::SYNC_TASK );
}
/**
* Queries for the next batch of downloadable products, applying logic to ensure we only fetch those that actually
* have downloadable files (a downloadable product can be created that does not have downloadable files and/or
* downloadable files can be removed from existing downloadable products).
*
* @return array
*/
private function get_next_set_of_downloadable_products(): array {
$query_filter = function ( array $query ): array {
$query['meta_query'][] = array(
'key' => '_downloadable_files',
'compare' => 'EXISTS',
);
return $query;
};
$page = (int) get_option( self::SYNC_TASK_PAGE, 1 );
add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
$products = wc_get_products(
array(
'limit' => self::SYNC_TASK_BATCH_SIZE,
'page' => $page,
'paginate' => true,
)
);
remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
$progress = $products->max_num_pages > 0 ? (int) ( ( $page / $products->max_num_pages ) * 100 ) : 1;
update_option( self::SYNC_TASK_PAGE, $page + 1 );
update_option( self::SYNC_TASK_PROGRESS, $progress );
return $products->products;
}
/**
* Processes an individual downloadable product, adding the parent paths for any downloadable files to the
* Approved Download Directories list.
*
* Any such paths will be added with the disabled flag set, because we want a site administrator to review
* and approve first.
*
* @param WC_Product $product The product we wish to examine for downloadable file paths.
*/
private function process_product( WC_Product $product ) {
$downloads = $product->get_downloads();
foreach ( $downloads as $downloadable ) {
$parent_url = _x( 'invalid URL', 'Approved product download URLs migration', 'woocommerce' );
try {
$download_file = $downloadable->get_file();
/**
* Controls whether shortcodes should be resolved and validated using the Approved Download Directory feature.
*
* @param bool $should_validate
*/
if ( apply_filters( 'woocommerce_product_downloads_approved_directory_validation_for_shortcodes', true ) && 'shortcode' === $downloadable->get_type_of_file_path() ) {
$download_file = do_shortcode( $download_file );
}
$parent_url = ( new URL( $download_file ) )->get_parent_url();
$this->register->add_approved_directory( $parent_url, false );
} catch ( Exception $e ) {
wc_get_logger()->log(
'error',
sprintf(
/* translators: %s is a URL, %d is a product ID. */
__( 'Product download migration: %1$s (for product %1$d) could not be added to the list of approved download directories.', 'woocommerce' ),
$parent_url,
$product->get_id()
)
);
}
}
}
/**
* Indicates if a synchronization of product download directories is in progress.
*
* @return bool
*/
public function in_progress(): bool {
return (bool) get_option( self::SYNC_TASK_PAGE, false );
}
/**
* Returns a value between 0 and 100 representing the percentage complete of the current sync.
*
* @return int
*/
public function get_progress(): int {
return min( 100, max( 0, (int) get_option( self::SYNC_TASK_PROGRESS, 0 ) ) );
}
}
Internal/RestApiUtil.php 0000644 00000013304 15153704500 0011237 0 ustar 00 <?php
/**
* ApiUtil class file.
*/
namespace Automattic\WooCommerce\Internal;
/**
* Helper methos for the REST API.
*
* Class ApiUtil
*
* @package Automattic\WooCommerce\Internal
*/
class RestApiUtil {
/**
* Converts a create refund request from the public API format:
*
* [
* "reason" => "",
* "api_refund" => "x",
* "api_restock" => "x",
* "line_items" => [
* "id" => "111",
* "quantity" => 222,
* "refund_total" => 333,
* "refund_tax" => [
* [
* "id": "444",
* "refund_total": 555
* ],...
* ],...
* ]
*
* ...to the internally used format:
*
* [
* "reason" => null, (if it's missing or any empty value, set as null)
* "api_refund" => true, (if it's missing or non-bool, set as "true")
* "api_restock" => true, (if it's missing or non-bool, set as "true")
* "line_items" => [ (convert sequential array to associative based on "id")
* "111" => [
* "qty" => 222, (rename "quantity" to "qty")
* "refund_total" => 333,
* "refund_tax" => [ (convert sequential array to associative based on "id" and "refund_total)
* "444" => 555,...
* ],...
* ]
* ]
*
* It also calculates the amount if missing and whenever possible, see maybe_calculate_refund_amount_from_line_items.
*
* The conversion is done in a way that if the request is already in the internal format,
* then nothing is changed for compatibility. For example, if the line items array
* is already an associative array or any of its elements
* is missing the "id" key, then the entire array is left unchanged.
* Same for the "refund_tax" array inside each line item.
*
* @param \WP_REST_Request $request The request to adjust.
*/
public static function adjust_create_refund_request_parameters( \WP_REST_Request &$request ) {
if ( empty( $request['reason'] ) ) {
$request['reason'] = null;
}
if ( ! is_bool( $request['api_refund'] ) ) {
$request['api_refund'] = true;
}
if ( ! is_bool( $request['api_restock'] ) ) {
$request['api_restock'] = true;
}
if ( empty( $request['line_items'] ) ) {
$request['line_items'] = array();
} else {
$request['line_items'] = self::adjust_line_items_for_create_refund_request( $request['line_items'] );
}
if ( ! isset( $request['amount'] ) ) {
$amount = self::calculate_refund_amount_from_line_items( $request );
if ( null !== $amount ) {
$request['amount'] = strval( $amount );
}
}
}
/**
* Calculate the "amount" parameter for the request based on the amounts found in line items.
* This will ONLY be possible if ALL of the following is true:
*
* - "line_items" in the request is a non-empty array.
* - All line items have a "refund_total" field with a numeric value.
* - All values inside "refund_tax" in all line items are a numeric value.
*
* The request is assumed to be in internal format already.
*
* @param \WP_REST_Request $request The request to maybe calculate the total amount for.
* @return number|null The calculated amount, or null if it can't be calculated.
*/
private static function calculate_refund_amount_from_line_items( $request ) {
$line_items = $request['line_items'];
if ( ! is_array( $line_items ) || empty( $line_items ) ) {
return null;
}
$amount = 0;
foreach ( $line_items as $item ) {
if ( ! isset( $item['refund_total'] ) || ! is_numeric( $item['refund_total'] ) ) {
return null;
}
$amount += $item['refund_total'];
if ( ! isset( $item['refund_tax'] ) ) {
continue;
}
foreach ( $item['refund_tax'] as $tax ) {
if ( ! is_numeric( $tax ) ) {
return null;
}
$amount += $tax;
}
}
return $amount;
}
/**
* Convert the line items of a refund request to internal format (see adjust_create_refund_request_parameters).
*
* @param array $line_items The line items to convert.
* @return array The converted line items.
*/
private static function adjust_line_items_for_create_refund_request( $line_items ) {
if ( ! is_array( $line_items ) || empty( $line_items ) || self::is_associative( $line_items ) ) {
return $line_items;
}
$new_array = array();
foreach ( $line_items as $item ) {
if ( ! isset( $item['id'] ) ) {
return $line_items;
}
if ( isset( $item['quantity'] ) && ! isset( $item['qty'] ) ) {
$item['qty'] = $item['quantity'];
}
unset( $item['quantity'] );
if ( isset( $item['refund_tax'] ) ) {
$item['refund_tax'] = self::adjust_taxes_for_create_refund_request_line_item( $item['refund_tax'] );
}
$id = $item['id'];
$new_array[ $id ] = $item;
unset( $new_array[ $id ]['id'] );
}
return $new_array;
}
/**
* Adjust the taxes array from a line item in a refund request, see adjust_create_refund_parameters.
*
* @param array $taxes_array The array to adjust.
* @return array The adjusted array.
*/
private static function adjust_taxes_for_create_refund_request_line_item( $taxes_array ) {
if ( ! is_array( $taxes_array ) || empty( $taxes_array ) || self::is_associative( $taxes_array ) ) {
return $taxes_array;
}
$new_array = array();
foreach ( $taxes_array as $item ) {
if ( ! isset( $item['id'] ) || ! isset( $item['refund_total'] ) ) {
return $taxes_array;
}
$id = $item['id'];
$refund_total = $item['refund_total'];
$new_array[ $id ] = $refund_total;
}
return $new_array;
}
/**
* Is an array sequential or associative?
*
* @param array $array The array to check.
* @return bool True if the array is associative, false if it's sequential.
*/
private static function is_associative( array $array ) {
return array_keys( $array ) !== range( 0, count( $array ) - 1 );
}
}
Internal/RestockRefundedItemsAdjuster.php 0000644 00000004121 15153704500 0014622 0 ustar 00 <?php
/**
* RestockRefundedItemsAdjuster class file.
*/
namespace Automattic\WooCommerce\Internal;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* Class to adjust or initialize the restock refunded items.
*/
class RestockRefundedItemsAdjuster {
/**
* The order factory to use.
*
* @var WC_Order_Factory
*/
private $order_factory;
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
$this->order_factory = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Order_Factory::class );
add_action( 'woocommerce_before_save_order_items', array( $this, 'initialize_restock_refunded_items' ), 10, 2 );
}
/**
* Initializes the restock refunded items meta for order version less than 5.5.
*
* @see https://github.com/woocommerce/woocommerce/issues/29502
*
* @param int $order_id Order ID.
* @param array $items Order items to save.
*/
public function initialize_restock_refunded_items( $order_id, $items ) {
$order = wc_get_order( $order_id );
$order_version = $order->get_version();
if ( version_compare( $order_version, '5.5', '>=' ) ) {
return;
}
// If there are no refund lines, then this migration isn't necessary because restock related meta's wouldn't be set.
if ( 0 === count( $order->get_refunds() ) ) {
return;
}
if ( isset( $items['order_item_id'] ) ) {
foreach ( $items['order_item_id'] as $item_id ) {
$item = $this->order_factory::get_order_item( absint( $item_id ) );
if ( ! $item ) {
continue;
}
if ( 'line_item' !== $item->get_type() ) {
continue;
}
// There could be code paths in custom code which don't update version number but still update the items.
if ( '' !== $item->get_meta( '_restock_refunded_items', true ) ) {
continue;
}
$refunded_item_quantity = abs( $order->get_qty_refunded_for_item( $item->get_id() ) );
$item->add_meta_data( '_restock_refunded_items', $refunded_item_quantity, false );
$item->save();
}
}
}
}
Internal/Settings/OptionSanitizer.php 0000644 00000003452 15153704500 0013776 0 ustar 00 <?php
/**
* FormatValidator class.
*/
namespace Automattic\WooCommerce\Internal\Settings;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
defined( 'ABSPATH' ) || exit;
/**
* This class handles sanitization of core options that need to conform to certain format.
*
* @since 6.6.0
*/
class OptionSanitizer {
use AccessiblePrivateMethods;
/**
* OptionSanitizer constructor.
*/
public function __construct() {
// Sanitize color options.
$color_options = array(
'woocommerce_email_base_color',
'woocommerce_email_background_color',
'woocommerce_email_body_background_color',
'woocommerce_email_text_color',
);
foreach ( $color_options as $option_name ) {
self::add_filter(
"woocommerce_admin_settings_sanitize_option_{$option_name}",
array( $this, 'sanitize_color_option' ),
10,
2
);
}
// Cast "Out of stock threshold" field to absolute integer to prevent storing empty value.
self::add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_notify_no_stock_amount', 'absint' );
}
/**
* Sanitizes values for options of type 'color' before persisting to the database.
* Falls back to previous/default value for the option if given an invalid value.
*
* @since 6.6.0
* @param string $value Option value.
* @param array $option Option data.
* @return string Color in hex format.
*/
private function sanitize_color_option( $value, $option ) {
$value = sanitize_hex_color( $value );
// If invalid, try the current value.
if ( ! $value && ! empty( $option['id'] ) ) {
$value = sanitize_hex_color( get_option( $option['id'] ) );
}
// If still invalid, try the default.
if ( ! $value && ! empty( $option['default'] ) ) {
$value = sanitize_hex_color( $option['default'] );
}
return (string) $value;
}
}
Internal/Traits/AccessiblePrivateMethods.php 0000644 00000017745 15153704500 0015231 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Traits;
use Automattic\WooCommerce\Utilities\ArrayUtil;
/**
* This trait allows making private methods of a class accessible from outside.
* This is useful to define hook handlers with the [$this, 'method'] or [__CLASS__, 'method'] syntax
* without having to make the method public (and thus having to keep it forever for backwards compatibility).
*
* Example:
*
* class Foobar {
* use AccessiblePrivateMethods;
*
* public function __construct() {
* self::add_action('some_action', [$this, 'handle_some_action']);
* }
*
* public static function init() {
* self::add_filter('some_filter', [__CLASS__, 'handle_some_filter']);
* }
*
* private function handle_some_action() {
* }
*
* private static function handle_some_filter() {
* }
* }
*
* For this to work the callback must be an array and the first element of the array must be either '$this', '__CLASS__',
* or another instance of the same class; otherwise the method won't be marked as accessible
* (but the corresponding WordPress 'add_action' and 'add_filter' functions will still be called).
*
* No special procedure is needed to remove hooks set up with these methods, the regular 'remove_action'
* and 'remove_filter' functions provided by WordPress can be used as usual.
*/
trait AccessiblePrivateMethods {
//phpcs:disable PSR2.Classes.PropertyDeclaration.Underscore
/**
* List of instance methods marked as externally accessible.
*
* @var array
*/
private $_accessible_private_methods = array();
/**
* List of static methods marked as externally accessible.
*
* @var array
*/
private static $_accessible_static_private_methods = array();
//phpcs:enable PSR2.Classes.PropertyDeclaration.Underscore
/**
* Register a WordPress action.
* If the callback refers to a private or protected instance method in this class, the method is marked as externally accessible.
*
* $callback can be a standard callable, or a string representing the name of a method in this class.
*
* @param string $hook_name The name of the action to add the callback to.
* @param callable|string $callback The callback to be run when the action is called.
* @param int $priority Optional. Used to specify the order in which the functions
* associated with a particular action are executed.
* Lower numbers correspond with earlier execution,
* and functions with the same priority are executed
* in the order in which they were added to the action. Default 10.
* @param int $accepted_args Optional. The number of arguments the function accepts. Default 1.
*/
protected static function add_action( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void {
self::process_callback_before_hooking( $callback );
add_action( $hook_name, $callback, $priority, $accepted_args );
}
/**
* Register a WordPress filter.
* If the callback refers to a private or protected instance method in this class, the method is marked as externally accessible.
*
* $callback can be a standard callable, or a string representing the name of a method in this class.
*
* @param string $hook_name The name of the filter to add the callback to.
* @param callable|string $callback The callback to be run when the filter is called.
* @param int $priority Optional. Used to specify the order in which the functions
* associated with a particular filter are executed.
* Lower numbers correspond with earlier execution,
* and functions with the same priority are executed
* in the order in which they were added to the filter. Default 10.
* @param int $accepted_args Optional. The number of arguments the function accepts. Default 1.
*/
protected static function add_filter( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void {
self::process_callback_before_hooking( $callback );
add_filter( $hook_name, $callback, $priority, $accepted_args );
}
/**
* Do the required processing to a callback before invoking the WordPress 'add_action' or 'add_filter' function.
*
* @param callable $callback The callback to process.
* @return void
*/
protected static function process_callback_before_hooking( $callback ): void {
if ( ! is_array( $callback ) || count( $callback ) < 2 ) {
return;
}
$first_item = $callback[0];
if ( __CLASS__ === $first_item ) {
static::mark_static_method_as_accessible( $callback[1] );
} elseif ( is_object( $first_item ) && get_class( $first_item ) === __CLASS__ ) {
$first_item->mark_method_as_accessible( $callback[1] );
}
}
/**
* Register a private or protected instance method of this class as externally accessible.
*
* @param string $method_name Method name.
* @return bool True if the method has been marked as externally accessible, false if the method doesn't exist.
*/
protected function mark_method_as_accessible( string $method_name ): bool {
// Note that an "is_callable" check would be useless here:
// "is_callable" always returns true if the class implements __call.
if ( method_exists( $this, $method_name ) ) {
$this->_accessible_private_methods[ $method_name ] = $method_name;
return true;
}
return false;
}
/**
* Register a private or protected static method of this class as externally accessible.
*
* @param string $method_name Method name.
* @return bool True if the method has been marked as externally accessible, false if the method doesn't exist.
*/
protected static function mark_static_method_as_accessible( string $method_name ): bool {
if ( method_exists( __CLASS__, $method_name ) ) {
static::$_accessible_static_private_methods[ $method_name ] = $method_name;
return true;
}
return false;
}
/**
* Undefined/inaccessible instance method call handler.
*
* @param string $name Called method name.
* @param array $arguments Called method arguments.
* @return mixed
* @throws \Error The called instance method doesn't exist or is private/protected and not marked as externally accessible.
*/
public function __call( $name, $arguments ) {
if ( isset( $this->_accessible_private_methods[ $name ] ) ) {
return call_user_func_array( array( $this, $name ), $arguments );
} elseif ( is_callable( array( 'parent', '__call' ) ) ) {
return parent::__call( $name, $arguments );
} elseif ( method_exists( $this, $name ) ) {
throw new \Error( 'Call to private method ' . get_class( $this ) . '::' . $name );
} else {
throw new \Error( 'Call to undefined method ' . get_class( $this ) . '::' . $name );
}
}
/**
* Undefined/inaccessible static method call handler.
*
* @param string $name Called method name.
* @param array $arguments Called method arguments.
* @return mixed
* @throws \Error The called static method doesn't exist or is private/protected and not marked as externally accessible.
*/
public static function __callStatic( $name, $arguments ) {
if ( isset( static::$_accessible_static_private_methods[ $name ] ) ) {
return call_user_func_array( array( __CLASS__, $name ), $arguments );
} elseif ( is_callable( array( 'parent', '__callStatic' ) ) ) {
return parent::__callStatic( $name, $arguments );
} elseif ( 'add_action' === $name || 'add_filter' === $name ) {
$proper_method_name = 'add_static_' . substr( $name, 4 );
throw new \Error( __CLASS__ . '::' . $name . " can't be called statically, did you mean '$proper_method_name'?" );
} elseif ( method_exists( __CLASS__, $name ) ) {
throw new \Error( 'Call to private method ' . __CLASS__ . '::' . $name );
} else {
throw new \Error( 'Call to undefined method ' . __CLASS__ . '::' . $name );
}
}
}
Internal/Utilities/BlocksUtil.php 0000644 00000004275 15153704500 0013067 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with blocks.
*/
class BlocksUtil {
/**
* Return blocks with their inner blocks flattened.
*
* @param array $blocks Array of blocks as returned by parse_blocks().
* @return array All blocks.
*/
public static function flatten_blocks( $blocks ) {
return array_reduce(
$blocks,
function( $carry, $block ) {
array_push( $carry, array_diff_key( $block, array_flip( array( 'innerBlocks' ) ) ) );
if ( isset( $block['innerBlocks'] ) ) {
$inner_blocks = self::flatten_blocks( $block['innerBlocks'] );
return array_merge( $carry, $inner_blocks );
}
return $carry;
},
array()
);
}
/**
* Get all instances of the specified block from the widget area.
*
* @param array $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_blocks_from_widget_area( $block_name ) {
return array_reduce(
get_option( 'widget_block' ),
function ( $acc, $block ) use ( $block_name ) {
$parsed_blocks = ! empty( $block ) && is_array( $block ) ? parse_blocks( $block['content'] ) : array();
if ( ! empty( $parsed_blocks ) && $block_name === $parsed_blocks[0]['blockName'] ) {
array_push( $acc, $parsed_blocks[0] );
return $acc;
}
return $acc;
},
array()
);
}
/**
* Get all instances of the specified block on a specific template part.
*
* @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @param string $template_part_slug The woo page to search, e.g. `header`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_block_from_template_part( $block_name, $template_part_slug ) {
$template = get_block_template( get_stylesheet() . '//' . $template_part_slug, 'wp_template_part' );
$blocks = parse_blocks( $template->content );
$flatten_blocks = self::flatten_blocks( $blocks );
return array_values(
array_filter(
$flatten_blocks,
function ( $block ) use ( $block_name ) {
return ( $block_name === $block['blockName'] );
}
)
);
}
}
Internal/Utilities/COTMigrationUtil.php 0000644 00000014072 15153704500 0014145 0 ustar 00 <?php
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\{ DataSynchronizer, OrdersTableDataStore };
use WC_Order;
use WP_Post;
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
class COTMigrationUtil {
/**
* Custom order table controller.
*
* @var CustomOrdersTableController
*/
private $table_controller;
/**
* Data synchronizer.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* Initialize method, invoked by the DI container.
*
* @internal Automatically called by the container.
* @param CustomOrdersTableController $table_controller Custom order table controller.
* @param DataSynchronizer $data_synchronizer Data synchronizer.
*
* @return void
*/
final public function init( CustomOrdersTableController $table_controller, DataSynchronizer $data_synchronizer ) {
$this->table_controller = $table_controller;
$this->data_synchronizer = $data_synchronizer;
}
/**
* Helper function to get screen name of orders page in wp-admin.
*
* @throws \Exception If called from outside of wp-admin.
*
* @return string
*/
public function get_order_admin_screen() : string {
if ( ! is_admin() ) {
throw new \Exception( 'This function should only be called in admin.' );
}
return $this->custom_orders_table_usage_is_enabled() && function_exists( 'wc_get_page_screen_id' )
? wc_get_page_screen_id( 'shop-order' )
: 'shop_order';
}
/**
* Helper function to get whether custom order tables are enabled or not.
*
* @return bool
*/
private function custom_orders_table_usage_is_enabled() : bool {
return $this->table_controller->custom_orders_table_usage_is_enabled();
}
/**
* Checks if posts and order custom table sync is enabled and there are no pending orders.
*
* @return bool
*/
public function is_custom_order_tables_in_sync() : bool {
$sync_status = $this->data_synchronizer->get_sync_status();
return 0 === $sync_status['current_pending_count'] && $this->data_synchronizer->data_sync_is_enabled();
}
/**
* Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
* This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
*
* @param WP_Post|null $post Post object, meta will be fetched from this only when `$data` is not passed.
* @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
* @param string $key Key to fetch metadata for.
* @param bool $single Whether metadata is single.
*
* @return array|mixed|string Value of the meta key.
*/
public function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
if ( isset( $data ) ) {
if ( method_exists( $data, "get$key" ) ) {
return $data->{"get$key"}();
}
return $data->get_meta( $key, $single );
} else {
return isset( $post->ID ) ? get_post_meta( $post->ID, $key, $single ) : false;
}
}
/**
* Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
*
* @param WC_Order|WP_Post $post_or_order_object Post or order object.
*
* @return bool|WC_Order|WC_Order_Refund WC_Order object.
*/
public function init_theorder_object( $post_or_order_object ) {
global $theorder;
if ( $theorder instanceof WC_Order ) {
return $theorder;
}
if ( $post_or_order_object instanceof WC_Order ) {
$theorder = $post_or_order_object;
} else {
$theorder = wc_get_order( $post_or_order_object->ID );
}
return $theorder;
}
/**
* Helper function to get ID from a post or order object.
*
* @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
*
* @return int Order or post ID.
*/
public function get_post_or_order_id( $post_or_order_object ) : int {
if ( is_numeric( $post_or_order_object ) ) {
return (int) $post_or_order_object;
} elseif ( $post_or_order_object instanceof WC_Order ) {
return $post_or_order_object->get_id();
} elseif ( $post_or_order_object instanceof WP_Post ) {
return $post_or_order_object->ID;
}
return 0;
}
/**
* Checks if passed id, post or order object is a WC_Order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
* @param string[] $types Types to match against.
*
* @return bool Whether the passed param is an order.
*/
public function is_order( $order_id, array $types = array( 'shop_order' ) ) : bool {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return in_array( $order_data_store->get_order_type( $order_id ), $types, true );
}
/**
* Returns type pf passed id, post or order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
*
* @return string|null Type of the order.
*/
public function get_order_type( $order_id ) {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return $order_data_store->get_order_type( $order_id );
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public function get_table_for_orders() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
$table_name = OrdersTableDataStore::get_orders_table_name();
} else {
global $wpdb;
$table_name = $wpdb->posts;
}
return $table_name;
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public function get_table_for_order_meta() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
$table_name = OrdersTableDataStore::get_meta_table_name();
} else {
global $wpdb;
$table_name = $wpdb->postmeta;
}
return $table_name;
}
}
Internal/Utilities/DatabaseUtil.php 0000644 00000023674 15153704500 0013362 0 ustar 00 <?php
/**
* DatabaseUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use DateTime;
use DateTimeZone;
/**
* A class of utilities for dealing with the database.
*/
class DatabaseUtil {
/**
* Wrapper for the WordPress dbDelta function, allows to execute a series of SQL queries.
*
* @param string $queries The SQL queries to execute.
* @param bool $execute Ture to actually execute the queries, false to only simulate the execution.
* @return array The result of the execution (or simulation) from dbDelta.
*/
public function dbdelta( string $queries = '', bool $execute = true ): array {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
return dbDelta( $queries, $execute );
}
/**
* Given a set of table creation SQL statements, check which of the tables are currently missing in the database.
*
* @param string $creation_queries The SQL queries to execute ("CREATE TABLE" statements, same format as for dbDelta).
* @return array An array containing the names of the tables that currently don't exist in the database.
*/
public function get_missing_tables( string $creation_queries ): array {
global $wpdb;
$suppress_errors = $wpdb->suppress_errors( true );
$dbdelta_output = $this->dbdelta( $creation_queries, false );
$wpdb->suppress_errors( $suppress_errors );
$parsed_output = $this->parse_dbdelta_output( $dbdelta_output );
return $parsed_output['created_tables'];
}
/**
* Parses the output given by dbdelta and returns information about it.
*
* @param array $dbdelta_output The output from the execution of dbdelta.
* @return array[] An array containing a 'created_tables' key whose value is an array with the names of the tables that have been (or would have been) created.
*/
public function parse_dbdelta_output( array $dbdelta_output ): array {
$created_tables = array();
foreach ( $dbdelta_output as $table_name => $result ) {
if ( "Created table $table_name" === $result ) {
$created_tables[] = str_replace( '(', '', $table_name );
}
}
return array( 'created_tables' => $created_tables );
}
/**
* Drops a database table.
*
* @param string $table_name The name of the table to drop.
* @param bool $add_prefix True if the table name passed needs to be prefixed with $wpdb->prefix before processing.
* @return bool True on success, false on error.
*/
public function drop_database_table( string $table_name, bool $add_prefix = false ) {
global $wpdb;
if ( $add_prefix ) {
$table_name = $wpdb->prefix . $table_name;
}
//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
}
/**
* Drops a table index, if both the table and the index exist.
*
* @param string $table_name The name of the table that contains the index.
* @param string $index_name The name of the index to be dropped.
* @return bool True if the index has been dropped, false if either the table or the index don't exist.
*/
public function drop_table_index( string $table_name, string $index_name ): bool {
global $wpdb;
if ( empty( $this->get_index_columns( $table_name, $index_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name DROP INDEX $index_name" );
return true;
}
/**
* Create a primary key for a table, only if the table doesn't have a primary key already.
*
* @param string $table_name Table name.
* @param array $columns An array with the index column names.
* @return bool True if the key has been created, false if the table already had a primary key.
*/
public function create_primary_key( string $table_name, array $columns ) {
global $wpdb;
if ( ! empty( $this->get_index_columns( $table_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name ADD PRIMARY KEY(`" . join( '`,`', $columns ) . '`)' );
return true;
}
/**
* Get the columns of a given table index, or of the primary key.
*
* @param string $table_name Table name.
* @param string $index_name Index name, empty string for the primary key.
* @return array The index columns. Empty array if the table or the index don't exist.
*/
public function get_index_columns( string $table_name, string $index_name = '' ): array {
global $wpdb;
if ( empty( $index_name ) ) {
$index_name = 'PRIMARY';
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM $table_name WHERE Key_name = %s", $index_name ) );
if ( empty( $results ) ) {
return array();
}
return array_column( $results, 'Column_name' );
}
/**
* Formats an object value of type `$type` for inclusion in the database.
*
* @param mixed $value Raw value.
* @param string $type Data type.
* @return mixed
* @throws \Exception When an invalid type is passed.
*/
public function format_object_value_for_db( $value, string $type ) {
switch ( $type ) {
case 'decimal':
$value = wc_format_decimal( $value, false, true );
break;
case 'int':
$value = (int) $value;
break;
case 'bool':
$value = wc_string_to_bool( $value );
break;
case 'string':
$value = strval( $value );
break;
case 'date':
// Date properties are converted to the WP timezone (see WC_Data::set_date_prop() method), however
// for our own tables we persist dates in GMT.
$value = $value ? ( new DateTime( $value ) )->setTimezone( new DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
break;
case 'date_epoch':
$value = $value ? ( new DateTime( "@{$value}" ) )->format( 'Y-m-d H:i:s' ) : null;
break;
default:
throw new \Exception( 'Invalid type received: ' . $type );
}
return $value;
}
/**
* Returns the `$wpdb` placeholder to use for data type `$type`.
*
* @param string $type Data type.
* @return string
* @throws \Exception When an invalid type is passed.
*/
public function get_wpdb_format_for_type( string $type ) {
static $wpdb_placeholder_for_type = array(
'int' => '%d',
'decimal' => '%f',
'string' => '%s',
'date' => '%s',
'date_epoch' => '%s',
'bool' => '%d',
);
if ( ! isset( $wpdb_placeholder_for_type[ $type ] ) ) {
throw new \Exception( 'Invalid column type: ' . $type );
}
return $wpdb_placeholder_for_type[ $type ];
}
/**
* Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
*
* @param array $columns List of column names.
*
* @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
*/
public function generate_on_duplicate_statement_clause( array $columns ): string {
$update_value_statements = array();
foreach ( $columns as $column ) {
$update_value_statements[] = "`$column` = VALUES( `$column` )";
}
$update_value_clause = implode( ', ', $update_value_statements );
return "ON DUPLICATE KEY UPDATE $update_value_clause";
}
/**
* Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. This needs unique constraints to be set on the table on all ID columns.
*
* You can use this function only when:
* 1. There is only one unique constraint on the table. The constraint can contain multiple columns, but it must be the only one unique constraint.
* 2. The complete unique constraint must be part of the $data array.
* 3. You do not need the LAST_INSERT_ID() value.
*
* @param string $table_name Table name.
* @param array $data Unescaped data to update (in column => value pairs).
* @param array $format An array of formats to be mapped to each of the values in $data.
*
* @return int Returns the value of DB's ON DUPLICATE KEY UPDATE clause.
*/
public function insert_on_duplicate_key_update( $table_name, $data, $format ) : int {
global $wpdb;
if ( empty( $data ) ) {
return 0;
}
$columns = array_keys( $data );
$value_format = array();
$values = array();
$index = 0;
// Directly use NULL for placeholder if the value is NULL, since otherwise $wpdb->prepare will convert it to empty string.
foreach ( $data as $key => $value ) {
if ( is_null( $value ) ) {
$value_format[] = 'NULL';
} else {
$values[] = $value;
$value_format[] = $format[ $index ];
}
$index++;
}
$column_clause = '`' . implode( '`, `', $columns ) . '`';
$value_format_clause = implode( ', ', $value_format );
$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
$sql = $wpdb->prepare(
"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_format_clause )
$on_duplicate_clause
",
$values
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
return $wpdb->query( $sql );
}
/**
* Get max index length.
*
* @return int Max index length.
*/
public function get_max_index_length() : int {
/**
* Filters the maximum index length in the database.
*
* Indexes have a maximum size of 767 bytes. Historically, we haven't need to be concerned about that.
* As of WP 4.2, however, they moved to utf8mb4, which uses 4 bytes per character. This means that an index which
* used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters.
*
* Additionally, MyISAM engine also limits the index size to 1000 bytes. We add this filter so that interested folks on InnoDB engine can increase the size till allowed 3071 bytes.
*
* @param int $max_index_length Maximum index length. Default 191.
*
* @since 8.0.0
*/
$max_index_length = apply_filters( 'woocommerce_database_max_index_length', 191 );
// Index length cannot be more than 768, which is 3078 bytes in utf8mb4 and max allowed by InnoDB engine.
return min( absint( $max_index_length ), 767 );
}
}
Internal/Utilities/HtmlSanitizer.php 0000644 00000005321 15153704500 0013602 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Utility for re-using WP Kses-based sanitization rules.
*/
class HtmlSanitizer {
/**
* Rules for allowing minimal HTML (breaks, images, paragraphs and spans) without any links.
*/
public const LOW_HTML_BALANCED_TAGS_NO_LINKS = array(
'pre_processors' => array(
'stripslashes',
'force_balance_tags',
),
'wp_kses_rules' => array(
'br' => true,
'img' => array(
'alt' => true,
'class' => true,
'src' => true,
'title' => true,
),
'p' => array(
'class' => true,
),
'span' => array(
'class' => true,
'title' => true,
),
),
);
/**
* Sanitizes the HTML according to the provided rules.
*
* @see wp_kses()
*
* @param string $html HTML string to be sanitized.
* @param array $sanitizer_rules {
* Optional and defaults to self::TRIMMED_BALANCED_LOW_HTML_NO_LINKS. Otherwise, one or more of the following
* keys should be set.
*
* @type array $pre_processors Callbacks to run before invoking `wp_kses()`.
* @type array $wp_kses_rules Element names and attributes to allow, per `wp_kses()`.
* }
*
* @return string
*/
public function sanitize( string $html, array $sanitizer_rules = self::LOW_HTML_BALANCED_TAGS_NO_LINKS ): string {
if ( isset( $sanitizer_rules['pre_processors'] ) && is_array( $sanitizer_rules['pre_processors'] ) ) {
$html = $this->apply_string_callbacks( $sanitizer_rules['pre_processors'], $html );
}
// If no KSES rules are specified, assume all HTML should be stripped.
$kses_rules = isset( $sanitizer_rules['wp_kses_rules'] ) && is_array( $sanitizer_rules['wp_kses_rules'] )
? $sanitizer_rules['wp_kses_rules']
: array();
return wp_kses( $html, $kses_rules );
}
/**
* Applies callbacks used to process the string before and after wp_kses().
*
* If a callback is invalid we will short-circuit and return an empty string, on the grounds that it is better to
* output nothing than risky HTML. We also call the problem out via _doing_it_wrong() to highlight the problem (and
* increase the chances of this being caught during development).
*
* @param callable[] $callbacks The callbacks used to mutate the string.
* @param string $string The string being processed.
*
* @return string
*/
private function apply_string_callbacks( array $callbacks, string $string ): string {
foreach ( $callbacks as $callback ) {
if ( ! is_callable( $callback ) ) {
_doing_it_wrong( __CLASS__ . '::apply', esc_html__( 'String processors must be an array of valid callbacks.', 'woocommerce' ), esc_html( WC()->version ) );
return '';
}
$string = (string) $callback( $string );
}
return $string;
}
}
Internal/Utilities/URL.php 0000644 00000032143 15153704500 0011451 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Provides an easy method of assessing URLs, including filepaths (which will be silently
* converted to a file:// URL if provided).
*/
class URL {
/**
* Components of the URL being assessed.
*
* The keys match those potentially returned by the parse_url() function, except
* that they are always defined and 'drive' (Windows drive letter) has been added.
*
* @var string|null[]
*/
private $components = array(
'drive' => null,
'fragment' => null,
'host' => null,
'pass' => null,
'path' => null,
'port' => null,
'query' => null,
'scheme' => null,
'user' => null,
);
/**
* If the URL (or filepath) is absolute.
*
* @var bool
*/
private $is_absolute;
/**
* If the URL (or filepath) represents a directory other than the root directory.
*
* This is useful at different points in the process, when deciding whether to re-apply
* a trailing slash at the end of processing or when we need to calculate how many
* directory traversals are needed to form a (grand-)parent URL.
*
* @var bool
*/
private $is_non_root_directory;
/**
* The components of the URL's path.
*
* For instance, in the case of "file:///srv/www/wp.site" (noting that a file URL has
* no host component) this would contain:
*
* [ "srv", "www", "wp.site" ]
*
* In the case of a non-file URL such as "https://example.com/foo/bar/baz" (noting the
* host is not part of the path) it would contain:
*
* [ "foo", "bar", "baz" ]
*
* @var array
*/
private $path_parts = array();
/**
* The URL.
*
* @var string
*/
private $url;
/**
* Creates and processes the provided URL (or filepath).
*
* @throws URLException If the URL (or filepath) is seriously malformed.
*
* @param string $url The URL (or filepath).
*/
public function __construct( string $url ) {
$this->url = $url;
$this->preprocess();
$this->process_path();
}
/**
* Makes all slashes forward slashes, converts filepaths to file:// URLs, and
* other processing to help with comprehension of filepaths.
*
* @throws URLException If the URL is seriously malformed.
*/
private function preprocess() {
// For consistency, all slashes should be forward slashes.
$this->url = str_replace( '\\', '/', $this->url );
// Windows: capture the drive letter if provided.
if ( preg_match( '#^(file://)?([a-z]):/(?!/).*#i', $this->url, $matches ) ) {
$this->components['drive'] = $matches[2];
}
/*
* If there is no scheme, assume and prepend "file://". An exception is made for cases where the URL simply
* starts with exactly two forward slashes, which indicates 'any scheme' (most commonly, that is used when
* there is freedom to switch between 'http' and 'https').
*/
if ( ! preg_match( '#^[a-z]+://#i', $this->url ) && ! preg_match( '#^//(?!/)#', $this->url ) ) {
$this->url = 'file://' . $this->url;
}
$parsed_components = wp_parse_url( $this->url );
// If we received a really badly formed URL, let's go no further.
if ( false === $parsed_components ) {
throw new URLException(
sprintf(
/* translators: %s is the URL. */
__( '%s is not a valid URL.', 'woocommerce' ),
$this->url
)
);
}
$this->components = array_merge( $this->components, $parsed_components );
// File URLs cannot have a host. However, the initial path segment *or* the Windows drive letter
// (if present) may be incorrectly be interpreted as the host name.
if ( 'file' === $this->components['scheme'] && ! empty( $this->components['host'] ) ) {
// If we do not have a drive letter, then simply merge the host and the path together.
if ( null === $this->components['drive'] ) {
$this->components['path'] = $this->components['host'] . ( $this->components['path'] ?? '' );
}
// Restore the host to null in this situation.
$this->components['host'] = null;
}
}
/**
* Simplifies the path if possible, by resolving directory traversals to the extent possible
* without touching the filesystem.
*/
private function process_path() {
$segments = explode( '/', $this->components['path'] );
$this->is_absolute = substr( $this->components['path'], 0, 1 ) === '/' || ! empty( $this->components['host'] );
$this->is_non_root_directory = substr( $this->components['path'], -1, 1 ) === '/' && strlen( $this->components['path'] ) > 1;
$resolve_traversals = 'file' !== $this->components['scheme'] || $this->is_absolute;
$retain_traversals = false;
// Clean the path.
foreach ( $segments as $part ) {
// Drop empty segments.
if ( strlen( $part ) === 0 || '.' === $part ) {
continue;
}
// Directory traversals created with percent-encoding syntax should also be detected.
$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';
// Resolve directory traversals (if allowed: see further comment relating to this).
if ( $resolve_traversals && $is_traversal ) {
if ( count( $this->path_parts ) > 0 && ! $retain_traversals ) {
$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
continue;
} elseif ( $this->is_absolute ) {
continue;
}
}
/*
* Consider allowing directory traversals to be resolved (ie, the process that converts 'foo/bar/../baz' to
* 'foo/baz').
*
* 1. For this decision point, we are only concerned with relative filepaths (in all other cases,
* $resolve_traversals will already be true).
* 2. This is a 'one time' and unidirectional operation. We only wish to flip from false to true, and we
* never wish to do this more than once.
* 3. We only flip the switch after we have examined all leading '..' traversal segments.
*/
if ( false === $resolve_traversals && '..' !== $part && 'file' === $this->components['scheme'] && ! $this->is_absolute ) {
$resolve_traversals = true;
}
/*
* Set a flag indicating that traversals should be retained. This is done to ensure we don't prematurely
* discard traversals at the start of the path.
*/
$retain_traversals = $resolve_traversals && '..' === $part;
// Retain this part of the path.
$this->path_parts[] = $part;
}
// Protect against empty relative paths.
if ( count( $this->path_parts ) === 0 && ! $this->is_absolute ) {
$this->path_parts = array( '.' );
$this->is_non_root_directory = true;
}
// Reform the path from the processed segments, appending a leading slash if it is absolute and restoring
// the Windows drive letter if we have one.
$this->components['path'] = ( $this->is_absolute ? '/' : '' ) . implode( '/', $this->path_parts ) . ( $this->is_non_root_directory ? '/' : '' );
}
/**
* Returns the processed URL as a string.
*
* @return string
*/
public function __toString(): string {
return $this->get_url();
}
/**
* Returns all possible parent URLs for the current URL.
*
* @return string[]
*/
public function get_all_parent_urls(): array {
$max_parent = count( $this->path_parts );
$parents = array();
/*
* If we are looking at a relative path that begins with at least one traversal (example: "../../foo")
* then we should only return one parent URL (otherwise, we'd potentially have to return an infinite
* number of parent URLs since we can't know how far the tree extends).
*/
if ( $max_parent > 0 && ! $this->is_absolute && '..' === $this->path_parts[0] ) {
$max_parent = 1;
}
for ( $level = 1; $level <= $max_parent; $level++ ) {
$parents[] = $this->get_parent_url( $level );
}
return $parents;
}
/**
* Outputs the parent URL.
*
* For example, if $this->get_url() returns "https://example.com/foo/bar/baz" then
* this method will return "https://example.com/foo/bar/".
*
* When a grand-parent is needed, the optional $level parameter can be used. By default
* this is set to 1 (parent). 2 will yield the grand-parent, 3 will yield the great
* grand-parent, etc.
*
* If a level is specified that exceeds the number of path segments, this method will
* return false.
*
* @param int $level Used to indicate the level of parent.
*
* @return string|false
*/
public function get_parent_url( int $level = 1 ) {
if ( $level < 1 ) {
$level = 1;
}
$parts_count = count( $this->path_parts );
$parent_path_parts_to_keep = $parts_count - $level;
/*
* With the exception of file URLs, we do not allow obtaining (grand-)parent directories that require
* us to describe them using directory traversals. For example, given "http://hostname/foo/bar/baz.png" we do
* not permit determining anything more than 2 levels up (we cannot go beyond "http://hostname/").
*/
if ( 'file' !== $this->components['scheme'] && $parent_path_parts_to_keep < 0 ) {
return false;
}
// In the specific case of an absolute filepath describing the root directory, there can be no parent.
if ( 'file' === $this->components['scheme'] && $this->is_absolute && empty( $this->path_parts ) ) {
return false;
}
// Handle cases where the path starts with one or more 'dot segments'. Since the path has already been
// processed, we can be confident that any such segments are at the start of the path.
if ( $parts_count > 0 && ( '.' === $this->path_parts[0] || '..' === $this->path_parts[0] ) ) {
// Determine the index of the last dot segment (ex: given the path '/../../foo' it would be 1).
$single_dots = array_keys( $this->path_parts, '.', true );
$double_dots = array_keys( $this->path_parts, '..', true );
$max_dot_index = max( array_merge( $single_dots, $double_dots ) );
// Prepend the required number of traversals and discard unnessary trailing segments.
$last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 );
$parent_path = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) );
} elseif ( $parent_path_parts_to_keep < 0 ) {
// For relative filepaths only, we use traversals to describe the requested parent.
$parent_path = untrailingslashit( str_repeat( '../', $parent_path_parts_to_keep * -1 ) );
} else {
// Otherwise, in a very simple case, we just remove existing parts.
$parent_path = implode( '/', array_slice( $this->path_parts, 0, $parent_path_parts_to_keep ) );
}
if ( $this->is_relative() && '' === $parent_path ) {
$parent_path = '.';
}
// Append a trailing slash, since a parent is always a directory. The only exception is the current working directory.
$parent_path .= '/';
// For absolute paths, apply a leading slash (does not apply if we have a root path).
if ( $this->is_absolute && 0 !== strpos( $parent_path, '/' ) ) {
$parent_path = '/' . $parent_path;
}
// Form the parent URL (ditching the query and fragment, if set).
$parent_url = $this->get_url(
array(
'path' => $parent_path,
'query' => null,
'fragment' => null,
)
);
// We process the parent URL through a fresh instance of this class, for consistency.
return ( new self( $parent_url ) )->get_url();
}
/**
* Outputs the processed URL.
*
* Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
*
* @param array $component_overrides If provided, these will override values set in $this->components.
*
* @return string
*/
public function get_url( array $component_overrides = array() ): string {
$components = array_merge( $this->components, $component_overrides );
$scheme = null !== $components['scheme'] ? $components['scheme'] . '://' : '//';
$host = null !== $components['host'] ? $components['host'] : '';
$port = null !== $components['port'] ? ':' . $components['port'] : '';
$path = $this->get_path( $components['path'] );
// Special handling for hostless URLs (typically, filepaths) referencing the current working directory.
if ( '' === $host && ( '' === $path || '.' === $path ) ) {
$path = './';
}
$user = null !== $components['user'] ? $components['user'] : '';
$pass = null !== $components['pass'] ? ':' . $components['pass'] : '';
$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';
$query = null !== $components['query'] ? '?' . $components['query'] : '';
$fragment = null !== $components['fragment'] ? '#' . $components['fragment'] : '';
return $scheme . $user_pass . $host . $port . $path . $query . $fragment;
}
/**
* Outputs the path. Especially useful if it was a a regular filepath that was passed in originally.
*
* @param string $path_override If provided this will be used as the URL path. Does not impact drive letter.
*
* @return string
*/
public function get_path( string $path_override = null ): string {
return ( $this->components['drive'] ? $this->components['drive'] . ':' : '' ) . ( $path_override ?? $this->components['path'] );
}
/**
* Indicates if the URL or filepath was absolute.
*
* @return bool True if absolute, else false.
*/
public function is_absolute(): bool {
return $this->is_absolute;
}
/**
* Indicates if the URL or filepath was relative.
*
* @return bool True if relative, else false.
*/
public function is_relative(): bool {
return ! $this->is_absolute;
}
}
Internal/Utilities/URLException.php 0000644 00000000277 15153704500 0013333 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Exception;
/**
* Used to represent a problem encountered when processing a URL.
*/
class URLException extends Exception {}
Internal/Utilities/Users.php 0000644 00000001460 15153704500 0012106 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with users.
*/
class Users {
/**
* Indicates if the user qualifies as site administrator.
*
* In the context of multisite networks, this means that they must have the `manage_sites`
* capability. In all other cases, they must have the `manage_options` capability.
*
* @param int $user_id Optional, used to specify a specific user (otherwise we look at the current user).
*
* @return bool
*/
public static function is_site_administrator( int $user_id = 0 ): bool {
$user = 0 === $user_id ? wp_get_current_user() : get_user_by( 'id', $user_id );
if ( false === $user ) {
return false;
}
return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
}
}
Internal/Utilities/WebhookUtil.php 0000644 00000010777 15153704500 0013254 0 ustar 00 <?php
/**
* WebhookUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
/**
* Class with utility methods for dealing with webhooks.
*/
class WebhookUtil {
use AccessiblePrivateMethods;
/**
* Creates a new instance of the class.
*/
public function __construct() {
self::add_action( 'deleted_user', array( $this, 'reassign_webhooks_to_new_user_id' ), 10, 2 );
self::add_action( 'delete_user_form', array( $this, 'maybe_render_user_with_webhooks_warning' ), 10, 2 );
}
/**
* Whenever a user is deleted, re-assign their webhooks to the new user.
*
* If re-assignment isn't selected during deletion, assign the webhooks to user_id 0,
* so that an admin can edit and re-save them in order to get them to be assigned to a valid user.
*
* @param int $old_user_id ID of the deleted user.
* @param int|null $new_user_id ID of the user to reassign existing data to, or null if no re-assignment is requested.
*
* @return void
* @since 7.8.0
*/
private function reassign_webhooks_to_new_user_id( int $old_user_id, ?int $new_user_id ): void {
$webhook_ids = $this->get_webhook_ids_for_user( $old_user_id );
foreach ( $webhook_ids as $webhook_id ) {
$webhook = new \WC_Webhook( $webhook_id );
$webhook->set_user_id( $new_user_id ?? 0 );
$webhook->save();
}
}
/**
* When users are about to be deleted show an informative text if they have webhooks assigned.
*
* @param \WP_User $current_user The current logged in user.
* @param array $userids Array with the ids of the users that are about to be deleted.
* @return void
* @since 7.8.0
*/
private function maybe_render_user_with_webhooks_warning( \WP_User $current_user, array $userids ): void {
global $wpdb;
$at_least_one_user_with_webhooks = false;
foreach ( $userids as $user_id ) {
$webhook_ids = $this->get_webhook_ids_for_user( $user_id );
if ( empty( $webhook_ids ) ) {
continue;
}
$at_least_one_user_with_webhooks = true;
$user_data = get_userdata( $user_id );
$user_login = false === $user_data ? '' : $user_data->user_login;
$webhooks_count = count( $webhook_ids );
$text = sprintf(
/* translators: 1 = user id, 2 = user login, 3 = webhooks count */
_nx(
'User #%1$s %2$s has created %3$d WooCommerce webhook.',
'User #%1$s %2$s has created %3$d WooCommerce webhooks.',
$webhooks_count,
'user webhook count',
'woocommerce'
),
$user_id,
$user_login,
$webhooks_count
);
echo '<p>' . esc_html( $text ) . '</p>';
}
if ( ! $at_least_one_user_with_webhooks ) {
return;
}
$webhooks_settings_url = esc_url_raw( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=webhooks' ) );
// This block of code is copied from WordPress' users.php.
// phpcs:disable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
$users_have_content = (bool) apply_filters( 'users_have_additional_content', false, $userids );
if ( ! $users_have_content ) {
if ( $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_author IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
$users_have_content = true;
} elseif ( $wpdb->get_var( "SELECT link_id FROM {$wpdb->links} WHERE link_owner IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
$users_have_content = true;
}
}
// phpcs:enable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
if ( $users_have_content ) {
$text = __( 'If the "Delete all content" option is selected, the affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
} else {
$text = __( 'The affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
}
$text .= sprintf(
/* translators: 1 = url of the WooCommerce webhooks settings page */
__( 'After that they can be reassigned to the logged-in user by going to the <a href="%1$s">WooCommerce webhooks settings page</a> and re-saving them.', 'woocommerce' ),
$webhooks_settings_url
);
echo '<p>' . wp_kses_post( $text ) . '</p>';
}
/**
* Get the ids of the webhooks assigned to a given user.
*
* @param int $user_id User id.
* @return int[] Array of webhook ids.
*/
private function get_webhook_ids_for_user( int $user_id ): array {
$data_store = \WC_Data_Store::load( 'webhook' );
return $data_store->search_webhooks(
array(
'user_id' => $user_id,
)
);
}
}
Internal/WCCom/ConnectionHelper.php 0000644 00000001255 15153704500 0013243 0 ustar 00 <?php
/**
* Helpers for managing connection to WooCommerce.com.
*/
namespace Automattic\WooCommerce\Internal\WCCom;
defined( 'ABSPATH' ) || exit;
/**
* Class WCConnectionHelper.
*
* Helpers for managing connection to WooCommerce.com.
*/
final class ConnectionHelper {
/**
* Check if WooCommerce.com account is connected.
*
* @since 4.4.0
* @return bool Whether account is connected.
*/
public static function is_connected() {
$helper_options = get_option( 'woocommerce_helper_data', array() );
if ( is_array( $helper_options ) && array_key_exists( 'auth', $helper_options ) && ! empty( $helper_options['auth'] ) ) {
return true;
}
return false;
}
}
Packages.php 0000644 00000007632 15153704500 0007003 0 ustar 00 <?php
/**
* Loads WooCommerce packages from the /packages directory. These are packages developed outside of core.
*/
namespace Automattic\WooCommerce;
defined( 'ABSPATH' ) || exit;
/**
* Packages class.
*
* @since 3.7.0
*/
class Packages {
/**
* Static-only class.
*/
private function __construct() {}
/**
* Array of package names and their main package classes.
*
* @var array Key is the package name/directory, value is the main package class which handles init.
*/
protected static $packages = array(
'woocommerce-blocks' => '\\Automattic\\WooCommerce\\Blocks\\Package'
);
/**
* Init the package loader.
*
* @since 3.7.0
*/
public static function init() {
add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ) );
}
/**
* Callback for WordPress init hook.
*/
public static function on_init() {
self::load_packages();
}
/**
* Checks a package exists by looking for it's directory.
*
* @param string $package Package name.
* @return boolean
*/
public static function package_exists( $package ) {
return file_exists( dirname( __DIR__ ) . '/packages/' . $package );
}
/**
* Loads packages after plugins_loaded hook.
*
* Each package should include an init file which loads the package so it can be used by core.
*/
protected static function load_packages() {
// Initialize WooCommerce Admin.
\Automattic\WooCommerce\Admin\Composer\Package::init();
foreach ( self::$packages as $package_name => $package_class ) {
if ( ! self::package_exists( $package_name ) ) {
self::missing_package( $package_name );
continue;
}
call_user_func( array( $package_class, 'init' ) );
}
// Proxies "activated_plugin" hook for embedded packages listen on WC plugin activation
// https://github.com/woocommerce/woocommerce/issues/28697.
if ( is_admin() ) {
$activated_plugin = get_transient( 'woocommerce_activated_plugin' );
if ( $activated_plugin ) {
delete_transient( 'woocommerce_activated_plugin' );
/**
* WooCommerce is activated hook.
*
* @since 5.0.0
* @param bool $activated_plugin Activated plugin path,
* generally woocommerce/woocommerce.php.
*/
do_action( 'woocommerce_activated_plugin', $activated_plugin );
}
}
}
/**
* If a package is missing, add an admin notice.
*
* @param string $package Package name.
*/
protected static function missing_package( $package ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore
sprintf(
/* Translators: %s package name. */
esc_html__( 'Missing the WooCommerce %s package', 'woocommerce' ),
'<code>' . esc_html( $package ) . '</code>'
) . ' - ' . esc_html__( 'Your installation of WooCommerce is incomplete. If you installed WooCommerce from GitHub, please refer to this document to set up your development environment: https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment', 'woocommerce' )
);
}
add_action(
'admin_notices',
function() use ( $package ) {
?>
<div class="notice notice-error">
<p>
<strong>
<?php
printf(
/* Translators: %s package name. */
esc_html__( 'Missing the WooCommerce %s package', 'woocommerce' ),
'<code>' . esc_html( $package ) . '</code>'
);
?>
</strong>
<br>
<?php
printf(
/* translators: 1: is a link to a support document. 2: closing link */
esc_html__( 'Your installation of WooCommerce is incomplete. If you installed WooCommerce from GitHub, %1$splease refer to this document%2$s to set up your development environment.', 'woocommerce' ),
'<a href="' . esc_url( 'https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment' ) . '" target="_blank" rel="noopener noreferrer">',
'</a>'
);
?>
</p>
</div>
<?php
}
);
}
}
Proxies/ActionsProxy.php 0000644 00000002120 15153704500 0011343 0 ustar 00 <?php
/**
* ActionsProxy class file.
*/
namespace Automattic\WooCommerce\Proxies;
/**
* Proxy for interacting with WordPress actions and filters.
*
* This class should be used instead of directly accessing the WordPress functions, to ease unit testing.
*/
class ActionsProxy {
/**
* Retrieve the number of times an action is fired.
*
* @param string $tag The name of the action hook.
*
* @return int The number of times action hook $tag is fired.
*/
public function did_action( $tag ) {
return did_action( $tag );
}
/**
* Calls the callback functions that have been added to a filter hook.
*
* @param string $tag The name of the filter hook.
* @param mixed $value The value to filter.
* @param mixed ...$parameters Additional parameters to pass to the callback functions.
*
* @return mixed The filtered value after all hooked functions are applied to it.
*/
public function apply_filters( $tag, $value, ...$parameters ) {
return apply_filters( $tag, $value, ...$parameters );
}
// TODO: Add the rest of the actions and filters related methods.
}
Proxies/LegacyProxy.php 0000644 00000007706 15153704500 0011166 0 ustar 00 <?php
/**
* LegacyProxy class file.
*/
namespace Automattic\WooCommerce\Proxies;
use Automattic\WooCommerce\Internal\DependencyManagement\Definition;
use Automattic\WooCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* Proxy class to access legacy WooCommerce functionality.
*
* This class should be used to interact with code outside the `src` directory, especially functions and classes
* in the `includes` directory, unless a more specific proxy exists for the functionality at hand (e.g. `ActionsProxy`).
* Idempotent functions can be executed directly.
*/
class LegacyProxy {
/**
* Gets an instance of a given legacy class.
* This must not be used to get instances of classes in the `src` directory.
*
* If a given class needs a special procedure to get an instance of it,
* please add a private get_instance_of_(lowercased_class_name) and it will be
* automatically invoked. See also how objects of classes having a static `instance`
* method are retrieved, similar approaches can be used as needed to make use
* of existing factory methods such as e.g. 'load'.
*
* @param string $class_name The name of the class to get an instance for.
* @param mixed ...$args Parameters to be passed to the class constructor or to the appropriate internal 'get_instance_of_' method.
*
* @return object The instance of the class.
* @throws \Exception The requested class belongs to the `src` directory, or there was an error creating an instance of the class.
*/
public function get_instance_of( string $class_name, ...$args ) {
if ( false !== strpos( $class_name, '\\' ) ) {
throw new \Exception(
'The LegacyProxy class is not intended for getting instances of classes in the src directory, please use ' .
Definition::INJECTION_METHOD . ' method injection or the instance of ' . ContainerInterface::class . ' for that.'
);
}
// If a class has a dedicated method to obtain a instance, use it.
$method = 'get_instance_of_' . strtolower( $class_name );
if ( method_exists( __CLASS__, $method ) ) {
return $this->$method( ...$args );
}
// If the class is a singleton, use the "instance" method.
if ( method_exists( $class_name, 'instance' ) ) {
return $class_name::instance( ...$args );
}
// If the class has a "load" method, use it.
if ( method_exists( $class_name, 'load' ) ) {
return $class_name::load( ...$args );
}
// Fallback to simply creating a new instance of the class.
return new $class_name( ...$args );
}
/**
* Get an instance of a class implementing WC_Queue_Interface.
*
* @return \WC_Queue_Interface The instance.
*/
private function get_instance_of_wc_queue_interface() {
return \WC_Queue::instance();
}
/**
* Call a user function. This should be used to execute any non-idempotent function, especially
* those in the `includes` directory or provided by WordPress.
*
* @param string $function_name The function to execute.
* @param mixed ...$parameters The parameters to pass to the function.
*
* @return mixed The result from the function.
*/
public function call_function( $function_name, ...$parameters ) {
return call_user_func_array( $function_name, $parameters );
}
/**
* Call a static method in a class. This should be used to execute any non-idempotent method in classes
* from the `includes` directory.
*
* @param string $class_name The name of the class containing the method.
* @param string $method_name The name of the method.
* @param mixed ...$parameters The parameters to pass to the method.
*
* @return mixed The result from the method.
*/
public function call_static( $class_name, $method_name, ...$parameters ) {
return call_user_func_array( "$class_name::$method_name", $parameters );
}
/**
* Get the value of a global.
*
* @param string $global_name The name of the global to get the value for.
* @return mixed The value of the global.
*/
public function get_global( string $global_name ) {
return $GLOBALS[ $global_name ];
}
}
Utilities/ArrayUtil.php 0000644 00000026424 15153704500 0011154 0 ustar 00 <?php
/**
* A class of utilities for dealing with arrays.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with arrays.
*/
class ArrayUtil {
/**
* Automatic selector type for the 'select' method.
*/
public const SELECT_BY_AUTO = 0;
/**
* Object method selector type for the 'select' method.
*/
public const SELECT_BY_OBJECT_METHOD = 1;
/**
* Object property selector type for the 'select' method.
*/
public const SELECT_BY_OBJECT_PROPERTY = 2;
/**
* Array key selector type for the 'select' method.
*/
public const SELECT_BY_ARRAY_KEY = 3;
/**
* Get a value from an nested array by specifying the entire key hierarchy with '::' as separator.
*
* E.g. for [ 'foo' => [ 'bar' => [ 'fizz' => 'buzz' ] ] ] the value for key 'foo::bar::fizz' would be 'buzz'.
*
* @param array $array The array to get the value from.
* @param string $key The complete key hierarchy, using '::' as separator.
* @param mixed $default The value to return if the key doesn't exist in the array.
*
* @return mixed The retrieved value, or the supplied default value.
* @throws \Exception $array is not an array.
*/
public static function get_nested_value( array $array, string $key, $default = null ) {
$key_stack = explode( '::', $key );
$subkey = array_shift( $key_stack );
if ( isset( $array[ $subkey ] ) ) {
$value = $array[ $subkey ];
if ( count( $key_stack ) ) {
foreach ( $key_stack as $subkey ) {
if ( is_array( $value ) && isset( $value[ $subkey ] ) ) {
$value = $value[ $subkey ];
} else {
$value = $default;
break;
}
}
}
} else {
$value = $default;
}
return $value;
}
/**
* Checks if a given key exists in an array and its value can be evaluated as 'true'.
*
* @param array $array The array to check.
* @param string $key The key for the value to check.
* @return bool True if the key exists in the array and the value can be evaluated as 'true'.
*/
public static function is_truthy( array $array, string $key ) {
return isset( $array[ $key ] ) && $array[ $key ];
}
/**
* Gets the value for a given key from an array, or a default value if the key doesn't exist in the array.
*
* This is equivalent to "$array[$key] ?? $default" except in one case:
* when they key exists, has a null value, and a non-null default is supplied:
*
* $array = ['key' => null]
* $array['key'] ?? 'default' => 'default'
* ArrayUtil::get_value_or_default($array, 'key', 'default') => null
*
* @param array $array The array to get the value from.
* @param string $key The key to use to retrieve the value.
* @param null $default The default value to return if the key doesn't exist in the array.
* @return mixed|null The value for the key, or the default value passed.
*/
public static function get_value_or_default( array $array, string $key, $default = null ) {
return array_key_exists( $key, $array ) ? $array[ $key ] : $default;
}
/**
* Converts an array of numbers to a human-readable range, such as "1,2,3,5" to "1-3, 5". It also supports
* floating point numbers, however with some perhaps unexpected / undefined behaviour if used within a range.
* Source: https://stackoverflow.com/a/34254663/4574
*
* @param array $items An array (in any order, see $sort) of individual numbers.
* @param string $item_separator The string that separates sequential range groups. Defaults to ', '.
* @param string $range_separator The string that separates ranges. Defaults to '-'. A plausible example otherwise would be ' to '.
* @param bool|true $sort Sort the array prior to iterating? You'll likely always want to sort, but if not, you can set this to false.
*
* @return string
*/
public static function to_ranges_string( array $items, string $item_separator = ', ', string $range_separator = '-', bool $sort = true ): string {
if ( $sort ) {
sort( $items );
}
$point = null;
$range = false;
$str = '';
foreach ( $items as $i ) {
if ( null === $point ) {
$str .= $i;
} elseif ( ( $point + 1 ) === $i ) {
$range = true;
} else {
if ( $range ) {
$str .= $range_separator . $point;
$range = false;
}
$str .= $item_separator . $i;
}
$point = $i;
}
if ( $range ) {
$str .= $range_separator . $point;
}
return $str;
}
/**
* Helper function to generate a callback which can be executed on an array to select a value from each item.
*
* @param string $selector_name Field/property/method name to select.
* @param int $selector_type Selector type.
*
* @return \Closure Callback to select the value.
*/
private static function get_selector_callback( string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): \Closure {
if ( self::SELECT_BY_OBJECT_METHOD === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
return $item->$selector_name();
};
} elseif ( self::SELECT_BY_OBJECT_PROPERTY === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
return $item->$selector_name;
};
} elseif ( self::SELECT_BY_ARRAY_KEY === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
return $item[ $selector_name ];
};
} else {
$callback = function( $item ) use ( $selector_name ) {
if ( is_array( $item ) ) {
return $item[ $selector_name ];
} elseif ( method_exists( $item, $selector_name ) ) {
return $item->$selector_name();
} else {
return $item->$selector_name;
}
};
}
return $callback;
}
/**
* Select one single value from all the items in an array of either arrays or objects based on a selector.
* For arrays, the selector is a key name; for objects, the selector can be either a method name or a property name.
*
* @param array $items Items to apply the selection to.
* @param string $selector_name Key, method or property name to use as a selector.
* @param int $selector_type Selector type, one of the SELECT_BY_* constants.
* @return array The selected values.
*/
public static function select( array $items, string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): array {
$callback = self::get_selector_callback( $selector_name, $selector_type );
return array_map( $callback, $items );
}
/**
* Returns a new assoc array with format [ $key1 => $item1, $key2 => $item2, ... ] where $key is the value of the selector and items are original items passed.
*
* @param array $items Items to use for conversion.
* @param string $selector_name Key, method or property name to use as a selector.
* @param int $selector_type Selector type, one of the SELECT_BY_* constants.
*
* @return array The converted assoc array.
*/
public static function select_as_assoc( array $items, string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): array {
$selector_callback = self::get_selector_callback( $selector_name, $selector_type );
$result = array();
foreach ( $items as $item ) {
$key = $selector_callback( $item );
self::ensure_key_is_array( $result, $key );
$result[ $key ][] = $item;
}
return $result;
}
/**
* Returns whether two assoc array are same. The comparison is done recursively by keys, and the functions returns on first difference found.
*
* @param array $array1 First array to compare.
* @param array $array2 Second array to compare.
* @param bool $strict Whether to use strict comparison.
*
* @return bool Whether the arrays are different.
*/
public static function deep_compare_array_diff( array $array1, array $array2, bool $strict = true ) {
return self::deep_compute_or_compare_array_diff( $array1, $array2, true, $strict );
}
/**
* Computes difference between two assoc arrays recursively. Similar to PHP's native assoc_array_diff, but also supports nested arrays.
*
* @param array $array1 First array.
* @param array $array2 Second array.
* @param bool $strict Whether to also match type of values.
*
* @return array The difference between the two arrays.
*/
public static function deep_assoc_array_diff( array $array1, array $array2, bool $strict = true ): array {
return self::deep_compute_or_compare_array_diff( $array1, $array2, false, $strict );
}
/**
* Helper method to compare to compute difference between two arrays. Comparison is done recursively.
*
* @param array $array1 First array.
* @param array $array2 Second array.
* @param bool $compare Whether to compare the arrays. If true, then function will return false on first difference, in order to be slightly more efficient.
* @param bool $strict Whether to do string comparison.
*
* @return array|bool The difference between the two arrays, or if array are same, depending upon $compare param.
*/
private static function deep_compute_or_compare_array_diff( array $array1, array $array2, bool $compare, bool $strict = true ) {
$diff = array();
foreach ( $array1 as $key => $value ) {
if ( is_array( $value ) ) {
if ( ! array_key_exists( $key, $array2 ) || ! is_array( $array2[ $key ] ) ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
continue;
}
$new_diff = self::deep_assoc_array_diff( $value, $array2[ $key ], $strict );
if ( ! empty( $new_diff ) ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $new_diff;
}
} elseif ( $strict ) {
if ( ! array_key_exists( $key, $array2 ) || $value !== $array2[ $key ] ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
}
} else {
// phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- Intentional when $strict is false.
if ( ! array_key_exists( $key, $array2 ) || $value != $array2[ $key ] ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
}
}
}
return $compare ? false : $diff;
}
/**
* Push a value to an array, but only if the value isn't in the array already.
*
* @param array $array The array.
* @param mixed $value The value to maybe push.
* @return bool True if the value has been added to the array, false if the value was already in the array.
*/
public static function push_once( array &$array, $value ) : bool {
if ( in_array( $value, $array, true ) ) {
return false;
}
$array[] = $value;
return true;
}
/**
* Ensure that an associative array has a given key, and if not, set the key to an empty array.
*
* @param array $array The array to check.
* @param string $key The key to check.
* @param bool $throw_if_existing_is_not_array If true, an exception will be thrown if the key already exists in the array but the value is not an array.
* @return bool True if the key has been added to the array, false if not (the key already existed).
* @throws \Exception The key already exists in the array but the value is not an array.
*/
public static function ensure_key_is_array( array &$array, string $key, bool $throw_if_existing_is_not_array = false ): bool {
if ( ! isset( $array[ $key ] ) ) {
$array[ $key ] = array();
return true;
}
if ( $throw_if_existing_is_not_array && ! is_array( $array[ $key ] ) ) {
$type = is_object( $array[ $key ] ) ? get_class( $array[ $key ] ) : gettype( $array[ $key ] );
throw new \Exception( "Array key exists but it's not an array, it's a {$type}" );
}
return false;
}
}
Utilities/FeaturesUtil.php 0000644 00000010766 15153704500 0011656 0 ustar 00 <?php
/**
* FeaturesUtil class file.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
/**
* Class with methods that allow to retrieve information about the existing WooCommerce features,
* also has methods for WooCommerce plugins to declare (in)compatibility with the features.
*/
class FeaturesUtil {
/**
* Get all the existing WooCommerce features.
*
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
*
* - name
* - description
* - is_experimental
* - is_enabled (if $include_enabled_info is passed as true)
*
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
*/
public static function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
return wc_get_container()->get( FeaturesController::class )->get_features( $include_experimental, $include_enabled_info );
}
/**
* Check if a given feature is currently enabled.
*
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
*/
public static function feature_is_enabled( string $feature_id ): bool {
return wc_get_container()->get( FeaturesController::class )->feature_is_enabled( $feature_id );
}
/**
* Declare (in)compatibility with a given feature for a given plugin.
*
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook and
* SHOULD be executed from the main plugin file passing __FILE__ or 'my-plugin/my-plugin.php' for the
* $plugin_file argument.
*
* @param string $feature_id Unique feature id.
* @param string $plugin_file The full plugin file path.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
*/
public static function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
$plugin_id = wc_get_container()->get( PluginUtil::class )->get_wp_plugin_id( $plugin_file );
if ( ! $plugin_id ) {
$logger = wc_get_logger();
$logger->error( "FeaturesUtil::declare_compatibility: {$plugin_file} is not a known WordPress plugin." );
return false;
}
return wc_get_container()->get( FeaturesController::class )->declare_compatibility( $feature_id, $plugin_id, $positive_compatibility );
}
/**
* Get the ids of the features that a certain plugin has declared compatibility for.
*
* This method can't be called before the 'woocommerce_init' hook is fired.
*
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin ids.
*/
public static function get_compatible_features_for_plugin( string $plugin_name ): array {
return wc_get_container()->get( FeaturesController::class )->get_compatible_features_for_plugin( $plugin_name );
}
/**
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
*
* @param string $feature_id Feature id.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
*/
public static function get_compatible_plugins_for_feature( string $feature_id ): array {
return wc_get_container()->get( FeaturesController::class )->get_compatible_plugins_for_feature( $feature_id );
}
/**
* Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
* from the WooCommerce feature settings page.
*/
public static function allow_enabling_features_with_incompatible_plugins(): void {
wc_get_container()->get( FeaturesController::class )->allow_enabling_features_with_incompatible_plugins();
}
/**
* Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
* from the WordPress plugins page.
*/
public static function allow_activating_plugins_with_incompatible_features(): void {
wc_get_container()->get( FeaturesController::class )->allow_activating_plugins_with_incompatible_features();
}
}
Utilities/I18nUtil.php 0000644 00000003277 15153704500 0010616 0 ustar 00 <?php
/**
* A class of utilities for dealing with internationalization.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with internationalization.
*/
final class I18nUtil {
/**
* A cache for the i18n units data.
*
* @var array $units
*/
private static $units;
/**
* Get the translated label for a weight unit of measure.
*
* This will return the original input string if it isn't found in the units array. This way a custom unit of
* measure can be used even if it's not getting translated.
*
* @param string $weight_unit The abbreviated weight unit in English, e.g. kg.
*
* @return string
*/
public static function get_weight_unit_label( $weight_unit ) {
if ( empty( self::$units ) ) {
self::$units = include WC()->plugin_path() . '/i18n/units.php';
}
$label = $weight_unit;
if ( ! empty( self::$units['weight'][ $weight_unit ] ) ) {
$label = self::$units['weight'][ $weight_unit ];
}
return $label;
}
/**
* Get the translated label for a dimensions unit of measure.
*
* This will return the original input string if it isn't found in the units array. This way a custom unit of
* measure can be used even if it's not getting translated.
*
* @param string $dimensions_unit The abbreviated dimension unit in English, e.g. cm.
*
* @return string
*/
public static function get_dimensions_unit_label( $dimensions_unit ) {
if ( empty( self::$units ) ) {
self::$units = include WC()->plugin_path() . '/i18n/units.php';
}
$label = $dimensions_unit;
if ( ! empty( self::$units['dimensions'][ $dimensions_unit ] ) ) {
$label = self::$units['dimensions'][ $dimensions_unit ];
}
return $label;
}
}
Utilities/NumberUtil.php 0000644 00000002230 15153704500 0011313 0 ustar 00 <?php
/**
* A class of utilities for dealing with numbers.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with numbers.
*/
final class NumberUtil {
/**
* Round a number using the built-in `round` function, but unless the value to round is numeric
* (a number or a string that can be parsed as a number), apply 'floatval' first to it
* (so it will convert it to 0 in most cases).
*
* This is needed because in PHP 7 applying `round` to a non-numeric value returns 0,
* but in PHP 8 it throws an error. Specifically, in WooCommerce we have a few places where
* round('') is often executed.
*
* @param mixed $val The value to round.
* @param int $precision The optional number of decimal digits to round to.
* @param int $mode A constant to specify the mode in which rounding occurs.
*
* @return float The value rounded to the given precision as a float, or the supplied default value.
*/
public static function round( $val, int $precision = 0, int $mode = PHP_ROUND_HALF_UP ) : float {
if ( ! is_numeric( $val ) ) {
$val = floatval( $val );
}
return round( $val, $precision, $mode );
}
}
Utilities/OrderUtil.php 0000644 00000014376 15153704500 0011154 0 ustar 00 <?php
/**
* A class of utilities for dealing with orders.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\COTMigrationUtil;
use WC_Order;
use WP_Post;
/**
* A class of utilities for dealing with orders.
*/
final class OrderUtil {
/**
* Helper function to get screen name of orders page in wp-admin.
*
* @return string
*/
public static function get_order_admin_screen() : string {
return wc_get_container()->get( COTMigrationUtil::class )->get_order_admin_screen();
}
/**
* Helper function to get whether custom order tables are enabled or not.
*
* @return bool
*/
public static function custom_orders_table_usage_is_enabled() : bool {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled();
}
/**
* Helper function to get whether the orders cache should be used or not.
*
* @return bool True if the orders cache should be used, false otherwise.
*/
public static function orders_cache_usage_is_enabled() : bool {
return wc_get_container()->get( OrderCacheController::class )->orders_cache_usage_is_enabled();
}
/**
* Checks if posts and order custom table sync is enabled and there are no pending orders.
*
* @return bool
*/
public static function is_custom_order_tables_in_sync() : bool {
return wc_get_container()->get( COTMigrationUtil::class )->is_custom_order_tables_in_sync();
}
/**
* Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
* This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
*
* @param WP_Post|null $post Post object, meta will be fetched from this only when `$data` is not passed.
* @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
* @param string $key Key to fetch metadata for.
* @param bool $single Whether metadata is single.
*
* @return array|mixed|string Value of the meta key.
*/
public static function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
return wc_get_container()->get( COTMigrationUtil::class )->get_post_or_object_meta( $post, $data, $key, $single );
}
/**
* Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
*
* @param WC_Order|WP_Post $post_or_order_object Post or order object.
*
* @return bool|WC_Order|WC_Order_Refund WC_Order object.
*/
public static function init_theorder_object( $post_or_order_object ) {
return wc_get_container()->get( COTMigrationUtil::class )->init_theorder_object( $post_or_order_object );
}
/**
* Helper function to id from an post or order object.
*
* @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
*
* @return int Order or post ID.
*/
public static function get_post_or_order_id( $post_or_order_object ) : int {
return wc_get_container()->get( COTMigrationUtil::class )->get_post_or_order_id( $post_or_order_object );
}
/**
* Checks if passed id, post or order object is a WC_Order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
* @param string[] $types Types to match against.
*
* @return bool Whether the passed param is an order.
*/
public static function is_order( $order_id, $types = array( 'shop_order' ) ) {
return wc_get_container()->get( COTMigrationUtil::class )->is_order( $order_id, $types );
}
/**
* Returns type pf passed id, post or order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
*
* @return string|null Type of the order.
*/
public static function get_order_type( $order_id ) {
return wc_get_container()->get( COTMigrationUtil::class )->get_order_type( $order_id );
}
/**
* Helper method to generate admin url for an order.
*
* @param int $order_id Order ID.
*
* @return string Admin url for an order.
*/
public static function get_order_admin_edit_url( int $order_id ) : string {
return wc_get_container()->get( PageController::class )->get_edit_url( $order_id );
}
/**
* Helper method to generate admin URL for new order.
*
* @return string Link for new order.
*/
public static function get_order_admin_new_url() : string {
return wc_get_container()->get( PageController::class )->get_new_page_url();
}
/**
* Check if the current admin screen is an order list table.
*
* @param string $order_type Optional. The order type to check for. Default shop_order.
*
* @return bool
*/
public static function is_order_list_table_screen( $order_type = 'shop_order' ) : bool {
return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'list' );
}
/**
* Check if the current admin screen is for editing an order.
*
* @param string $order_type Optional. The order type to check for. Default shop_order.
*
* @return bool
*/
public static function is_order_edit_screen( $order_type = 'shop_order' ) : bool {
return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'edit' );
}
/**
* Check if the current admin screen is adding a new order.
*
* @param string $order_type Optional. The order type to check for. Default shop_order.
*
* @return bool
*/
public static function is_new_order_screen( $order_type = 'shop_order' ) : bool {
return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'new' );
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public static function get_table_for_orders() {
return wc_get_container()->get( COTMigrationUtil::class )->get_table_for_orders();
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public static function get_table_for_order_meta() {
return wc_get_container()->get( COTMigrationUtil::class )->get_table_for_order_meta();
}
}
Utilities/PluginUtil.php 0000644 00000017227 15153704500 0011335 0 ustar 00 <?php
/**
* A class of utilities for dealing with plugins.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
* A class of utilities for dealing with plugins.
*/
class PluginUtil {
use AccessiblePrivateMethods;
/**
* The LegacyProxy instance to use.
*
* @var LegacyProxy
*/
private $proxy;
/**
* The cached list of WooCommerce aware plugin ids.
*
* @var null|array
*/
private $woocommerce_aware_plugins = null;
/**
* The cached list of enabled WooCommerce aware plugin ids.
*
* @var null|array
*/
private $woocommerce_aware_active_plugins = null;
/**
* Creates a new instance of the class.
*/
public function __construct() {
self::add_action( 'activated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 );
self::add_action( 'deactivated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 );
}
/**
* Initialize the class instance.
*
* @internal
*
* @param LegacyProxy $proxy The instance of LegacyProxy to use.
*/
final public function init( LegacyProxy $proxy ) {
$this->proxy = $proxy;
require_once ABSPATH . WPINC . '/plugin.php';
}
/**
* Get a list with the names of the WordPress plugins that are WooCommerce aware
* (they have a "WC tested up to" header).
*
* @param bool $active_only True to return only active plugins, false to return all the active plugins.
* @return string[] A list of plugin ids (path/file.php).
*/
public function get_woocommerce_aware_plugins( bool $active_only = false ): array {
if ( is_null( $this->woocommerce_aware_plugins ) ) {
// In case `get_plugins` was called much earlier in the request (before our headers could be injected), we
// invalidate the plugin cache list.
wp_cache_delete( 'plugins', 'plugins' );
$all_plugins = $this->proxy->call_function( 'get_plugins' );
$this->woocommerce_aware_plugins =
array_keys(
array_filter(
$all_plugins,
array( $this, 'is_woocommerce_aware_plugin' )
)
);
$this->woocommerce_aware_active_plugins =
array_values(
array_filter(
$this->woocommerce_aware_plugins,
function ( $plugin_name ) {
return $this->proxy->call_function( 'is_plugin_active', $plugin_name );
}
)
);
}
return $active_only ? $this->woocommerce_aware_active_plugins : $this->woocommerce_aware_plugins;
}
/**
* Get the printable name of a plugin.
*
* @param string $plugin_id Plugin id (path/file.php).
* @return string Printable plugin name, or the plugin id itself if printable name is not available.
*/
public function get_plugin_name( string $plugin_id ): string {
$plugin_data = $this->proxy->call_function( 'get_plugin_data', WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $plugin_id );
return $plugin_data['Name'] ?? $plugin_id;
}
/**
* Check if a plugin is WooCommerce aware.
*
* @param string|array $plugin_file_or_data Plugin id (path/file.php) or plugin data (as returned by get_plugins).
* @return bool True if the plugin exists and is WooCommerce aware.
* @throws \Exception The input is neither a string nor an array.
*/
public function is_woocommerce_aware_plugin( $plugin_file_or_data ): bool {
if ( is_string( $plugin_file_or_data ) ) {
return in_array( $plugin_file_or_data, $this->get_woocommerce_aware_plugins(), true );
} elseif ( is_array( $plugin_file_or_data ) ) {
return '' !== ( $plugin_file_or_data['WC tested up to'] ?? '' );
} else {
throw new \Exception( 'is_woocommerce_aware_plugin requires a plugin name or an array of plugin data as input' );
}
}
/**
* Match plugin identifier passed as a parameter with the output from `get_plugins()`.
*
* @param string $plugin_file Plugin identifier, either 'my-plugin/my-plugin.php', or output from __FILE__.
*
* @return string|false Key from the array returned by `get_plugins` if matched. False if no match.
*/
public function get_wp_plugin_id( $plugin_file ) {
$wp_plugins = array_keys( $this->proxy->call_function( 'get_plugins' ) );
// Try to match plugin_basename().
$plugin_basename = $this->proxy->call_function( 'plugin_basename', $plugin_file );
if ( in_array( $plugin_basename, $wp_plugins, true ) ) {
return $plugin_basename;
}
// Try to match by the my-file/my-file.php (dir + file name), then by my-file.php (file name only).
$plugin_file = str_replace( array( '\\', '/' ), DIRECTORY_SEPARATOR, $plugin_file );
$file_name_parts = explode( DIRECTORY_SEPARATOR, $plugin_file );
$file_name = array_pop( $file_name_parts );
$directory_name = array_pop( $file_name_parts );
$full_matches = array();
$partial_matches = array();
foreach ( $wp_plugins as $wp_plugin ) {
if ( false !== strpos( $wp_plugin, $directory_name . DIRECTORY_SEPARATOR . $file_name ) ) {
$full_matches[] = $wp_plugin;
}
if ( false !== strpos( $wp_plugin, $file_name ) ) {
$partial_matches[] = $wp_plugin;
}
}
if ( 1 === count( $full_matches ) ) {
return $full_matches[0];
}
if ( 1 === count( $partial_matches ) ) {
return $partial_matches[0];
}
return false;
}
/**
* Handle plugin activation and deactivation by clearing the WooCommerce aware plugin ids cache.
*/
private function handle_plugin_de_activation(): void {
$this->woocommerce_aware_plugins = null;
$this->woocommerce_aware_active_plugins = null;
}
/**
* Util function to generate warning string for incompatible features based on active plugins.
*
* @param string $feature_id Feature id.
* @param array $plugin_feature_info Array of plugin feature info. See FeaturesControllers->get_compatible_plugins_for_feature() for details.
*
* @return string Warning string.
*/
public function generate_incompatible_plugin_feature_warning( string $feature_id, array $plugin_feature_info ) : string {
$feature_warning = '';
$incompatibles = array_merge( $plugin_feature_info['incompatible'], $plugin_feature_info['uncertain'] );
$incompatibles = array_filter( $incompatibles, 'is_plugin_active' );
$incompatible_count = count( $incompatibles );
if ( $incompatible_count > 0 ) {
if ( 1 === $incompatible_count ) {
/* translators: %s = printable plugin name */
$feature_warning = sprintf( __( '⚠ 1 Incompatible plugin detected (%s).', 'woocommerce' ), $this->get_plugin_name( $incompatibles[0] ) );
} elseif ( 2 === $incompatible_count ) {
$feature_warning = sprintf(
/* translators: %1\$s, %2\$s = printable plugin names */
__( '⚠ 2 Incompatible plugins detected (%1$s and %2$s).', 'woocommerce' ),
$this->get_plugin_name( $incompatibles[0] ),
$this->get_plugin_name( $incompatibles[1] )
);
} else {
$feature_warning = sprintf(
/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
_n(
'⚠ Incompatible plugins detected (%1$s, %2$s and %3$d other).',
'⚠ Incompatible plugins detected (%1$s and %2$s plugins and %3$d others).',
$incompatible_count - 2,
'woocommerce'
),
$this->get_plugin_name( $incompatibles[0] ),
$this->get_plugin_name( $incompatibles[1] ),
$incompatible_count - 2
);
}
$incompatible_plugins_url = add_query_arg(
array(
'plugin_status' => 'incompatible_with_feature',
'feature_id' => $feature_id,
),
admin_url( 'plugins.php' )
);
$extra_desc_tip = '<br>' . sprintf(
/* translators: %1$s opening link tag %2$s closing link tag. */
__( '%1$sView and manage%2$s', 'woocommerce' ),
'<a href="' . esc_url( $incompatible_plugins_url ) . '">',
'</a>'
);
$feature_warning .= $extra_desc_tip;
}
return $feature_warning;
}
}
Utilities/StringUtil.php 0000644 00000011156 15153704500 0011340 0 ustar 00 <?php
/**
* A class of utilities for dealing with strings.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with strings.
*/
final class StringUtil {
/**
* Checks to see whether or not a string starts with another.
*
* @param string $string The string we want to check.
* @param string $starts_with The string we're looking for at the start of $string.
* @param bool $case_sensitive Indicates whether the comparison should be case-sensitive.
*
* @return bool True if the $string starts with $starts_with, false otherwise.
*/
public static function starts_with( string $string, string $starts_with, bool $case_sensitive = true ): bool {
$len = strlen( $starts_with );
if ( $len > strlen( $string ) ) {
return false;
}
$string = substr( $string, 0, $len );
if ( $case_sensitive ) {
return strcmp( $string, $starts_with ) === 0;
}
return strcasecmp( $string, $starts_with ) === 0;
}
/**
* Checks to see whether or not a string ends with another.
*
* @param string $string The string we want to check.
* @param string $ends_with The string we're looking for at the end of $string.
* @param bool $case_sensitive Indicates whether the comparison should be case-sensitive.
*
* @return bool True if the $string ends with $ends_with, false otherwise.
*/
public static function ends_with( string $string, string $ends_with, bool $case_sensitive = true ): bool {
$len = strlen( $ends_with );
if ( $len > strlen( $string ) ) {
return false;
}
$string = substr( $string, -$len );
if ( $case_sensitive ) {
return strcmp( $string, $ends_with ) === 0;
}
return strcasecmp( $string, $ends_with ) === 0;
}
/**
* Checks if one string is contained into another at any position.
*
* @param string $string The string we want to check.
* @param string $contained The string we're looking for inside $string.
* @param bool $case_sensitive Indicates whether the comparison should be case-sensitive.
* @return bool True if $contained is contained inside $string, false otherwise.
*/
public static function contains( string $string, string $contained, bool $case_sensitive = true ): bool {
if ( $case_sensitive ) {
return false !== strpos( $string, $contained );
} else {
return false !== stripos( $string, $contained );
}
}
/**
* Get the name of a plugin in the form 'directory/file.php', as in the keys of the array returned by 'get_plugins'.
*
* @param string $plugin_file_path The path of the main plugin file (can be passed as __FILE__ from the plugin itself).
* @return string The name of the plugin in the form 'directory/file.php'.
*/
public static function plugin_name_from_plugin_file( string $plugin_file_path ): string {
return basename( dirname( $plugin_file_path ) ) . DIRECTORY_SEPARATOR . basename( $plugin_file_path );
}
/**
* Check if a string is null or is empty.
*
* @param string|null $value The string to check.
* @return bool True if the string is null or is empty.
*/
public static function is_null_or_empty( ?string $value ) {
return is_null( $value ) || '' === $value;
}
/**
* Check if a string is null, is empty, or has only whitespace characters
* (space, tab, vertical tab, form feed, carriage return, new line)
*
* @param string|null $value The string to check.
* @return bool True if the string is null, is empty, or contains only whitespace characters.
*/
public static function is_null_or_whitespace( ?string $value ) {
return is_null( $value ) || '' === $value || ctype_space( $value );
}
/**
* Convert an array of values to a list suitable for a SQL "IN" statement
* (so comma separated and delimited by parenthesis).
* e.g.: [1,2,3] --> (1,2,3)
*
* @param array $values The values to convert.
* @return string A parenthesized and comma-separated string generated from the values.
* @throws \InvalidArgumentException Empty values array passed.
*/
public static function to_sql_list( array $values ) {
if ( empty( $values ) ) {
throw new \InvalidArgumentException( self::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ . ': the values array is empty' );
}
return '(' . implode( ',', $values ) . ')';
}
/**
* Get the name of a class without the namespace.
*
* @param string $class_name The full class name.
* @return string The class name without the namespace.
*/
public static function class_name_without_namespace( string $class_name ) {
// A '?:' would convert this to a one-liner, but WP coding standards disallow these :shrug:.
$result = substr( strrchr( $class_name, '\\' ), 1 );
return $result ? $result : $class_name;
}
}
API/Google/Ads.php 0000644 00000025013 15153721356 0007622 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAccountAccessQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAccountQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsBillingStatusQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsProductLinkInvitationQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Exception;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Enums\AccessRoleEnum\AccessRole;
use Google\Ads\GoogleAds\V18\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
use Google\Ads\GoogleAds\V18\Resources\ProductLinkInvitation;
use Google\Ads\GoogleAds\V18\Services\ListAccessibleCustomersRequest;
use Google\Ads\GoogleAds\V18\Services\UpdateProductLinkInvitationRequest;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
defined( 'ABSPATH' ) || exit;
/**
* Class Ads
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class Ads implements OptionsAwareInterface {
use ExceptionTrait;
use OptionsAwareTrait;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* Ads constructor.
*
* @param GoogleAdsClient $client
*/
public function __construct( GoogleAdsClient $client ) {
$this->client = $client;
}
/**
* Get Ads accounts associated with the connected Google account.
*
* @return array
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_ads_accounts(): array {
try {
$customers = $this->client->getCustomerServiceClient()->listAccessibleCustomers( new ListAccessibleCustomersRequest() );
$accounts = [];
foreach ( $customers->getResourceNames() as $name ) {
$account = $this->get_account_details( $name );
if ( $account ) {
$accounts[] = $account;
}
}
return $accounts;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
// Return an empty list if the user has not signed up to ads yet.
if ( isset( $errors['NOT_ADS_USER'] ) ) {
return [];
}
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error retrieving accounts: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Get billing status.
*
* @return string
*/
public function get_billing_status(): string {
$ads_id = $this->options->get_ads_id();
if ( ! $ads_id ) {
return BillingSetupStatus::UNKNOWN;
}
try {
$results = ( new AdsBillingStatusQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->get_results();
foreach ( $results->iterateAllElements() as $row ) {
$billing_setup = $row->getBillingSetup();
$status = BillingSetupStatus::label( $billing_setup->getStatus() );
return apply_filters( 'woocommerce_gla_ads_billing_setup_status', $status, $ads_id );
}
} catch ( ApiException | ValidationException $e ) {
// Do not act upon error as we might not have permission to access this account yet.
if ( 'PERMISSION_DENIED' !== $e->getStatus() ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
}
}
return apply_filters( 'woocommerce_gla_ads_billing_setup_status', BillingSetupStatus::UNKNOWN, $ads_id );
}
/**
* Accept the pending approval link sent from a merchant account.
*
* @param int $merchant_id Merchant Center account id.
* @throws Exception When the pending approval link can not be found.
*/
public function accept_merchant_link( int $merchant_id ) {
$link = $this->get_merchant_link( $merchant_id, 10 );
$request = new UpdateProductLinkInvitationRequest();
$request->setCustomerId( $this->options->get_ads_id() );
$request->setResourceName( $link->getResourceName() );
$request->setProductLinkInvitationStatus( ProductLinkInvitationStatus::ACCEPTED );
$this->client->getProductLinkInvitationServiceClient()->updateProductLinkInvitation( $request );
}
/**
* Check if we have access to the ads account.
*
* @param string $email Email address of the connected account.
*
* @return bool
*/
public function has_access( string $email ): bool {
$ads_id = $this->options->get_ads_id();
try {
$results = ( new AdsAccountAccessQuery() )
->set_client( $this->client, $ads_id )
->where( 'customer_user_access.email_address', $email )
->get_results();
foreach ( $results->iterateAllElements() as $row ) {
$access = $row->getCustomerUserAccess();
if ( AccessRole::ADMIN === $access->getAccessRole() ) {
return true;
}
}
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
}
return false;
}
/**
* Get the ads account currency.
*
* @since 1.4.1
*
* @return string
*/
public function get_ads_currency(): string {
// Retrieve account currency from the API if we haven't done so previously.
if ( $this->options->get_ads_id() && ! $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ) ) {
$this->request_ads_currency();
}
return strtoupper( $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ) ?? get_woocommerce_currency() );
}
/**
* Request the Ads Account currency, and cache it as an option.
*
* @since 1.1.0
*
* @return boolean
*/
public function request_ads_currency(): bool {
try {
$ads_id = $this->options->get_ads_id();
$account = ResourceNames::forCustomer( $ads_id );
$customer = ( new AdsAccountQuery() )
->set_client( $this->client, $ads_id )
->columns( [ 'customer.currency_code' ] )
->where( 'customer.resource_name', $account, '=' )
->get_result()
->getCustomer();
$currency = $customer->getCurrencyCode();
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$currency = null;
}
return $this->options->update( OptionsInterface::ADS_ACCOUNT_CURRENCY, $currency );
}
/**
* Save the Ads account currency to the same value as the Store currency.
*
* @since 1.1.0
*
* @return boolean
*/
public function use_store_currency(): bool {
return $this->options->update( OptionsInterface::ADS_ACCOUNT_CURRENCY, get_woocommerce_currency() );
}
/**
* Convert ads ID from a resource name to an int.
*
* @param string $name Resource name containing ID number.
*
* @return int
*/
public function parse_ads_id( string $name ): int {
return absint( str_replace( 'customers/', '', $name ) );
}
/**
* Update the Ads ID to use for requests.
*
* @param int $id Ads ID number.
*
* @return bool
*/
public function update_ads_id( int $id ): bool {
return $this->options->update( OptionsInterface::ADS_ID, $id );
}
/**
* Returns true if the Ads id exists in the options.
*
* @return bool
*/
public function ads_id_exists(): bool {
return ! empty( $this->options->get( OptionsInterface::ADS_ID ) );
}
/**
* Update the billing flow URL so we can retrieve it again later.
*
* @param string $url Billing flow URL.
*
* @return bool
*/
public function update_billing_url( string $url ): bool {
return $this->options->update( OptionsInterface::ADS_BILLING_URL, $url );
}
/**
* Update the OCID for the account so that we can reference it later in order
* to link to accept invite link or to send customer to conversion settings page
* in their account.
*
* @param string $url Billing flow URL.
*
* @return bool
*/
public function update_ocid_from_billing_url( string $url ): bool {
$query_string = wp_parse_url( $url, PHP_URL_QUERY );
// Return if no params.
if ( empty( $query_string ) ) {
return false;
}
parse_str( $query_string, $params );
if ( empty( $params['ocid'] ) ) {
return false;
}
return $this->options->update( OptionsInterface::ADS_ACCOUNT_OCID, $params['ocid'] );
}
/**
* Fetch the account details.
* Returns null for any account that fails or is not the right type.
*
* @param string $account Customer resource name.
* @return null|array
*/
private function get_account_details( string $account ): ?array {
try {
$customer = ( new AdsAccountQuery() )
->set_client( $this->client, $this->parse_ads_id( $account ) )
->where( 'customer.resource_name', $account, '=' )
->get_result()
->getCustomer();
if ( ! $customer || $customer->getManager() || $customer->getTestAccount() ) {
return null;
}
return [
'id' => $customer->getId(),
'name' => $customer->getDescriptiveName(),
];
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
}
return null;
}
/**
* Get the pending approval link sent from a Google Merchant account.
*
* The invitation link may not be available in Google Ads immediately after
* the invitation is sent from Google Merchant Center, so this method offers
* a parameter to specify the number of retries.
*
* @param int $merchant_id Merchant Center account id.
* @param int $attempts_left The number of attempts left to get the link.
*
* @return ProductLinkInvitation
* @throws Exception When the pending approval link can not be found.
*/
private function get_merchant_link( int $merchant_id, int $attempts_left = 0 ): ProductLinkInvitation {
$res = ( new AdsProductLinkInvitationQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'product_link_invitation.status', ProductLinkInvitationStatus::name( ProductLinkInvitationStatus::PENDING_APPROVAL ) )
->get_results();
foreach ( $res->iterateAllElements() as $row ) {
$link = $row->getProductLinkInvitation();
$mc = $link->getMerchantCenter();
$mc_id = $mc->getMerchantCenterId();
if ( absint( $mc_id ) === $merchant_id ) {
return $link;
}
}
if ( $attempts_left > 0 ) {
sleep( 1 );
return $this->get_merchant_link( $merchant_id, $attempts_left - 1 );
}
throw new Exception( __( 'Unable to find the pending approval link sent from the Merchant Center account', 'google-listings-and-ads' ) );
}
}
API/Google/AdsAsset.php 0000644 00000021774 15153721356 0010634 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Enums\AssetTypeEnum\AssetType;
use Google\Ads\GoogleAds\V18\Resources\Asset;
use Google\Ads\GoogleAds\V18\Services\AssetOperation;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Common\TextAsset;
use Google\Ads\GoogleAds\V18\Common\ImageAsset;
use Google\Ads\GoogleAds\V18\Common\CallToActionAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Google\ApiCore\ApiException;
use Exception;
/**
* Class AdsAsset
*
* Used for the Performance Max Campaigns
* https://developers.google.com/google-ads/api/docs/performance-max/assets
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsAsset implements OptionsAwareInterface {
use OptionsAwareTrait;
/**
* WP Proxy
*
* @var WP
*/
protected WP $wp;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected GoogleAdsClient $client;
/**
* Maximum payload size in bytes.
*
* @var int
*/
protected const MAX_PAYLOAD_BYTES = 30 * 1024 * 1024;
/**
* Maximum image size in bytes.
*
* @var int
*/
protected const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
/**
* AdsAsset constructor.
*
* @param GoogleAdsClient $client The Google Ads client.
* @param WP $wp The WordPress proxy.
*/
public function __construct( GoogleAdsClient $client, WP $wp ) {
$this->client = $client;
$this->wp = $wp;
}
/**
* Temporary ID to use within a batch job.
* A negative number which is unique for all the created resources.
*
* @var int
*/
protected static $temporary_id = -5;
/**
* Return a temporary resource name for the asset.
*
* @param int $temporary_id The temporary ID to use for the asset.
*
* @return string The Asset resource name.
*/
protected function temporary_resource_name( int $temporary_id ): string {
return ResourceNames::forAsset( $this->options->get_ads_id(), $temporary_id );
}
/**
* Returns the asset type for the given field type.
*
* @param string $field_type The field type.
*
* @return int The asset type.
* @throws Exception If the field type is not supported.
*/
protected function get_asset_type_by_field_type( string $field_type ): int {
switch ( $field_type ) {
case AssetFieldType::LOGO:
case AssetFieldType::MARKETING_IMAGE:
case AssetFieldType::SQUARE_MARKETING_IMAGE:
case AssetFieldType::PORTRAIT_MARKETING_IMAGE:
return AssetType::IMAGE;
case AssetFieldType::CALL_TO_ACTION_SELECTION:
return AssetType::CALL_TO_ACTION;
case AssetFieldType::HEADLINE:
case AssetFieldType::LONG_HEADLINE:
case AssetFieldType::DESCRIPTION:
case AssetFieldType::BUSINESS_NAME:
return AssetType::TEXT;
default:
throw new Exception( 'Asset Field type not supported' );
}
}
/**
* Returns the image data.
*
* @param string $url The image url.
*
* @return array The image data.
* @throws Exception If the image url is not a valid url or the image size is too large.
*/
protected function get_image_data( string $url ): array {
$image_data = $this->wp->wp_remote_get( $url );
if ( is_wp_error( $image_data ) || empty( $image_data['body'] ) ) {
throw new Exception( sprintf( 'There was a problem loading the url: %s', $url ) );
}
$size = $image_data['headers']->offsetGet( 'content-length' );
if ( $size > self::MAX_IMAGE_SIZE_BYTES ) {
throw new Exception( 'Image size is too large.' );
}
return [
'body' => $image_data['body'],
'size' => $size,
];
}
/**
* Returns a list of batches of assets.
*
* @param array $assets A list of assets.
* @param int $max_size The maximum size of the payload in bytes.
*
* @return array A list of batches of assets.
* @throws Exception If the image url is not a valid url, if the field type is not supported or the image size is too big.
*/
protected function create_batches( array $assets, int $max_size = self::MAX_PAYLOAD_BYTES ): array {
$batch_size = 0;
$index = 0;
$batches = [];
foreach ( $assets as $asset ) {
if ( $this->get_asset_type_by_field_type( $asset['field_type'] ) === AssetType::IMAGE ) {
$image_data = $this->get_image_data( $asset['content'] );
$asset['body'] = $image_data['body'];
$batch_size += $image_data['size'];
if ( $batch_size > $max_size ) {
$batches[ ++$index ][] = $asset;
$batch_size = $image_data['size'];
continue;
}
}
$batches[ $index ][] = $asset;
}
return $batches;
}
/**
* Creates the assets so they can be used in the asset groups.
*
* @param array $assets The assets to create.
* @param int $batch_size The maximum size of the payload in bytes.
*
* @return array A list of Asset's ARN created.
*
* @throws Exception If the asset type is not supported or if the image url is not a valid url.
* @throws ApiException If any of the operations fail.
*/
public function create_assets( array $assets, int $batch_size = self::MAX_PAYLOAD_BYTES ): array {
if ( empty( $assets ) ) {
return [];
}
$batches = $this->create_batches( $assets, $batch_size );
$arns = [];
foreach ( $batches as $batch ) {
$operations = [];
foreach ( $batch as $asset ) {
$operations[] = $this->create_operation( $asset, self::$temporary_id-- );
}
// If the mutate operation fails, it will throw an exception that will be caught by the caller.
$arns = [ ...$arns, ...$this->mutate( $operations ) ];
}
return $arns;
}
/**
* Returns an operation to create a text asset.
*
* @param array $data The asset data.
* @param int $temporary_id The temporary ID to use for the asset.
*
* @return MutateOperation The create asset operation.
* @throws Exception If the asset type is not supported.
*/
protected function create_operation( array $data, int $temporary_id ): MutateOperation {
$asset = new Asset(
[
'resource_name' => $this->temporary_resource_name( $temporary_id ),
]
);
switch ( $this->get_asset_type_by_field_type( $data['field_type'] ) ) {
case AssetType::CALL_TO_ACTION:
$asset->setCallToActionAsset( new CallToActionAsset( [ 'call_to_action' => CallToActionType::number( $data['content'] ) ] ) );
break;
case AssetType::IMAGE:
$asset->setImageAsset( new ImageAsset( [ 'data' => $data['body'] ] ) );
$asset->setName( basename( $data['content'] ) );
break;
case AssetType::TEXT:
$asset->setTextAsset( new TextAsset( [ 'text' => $data['content'] ] ) );
break;
default:
throw new Exception( 'Asset type not supported' );
}
$operation = ( new AssetOperation() )->setCreate( $asset );
return ( new MutateOperation() )->setAssetOperation( $operation );
}
/**
* Returns the asset content for the given row.
*
* @param GoogleAdsRow $row Data row returned from a query request.
*
* @return string The asset content.
*/
protected function get_asset_content( GoogleAdsRow $row ): string {
/** @var Asset $asset */
$asset = $row->getAsset();
switch ( $asset->getType() ) {
case AssetType::IMAGE:
return $asset->getImageAsset()->getFullSize()->getUrl();
case AssetType::TEXT:
return $asset->getTextAsset()->getText();
case AssetType::CALL_TO_ACTION:
// When CallToActionType::UNSPECIFIED is returned, does not have a CallToActionAsset.
if ( ! $asset->getCallToActionAsset() ) {
return CallToActionType::UNSPECIFIED;
}
return CallToActionType::label( $asset->getCallToActionAsset()->getCallToAction() );
default:
return '';
}
}
/**
* Convert Asset data to an array.
*
* @param GoogleAdsRow $row Data row returned from a query request.
*
* @return array The asset data converted.
*/
public function convert_asset( GoogleAdsRow $row ): array {
return [
'id' => $row->getAsset()->getId(),
'content' => $this->get_asset_content( $row ),
];
}
/**
* Send a batch of operations to mutate assets.
*
* @param MutateOperation[] $operations
*
* @return array A list of Asset's ARN created.
* @throws ApiException If any of the operations fail.
*/
protected function mutate( array $operations ): array {
$arns = [];
$request = new MutateGoogleAdsRequest();
$request->setCustomerId( $this->options->get_ads_id() );
$request->setMutateOperations( $operations );
$responses = $this->client->getGoogleAdsServiceClient()->mutate( $request );
foreach ( $responses->getMutateOperationResponses() as $response ) {
if ( 'asset_result' === $response->getResponse() ) {
$asset_result = $response->getAssetResult();
$arns[] = $asset_result->getResourceName();
}
}
return $arns;
}
}
API/Google/AdsAssetGroup.php 0000644 00000033504 15153721356 0011643 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAssetGroupQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
use Google\Ads\GoogleAds\V18\Enums\AssetGroupStatusEnum\AssetGroupStatus;
use Google\Ads\GoogleAds\V18\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
use Google\Ads\GoogleAds\V18\Resources\AssetGroup;
use Google\Ads\GoogleAds\V18\Resources\AssetGroupListingGroupFilter;
use Google\Ads\GoogleAds\V18\Services\AssetGroupListingGroupFilterOperation;
use Google\Ads\GoogleAds\V18\Services\AssetGroupOperation;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\V18\Services\Client\AssetGroupServiceClient;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Google\Protobuf\FieldMask;
use Exception;
use DateTime;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
/**
* Class AdsAssetGroup
*
* Used for the Performance Max Campaigns
* https://developers.google.com/google-ads/api/docs/performance-max/asset-groups
*
* @since 1.12.2
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsAssetGroup implements OptionsAwareInterface {
use ExceptionTrait;
use OptionsAwareTrait;
/**
* Temporary ID to use within a batch job.
* A negative number which is unique for all the created resources.
*
* @var int
*/
protected const TEMPORARY_ID = -3;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* The AdsAssetGroupAsset class.
*
* @var AdsAssetGroupAsset
*/
protected $asset_group_asset;
/**
* List of asset group resource names.
*
* @var string[]
*/
protected $asset_groups;
/**
* AdsAssetGroup constructor.
*
* @param GoogleAdsClient $client
* @param AdsAssetGroupAsset $asset_group_asset
*/
public function __construct( GoogleAdsClient $client, AdsAssetGroupAsset $asset_group_asset ) {
$this->client = $client;
$this->asset_group_asset = $asset_group_asset;
}
/**
* Create an asset group.
*
* @since 2.4.0
*
* @param int $campaign_id
*
* @return int id The asset group id.
* @throws ExceptionWithResponseData When an ApiException or Exception is caught.
*/
public function create_asset_group( int $campaign_id ): int {
try {
$campaign_resource_name = ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id );
$current_date_time = ( new DateTime( 'now', wp_timezone() ) )->format( 'Y-m-d H:i:s' );
$asset_group_name = sprintf(
/* translators: %s: current date time. */
__( 'PMax %s', 'google-listings-and-ads' ),
$current_date_time
);
$operations = $this->create_operations( $campaign_resource_name, $asset_group_name );
return $this->mutate( $operations );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$message = $e->getMessage();
$code = $e->getCode();
$data = [];
if ( $e instanceof ApiException ) {
$errors = $this->get_exception_errors( $e );
/* translators: %s Error message */
$message = sprintf( __( 'Error creating asset group: %s', 'google-listings-and-ads' ), reset( $errors ) );
$code = $this->map_grpc_code_to_http_status_code( $e );
$data = [
'errors' => $errors,
];
}
throw new ExceptionWithResponseData(
$message,
$code,
null,
$data
);
}
}
/**
* Returns a set of operations to create an asset group.
*
* @param string $campaign_resource_name
* @param string $asset_group_name The asset group name.
* @return array
*/
public function create_operations( string $campaign_resource_name, string $asset_group_name ): array {
// Asset must be created before listing group.
return [
$this->asset_group_create_operation( $campaign_resource_name, $asset_group_name ),
$this->listing_group_create_operation(),
];
}
/**
* Returns an asset group create operation.
*
* @param string $campaign_resource_name
* @param string $campaign_name
*
* @return MutateOperation
*/
protected function asset_group_create_operation( string $campaign_resource_name, string $campaign_name ): MutateOperation {
$asset_group = new AssetGroup(
[
'resource_name' => $this->temporary_resource_name(),
'name' => $campaign_name . ' Asset Group',
'campaign' => $campaign_resource_name,
'status' => AssetGroupStatus::ENABLED,
]
);
$operation = ( new AssetGroupOperation() )->setCreate( $asset_group );
return ( new MutateOperation() )->setAssetGroupOperation( $operation );
}
/**
* Returns an asset group listing group filter create operation.
*
* @return MutateOperation
*/
protected function listing_group_create_operation(): MutateOperation {
$listing_group = new AssetGroupListingGroupFilter(
[
'asset_group' => $this->temporary_resource_name(),
'type' => ListingGroupFilterType::UNIT_INCLUDED,
'listing_source' => ListingGroupFilterListingSource::SHOPPING,
]
);
$operation = ( new AssetGroupListingGroupFilterOperation() )->setCreate( $listing_group );
return ( new MutateOperation() )->setAssetGroupListingGroupFilterOperation( $operation );
}
/**
* Returns an asset group delete operation.
*
* @param string $campaign_resource_name
*
* @return MutateOperation[]
*/
protected function asset_group_delete_operations( string $campaign_resource_name ): array {
$operations = [];
$this->asset_groups = [];
$results = ( new AdsAssetGroupQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'asset_group.campaign', $campaign_resource_name )
->get_results();
/** @var GoogleAdsRow $row */
foreach ( $results->iterateAllElements() as $row ) {
$resource_name = $row->getAssetGroup()->getResourceName();
$this->asset_groups[] = $resource_name;
$operation = ( new AssetGroupOperation() )->setRemove( $resource_name );
$operations[] = ( new MutateOperation() )->setAssetGroupOperation( $operation );
}
return $operations;
}
/**
* Return a temporary resource name for the asset group.
*
* @return string
*/
protected function temporary_resource_name() {
return ResourceNames::forAssetGroup( $this->options->get_ads_id(), self::TEMPORARY_ID );
}
/**
* Get Asset Groups for a specific campaign. Limit to first AdsAssetGroup.
*
* @since 2.4.0
*
* @param int $campaign_id The campaign ID.
* @param bool $include_assets Whether to include the assets in the response.
*
* @return array The asset groups for the campaign.
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_asset_groups_by_campaign_id( int $campaign_id, bool $include_assets = true ): array {
try {
$asset_groups_converted = [];
$asset_group_results = ( new AdsAssetGroupQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->add_columns( [ 'asset_group.path1', 'asset_group.path2', 'asset_group.id', 'asset_group.final_urls' ] )
->where( 'campaign.id', $campaign_id )
->where( 'asset_group.status', 'REMOVED', '!=' )
->get_results();
/** @var GoogleAdsRow $row */
foreach ( $asset_group_results->getPage()->getIterator() as $row ) {
$asset_groups_converted[ $row->getAssetGroup()->getId() ] = $this->convert_asset_group( $row );
break; // Limit to only first asset group.
}
if ( $include_assets ) {
return array_values( $this->get_assets( $asset_groups_converted ) );
}
return array_values( $asset_groups_converted );
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error retrieving asset groups: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Get assets for asset groups.
*
* @since 2.4.0
*
* @param array $asset_groups The asset groups converted.
*
* @return array The asset groups with assets.
*/
protected function get_assets( array $asset_groups ): array {
$asset_group_ids = array_keys( $asset_groups );
$assets = $this->asset_group_asset->get_assets_by_asset_group_ids( $asset_group_ids );
foreach ( $asset_group_ids as $asset_group_id ) {
$asset_groups[ $asset_group_id ]['assets'] = $assets[ $asset_group_id ] ?? (object) [];
}
return $asset_groups;
}
/**
* Edit an asset group.
*
* @param int $asset_group_id The asset group ID.
* @param array $data The asset group data.
* @param array $assets A list of assets data.
*
* @return int The asset group ID.
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function edit_asset_group( int $asset_group_id, array $data, array $assets = [] ): int {
try {
$operations = $this->asset_group_asset->edit_operations( $asset_group_id, $assets );
// PMax only supports one final URL but it is required to be an array.
if ( ! empty( $data['final_url'] ) ) {
$data['final_urls'] = [ $data['final_url'] ];
unset( $data['final_url'] );
}
if ( ! empty( $data ) ) {
// If the asset group does not contain a final URL, it is required to update first the asset group with the final URL and then the assets.
$operations = [ $this->edit_operation( $asset_group_id, $data ), ...$operations ];
}
if ( ! empty( $operations ) ) {
$this->mutate( $operations );
}
return $asset_group_id;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
if ( $e->getCode() === 413 ) {
$errors = [ 'Request entity too large' ];
$code = $e->getCode();
} else {
$errors = $this->get_exception_errors( $e );
$code = $this->map_grpc_code_to_http_status_code( $e );
if ( array_key_exists( 'DUPLICATE_ASSETS_WITH_DIFFERENT_FIELD_VALUE', $errors ) ) {
$errors['DUPLICATE_ASSETS_WITH_DIFFERENT_FIELD_VALUE'] = __( 'Each image type (landscape, square, portrait or logo) cannot contain duplicated images.', 'google-listings-and-ads' );
}
}
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error editing asset group: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$code,
null,
[
'errors' => $errors,
'id' => $asset_group_id,
]
);
}
}
/**
* Returns an asset group edit operation.
*
* @param integer $asset_group_id The Asset Group ID
* @param array $fields The fields to update.
*
* @return MutateOperation
*/
protected function edit_operation( int $asset_group_id, array $fields ): MutateOperation {
$fields['resource_name'] = ResourceNames::forAssetGroup( $this->options->get_ads_id(), $asset_group_id );
$asset_group = new AssetGroup( $fields );
$operation = new AssetGroupOperation();
$operation->setUpdate( $asset_group );
// We create the FieldMask manually because empty paths (path1 and path2) are not processed by the library.
// See similar issue here: https://github.com/googleads/google-ads-php/issues/487
$operation->setUpdateMask( ( new FieldMask() )->setPaths( [ 'resource_name', ...array_keys( $fields ) ] ) );
return ( new MutateOperation() )->setAssetGroupOperation( $operation );
}
/**
* Convert Asset Group data to an array.
*
* @since 2.4.0
*
* @param GoogleAdsRow $row Data row returned from a query request.
*
* @return array
*/
protected function convert_asset_group( GoogleAdsRow $row ): array {
return [
'id' => $row->getAssetGroup()->getId(),
'final_url' => iterator_to_array( $row->getAssetGroup()->getFinalUrls() )[0] ?? '',
'display_url_path' => [ $row->getAssetGroup()->getPath1(), $row->getAssetGroup()->getPath2() ],
];
}
/**
* Send a batch of operations to mutate an asset group.
*
* @since 2.4.0
*
* @param MutateOperation[] $operations
*
* @return int If the asset group operation is present, it will return the asset group id otherwise 0 for other operations.
* @throws ApiException If any of the operations fail.
* @throws Exception If the resource name is not in the expected format.
*/
protected function mutate( array $operations ): int {
$request = new MutateGoogleAdsRequest();
$request->setCustomerId( $this->options->get_ads_id() );
$request->setMutateOperations( $operations );
$responses = $this->client->getGoogleAdsServiceClient()->mutate( $request );
foreach ( $responses->getMutateOperationResponses() as $response ) {
if ( 'asset_group_result' === $response->getResponse() ) {
$asset_group_result = $response->getAssetGroupResult();
return $this->parse_asset_group_id( $asset_group_result->getResourceName() );
}
}
return 0;
}
/**
* Convert ID from a resource name to an int.
*
* @since 2.4.0
*
* @param string $name Resource name containing ID number.
*
* @return int The asset group ID.
* @throws Exception When unable to parse resource ID.
*/
protected function parse_asset_group_id( string $name ): int {
try {
$parts = AssetGroupServiceClient::parseName( $name );
return absint( $parts['asset_group_id'] );
} catch ( ValidationException $e ) {
throw new Exception( __( 'Invalid asset group ID', 'google-listings-and-ads' ) );
}
}
}
API/Google/AdsAssetGroupAsset.php 0000644 00000031607 15153721356 0012645 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAssetGroupAssetQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Resources\AssetGroupAsset;
use Google\ApiCore\ApiException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\V18\Services\AssetGroupAssetOperation;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
/**
* Class AdsAssetGroupAsset
*
* Use to get assets group assets for specific asset groups.
* https://developers.google.com/google-ads/api/reference/rpc/v18/AssetGroupAsset
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsAssetGroupAsset implements OptionsAwareInterface {
use ExceptionTrait;
use OptionsAwareTrait;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* Ads Asset class.
*
* @var AdsAsset
*/
protected $asset;
/**
* Temporary ID to use within a batch job.
* A negative number which is unique for all the created resources.
*
* @var int
*/
protected static $temporary_id = -4;
/**
* AdsAssetGroupAsset constructor.
*
* @param GoogleAdsClient $client
* @param AdsAsset $asset
*/
public function __construct( GoogleAdsClient $client, AdsAsset $asset ) {
$this->client = $client;
$this->asset = $asset;
}
/**
* Get the asset field types to use for the asset group assets query.
*
* @return string[]
*/
protected function get_asset_field_types_query(): array {
return [
AssetFieldType::name( AssetFieldType::BUSINESS_NAME ),
AssetFieldType::name( AssetFieldType::CALL_TO_ACTION_SELECTION ),
AssetFieldType::name( AssetFieldType::DESCRIPTION ),
AssetFieldType::name( AssetFieldType::HEADLINE ),
AssetFieldType::name( AssetFieldType::LOGO ),
AssetFieldType::name( AssetFieldType::LONG_HEADLINE ),
AssetFieldType::name( AssetFieldType::MARKETING_IMAGE ),
AssetFieldType::name( AssetFieldType::SQUARE_MARKETING_IMAGE ),
AssetFieldType::name( AssetFieldType::PORTRAIT_MARKETING_IMAGE ),
];
}
/**
* Get Assets for specific asset groups ids.
*
* @param array $asset_groups_ids The asset groups ids.
* @param array $fields The asset field types to get.
*
* @return array The assets for the asset groups.
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_assets_by_asset_group_ids( array $asset_groups_ids, array $fields = [] ): array {
try {
if ( empty( $asset_groups_ids ) ) {
return [];
}
if ( empty( $fields ) ) {
$fields = $this->get_asset_field_types_query();
}
$asset_group_assets = [];
$asset_results = ( new AdsAssetGroupAssetQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->add_columns( [ 'asset_group.id' ] )
->where( 'asset_group.id', $asset_groups_ids, 'IN' )
->where( 'asset_group_asset.field_type', $fields, 'IN' )
->where( 'asset_group_asset.status', 'REMOVED', '!=' )
->get_results();
/** @var GoogleAdsRow $row */
foreach ( $asset_results->iterateAllElements() as $row ) {
/** @var AssetGroupAsset $asset_group_asset */
$asset_group_asset = $row->getAssetGroupAsset();
$field_type = AssetFieldType::label( $asset_group_asset->getFieldType() );
switch ( $field_type ) {
case AssetFieldType::BUSINESS_NAME:
case AssetFieldType::CALL_TO_ACTION_SELECTION:
$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ] = $this->asset->convert_asset( $row );
break;
default:
$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ][] = $this->asset->convert_asset( $row );
}
}
return $asset_group_assets;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error retrieving asset groups assets: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Get Assets for specific final URL.
*
* @param string $url The final url.
* @param bool $only_first_asset_group Whether to return only the first asset group found.
*
* @return array The assets for the asset groups with a specific final url.
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_assets_by_final_url( string $url, bool $only_first_asset_group = false ): array {
try {
$asset_group_assets = [];
// Search urls with and without trailing slash.
$asset_results = ( new AdsAssetGroupAssetQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->add_columns( [ 'asset_group.id', 'asset_group.path1', 'asset_group.path2' ] )
->where( 'asset_group.final_urls', [ trailingslashit( $url ), untrailingslashit( $url ) ], 'CONTAINS ANY' )
->where( 'asset_group_asset.field_type', $this->get_asset_field_types_query(), 'IN' )
->where( 'asset_group_asset.status', 'REMOVED', '!=' )
->where( 'asset_group.status', 'REMOVED', '!=' )
->where( 'campaign.status', 'REMOVED', '!=' )
->get_results();
/** @var GoogleAdsRow $row */
foreach ( $asset_results->iterateAllElements() as $row ) {
/** @var AssetGroupAsset $asset_group_asset */
$asset_group_asset = $row->getAssetGroupAsset();
$field_type = AssetFieldType::label( $asset_group_asset->getFieldType() );
switch ( $field_type ) {
case AssetFieldType::BUSINESS_NAME:
case AssetFieldType::CALL_TO_ACTION_SELECTION:
$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ] = $this->asset->convert_asset( $row )['content'];
break;
default:
$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ][] = $this->asset->convert_asset( $row )['content'];
}
$asset_group_assets[ $row->getAssetGroup()->getId() ]['display_url_path'] = [
$row->getAssetGroup()->getPath1(),
$row->getAssetGroup()->getPath2(),
];
}
if ( $only_first_asset_group ) {
return reset( $asset_group_assets ) ?: [];
}
return $asset_group_assets;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error retrieving asset groups assets by final url: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Get assets to be deleted.
*
* @param array $assets A list of assets.
*
* @return array The assets to be deleted.
*/
public function get_assets_to_be_deleted( array $assets ): array {
return array_values(
array_filter(
$assets,
function ( $asset ) {
return ! empty( $asset['id'] );
}
)
);
}
/**
* Get assets to be created.
*
* @param array $assets A list of assets.
*
* @return array The assets to be created.
*/
public function get_assets_to_be_created( array $assets ): array {
return array_values(
array_filter(
$assets,
function ( $asset ) {
return ! empty( $asset['content'] );
}
)
);
}
/**
* Get specific assets by asset types.
*
* @param int $asset_group_id The asset group id.
* @param array $asset_field_types The asset field types types.
*
* @return array The assets.
*/
protected function get_specific_assets( int $asset_group_id, array $asset_field_types ): array {
$result = $this->get_assets_by_asset_group_ids( [ $asset_group_id ], $asset_field_types );
$asset_group_assets = $result[ $asset_group_id ] ?? [];
$specific_assets = [];
foreach ( $asset_group_assets as $field_type => $assets ) {
foreach ( $assets as $asset ) {
$specific_assets[] = array_merge( $asset, [ 'field_type' => $field_type ] );
}
}
return $specific_assets;
}
/**
* Check if a asset type will be edited.
*
* @param string $field_type The asset field type.
* @param array $assets The assets.
*
* @return bool True if the asset type is edited.
*/
protected function maybe_asset_type_is_edited( string $field_type, array $assets ): bool {
return in_array( $field_type, array_column( $assets, 'field_type' ), true );
}
/**
* Get override asset operations.
*
* @param int $asset_group_id The asset group id.
* @param array $asset_field_types The asset field types.
*
* @return array The asset group asset operations.
*/
protected function get_override_operations( int $asset_group_id, array $asset_field_types ): array {
return array_map(
function ( $asset ) use ( $asset_group_id ) {
return $this->delete_operation( $asset_group_id, $asset['field_type'], $asset['id'] );
},
$this->get_specific_assets( $asset_group_id, $asset_field_types )
);
}
/**
* Edit assets group assets.
*
* @param int $asset_group_id The asset group id.
* @param array $assets The assets to create.
*
* @return array The asset group asset operations.
* @throws Exception If the asset type is not supported.
*/
public function edit_operations( int $asset_group_id, array $assets ): array {
if ( empty( $assets ) ) {
return [];
}
$asset_group_assets_operations = [];
$assets_for_creation = $this->get_assets_to_be_created( $assets );
$asset_arns = $this->asset->create_assets( $assets_for_creation );
$total_assets = count( $assets_for_creation );
$delete_asset_group_assets_operations = [];
if ( $this->maybe_asset_type_is_edited( AssetFieldType::LOGO, $assets ) ) {
// As we are not working with the LANDSCAPE_LOGO, we delete it so it does not interfere with the maximum quantities of logos.
$delete_asset_group_assets_operations = $this->get_override_operations( $asset_group_id, [ AssetFieldType::name( AssetFieldType::LANDSCAPE_LOGO ) ] );
}
// The asset mutation operation results (ARNs) are returned in the same order as the operations are specified.
// See: https://youtu.be/9KaVjqW5tVM?t=103
for ( $i = 0; $i < $total_assets; $i++ ) {
$asset_group_assets_operations[] = $this->create_operation( $asset_group_id, $assets_for_creation[ $i ]['field_type'], $asset_arns[ $i ] );
}
foreach ( $this->get_assets_to_be_deleted( $assets ) as $asset ) {
$delete_asset_group_assets_operations[] = $this->delete_operation( $asset_group_id, $asset['field_type'], $asset['id'] );
}
// The delete operations must be executed first otherwise will cause a conflict with existing assets with identical content.
// See here: https://github.com/woocommerce/google-listings-and-ads/pull/1870
return array_merge( $delete_asset_group_assets_operations, $asset_group_assets_operations );
}
/**
* Creates an operation for an asset group asset.
*
* @param int $asset_group_id The ID of the asset group.
* @param string $asset_field_type The field type of the asset.
* @param string $asset_arn The the asset ARN.
*
* @return MutateOperation The mutate create operation for the asset group asset.
*/
protected function create_operation( int $asset_group_id, string $asset_field_type, string $asset_arn ): MutateOperation {
$operation = new AssetGroupAssetOperation();
$new_asset_group_asset = new AssetGroupAsset(
[
'asset' => $asset_arn,
'asset_group' => ResourceNames::forAssetGroup( $this->options->get_ads_id(), $asset_group_id ),
'field_type' => AssetFieldType::number( $asset_field_type ),
]
);
return ( new MutateOperation() )->setAssetGroupAssetOperation( $operation->setCreate( $new_asset_group_asset ) );
}
/**
* Returns a delete operation for asset group asset.
*
* @param int $asset_group_id The ID of the asset group.
* @param string $asset_field_type The field type of the asset.
* @param int $asset_id The ID of the asset.
*
* @return MutateOperation The remove operation for the asset group asset.
*/
protected function delete_operation( int $asset_group_id, string $asset_field_type, int $asset_id ): MutateOperation {
$asset_group_asset_resource_name = ResourceNames::forAssetGroupAsset( $this->options->get_ads_id(), $asset_group_id, $asset_id, AssetFieldType::name( $asset_field_type ) );
$operation = ( new AssetGroupAssetOperation() )->setRemove( $asset_group_asset_resource_name );
return ( new MutateOperation() )->setAssetGroupAssetOperation( $operation );
}
}
API/Google/AdsCampaign.php 0000644 00000050434 15153721356 0011267 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignCriterionQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Google\Ads\GoogleAds\Util\FieldMasks;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Common\MaximizeConversionValue;
use Google\Ads\GoogleAds\V18\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
use Google\Ads\GoogleAds\V18\Resources\Campaign;
use Google\Ads\GoogleAds\V18\Resources\Campaign\ShoppingSetting;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignServiceClient;
use Google\Ads\GoogleAds\V18\Services\CampaignOperation;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Exception;
/**
* Class AdsCampaign (Performance Max Campaign)
* https://developers.google.com/google-ads/api/docs/performance-max/overview
*
* ContainerAware used for:
* - AdsAssetGroup
* - TransientsInterface
* - WC
*
* @since 1.12.2 Refactored to support PMax and (legacy) SSC.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsCampaign implements ContainerAwareInterface, OptionsAwareInterface {
use ContainerAwareTrait;
use ExceptionTrait;
use OptionsAwareTrait;
use MicroTrait;
/**
* Temporary ID to use within a batch job.
* A negative number which is unique for all the created resources.
*
* @var int
*/
protected const TEMPORARY_ID = -1;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* @var AdsCampaignBudget $budget
*/
protected $budget;
/**
* @var AdsCampaignCriterion $criterion
*/
protected $criterion;
/**
* @var GoogleHelper $google_helper
*/
protected $google_helper;
/**
* @var AdsCampaignLabel $campaign_label
*/
protected $campaign_label;
/**
* AdsCampaign constructor.
*
* @param GoogleAdsClient $client
* @param AdsCampaignBudget $budget
* @param AdsCampaignCriterion $criterion
* @param GoogleHelper $google_helper
* @param AdsCampaignLabel $campaign_label
*/
public function __construct( GoogleAdsClient $client, AdsCampaignBudget $budget, AdsCampaignCriterion $criterion, GoogleHelper $google_helper, AdsCampaignLabel $campaign_label ) {
$this->client = $client;
$this->budget = $budget;
$this->criterion = $criterion;
$this->google_helper = $google_helper;
$this->campaign_label = $campaign_label;
}
/**
* Returns a list of campaigns with targeted locations retrieved from campaign criterion.
*
* @param bool $exclude_removed Exclude removed campaigns (default true).
* @param bool $fetch_criterion Combine the campaign data with criterion data (default true).
* @param array $args Arguments for fetching campaigns, for example: per_page for limiting the number of results.
*
* @return array
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_campaigns( bool $exclude_removed = true, bool $fetch_criterion = true, array $args = [] ): array {
try {
$query = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() );
if ( $exclude_removed ) {
$query->where( 'campaign.status', 'REMOVED', '!=' );
}
$count = 0;
$campaign_results = $query->get_results();
$converted_campaigns = [];
foreach ( $campaign_results->iterateAllElements() as $row ) {
++$count;
$campaign = $this->convert_campaign( $row );
$converted_campaigns[ $campaign['id'] ] = $campaign;
// Break early if we request a limited result.
if ( ! empty( $args['per_page'] ) && $count >= $args['per_page'] ) {
break;
}
}
if ( $exclude_removed ) {
// Cache campaign count.
$campaign_count = $campaign_results->getPage()->getResponseObject()->getTotalResultsCount();
$this->container->get( TransientsInterface::class )->set(
TransientsInterface::ADS_CAMPAIGN_COUNT,
$campaign_count,
HOUR_IN_SECONDS * 12
);
}
if ( $fetch_criterion ) {
$converted_campaigns = $this->combine_campaigns_and_campaign_criterion_results( $converted_campaigns );
}
return array_values( $converted_campaigns );
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error retrieving campaigns: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Retrieve a single campaign with targeted locations retrieved from campaign criterion.
*
* @param int $id Campaign ID.
*
* @return array
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_campaign( int $id ): array {
try {
$campaign_results = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() )
->where( 'campaign.id', $id, '=' )
->get_results();
$converted_campaigns = [];
// Get only the first element from campaign results
foreach ( $campaign_results->iterateAllElements() as $row ) {
$campaign = $this->convert_campaign( $row );
$converted_campaigns[ $campaign['id'] ] = $campaign;
break;
}
if ( ! empty( $converted_campaigns ) ) {
$combined_results = $this->combine_campaigns_and_campaign_criterion_results( $converted_campaigns );
return reset( $combined_results );
}
return [];
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error retrieving campaign: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[
'errors' => $errors,
'id' => $id,
]
);
}
}
/**
* Create a new campaign.
*
* @param array $params Request parameters.
*
* @return array
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function create_campaign( array $params ): array {
try {
$base_country = $this->container->get( WC::class )->get_base_country();
$location_ids = array_map(
function ( $country_code ) {
return $this->google_helper->find_country_id_by_code( $country_code );
},
$params['targeted_locations']
);
$location_ids = array_filter( $location_ids );
// Operations must be in a specific order to match the temporary ID's.
$operations = array_merge(
[ $this->budget->create_operation( $params['name'], $params['amount'] ) ],
[ $this->create_operation( $params['name'], $base_country ) ],
$this->container->get( AdsAssetGroup::class )->create_operations(
$this->temporary_resource_name(),
$params['name']
),
$this->criterion->create_operations(
$this->temporary_resource_name(),
$location_ids
)
);
$campaign_id = $this->mutate( $operations );
if ( isset( $params['label'] ) ) {
$this->campaign_label->assign_label_to_campaign_by_label_name( $campaign_id, $params['label'] );
}
// Clear cached campaign count.
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );
return [
'id' => $campaign_id,
'status' => CampaignStatus::ENABLED,
'type' => CampaignType::PERFORMANCE_MAX,
'country' => $base_country,
] + $params;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
/* translators: %s Error message */
$message = sprintf( __( 'Error creating campaign: %s', 'google-listings-and-ads' ), reset( $errors ) );
if ( isset( $errors['DUPLICATE_CAMPAIGN_NAME'] ) ) {
$message = __( 'A campaign with this name already exists', 'google-listings-and-ads' );
}
throw new ExceptionWithResponseData(
$message,
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Edit a campaign.
*
* @param int $campaign_id Campaign ID.
* @param array $params Request parameters.
*
* @return int
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function edit_campaign( int $campaign_id, array $params ): int {
try {
$operations = [];
$campaign_fields = [];
if ( ! empty( $params['name'] ) ) {
$campaign_fields['name'] = $params['name'];
}
if ( ! empty( $params['status'] ) ) {
$campaign_fields['status'] = CampaignStatus::number( $params['status'] );
}
if ( ! empty( $params['amount'] ) ) {
$operations[] = $this->budget->edit_operation( $campaign_id, $params['amount'] );
}
if ( ! empty( $campaign_fields ) ) {
$operations[] = $this->edit_operation( $campaign_id, $campaign_fields );
}
if ( ! empty( $operations ) ) {
return $this->mutate( $operations ) ?: $campaign_id;
}
return $campaign_id;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Error editing campaign: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[
'errors' => $errors,
'id' => $campaign_id,
]
);
}
}
/**
* Delete a campaign.
*
* @param int $campaign_id Campaign ID.
*
* @return int
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function delete_campaign( int $campaign_id ): int {
try {
$campaign_resource_name = ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id );
$operations = [
$this->delete_operation( $campaign_resource_name ),
];
// Clear cached campaign count.
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );
return $this->mutate( $operations );
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
/* translators: %s Error message */
$message = sprintf( __( 'Error deleting campaign: %s', 'google-listings-and-ads' ), reset( $errors ) );
if ( isset( $errors['OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE'] ) ) {
$message = __( 'This campaign has already been deleted', 'google-listings-and-ads' );
}
throw new ExceptionWithResponseData(
$message,
$this->map_grpc_code_to_http_status_code( $e ),
null,
[
'errors' => $errors,
'id' => $campaign_id,
]
);
}
}
/**
* Retrieves the status of converting campaigns.
* The status is cached for an hour during unconverted.
*
* - unconverted - Still need to convert some older campaigns
* - converted - All campaigns are converted to PMax campaigns
* - not-applicable - User never had any older campaign types
*
* @since 2.0.3
*
* @return string
*/
public function get_campaign_convert_status(): string {
$convert_status = $this->options->get( OptionsInterface::CAMPAIGN_CONVERT_STATUS );
if ( ! is_array( $convert_status ) || empty( $convert_status['status'] ) ) {
$convert_status = [ 'status' => 'unknown' ];
}
// Refetch if status is unconverted and older than an hour.
if (
in_array( $convert_status['status'], [ 'unconverted', 'unknown' ], true ) &&
( empty( $convert_status['updated'] ) || time() - $convert_status['updated'] > HOUR_IN_SECONDS )
) {
$old_campaigns = 0;
$old_removed_campaigns = 0;
$convert_status['status'] = 'unconverted';
try {
foreach ( $this->get_campaigns( false, false ) as $campaign ) {
if ( CampaignType::PERFORMANCE_MAX !== $campaign['type'] ) {
if ( CampaignStatus::REMOVED === $campaign['status'] ) {
++$old_removed_campaigns;
} else {
++$old_campaigns;
}
}
}
// No old campaign types means we don't need to convert.
if ( ! $old_removed_campaigns && ! $old_campaigns ) {
$convert_status['status'] = 'not-applicable';
}
// All old campaign types have been removed, means we converted.
if ( ! $old_campaigns && $old_removed_campaigns > 0 ) {
$convert_status['status'] = 'converted';
}
} catch ( Exception $e ) {
// Error when retrieving campaigns, do not handle conversion.
$convert_status['status'] = 'unknown';
}
$convert_status['updated'] = time();
$this->options->update( OptionsInterface::CAMPAIGN_CONVERT_STATUS, $convert_status );
}
return $convert_status['status'];
}
/**
* Return a temporary resource name for the campaign.
*
* @return string
*/
protected function temporary_resource_name() {
return ResourceNames::forCampaign( $this->options->get_ads_id(), self::TEMPORARY_ID );
}
/**
* Returns a campaign create operation.
*
* @param string $campaign_name
* @param string $country
*
* @return MutateOperation
*/
protected function create_operation( string $campaign_name, string $country ): MutateOperation {
$campaign = new Campaign(
[
'resource_name' => $this->temporary_resource_name(),
'name' => $campaign_name,
'advertising_channel_type' => AdvertisingChannelType::PERFORMANCE_MAX,
'status' => CampaignStatus::number( 'enabled' ),
'campaign_budget' => $this->budget->temporary_resource_name(),
'maximize_conversion_value' => new MaximizeConversionValue(),
'url_expansion_opt_out' => false,
'shopping_setting' => new ShoppingSetting(
[
'merchant_id' => $this->options->get_merchant_id(),
'feed_label' => $country,
]
),
]
);
$operation = ( new CampaignOperation() )->setCreate( $campaign );
return ( new MutateOperation() )->setCampaignOperation( $operation );
}
/**
* Returns a campaign edit operation.
*
* @param integer $campaign_id
* @param array $fields
*
* @return MutateOperation
*/
protected function edit_operation( int $campaign_id, array $fields ): MutateOperation {
$fields['resource_name'] = ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id );
$campaign = new Campaign( $fields );
$operation = new CampaignOperation();
$operation->setUpdate( $campaign );
$operation->setUpdateMask( FieldMasks::allSetFieldsOf( $campaign ) );
return ( new MutateOperation() )->setCampaignOperation( $operation );
}
/**
* Returns a campaign delete operation.
*
* @param string $campaign_resource_name
*
* @return MutateOperation
*/
protected function delete_operation( string $campaign_resource_name ): MutateOperation {
$operation = ( new CampaignOperation() )->setRemove( $campaign_resource_name );
return ( new MutateOperation() )->setCampaignOperation( $operation );
}
/**
* Convert campaign data to an array.
*
* @param GoogleAdsRow $row Data row returned from a query request.
*
* @return array
*/
protected function convert_campaign( GoogleAdsRow $row ): array {
$campaign = $row->getCampaign();
$data = [
'id' => $campaign->getId(),
'name' => $campaign->getName(),
'status' => CampaignStatus::label( $campaign->getStatus() ),
'type' => CampaignType::label( $campaign->getAdvertisingChannelType() ),
'targeted_locations' => [],
];
$budget = $row->getCampaignBudget();
if ( $budget ) {
$data += [
'amount' => $this->from_micro( $budget->getAmountMicros() ),
];
}
$shopping = $campaign->getShoppingSetting();
if ( $shopping ) {
$data += [
'country' => $shopping->getFeedLabel(),
];
}
return $data;
}
/**
* Combine converted campaigns data with campaign criterion results data
*
* @param array $campaigns Campaigns data returned from a query request and converted by convert_campaign function.
*
* @return array
*/
protected function combine_campaigns_and_campaign_criterion_results( array $campaigns ): array {
if ( empty( $campaigns ) ) {
return [];
}
$campaign_criterion_results = ( new AdsCampaignCriterionQuery() )->set_client( $this->client, $this->options->get_ads_id() )
->where( 'campaign.id', array_keys( $campaigns ), 'IN' )
// negative: Whether to target (false) or exclude (true) the criterion.
->where( 'campaign_criterion.negative', 'false', '=' )
->where( 'campaign_criterion.status', 'REMOVED', '!=' )
->where( 'campaign_criterion.location.geo_target_constant', '', 'IS NOT NULL' )
->get_results();
/** @var GoogleAdsRow $row */
foreach ( $campaign_criterion_results->iterateAllElements() as $row ) {
$campaign = $row->getCampaign();
$campaign_id = $campaign->getId();
if ( ! isset( $campaigns[ $campaign_id ] ) ) {
continue;
}
$campaign_criterion = $row->getCampaignCriterion();
$location = $campaign_criterion->getLocation();
$geo_target_constant = $location->getGeoTargetConstant();
$location_id = $this->parse_geo_target_location_id( $geo_target_constant );
$country_code = $this->google_helper->find_country_code_by_id( $location_id );
if ( $country_code ) {
$campaigns[ $campaign_id ]['targeted_locations'][] = $country_code;
}
}
return $campaigns;
}
/**
* Send a batch of operations to mutate a campaign.
*
* @param MutateOperation[] $operations
*
* @return int Campaign ID from the MutateOperationResponse.
* @throws ApiException If any of the operations fail.
*/
protected function mutate( array $operations ): int {
$request = new MutateGoogleAdsRequest();
$request->setCustomerId( $this->options->get_ads_id() );
$request->setMutateOperations( $operations );
$responses = $this->client->getGoogleAdsServiceClient()->mutate( $request );
foreach ( $responses->getMutateOperationResponses() as $response ) {
if ( 'campaign_result' === $response->getResponse() ) {
$campaign_result = $response->getCampaignResult();
return $this->parse_campaign_id( $campaign_result->getResourceName() );
}
}
// When editing only the budget there is no campaign mutate result.
return 0;
}
/**
* Convert ID from a resource name to an int.
*
* @param string $name Resource name containing ID number.
*
* @return int
* @throws Exception When unable to parse resource ID.
*/
protected function parse_campaign_id( string $name ): int {
try {
$parts = CampaignServiceClient::parseName( $name );
return absint( $parts['campaign_id'] );
} catch ( ValidationException $e ) {
throw new Exception( __( 'Invalid campaign ID', 'google-listings-and-ads' ) );
}
}
/**
* Convert location ID from a geo target constant resource name to an int.
*
* @param string $geo_target_constant Resource name containing ID number.
*
* @return int
* @throws Exception When unable to parse resource ID.
*/
protected function parse_geo_target_location_id( string $geo_target_constant ): int {
if ( 1 === preg_match( '#geoTargetConstants/(?<id>\d+)#', $geo_target_constant, $parts ) ) {
return absint( $parts['id'] );
} else {
throw new Exception( __( 'Invalid geo target location ID', 'google-listings-and-ads' ) );
}
}
}
API/Google/AdsCampaignBudget.php 0000644 00000011120 15153721356 0012407 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignBudgetQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\FieldMasks;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Resources\CampaignBudget;
use Google\Ads\GoogleAds\V18\Services\CampaignBudgetOperation;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignBudgetServiceClient;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\ApiCore\ValidationException;
use Exception;
/**
* Class AdsCampaignBudget
*
* @since 1.12.2 Refactored to support PMax and (legacy) SSC.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsCampaignBudget implements OptionsAwareInterface {
use MicroTrait;
use OptionsAwareTrait;
/**
* Temporary ID to use within a batch job.
* A negative number which is unique for all the created resources.
*
* @var int
*/
protected const TEMPORARY_ID = -2;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* AdsCampaignBudget constructor.
*
* @param GoogleAdsClient $client
*/
public function __construct( GoogleAdsClient $client ) {
$this->client = $client;
}
/**
* Returns a new campaign budget create operation.
*
* @param string $campaign_name New campaign name.
* @param float $amount Budget amount in the local currency.
*
* @return MutateOperation
*/
public function create_operation( string $campaign_name, float $amount ): MutateOperation {
$budget = new CampaignBudget(
[
'resource_name' => $this->temporary_resource_name(),
'name' => $campaign_name . ' Budget',
'amount_micros' => $this->to_micro( $amount ),
'explicitly_shared' => false,
]
);
$operation = ( new CampaignBudgetOperation() )->setCreate( $budget );
return ( new MutateOperation() )->setCampaignBudgetOperation( $operation );
}
/**
* Updates a new campaign budget.
*
* @param int $campaign_id Campaign ID.
* @param float $amount Budget amount in the local currency.
*
* @return string Resource name of the updated budget.
* @throws Exception If no linked budget has been found.
*/
public function edit_operation( int $campaign_id, float $amount ): MutateOperation {
$budget_id = $this->get_budget_from_campaign( $campaign_id );
$budget = new CampaignBudget(
[
'resource_name' => ResourceNames::forCampaignBudget( $this->options->get_ads_id(), $budget_id ),
'amount_micros' => $this->to_micro( $amount ),
]
);
$operation = new CampaignBudgetOperation();
$operation->setUpdate( $budget );
$operation->setUpdateMask( FieldMasks::allSetFieldsOf( $budget ) );
return ( new MutateOperation() )->setCampaignBudgetOperation( $operation );
}
/**
* Return a temporary resource name for the campaign budget.
*
* @return string
*/
public function temporary_resource_name() {
return ResourceNames::forCampaignBudget( $this->options->get_ads_id(), self::TEMPORARY_ID );
}
/**
* Retrieve the linked budget ID from a campaign ID.
*
* @param int $campaign_id Campaign ID.
*
* @return int
* @throws Exception If no linked budget has been found.
*/
protected function get_budget_from_campaign( int $campaign_id ): int {
$results = ( new AdsCampaignBudgetQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'campaign.id', $campaign_id )
->get_results();
foreach ( $results->iterateAllElements() as $row ) {
$campaign = $row->getCampaign();
return $this->parse_campaign_budget_id( $campaign->getCampaignBudget() );
}
/* translators: %d Campaign ID */
throw new Exception( sprintf( __( 'No budget found for campaign %d', 'google-listings-and-ads' ), $campaign_id ) );
}
/**
* Convert ID from a resource name to an int.
*
* @param string $name Resource name containing ID number.
*
* @return int
* @throws Exception When unable to parse resource ID.
*/
protected function parse_campaign_budget_id( string $name ): int {
try {
$parts = CampaignBudgetServiceClient::parseName( $name );
return absint( $parts['campaign_budget_id'] );
} catch ( ValidationException $e ) {
throw new Exception( __( 'Invalid campaign budget ID', 'google-listings-and-ads' ) );
}
}
}
API/Google/AdsCampaignCriterion.php 0000644 00000003664 15153721356 0013151 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Common\LocationInfo;
use Google\Ads\GoogleAds\V18\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
use Google\Ads\GoogleAds\V18\Resources\CampaignCriterion;
use Google\Ads\GoogleAds\V18\Services\CampaignCriterionOperation;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
/**
* Class AdsCampaignCriterion
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsCampaignCriterion {
use ExceptionTrait;
/**
* Returns a set of operations to create multiple campaign criteria.
*
* @param string $campaign_resource_name Campaign resource name.
* @param array $location_ids Targeted locations IDs.
*
* @return array
*/
public function create_operations( string $campaign_resource_name, array $location_ids ): array {
return array_map(
function ( $location_id ) use ( $campaign_resource_name ) {
return $this->create_operation( $campaign_resource_name, $location_id );
},
$location_ids
);
}
/**
* Returns a new campaign criterion create operation.
*
* @param string $campaign_resource_name Campaign resource name.
* @param int $location_id Targeted location ID.
*
* @return MutateOperation
*/
protected function create_operation( string $campaign_resource_name, int $location_id ): MutateOperation {
$campaign_criterion = new CampaignCriterion(
[
'campaign' => $campaign_resource_name,
'negative' => false,
'status' => CampaignCriterionStatus::ENABLED,
'location' => new LocationInfo(
[
'geo_target_constant' => ResourceNames::forGeoTargetConstant( $location_id ),
]
),
]
);
$operation = ( new CampaignCriterionOperation() )->setCreate( $campaign_criterion );
return ( new MutateOperation() )->setCampaignCriterionOperation( $operation );
}
}
API/Google/AdsCampaignLabel.php 0000644 00000010701 15153721356 0012220 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignLabelQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Resources\Label;
use Google\Ads\GoogleAds\V18\Resources\CampaignLabel;
use Google\Ads\GoogleAds\V18\Services\LabelOperation;
use Google\Ads\GoogleAds\V18\Services\CampaignLabelOperation;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
/**
* Class AdsCampaignLabel
* https://developers.google.com/google-ads/api/docs/reporting/labels
*
* @since 2.8.1
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsCampaignLabel implements OptionsAwareInterface {
use OptionsAwareTrait;
/**
* Temporary ID to use within a batch job.
* A negative number which is unique for all the created resources.
*
* @var int
*/
protected const TEMPORARY_ID = -1;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* AdsCampaignLabel constructor.
*
* @param GoogleAdsClient $client
*/
public function __construct( GoogleAdsClient $client ) {
$this->client = $client;
}
/**
* Get the label ID by name.
*
* @param string $name The label name.
*
* @return null|int The label ID.
*
* @throws ApiException If the search call fails.
*/
protected function get_label_id_by_name( string $name ) {
$query = new AdsCampaignLabelQuery();
$query->set_client( $this->client, $this->options->get_ads_id() );
$query->where( 'label.name', $name, '=' );
$label_results = $query->get_results();
foreach ( $label_results->iterateAllElements() as $row ) {
return $row->getLabel()->getId();
}
return null;
}
/**
* Assign a label to a campaign by label name.
*
* @param int $campaign_id The campaign ID.
* @param string $label_name The label name.
*
* @throws ApiException If searching for the label fails.
*/
public function assign_label_to_campaign_by_label_name( int $campaign_id, string $label_name ) {
$label_id = $this->get_label_id_by_name( $label_name );
$operations = [];
if ( ! $label_id ) {
$operations[] = $this->create_operation( $label_name );
$label_id = self::TEMPORARY_ID;
}
$operations[] = $this->assign_label_to_campaign_operation( $campaign_id, $label_id );
$this->mutate( $operations );
}
/**
* Create a label operation.
*
* @param string $name The label name.
*
* @return MutateOperation
*/
protected function create_operation( string $name ): MutateOperation {
$label = new Label(
[
'name' => $name,
'resource_name' => $this->temporary_resource_name(),
]
);
$operation = ( new LabelOperation() )->setCreate( $label );
return ( new MutateOperation() )->setLabelOperation( $operation );
}
/**
* Return a temporary resource name for the label.
*
* @return string
*/
protected function temporary_resource_name() {
return ResourceNames::forLabel( $this->options->get_ads_id(), self::TEMPORARY_ID );
}
/**
* Creates a campaign label operation.
*
* @param int $campaign_id The campaign ID.
* @param int $label_id The label ID.
*
* @return MutateOperation
*/
protected function assign_label_to_campaign_operation( int $campaign_id, int $label_id ): MutateOperation {
$label_resource_name = ResourceNames::forLabel( $this->options->get_ads_id(), $label_id );
$campaign_label = new CampaignLabel(
[
'campaign' => ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id ),
'label' => $label_resource_name,
]
);
$operation = ( new CampaignLabelOperation() )->setCreate( $campaign_label );
return ( new MutateOperation() )->setCampaignLabelOperation( $operation );
}
/**
* Mutate the operations.
*
* @param array $operations The operations to mutate.
*
* @throws ApiException — Thrown if the API call fails.
*/
protected function mutate( array $operations ) {
$request = new MutateGoogleAdsRequest();
$request->setCustomerId( $this->options->get_ads_id() );
$request->setMutateOperations( $operations );
$this->client->getGoogleAdsServiceClient()->mutate( $request );
}
}
API/Google/AdsConversionAction.php 0000644 00000014776 15153721356 0013044 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsConversionActionQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Exception;
use Google\Ads\GoogleAds\V18\Common\TagSnippet;
use Google\Ads\GoogleAds\V18\Enums\ConversionActionCategoryEnum\ConversionActionCategory;
use Google\Ads\GoogleAds\V18\Enums\ConversionActionStatusEnum\ConversionActionStatus;
use Google\Ads\GoogleAds\V18\Enums\ConversionActionTypeEnum\ConversionActionType;
use Google\Ads\GoogleAds\V18\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
use Google\Ads\GoogleAds\V18\Enums\TrackingCodeTypeEnum\TrackingCodeType;
use Google\Ads\GoogleAds\V18\Resources\ConversionAction;
use Google\Ads\GoogleAds\V18\Resources\ConversionAction\ValueSettings;
use Google\Ads\GoogleAds\V18\Services\ConversionActionOperation;
use Google\Ads\GoogleAds\V18\Services\Client\ConversionActionServiceClient;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\MutateConversionActionResult;
use Google\Ads\GoogleAds\V18\Services\MutateConversionActionsRequest;
use Google\ApiCore\ApiException;
/**
* Class AdsConversionAction
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsConversionAction implements OptionsAwareInterface {
use ExceptionTrait;
use OptionsAwareTrait;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* AdsConversionAction constructor.
*
* @param GoogleAdsClient $client
*/
public function __construct( GoogleAdsClient $client ) {
$this->client = $client;
}
/**
* Create the 'Google for WooCommerce purchase action' conversion action.
*
* @return array An array with some conversion action details.
* @throws Exception If the conversion action can't be created or retrieved.
*/
public function create_conversion_action(): array {
try {
$unique = sprintf( '%04x', wp_rand( 0, 0xffff ) );
$conversion_action_operation = new ConversionActionOperation();
$conversion_action_operation->setCreate(
new ConversionAction(
[
'name' => apply_filters(
'woocommerce_gla_conversion_action_name',
sprintf(
/* translators: %1 is a random 4-digit string */
__( '[%1$s] Google for WooCommerce purchase action', 'google-listings-and-ads' ),
$unique
)
),
'category' => ConversionActionCategory::PURCHASE,
'type' => ConversionActionType::WEBPAGE,
'status' => ConversionActionStatus::ENABLED,
'value_settings' => new ValueSettings(
[
'default_value' => 0,
'always_use_default_value' => false,
]
),
]
)
);
// Create the conversion.
$request = new MutateConversionActionsRequest();
$request->setCustomerId( $this->options->get_ads_id() );
$request->setOperations( [ $conversion_action_operation ] );
$response = $this->client->getConversionActionServiceClient()->mutateConversionActions(
$request
);
/** @var MutateConversionActionResult $added_conversion_action */
$added_conversion_action = $response->getResults()->offsetGet( 0 );
return $this->get_conversion_action( $added_conversion_action->getResourceName() );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$message = $e->getMessage();
$code = $e->getCode();
if ( $e instanceof ApiException ) {
if ( $this->has_api_exception_error( $e, 'DUPLICATE_NAME' ) ) {
$message = __( 'A conversion action with this name already exists', 'google-listings-and-ads' );
} else {
$message = $e->getBasicMessage();
}
$code = $this->map_grpc_code_to_http_status_code( $e );
}
throw new Exception(
/* translators: %s Error message */
sprintf( __( 'Error creating conversion action: %s', 'google-listings-and-ads' ), $message ),
$code
);
}
}
/**
* Retrieve a Conversion Action.
*
* @param string|int $resource_name The Conversion Action to retrieve (also accepts the Conversion Action ID).
*
* @return array An array with some conversion action details.
* @throws Exception If the Conversion Action can't be retrieved.
*/
public function get_conversion_action( $resource_name ): array {
try {
// Accept IDs too
if ( is_numeric( $resource_name ) ) {
$resource_name = ConversionActionServiceClient::conversionActionName( strval( $this->options->get_ads_id() ), strval( $resource_name ) );
}
$results = ( new AdsConversionActionQuery() )->set_client( $this->client, $this->options->get_ads_id() )
->where( 'conversion_action.resource_name', $resource_name, '=' )
->get_results();
// Get only the first element from results.
foreach ( $results->iterateAllElements() as $row ) {
return $this->convert_conversion_action( $row );
}
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$message = $e->getMessage();
$code = $e->getCode();
if ( $e instanceof ApiException ) {
$message = $e->getBasicMessage();
$code = $this->map_grpc_code_to_http_status_code( $e );
}
throw new Exception(
/* translators: %s Error message */
sprintf( __( 'Error retrieving conversion action: %s', 'google-listings-and-ads' ), $message ),
$code
);
}
}
/**
* Convert conversion action data to an array.
*
* @param GoogleAdsRow $row Data row returned from a query request.
*
* @return array An array with some conversion action details.
*/
private function convert_conversion_action( GoogleAdsRow $row ): array {
$conversion_action = $row->getConversionAction();
$return = [
'id' => $conversion_action->getId(),
'name' => $conversion_action->getName(),
'status' => ConversionActionStatus::name( $conversion_action->getStatus() ),
];
foreach ( $conversion_action->getTagSnippets() as $t ) {
/** @var TagSnippet $t */
if ( $t->getType() !== TrackingCodeType::WEBPAGE ) {
continue;
}
if ( $t->getPageFormat() !== TrackingCodePageFormat::HTML ) {
continue;
}
preg_match( "#send_to': '([^/]+)/([^']+)'#", $t->getEventSnippet(), $matches );
$return['conversion_id'] = $matches[1];
$return['conversion_label'] = $matches[2];
break;
}
return $return;
}
}
API/Google/AdsReport.php 0000644 00000016331 15153721356 0011021 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsProductReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use DateTime;
use Google\Ads\GoogleAds\V18\Common\Segments;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\ApiCore\ApiException;
/**
* Class AdsReport
*
* ContainerAware used for:
* - AdsCampaign
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AdsReport implements ContainerAwareInterface, OptionsAwareInterface {
use ContainerAwareTrait;
use ExceptionTrait;
use MicroTrait;
use OptionsAwareTrait;
use ReportTrait;
/**
* The Google Ads Client.
*
* @var GoogleAdsClient
*/
protected $client;
/**
* Have we completed the conversion to PMax campaigns.
*
* @var bool
*/
protected $has_converted;
/**
* AdsReport constructor.
*
* @param GoogleAdsClient $client
*/
public function __construct( GoogleAdsClient $client ) {
$this->client = $client;
}
/**
* Get report data for campaigns.
*
* @param string $type Report type (campaigns or products).
* @param array $args Query arguments.
*
* @return array
* @throws ExceptionWithResponseData If the report data can't be retrieved.
*/
public function get_report_data( string $type, array $args ): array {
try {
$this->has_converted = 'converted' === $this->container->get( AdsCampaign::class )->get_campaign_convert_status();
if ( 'products' === $type ) {
$query = new AdsProductReportQuery( $args );
} else {
$query = new AdsCampaignReportQuery( $args );
}
$results = $query
->set_client( $this->client, $this->options->get_ads_id() )
->get_results();
$page = $results->getPage();
$this->init_report_totals( $args['fields'] ?? [] );
// Iterate only this page (iterateAllElements will iterate all pages).
foreach ( $page->getIterator() as $row ) {
$this->add_report_row( $type, $row, $args );
}
if ( $page->hasNextPage() ) {
$this->report_data['next_page'] = $page->getNextPageToken();
}
// Sort intervals to generate an ordered graph.
if ( isset( $this->report_data['intervals'] ) ) {
ksort( $this->report_data['intervals'] );
}
$this->remove_report_indexes( [ 'products', 'campaigns', 'intervals' ] );
return $this->report_data;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Unable to retrieve report data: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[
'errors' => $errors,
'report_type' => $type,
'report_query_args' => $args,
]
);
}
}
/**
* Add data for a report row.
*
* @param string $type Report type (campaigns or products).
* @param GoogleAdsRow $row Report row.
* @param array $args Request arguments.
*/
protected function add_report_row( string $type, GoogleAdsRow $row, array $args ) {
$campaign = $row->getCampaign();
$segments = $row->getSegments();
$metrics = $this->get_report_row_metrics( $row, $args );
if ( 'products' === $type && $segments ) {
$product_id = $segments->getProductItemId();
$this->increase_report_data(
'products',
(string) $product_id,
[
'id' => $product_id,
'name' => $segments->getProductTitle(),
'subtotals' => $metrics,
]
);
}
if ( 'campaigns' === $type && $campaign ) {
$campaign_id = $campaign->getId();
$campaign_name = $campaign->getName();
$campaign_type = CampaignType::label( $campaign->getAdvertisingChannelType() );
$is_converted = $this->has_converted && CampaignType::PERFORMANCE_MAX !== $campaign_type;
$this->increase_report_data(
'campaigns',
(string) $campaign_id,
[
'id' => $campaign_id,
'name' => $campaign_name,
'status' => CampaignStatus::label( $campaign->getStatus() ),
'isConverted' => $is_converted,
'subtotals' => $metrics,
]
);
}
if ( $segments && ! empty( $args['interval'] ) ) {
$interval = $this->get_segment_interval( $args['interval'], $segments );
$this->increase_report_data(
'intervals',
$interval,
[
'interval' => $interval,
'subtotals' => $metrics,
]
);
}
$this->increase_report_totals( $metrics );
}
/**
* Get metrics for a report row.
*
* @param GoogleAdsRow $row Report row.
* @param array $args Request arguments.
*
* @return array
*/
protected function get_report_row_metrics( GoogleAdsRow $row, array $args ): array {
$metrics = $row->getMetrics();
if ( ! $metrics || empty( $args['fields'] ) ) {
return [];
}
$data = [];
foreach ( $args['fields'] as $field ) {
switch ( $field ) {
case 'clicks':
$data['clicks'] = $metrics->getClicks();
break;
case 'impressions':
$data['impressions'] = $metrics->getImpressions();
break;
case 'spend':
$data['spend'] = $this->from_micro( $metrics->getCostMicros() );
break;
case 'sales':
$data['sales'] = $metrics->getConversionsValue();
break;
case 'conversions':
$data['conversions'] = $metrics->getConversions();
break;
}
}
return $data;
}
/**
* Get a unique interval index based on the segments data.
*
* Types:
* day = <year>-<month>-<day>
* week = <year>-<weeknumber>
* month = <year>-<month>
* quarter = <year>-<quarter>
* year = <year>
*
* @param string $interval Interval type.
* @param Segments $segments Report segment data.
*
* @return string
* @throws InvalidValue When invalid interval type is given.
*/
protected function get_segment_interval( string $interval, Segments $segments ): string {
switch ( $interval ) {
case 'day':
$date = new DateTime( $segments->getDate() );
break;
case 'week':
$date = new DateTime( $segments->getWeek() );
break;
case 'month':
$date = new DateTime( $segments->getMonth() );
break;
case 'quarter':
$date = new DateTime( $segments->getQuarter() );
break;
case 'year':
$date = DateTime::createFromFormat( 'Y', (string) $segments->getYear() );
break;
default:
throw InvalidValue::not_in_allowed_list( $interval, [ 'day', 'week', 'month', 'quarter', 'year' ] );
}
return TimeInterval::time_interval_id( $interval, $date );
}
}
API/Google/AssetFieldType.php 0000644 00000007421 15153721356 0012003 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Google\Ads\GoogleAds\V18\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
use UnexpectedValueException;
/**
* Mapping between Google and internal AssetFieldTypes
* https://developers.google.com/google-ads/api/reference/rpc/v18/AssetFieldTypeEnum.AssetFieldType
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class AssetFieldType extends StatusMapping {
/**
* Not specified.
*
* @var string
*/
public const UNSPECIFIED = 'unspecified';
/**
* Used for return value only. Represents value unknown in this version.
*
* @var string
*/
public const UNKNOWN = 'unknown';
/**
* The asset is linked for use as a headline.
*
* @var string
*/
public const HEADLINE = 'headline';
/**
* The asset is linked for use as a description.
*
* @var string
*/
public const DESCRIPTION = 'description';
/**
* The asset is linked for use as a marketing image.
*
* @var string
*/
public const MARKETING_IMAGE = 'marketing_image';
/**
* The asset is linked for use as a long headline.
*
* @var string
*/
public const LONG_HEADLINE = 'long_headline';
/**
* The asset is linked for use as a business name.
*
* @var string
*/
public const BUSINESS_NAME = 'business_name';
/**
* The asset is linked for use as a square marketing image.
*
* @var string
*/
public const SQUARE_MARKETING_IMAGE = 'square_marketing_image';
/**
* The asset is linked for use as a logo.
*
* @var string
*/
public const LOGO = 'logo';
/**
* The asset is linked for use to select a call-to-action.
*
* @var string
*/
public const CALL_TO_ACTION_SELECTION = 'call_to_action_selection';
/**
* The asset is linked for use as a portrait marketing image.
*
* @var string
*/
public const PORTRAIT_MARKETING_IMAGE = 'portrait_marketing_image';
/**
* The asset is linked for use as a landscape logo.
*
* @var string
*/
public const LANDSCAPE_LOGO = 'landscape_logo';
/**
* The asset is linked for use as a YouTube video.
*
* @var string
*/
public const YOUTUBE_VIDEO = 'youtube_video';
/**
* The asset is linked for use as a media bundle.
*
* @var string
*/
public const MEDIA_BUNDLE = 'media_bundle';
/**
* Mapping between status number and it's label.
*
* @var string
*/
protected const MAPPING = [
AdsAssetFieldType::UNSPECIFIED => self::UNSPECIFIED,
AdsAssetFieldType::UNKNOWN => self::UNKNOWN,
AdsAssetFieldType::HEADLINE => self::HEADLINE,
AdsAssetFieldType::DESCRIPTION => self::DESCRIPTION,
AdsAssetFieldType::MARKETING_IMAGE => self::MARKETING_IMAGE,
AdsAssetFieldType::LONG_HEADLINE => self::LONG_HEADLINE,
AdsAssetFieldType::BUSINESS_NAME => self::BUSINESS_NAME,
AdsAssetFieldType::SQUARE_MARKETING_IMAGE => self::SQUARE_MARKETING_IMAGE,
AdsAssetFieldType::LOGO => self::LOGO,
AdsAssetFieldType::CALL_TO_ACTION_SELECTION => self::CALL_TO_ACTION_SELECTION,
AdsAssetFieldType::PORTRAIT_MARKETING_IMAGE => self::PORTRAIT_MARKETING_IMAGE,
AdsAssetFieldType::LANDSCAPE_LOGO => self::LANDSCAPE_LOGO,
AdsAssetFieldType::YOUTUBE_VIDEO => self::YOUTUBE_VIDEO,
AdsAssetFieldType::MEDIA_BUNDLE => self::MEDIA_BUNDLE,
];
/**
* Get the enum name for the given label.
*
* @param string $label The label.
* @return string The enum name.
*
* @throws UnexpectedValueException If the label does not exist.
*/
public static function name( string $label ): string {
return AdsAssetFieldType::name( self::number( $label ) );
}
}
API/Google/BillingSetupStatus.php 0000644 00000002477 15153721356 0012731 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Google\Ads\GoogleAds\V18\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
* Mapping between Google and internal BillingSetupStatus
* https://developers.google.com/google-ads/api/reference/rpc/v18/BillingSetupStatusEnum.BillingSetupStatus
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class BillingSetupStatus extends StatusMapping {
/**
* Used for return value only. Represents value unknown in this version.
*
* @var string
*/
public const UNKNOWN = 'unknown';
/**
* The billing setup is pending approval.
*
* @var string
*/
public const PENDING = 'pending';
/**
* The billing setup has been approved.
*
* @var string
*/
public const APPROVED = 'approved';
/**
* The billing setup was cancelled by the user prior to approval.
*
* @var string
*/
public const CANCELLED = 'cancelled';
/**
* Mapping between status number and it's label.
*
* @var string
*/
protected const MAPPING = [
AdsBillingSetupStatus::PENDING => self::PENDING,
AdsBillingSetupStatus::APPROVED => self::APPROVED,
AdsBillingSetupStatus::CANCELLED => self::CANCELLED,
];
}
API/Google/CallToActionType.php 0000644 00000004703 15153721356 0012274 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Google\Ads\GoogleAds\V18\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
* Mapping between Google and internal CallToActionType
* https://developers.google.com/google-ads/api/reference/rpc/v18/CallToActionTypeEnum.CallToActionType
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class CallToActionType extends StatusMapping {
/**
* Not specified.
*
* @var string
*/
public const UNSPECIFIED = 'unspecified';
/**
* Represents value unknown in this version.
*
* @var string
*/
public const UNKNOWN = 'unknown';
/**
* The call to action type is learn more.
*
* @var string
*/
public const LEARN_MORE = 'learn_more';
/**
* The call to action type is get quote.
*
* @var string
*/
public const GET_QUOTE = 'get_quote';
/**
* The call to action type is apply now.
*
* @var string
*/
public const APPLY_NOW = 'apply_now';
/**
* The call to action type is sign up.
*
* @var string
*/
public const SIGN_UP = 'sign_up';
/**
* The call to action type is contact us.
*
* @var string
*/
public const CONTACT_US = 'contact_us';
/**
* The call to action type is subscribe.
*
* @var string
*/
public const SUBSCRIBE = 'subscribe';
/**
* The call to action type is download.
*
* @var string
*/
public const DOWNLOAD = 'download';
/**
* The call to action type is book now.
*
* @var string
*/
public const BOOK_NOW = 'book_now';
/**
* The call to action type is shop now.
*
* @var string
*/
public const SHOP_NOW = 'shop_now';
/**
* Mapping between status number and it's label.
*
* @var string
*/
protected const MAPPING = [
AdsCallToActionType::UNSPECIFIED => self::UNSPECIFIED,
AdsCallToActionType::UNKNOWN => self::UNKNOWN,
AdsCallToActionType::LEARN_MORE => self::LEARN_MORE,
AdsCallToActionType::GET_QUOTE => self::GET_QUOTE,
AdsCallToActionType::APPLY_NOW => self::APPLY_NOW,
AdsCallToActionType::SIGN_UP => self::SIGN_UP,
AdsCallToActionType::CONTACT_US => self::CONTACT_US,
AdsCallToActionType::SUBSCRIBE => self::SUBSCRIBE,
AdsCallToActionType::DOWNLOAD => self::DOWNLOAD,
AdsCallToActionType::BOOK_NOW => self::BOOK_NOW,
AdsCallToActionType::SHOP_NOW => self::SHOP_NOW,
];
}
API/Google/CampaignStatus.php 0000644 00000002162 15153721356 0012036 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Google\Ads\GoogleAds\V18\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
* Mapping between Google and internal CampaignStatus
* https://developers.google.com/google-ads/api/reference/rpc/v18/CampaignStatusEnum.CampaignStatus
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class CampaignStatus extends StatusMapping {
/**
* Campaign is currently serving ads depending on budget information.
*
* @var string
*/
public const ENABLED = 'enabled';
/**
* Campaign has been paused by the user.
*
* @var string
*/
public const PAUSED = 'paused';
/**
* Campaign has been removed.
*
* @var string
*/
public const REMOVED = 'removed';
/**
* Mapping between status number and it's label.
*
* @var string
*/
protected const MAPPING = [
AdsCampaignStatus::ENABLED => self::ENABLED,
AdsCampaignStatus::PAUSED => self::PAUSED,
AdsCampaignStatus::REMOVED => self::REMOVED,
];
}
API/Google/CampaignType.php 0000644 00000004752 15153721356 0011503 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Google\Ads\GoogleAds\V18\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
* Mapping between Google and internal CampaignTypes
* https://developers.google.com/google-ads/api/reference/rpc/v18/AdvertisingChannelTypeEnum.AdvertisingChannelType
*
* @since 1.12.2
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class CampaignType extends StatusMapping {
/**
* Not specified.
*
* @var string
*/
public const UNSPECIFIED = 'unspecified';
/**
* Used for return value only. Represents value unknown in this version.
*
* @var string
*/
public const UNKNOWN = 'unknown';
/**
* Search Network. Includes display bundled, and Search+ campaigns.
*
* @var string
*/
public const SEARCH = 'search';
/**
* Google Display Network only.
*
* @var string
*/
public const DISPLAY = 'display';
/**
* Shopping campaigns serve on the shopping property and on google.com search results.
*
* @var string
*/
public const SHOPPING = 'shopping';
/**
* Hotel Ads campaigns.
*
* @var string
*/
public const HOTEL = 'hotel';
/**
* Video campaigns.
*
* @var string
*/
public const VIDEO = 'video';
/**
* App Campaigns, and App Campaigns for Engagement, that run across multiple channels.
*
* @var string
*/
public const MULTI_CHANNEL = 'multi_channel';
/**
* Local ads campaigns.
*
* @var string
*/
public const LOCAL = 'local';
/**
* Smart campaigns.
*
* @var string
*/
public const SMART = 'smart';
/**
* Performance Max campaigns.
*
* @var string
*/
public const PERFORMANCE_MAX = 'performance_max';
/**
* Mapping between status number and it's label.
*
* @var string
*/
protected const MAPPING = [
AdsCampaignType::UNSPECIFIED => self::UNSPECIFIED,
AdsCampaignType::UNKNOWN => self::UNKNOWN,
AdsCampaignType::SEARCH => self::SEARCH,
AdsCampaignType::DISPLAY => self::DISPLAY,
AdsCampaignType::SHOPPING => self::SHOPPING,
AdsCampaignType::HOTEL => self::HOTEL,
AdsCampaignType::VIDEO => self::VIDEO,
AdsCampaignType::MULTI_CHANNEL => self::MULTI_CHANNEL,
AdsCampaignType::LOCAL => self::LOCAL,
AdsCampaignType::SMART => self::SMART,
AdsCampaignType::PERFORMANCE_MAX => self::PERFORMANCE_MAX,
];
}
API/Google/Connection.php 0000644 00000012736 15153721356 0011222 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Client\ClientExceptionInterface;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class Connection
*
* ContainerAware used to access:
* - Ads
* - Client
* - Merchant
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class Connection implements ContainerAwareInterface, OptionsAwareInterface {
use ContainerAwareTrait;
use ExceptionTrait;
use OptionsAwareTrait;
/**
* Get the connection URL for performing a connection redirect.
*
* @param string $return_url The return URL.
* @param string $login_hint Suggested Google account to use for connection.
*
* @return string
* @throws Exception When a ClientException is caught or the response doesn't contain the oauthUrl.
*/
public function connect( string $return_url, string $login_hint = '' ): string {
try {
$post_body = [ 'returnUrl' => $return_url ];
if ( ! empty( $login_hint ) ) {
$post_body['loginHint'] = $login_hint;
}
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_connection_url(),
[
'body' => wp_json_encode( $post_body ),
]
);
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 === $result->getStatusCode() && ! empty( $response['oauthUrl'] ) ) {
$this->options->update( OptionsInterface::GOOGLE_CONNECTED, true );
return $response['oauthUrl'];
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
throw new Exception( __( 'Unable to connect Google account', 'google-listings-and-ads' ) );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception( __( 'Unable to connect Google account', 'google-listings-and-ads' ) );
}
}
/**
* Disconnect from the Google account.
*
* @return string
*/
public function disconnect(): string {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->delete( $this->get_connection_url() );
$this->options->update( OptionsInterface::GOOGLE_CONNECTED, false );
return $result->getBody()->getContents();
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
return $e->getMessage();
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
return $e->getMessage();
}
}
/**
* Get the status of the connection.
*
* @return array
* @throws Exception When a ClientException is caught or the response contains an error.
*/
public function get_status(): array {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->get( $this->get_connection_url() );
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 === $result->getStatusCode() ) {
$connected = isset( $response['status'] ) && 'connected' === $response['status'];
$this->options->update( OptionsInterface::GOOGLE_CONNECTED, $connected );
return $response;
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
$message = $response['message'] ?? __( 'Invalid response when retrieving status', 'google-listings-and-ads' );
throw new Exception( $message, $result->getStatusCode() );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception( $this->client_exception_message( $e, __( 'Error retrieving status', 'google-listings-and-ads' ) ) );
}
}
/**
* Get the reconnect status which checks:
* - The Google account is connected
* - We have access to the connected MC account
* - We have access to the connected Ads account
*
* @return array
* @throws Exception When a ClientException is caught or the response contains an error.
*/
public function get_reconnect_status(): array {
$status = $this->get_status();
$email = $status['email'] ?? '';
if ( ! isset( $status['status'] ) || 'connected' !== $status['status'] ) {
return $status;
}
$merchant_id = $this->options->get_merchant_id();
if ( $merchant_id ) {
/** @var Merchant $merchant */
$merchant = $this->container->get( Merchant::class );
$status['merchant_account'] = $merchant_id;
$status['merchant_access'] = $merchant->has_access( $email ) ? 'yes' : 'no';
}
$ads_id = $this->options->get_ads_id();
if ( $ads_id ) {
/** @var Ads $ads */
$ads = $this->container->get( Ads::class );
$status['ads_account'] = $ads_id;
$status['ads_access'] = $ads->has_access( $email ) ? 'yes' : 'no';
}
return $status;
}
/**
* Get the Google connection URL.
*
* @return string
*/
protected function get_connection_url(): string {
return "{$this->container->get( 'connect_server_root' )}google/connection/google-mc";
}
}
API/Google/ExceptionTrait.php 0000644 00000013627 15153721356 0012065 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Exception\BadResponseException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Client\ClientExceptionInterface;
use Google\ApiCore\ApiException;
use Google\Rpc\Code;
use Exception;
/**
* Trait ExceptionTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
trait ExceptionTrait {
/**
* Check if the ApiException contains a specific error.
*
* @param ApiException $exception Exception to check.
* @param string $error_code Error code we are checking.
*
* @return bool
*/
protected function has_api_exception_error( ApiException $exception, string $error_code ): bool {
$meta = $exception->getMetadata();
if ( empty( $meta ) || ! is_array( $meta ) ) {
return false;
}
foreach ( $meta as $data ) {
if ( empty( $data['errors'] ) || ! is_array( $data['errors'] ) ) {
continue;
}
foreach ( $data['errors'] as $error ) {
if ( in_array( $error_code, $error['errorCode'], true ) ) {
return true;
}
}
}
return false;
}
/**
* Returns a list of detailed errors from an exception instance that extends ApiException
* or GoogleServiceException. Other Exception instances will also be converted to an array
* in the same structure.
*
* The following are the example sources of ApiException, GoogleServiceException,
* and other Exception in order:
*
* @link https://github.com/googleads/google-ads-php/blob/v25.0.0/src/Google/Ads/GoogleAds/V18/Services/Client/CustomerServiceClient.php#L303
* @link https://github.com/googleapis/google-api-php-client/blob/v2.16.1/src/Http/REST.php#L119-L135
* @link https://github.com/googleapis/google-api-php-client/blob/v2.16.1/src/Service/Resource.php#L86-L174
*
* @param ApiException|GoogleServiceException|Exception $exception Exception to check.
*
* @return array
*/
protected function get_exception_errors( Exception $exception ): array {
if ( $exception instanceof ApiException ) {
return $this->get_api_exception_errors( $exception );
}
if ( $exception instanceof GoogleServiceException ) {
return $this->get_google_service_exception_errors( $exception );
}
// Fallback for handling other Exception instances.
$code = $exception->getCode();
return [ $code => $exception->getMessage() ];
}
/**
* Returns a list of detailed errors from an ApiException.
* If no errors are found the default Exception message is returned.
*
* @param ApiException $exception Exception to check.
*
* @return array
*/
private function get_api_exception_errors( ApiException $exception ): array {
$errors = [];
$meta = $exception->getMetadata();
if ( is_array( $meta ) ) {
foreach ( $meta as $data ) {
if ( empty( $data['errors'] ) || ! is_array( $data['errors'] ) ) {
continue;
}
foreach ( $data['errors'] as $error ) {
if ( empty( $error['message'] ) ) {
continue;
}
if ( ! empty( $error['errorCode'] ) && is_array( $error['errorCode'] ) ) {
$error_code = reset( $error['errorCode'] );
} else {
$error_code = 'ERROR';
}
$errors[ $error_code ] = $error['message'];
}
}
}
$errors[ $exception->getStatus() ] = $exception->getBasicMessage();
return $errors;
}
/**
* Returns a list of detailed errors from a GoogleServiceException.
*
* @param GoogleServiceException $exception Exception to check.
*
* @return array
*/
private function get_google_service_exception_errors( GoogleServiceException $exception ): array {
$errors = [];
if ( ! is_null( $exception->getErrors() ) ) {
foreach ( $exception->getErrors() as $error ) {
if ( ! isset( $error['message'] ) ) {
continue;
}
$error_code = $error['reason'] ?? 'ERROR';
$errors[ $error_code ] = $error['message'];
}
}
if ( 0 === count( $errors ) ) {
$errors['unknown'] = __( 'An unknown error occurred in the Shopping Content Service.', 'google-listings-and-ads' );
}
return $errors;
}
/**
* Get an error message from a ClientException.
*
* @param ClientExceptionInterface $exception Exception to check.
* @param string $default_error Default error message.
*
* @return string
*/
protected function client_exception_message( ClientExceptionInterface $exception, string $default_error ): string {
if ( $exception instanceof BadResponseException ) {
$response = json_decode( $exception->getResponse()->getBody()->getContents(), true );
$message = $response['message'] ?? false;
return $message ? $default_error . ': ' . $message : $default_error;
}
return $default_error;
}
/**
* Map a gRPC code to HTTP status code.
*
* @param ApiException $exception Exception to check.
*
* @return int The HTTP status code.
*
* @see Google\Rpc\Code for the list of gRPC codes.
*/
protected function map_grpc_code_to_http_status_code( ApiException $exception ) {
switch ( $exception->getCode() ) {
case Code::OK:
return 200;
case Code::CANCELLED:
return 499;
case Code::UNKNOWN:
return 500;
case Code::INVALID_ARGUMENT:
return 400;
case Code::DEADLINE_EXCEEDED:
return 504;
case Code::NOT_FOUND:
return 404;
case Code::ALREADY_EXISTS:
return 409;
case Code::PERMISSION_DENIED:
return 403;
case Code::UNAUTHENTICATED:
return 401;
case Code::RESOURCE_EXHAUSTED:
return 429;
case Code::FAILED_PRECONDITION:
return 400;
case Code::ABORTED:
return 409;
case Code::OUT_OF_RANGE:
return 400;
case Code::UNIMPLEMENTED:
return 501;
case Code::INTERNAL:
return 500;
case Code::UNAVAILABLE:
return 503;
case Code::DATA_LOSS:
return 500;
default:
return 500;
}
}
}
API/Google/LocationIDTrait.php 0000644 00000003341 15153721356 0012104 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidState;
defined( 'ABSPATH' ) || exit;
/**
* Trait LocationIDTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
trait LocationIDTrait {
/**
* Mapping data for location IDs.
*
* @see https://developers.google.com/adwords/api/docs/appendix/geotargeting
*
* @var string[]
*/
protected $mapping = [
'AL' => 21133,
'AK' => 21132,
'AZ' => 21136,
'AR' => 21135,
'CA' => 21137,
'CO' => 21138,
'CT' => 21139,
'DE' => 21141,
'DC' => 21140,
'FL' => 21142,
'GA' => 21143,
'HI' => 21144,
'ID' => 21146,
'IL' => 21147,
'IN' => 21148,
'IA' => 21145,
'KS' => 21149,
'KY' => 21150,
'LA' => 21151,
'ME' => 21154,
'MD' => 21153,
'MA' => 21152,
'MI' => 21155,
'MN' => 21156,
'MS' => 21158,
'MO' => 21157,
'MT' => 21159,
'NE' => 21162,
'NV' => 21166,
'NH' => 21163,
'NJ' => 21164,
'NM' => 21165,
'NY' => 21167,
'NC' => 21160,
'ND' => 21161,
'OH' => 21168,
'OK' => 21169,
'OR' => 21170,
'PA' => 21171,
'RI' => 21172,
'SC' => 21173,
'SD' => 21174,
'TN' => 21175,
'TX' => 21176,
'UT' => 21177,
'VT' => 21179,
'VA' => 21178,
'WA' => 21180,
'WV' => 21183,
'WI' => 21182,
'WY' => 21184,
];
/**
* Get the location ID for a given state.
*
* @param string $state
*
* @return int
* @throws InvalidState When the provided state is not found in the mapping.
*/
protected function get_state_id( string $state ): int {
if ( ! array_key_exists( $state, $this->mapping ) ) {
throw InvalidState::from_state( $state );
}
return $this->mapping[ $state ];
}
}
API/Google/Merchant.php 0000644 00000035142 15153721356 0010660 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Account;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAdsLink;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductstatusesCustomBatchResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductstatusesCustomBatchRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RequestPhoneVerificationRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RequestReviewFreeListingsRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RequestReviewShoppingAdsRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\VerifyPhoneNumberRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\ResponseInterface;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class Merchant
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class Merchant implements OptionsAwareInterface {
use ExceptionTrait;
use OptionsAwareTrait;
/**
* The shopping service.
*
* @var ShoppingContent
*/
protected $service;
/**
* Merchant constructor.
*
* @param ShoppingContent $service
*/
public function __construct( ShoppingContent $service ) {
$this->service = $service;
}
/**
* @return Product[]
*/
public function get_products(): array {
$products = $this->service->products->listProducts( $this->options->get_merchant_id() );
$return = [];
while ( ! empty( $products->getResources() ) ) {
foreach ( $products->getResources() as $product ) {
$return[] = $product;
}
if ( empty( $products->getNextPageToken() ) ) {
break;
}
$products = $this->service->products->listProducts(
$this->options->get_merchant_id(),
[ 'pageToken' => $products->getNextPageToken() ]
);
}
return $return;
}
/**
* Claim a website for the user's Merchant Center account.
*
* @param bool $overwrite Whether to include the overwrite directive.
* @return bool
* @throws Exception If the website claim fails.
*/
public function claimwebsite( bool $overwrite = false ): bool {
try {
$id = $this->options->get_merchant_id();
$params = $overwrite ? [ 'overwrite' => true ] : [];
$this->service->accounts->claimwebsite( $id, $id, $params );
do_action( 'woocommerce_gla_site_claim_success', [ 'details' => 'google_proxy' ] );
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'google_proxy' ] );
$error_message = __( 'Unable to claim website.', 'google-listings-and-ads' );
if ( 403 === $e->getCode() ) {
$error_message = __( 'Website already claimed, use overwrite to complete the process.', 'google-listings-and-ads' );
}
throw new Exception( $error_message, $e->getCode() );
}
return true;
}
/**
* Request verification code to start phone verification.
*
* @param string $region_code Two-letter country code (ISO 3166-1 alpha-2) for the phone number, for
* example CA for Canadian numbers.
* @param string $phone_number Phone number to be verified.
* @param string $verification_method Verification method to receive verification code.
* @param string $language_code Language code IETF BCP 47 syntax (for example, en-US). Language code is used
* to provide localized SMS and PHONE_CALL. Default language used is en-US if
* not provided.
*
* @return string The verification ID to use in subsequent calls to
* `Merchant::verify_phone_number`.
*
* @throws GoogleServiceException If there are any Google API errors.
*
* @see https://tools.ietf.org/html/bcp47 IETF BCP 47 language codes.
* @see https://wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements ISO 3166-1 alpha-2
* officially assigned codes.
*
* @since 1.5.0
*/
public function request_phone_verification( string $region_code, string $phone_number, string $verification_method, string $language_code = 'en-US' ): string {
$merchant_id = $this->options->get_merchant_id();
$request = new RequestPhoneVerificationRequest(
[
'phoneRegionCode' => $region_code,
'phoneNumber' => $phone_number,
'phoneVerificationMethod' => $verification_method,
'languageCode' => $language_code,
]
);
try {
return $this->service->accounts->requestphoneverification( $merchant_id, $merchant_id, $request )->getVerificationId();
} catch ( GoogleServiceException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw $e;
}
}
/**
* Validates verification code to verify phone number for the account.
*
* @param string $verification_id The verification ID returned by
* `Merchant::request_phone_verification`.
* @param string $verification_code The verification code that was sent to the phone number for validation.
* @param string $verification_method Verification method used to receive verification code.
*
* @return string Verified phone number if verification is successful.
*
* @throws GoogleServiceException If there are any Google API errors.
*
* @since 1.5.0
*/
public function verify_phone_number( string $verification_id, string $verification_code, string $verification_method ): string {
$merchant_id = $this->options->get_merchant_id();
$request = new VerifyPhoneNumberRequest(
[
'verificationId' => $verification_id,
'verificationCode' => $verification_code,
'phoneVerificationMethod' => $verification_method,
]
);
try {
return $this->service->accounts->verifyphonenumber( $merchant_id, $merchant_id, $request )->getVerifiedPhoneNumber();
} catch ( GoogleServiceException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw $e;
}
}
/**
* Retrieve the user's Merchant Center account information.
*
* @param int $id Optional - the Merchant Center account to retrieve
*
* @return Account The user's Merchant Center account.
* @throws ExceptionWithResponseData If the account can't be retrieved.
*/
public function get_account( int $id = 0 ): Account {
$id = $id ?: $this->options->get_merchant_id();
try {
$mc_account = $this->service->accounts->get( $id, $id );
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Unable to retrieve Merchant Center account: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$e->getCode(),
null,
[ 'errors' => $errors ]
);
}
return $mc_account;
}
/**
* Get hash of the site URL we used during onboarding.
* If not available in a local option, it's fetched from the Merchant Center account.
*
* @since 1.13.0
* @return string|null
*/
public function get_claimed_url_hash(): ?string {
$claimed_url_hash = $this->options->get( OptionsInterface::CLAIMED_URL_HASH );
if ( empty( $claimed_url_hash ) && $this->options->get_merchant_id() ) {
try {
$account_url = $this->get_account()->getWebsiteUrl();
if ( empty( $account_url ) || ! $this->get_accountstatus()->getWebsiteClaimed() ) {
return null;
}
$claimed_url_hash = md5( untrailingslashit( $account_url ) );
$this->options->update( OptionsInterface::CLAIMED_URL_HASH, $claimed_url_hash );
} catch ( Exception $e ) {
return null;
}
}
return $claimed_url_hash;
}
/**
* Retrieve the user's Merchant Center account information.
*
* @param int $id Optional - the Merchant Center account to retrieve
* @return AccountStatus The user's Merchant Center account status.
* @throws Exception If the account can't be retrieved.
*/
public function get_accountstatus( int $id = 0 ): AccountStatus {
$id = $id ?: $this->options->get_merchant_id();
try {
$mc_account_status = $this->service->accountstatuses->get( $id, $id );
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw new Exception( __( 'Unable to retrieve Merchant Center account status.', 'google-listings-and-ads' ), $e->getCode() );
}
return $mc_account_status;
}
/**
* Retrieve a batch of Merchant Center Product Statuses using the provided Merchant Center product IDs.
*
* @since 1.1.0
*
* @param string[] $mc_product_ids
*
* @return ProductstatusesCustomBatchResponse;
*/
public function get_productstatuses_batch( array $mc_product_ids ): ProductstatusesCustomBatchResponse {
$merchant_id = $this->options->get_merchant_id();
$entries = [];
foreach ( $mc_product_ids as $index => $id ) {
$entries[] = [
'batchId' => $index + 1,
'productId' => $id,
'method' => 'GET',
'merchantId' => $merchant_id,
];
}
// Retrieve batch.
$request = new ProductstatusesCustomBatchRequest();
$request->setEntries( $entries );
return $this->service->productstatuses->custombatch( $request );
}
/**
* Update the provided Merchant Center account information.
*
* @param Account $account The Account data to update.
*
* @return Account The user's Merchant Center account.
* @throws ExceptionWithResponseData If the account can't be updated.
*/
public function update_account( Account $account ): Account {
try {
$account = $this->service->accounts->update( $account->getId(), $account->getId(), $account );
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Unable to update Merchant Center account: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$e->getCode(),
null,
[ 'errors' => $errors ]
);
}
return $account;
}
/**
* Link a Google Ads ID to this Merchant account.
*
* @param int $ads_id Google Ads ID to link.
*
* @return bool True if the link invitation is waiting for acceptance. False if the link is already active.
* @throws ExceptionWithResponseData When unable to retrieve or update account data.
*/
public function link_ads_id( int $ads_id ): bool {
$account = $this->get_account();
$ads_links = $account->getAdsLinks() ?? [];
// Stop early if we already have a link setup.
foreach ( $ads_links as $link ) {
if ( $ads_id === absint( $link->getAdsId() ) ) {
return $link->getStatus() !== 'active';
}
}
$link = new AccountAdsLink();
$link->setAdsId( $ads_id );
$link->setStatus( 'active' );
$account->setAdsLinks( array_merge( $ads_links, [ $link ] ) );
$this->update_account( $account );
return true;
}
/**
* Check if we have access to the merchant account.
*
* @param string $email Email address of the connected account.
*
* @return bool
*/
public function has_access( string $email ): bool {
$id = $this->options->get_merchant_id();
try {
$account = $this->service->accounts->get( $id, $id );
foreach ( $account->getUsers() as $user ) {
if ( $email === $user->getEmailAddress() && $user->getAdmin() ) {
return true;
}
}
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
}
return false;
}
/**
* Update the Merchant Center ID to use for requests.
*
* @param int $id Merchant ID number.
*
* @return bool
*/
public function update_merchant_id( int $id ): bool {
return $this->options->update( OptionsInterface::MERCHANT_ID, $id );
}
/**
* Get the review status for an MC account
*
* @since 2.7.1
*
* @return array An array with the status for freeListingsProgram and shoppingAdsProgram
* @throws Exception When an exception happens in the Google API.
*/
public function get_account_review_status() {
try {
$id = $this->options->get_merchant_id();
return [
'freeListingsProgram' => $this->service->freelistingsprogram->get( $id ),
'shoppingAdsProgram' => $this->service->shoppingadsprogram->get( $id ),
];
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw new Exception( $e->getMessage(), $e->getCode() );
}
}
/**
* Request a review for an MC account
*
* @since 2.7.1
*
* @param string $region_code The region code to request the review
* @param array $types The types of programs to request the review
*
* @return ResponseInterface The Google API response
* @throws Exception When the request review produces an exception in the Google side or when
* the programs are not supported.
*/
public function account_request_review( $region_code, $types ) {
try {
$id = $this->options->get_merchant_id();
if ( in_array( 'freelistingsprogram', $types, true ) ) {
$request = new RequestReviewFreeListingsRequest();
$request->setRegionCode( $region_code );
return $this->service->freelistingsprogram->requestreview( $id, $request );
} elseif ( in_array( 'shoppingadsprogram', $types, true ) ) {
$request = new RequestReviewShoppingAdsRequest();
$request->setRegionCode( $region_code );
return $this->service->shoppingadsprogram->requestreview( $id, $request );
} else {
throw new Exception( 'Program type not supported', 400 );
}
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw new Exception( $e->getMessage(), $e->getCode() );
}
}
}
API/Google/MerchantMetrics.php 0000644 00000015644 15153721356 0012214 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantFreeListingReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
use DateTime;
use Exception;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\ApiCore\PagedListResponse;
/**
* Class MerchantMetrics
*
* @since 1.7.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class MerchantMetrics implements OptionsAwareInterface {
use OptionsAwareTrait;
/**
* The Google shopping client.
*
* @var ShoppingContent
*/
protected $shopping_client;
/**
* The Google ads client.
*
* @var GoogleAdsClient
*/
protected $ads_client;
/**
* @var WP
*/
protected $wp;
/**
* @var TransientsInterface
*/
protected $transients;
protected const MAX_QUERY_START_DATE = '2020-01-01';
/**
* MerchantMetrics constructor.
*
* @param ShoppingContent $shopping_client
* @param GoogleAdsClient $ads_client
* @param WP $wp
* @param TransientsInterface $transients
*/
public function __construct( ShoppingContent $shopping_client, GoogleAdsClient $ads_client, WP $wp, TransientsInterface $transients ) {
$this->shopping_client = $shopping_client;
$this->ads_client = $ads_client;
$this->wp = $wp;
$this->transients = $transients;
}
/**
* Get free listing metrics.
*
* @return array Of metrics or empty if no metrics were available.
* @type int $clicks Number of free clicks.
* @type int $impressions NUmber of free impressions.
*
* @throws Exception When unable to get clicks data.
*/
public function get_free_listing_metrics(): array {
if ( ! $this->options->get_merchant_id() ) {
// Merchant account not set up
return [];
}
// Google API requires a date clause to be set but there doesn't seem to be any limits on how wide the range
$query = ( new MerchantFreeListingReportQuery( [] ) )
->set_client( $this->shopping_client, $this->options->get_merchant_id() )
->where_date_between( self::MAX_QUERY_START_DATE, $this->get_tomorrow() )
->fields( [ 'clicks', 'impressions' ] );
/** @var SearchResponse $response */
$response = $query->get_results();
if ( empty( $response ) || empty( $response->getResults() ) ) {
return [];
}
$report_row = $response->getResults()[0];
return [
'clicks' => (int) $report_row->getMetrics()->getClicks(),
'impressions' => (int) $report_row->getMetrics()->getImpressions(),
];
}
/**
* Get free listing metrics but cached for 12 hours.
*
* PLEASE NOTE: These metrics will not be 100% accurate since there is no invalidation apart from the 12 hour refresh.
*
* @return array Of metrics or empty if no metrics were available.
* @type int $clicks Number of free clicks.
* @type int $impressions NUmber of free impressions.
*
* @throws Exception When unable to get data.
*/
public function get_cached_free_listing_metrics(): array {
$value = $this->transients->get( TransientsInterface::FREE_LISTING_METRICS );
if ( $value === null ) {
$value = $this->get_free_listing_metrics();
$this->transients->set( TransientsInterface::FREE_LISTING_METRICS, $value, HOUR_IN_SECONDS * 12 );
}
return $value;
}
/**
* Get ads metrics across all campaigns.
*
* @return array Of metrics or empty if no metrics were available.
*
* @throws Exception When unable to get data.
*/
public function get_ads_metrics(): array {
if ( ! $this->options->get_ads_id() ) {
// Ads account not set up
return [];
}
// Google API requires a date clause to be set but there doesn't seem to be any limits on how wide the range
$query = ( new AdsCampaignReportQuery( [] ) )
->set_client( $this->ads_client, $this->options->get_ads_id() )
->where_date_between( self::MAX_QUERY_START_DATE, $this->get_tomorrow() )
->fields( [ 'clicks', 'conversions', 'impressions' ] );
/** @var PagedListResponse $response */
$response = $query->get_results();
$page = $response->getPage();
if ( $page && $page->getIterator()->current() ) {
/** @var GoogleAdsRow $row */
$row = $page->getIterator()->current();
$metrics = $row->getMetrics();
if ( $metrics ) {
return [
'clicks' => $metrics->getClicks(),
'conversions' => (int) $metrics->getConversions(),
'impressions' => $metrics->getImpressions(),
];
}
}
return [];
}
/**
* Get ads metrics across all campaigns but cached for 12 hours.
*
* PLEASE NOTE: These metrics will not be 100% accurate since there is no invalidation apart from the 12 hour refresh.
*
* @return array Of metrics or empty if no metrics were available.
*
* @throws Exception When unable to get data.
*/
public function get_cached_ads_metrics(): array {
$value = $this->transients->get( TransientsInterface::ADS_METRICS );
if ( $value === null ) {
$value = $this->get_ads_metrics();
$this->transients->set( TransientsInterface::ADS_METRICS, $value, HOUR_IN_SECONDS * 12 );
}
return $value;
}
/**
* Return amount of active campaigns for the connected Ads account.
*
* @since 2.5.11
*
* @return int
*/
public function get_campaign_count(): int {
if ( ! $this->options->get_ads_id() ) {
return 0;
}
$campaign_count = 0;
$cached_count = $this->transients->get( TransientsInterface::ADS_CAMPAIGN_COUNT );
if ( null !== $cached_count ) {
return (int) $cached_count;
}
try {
$query = ( new AdsCampaignQuery() )->set_client( $this->ads_client, $this->options->get_ads_id() );
$query->where( 'campaign.status', 'REMOVED', '!=' );
$campaign_results = $query->get_results();
// Iterate through all paged results (total results count is not set).
foreach ( $campaign_results->iterateAllElements() as $row ) {
++$campaign_count;
}
} catch ( Exception $e ) {
$campaign_count = 0;
}
$this->transients->set( TransientsInterface::ADS_CAMPAIGN_COUNT, $campaign_count, HOUR_IN_SECONDS * 12 );
return $campaign_count;
}
/**
* Get tomorrow's date to ensure we include any metrics from the current day.
*
* @return string
*/
protected function get_tomorrow(): string {
return ( new DateTime( 'tomorrow', $this->wp->wp_timezone() ) )->format( 'Y-m-d' );
}
}
API/Google/MerchantReport.php 0000644 00000020622 15153721356 0012051 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantFreeListingReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantProductReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantProductViewReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ReportRow;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Segments;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\ShoppingContentDateTrait;
use DateTime;
use Exception;
/**
* Trait MerchantReportTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class MerchantReport implements OptionsAwareInterface {
use OptionsAwareTrait;
use ReportTrait;
use ShoppingContentDateTrait;
/**
* The shopping service.
*
* @var ShoppingContent
*/
protected $service;
/**
* Product helper class.
*
* @var ProductHelper
*/
protected $product_helper;
/**
* Merchant Report constructor.
*
* @param ShoppingContent $service
* @param ProductHelper $product_helper
*/
public function __construct( ShoppingContent $service, ProductHelper $product_helper ) {
$this->service = $service;
$this->product_helper = $product_helper;
}
/**
* Get ProductView Query response.
*
* @param string|null $next_page_token The next page token.
* @return array Associative array with product statuses and the next page token.
*
* @throws Exception If the product view report data can't be retrieved.
*/
public function get_product_view_report( $next_page_token = null ): array {
$batch_size = apply_filters( 'woocommerce_gla_product_view_report_page_size', 500 );
try {
$product_view_data = [
'statuses' => [],
'next_page_token' => null,
];
$query = new MerchantProductViewReportQuery(
[
'next_page' => $next_page_token,
'per_page' => $batch_size,
]
);
$response = $query
->set_client( $this->service, $this->options->get_merchant_id() )
->get_results();
$results = $response->getResults() ?? [];
foreach ( $results as $row ) {
/** @var ProductView $product_view */
$product_view = $row->getProductView();
$wc_product_id = $this->product_helper->get_wc_product_id( $product_view->getId() );
$mc_product_status = $this->convert_aggregated_status_to_mc_status( $product_view->getAggregatedDestinationStatus() );
// Skip if the product id does not exist
if ( ! $wc_product_id ) {
continue;
}
$product_view_data['statuses'][ $wc_product_id ] = [
'mc_id' => $product_view->getId(),
'product_id' => $wc_product_id,
'status' => $mc_product_status,
'expiration_date' => $this->convert_shopping_content_date( $product_view->getExpirationDate() ),
];
}
$product_view_data['next_page_token'] = $response->getNextPageToken();
return $product_view_data;
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw new Exception( __( 'Unable to retrieve Product View Report.', 'google-listings-and-ads' ) . $e->getMessage(), $e->getCode() );
}
}
/**
* Convert the product view aggregated status to the MC status.
*
* @param string $status The aggregated status of the product.
*
* @return string The MC status.
*/
protected function convert_aggregated_status_to_mc_status( string $status ): string {
switch ( $status ) {
case 'ELIGIBLE':
return MCStatus::APPROVED;
case 'ELIGIBLE_LIMITED':
return MCStatus::PARTIALLY_APPROVED;
case 'NOT_ELIGIBLE_OR_DISAPPROVED':
return MCStatus::DISAPPROVED;
case 'PENDING':
return MCStatus::PENDING;
default:
return MCStatus::NOT_SYNCED;
}
}
/**
* Get report data for free listings.
*
* @param string $type Report type (free_listings or products).
* @param array $args Query arguments.
*
* @return array
* @throws Exception If the report data can't be retrieved.
*/
public function get_report_data( string $type, array $args ): array {
try {
if ( 'products' === $type ) {
$query = new MerchantProductReportQuery( $args );
} else {
$query = new MerchantFreeListingReportQuery( $args );
}
$results = $query
->set_client( $this->service, $this->options->get_merchant_id() )
->get_results();
$this->init_report_totals( $args['fields'] ?? [] );
foreach ( $results->getResults() as $row ) {
$this->add_report_row( $type, $row, $args );
}
if ( $results->getNextPageToken() ) {
$this->report_data['next_page'] = $results->getNextPageToken();
}
// Sort intervals to generate an ordered graph.
if ( isset( $this->report_data['intervals'] ) ) {
ksort( $this->report_data['intervals'] );
}
$this->remove_report_indexes( [ 'products', 'free_listings', 'intervals' ] );
return $this->report_data;
} catch ( GoogleException $e ) {
do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
throw new Exception( __( 'Unable to retrieve report data.', 'google-listings-and-ads' ), $e->getCode() );
}
}
/**
* Add data for a report row.
*
* @param string $type Report type (free_listings or products).
* @param ReportRow $row Report row.
* @param array $args Request arguments.
*/
protected function add_report_row( string $type, ReportRow $row, array $args ) {
$segments = $row->getSegments();
$metrics = $this->get_report_row_metrics( $row, $args );
if ( 'free_listings' === $type ) {
$this->increase_report_data(
'free_listings',
'free',
[
'subtotals' => $metrics,
]
);
}
if ( 'products' === $type && $segments ) {
$product_id = $segments->getOfferId();
$this->increase_report_data(
'products',
(string) $product_id,
[
'id' => $product_id,
'subtotals' => $metrics,
]
);
// Retrieve product title and add to report.
if ( empty( $this->report_data['products'][ $product_id ]['name'] ) ) {
$name = $this->product_helper->get_wc_product_title( (string) $product_id );
$this->report_data['products'][ $product_id ]['name'] = $name;
}
}
if ( $segments && ! empty( $args['interval'] ) ) {
$interval = $this->get_segment_interval( $args['interval'], $segments );
$this->increase_report_data(
'intervals',
$interval,
[
'interval' => $interval,
'subtotals' => $metrics,
]
);
}
$this->increase_report_totals( $metrics );
}
/**
* Get metrics for a report row.
*
* @param ReportRow $row Report row.
* @param array $args Request arguments.
*
* @return array
*/
protected function get_report_row_metrics( ReportRow $row, array $args ): array {
$metrics = $row->getMetrics();
if ( ! $metrics || empty( $args['fields'] ) ) {
return [];
}
$data = [];
foreach ( $args['fields'] as $field ) {
switch ( $field ) {
case 'clicks':
$data['clicks'] = (int) $metrics->getClicks();
break;
case 'impressions':
$data['impressions'] = (int) $metrics->getImpressions();
break;
}
}
return $data;
}
/**
* Get a unique interval index based on the segments data.
*
* Types:
* day = <year>-<month>-<day>
*
* @param string $interval Interval type.
* @param Segments $segments Report segment data.
*
* @return string
* @throws InvalidValue When invalid interval type is given.
*/
protected function get_segment_interval( string $interval, Segments $segments ): string {
if ( 'day' !== $interval ) {
throw InvalidValue::not_in_allowed_list( $interval, [ 'day' ] );
}
$date = $segments->getDate();
$date = new DateTime( "{$date->getYear()}-{$date->getMonth()}-{$date->getDay()}" );
return TimeInterval::time_interval_id( $interval, $date );
}
}
API/Google/Middleware.php 0000644 00000045130 15153721356 0011172 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidTerm;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidDomainName;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\DateTimeUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\TosAccepted;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerExceptionInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\NotFoundExceptionInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Client\ClientExceptionInterface;
use DateTime;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class Middleware
*
* Container used for:
* - Ads
* - Client
* - DateTimeUtility
* - GoogleHelper
* - Merchant
* - WC
* - WP
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class Middleware implements ContainerAwareInterface, OptionsAwareInterface {
use ContainerAwareTrait;
use ExceptionTrait;
use OptionsAwareTrait;
use PluginHelper;
/**
* Get all Merchant Accounts associated with the connected account.
*
* @return array
* @throws Exception When an Exception is caught.
* @since 1.7.0
*/
public function get_merchant_accounts(): array {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->get( $this->get_manager_url( 'merchant-accounts' ) );
$response = json_decode( $result->getBody()->getContents(), true );
$accounts = [];
if ( 200 === $result->getStatusCode() && is_array( $response ) ) {
foreach ( $response as $account ) {
$accounts[] = $account;
}
}
return $accounts;
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception(
$this->client_exception_message( $e, __( 'Error retrieving accounts', 'google-listings-and-ads' ) ),
$e->getCode()
);
}
}
/**
* Create a new Merchant Center account.
*
* @return int Created merchant account ID
*
* @throws Exception When an Exception is caught or we receive an invalid response.
*/
public function create_merchant_account(): int {
$user = wp_get_current_user();
$tos = $this->mark_tos_accepted( 'google-mc', $user->user_email );
if ( ! $tos->accepted() ) {
throw new Exception( __( 'Unable to log accepted TOS', 'google-listings-and-ads' ) );
}
$site_url = esc_url_raw( $this->get_site_url() );
if ( ! wc_is_valid_url( $site_url ) ) {
throw new Exception( __( 'Invalid site URL.', 'google-listings-and-ads' ) );
}
try {
return $this->create_merchant_account_request(
$this->new_account_name(),
$site_url
);
} catch ( InvalidTerm $e ) {
// Try again with a default account name.
return $this->create_merchant_account_request(
$this->default_account_name(),
$site_url
);
}
}
/**
* Send a request to create a merchant account.
*
* @param string $name Site name
* @param string $site_url Website URL
*
* @return int Created merchant account ID
*
* @throws Exception When an Exception is caught or we receive an invalid response.
* @throws InvalidTerm When the account name contains invalid terms.
* @throws InvalidDomainName When the site URL ends with an invalid top-level domain.
* @since 1.5.0
*/
protected function create_merchant_account_request( string $name, string $site_url ): int {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_manager_url( 'create-merchant' ),
[
'body' => wp_json_encode(
[
'name' => $name,
'websiteUrl' => $site_url,
]
),
]
);
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 === $result->getStatusCode() && isset( $response['id'] ) ) {
$id = absint( $response['id'] );
$this->container->get( Merchant::class )->update_merchant_id( $id );
return $id;
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
$error = $response['message'] ?? __( 'Invalid response when creating account', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
} catch ( ClientExceptionInterface $e ) {
$message = $this->client_exception_message( $e, __( 'Error creating account', 'google-listings-and-ads' ) );
if ( preg_match( '/terms?.* are|is not allowed/', $message ) ) {
throw InvalidTerm::contains_invalid_terms( $name );
}
if ( strpos( $message, 'URL ends with an invalid top-level domain name' ) !== false ) {
throw InvalidDomainName::create_account_failed_invalid_top_level_domain_name(
$this->strip_url_protocol(
esc_url_raw( $this->get_site_url() )
)
);
}
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception( $message, $e->getCode() );
}
}
/**
* Link an existing Merchant Center account.
*
* @param int $id Existing account ID.
*
* @return int
*/
public function link_merchant_account( int $id ): int {
$this->container->get( Merchant::class )->update_merchant_id( $id );
return $id;
}
/**
* Link Merchant Center account to MCA.
*
* @return bool
* @throws Exception When a ClientException is caught or we receive an invalid response.
*/
public function link_merchant_to_mca(): bool {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_manager_url( 'link-merchant' ),
[
'body' => wp_json_encode(
[
'accountId' => $this->options->get_merchant_id(),
]
),
]
);
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 === $result->getStatusCode() && isset( $response['status'] ) && 'success' === $response['status'] ) {
return true;
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
$error = $response['message'] ?? __( 'Invalid response when linking merchant to MCA', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception(
$this->client_exception_message( $e, __( 'Error linking merchant to MCA', 'google-listings-and-ads' ) ),
$e->getCode()
);
}
}
/**
* Claim the website for a MCA.
*
* @param bool $overwrite To enable claim overwriting.
* @return bool
* @throws Exception When an Exception is caught or we receive an invalid response.
*/
public function claim_merchant_website( bool $overwrite = false ): bool {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_manager_url( 'claim-website' ),
[
'body' => wp_json_encode(
[
'accountId' => $this->options->get_merchant_id(),
'overwrite' => $overwrite,
]
),
]
);
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 === $result->getStatusCode() && isset( $response['status'] ) && 'success' === $response['status'] ) {
do_action( 'woocommerce_gla_site_claim_success', [ 'details' => 'google_manager' ] );
return true;
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'google_manager' ] );
$error = $response['message'] ?? __( 'Invalid response when claiming website', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'google_manager' ] );
throw new Exception(
$this->client_exception_message( $e, __( 'Error claiming website', 'google-listings-and-ads' ) ),
$e->getCode()
);
}
}
/**
* Create a new Google Ads account.
*
* @return array
* @throws Exception When a ClientException is caught, unsupported store country, or we receive an invalid response.
*/
public function create_ads_account(): array {
try {
$country = $this->container->get( WC::class )->get_base_country();
/** @var GoogleHelper $google_helper */
$google_helper = $this->container->get( GoogleHelper::class );
if ( ! $google_helper->is_country_supported( $country ) ) {
throw new Exception( __( 'Store country is not supported', 'google-listings-and-ads' ) );
}
$user = wp_get_current_user();
$tos = $this->mark_tos_accepted( 'google-ads', $user->user_email );
if ( ! $tos->accepted() ) {
throw new Exception( __( 'Unable to log accepted TOS', 'google-listings-and-ads' ) );
}
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_manager_url( $country . '/create-customer' ),
[
'body' => wp_json_encode(
[
'descriptive_name' => $this->new_account_name(),
'currency_code' => get_woocommerce_currency(),
'time_zone' => $this->get_site_timezone_string(),
]
),
]
);
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 === $result->getStatusCode() && isset( $response['resourceName'] ) ) {
/** @var Ads $ads */
$ads = $this->container->get( Ads::class );
$id = $ads->parse_ads_id( $response['resourceName'] );
$ads->update_ads_id( $id );
$ads->use_store_currency();
$billing_url = $response['invitationLink'] ?? '';
$ads->update_billing_url( $billing_url );
$ads->update_ocid_from_billing_url( $billing_url );
return [
'id' => $id,
'billing_url' => $billing_url,
];
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
$error = $response['message'] ?? __( 'Invalid response when creating account', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception(
$this->client_exception_message( $e, __( 'Error creating account', 'google-listings-and-ads' ) ),
$e->getCode()
);
}
}
/**
* Link an existing Google Ads account.
*
* @param int $id Existing account ID.
*
* @return array
* @throws Exception When a ClientException is caught or we receive an invalid response.
*/
public function link_ads_account( int $id ): array {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_manager_url( 'link-customer' ),
[
'body' => wp_json_encode(
[
'client_customer' => $id,
]
),
]
);
$response = json_decode( $result->getBody()->getContents(), true );
$name = "customers/{$id}";
if ( 200 === $result->getStatusCode() && isset( $response['resourceName'] ) && 0 === strpos( $response['resourceName'], $name ) ) {
/** @var Ads $ads */
$ads = $this->container->get( Ads::class );
$ads->update_ads_id( $id );
$ads->request_ads_currency();
return [ 'id' => $id ];
}
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
$error = $response['message'] ?? __( 'Invalid response when linking account', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception(
$this->client_exception_message( $e, __( 'Error linking account', 'google-listings-and-ads' ) ),
$e->getCode()
);
}
}
/**
* Determine whether the TOS have been accepted.
*
* @param string $service Name of service.
*
* @return TosAccepted
*/
public function check_tos_accepted( string $service ): TosAccepted {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->get( $this->get_tos_url( $service ) );
return new TosAccepted( 200 === $result->getStatusCode(), $result->getBody()->getContents() );
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
return new TosAccepted( false, $e->getMessage() );
}
}
/**
* Record TOS acceptance for a particular email address.
*
* @param string $service Name of service.
* @param string $email
*
* @return TosAccepted
*/
public function mark_tos_accepted( string $service, string $email ): TosAccepted {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->post(
$this->get_tos_url( $service ),
[
'body' => wp_json_encode(
[
'email' => $email,
]
),
]
);
return new TosAccepted(
200 === $result->getStatusCode(),
$result->getBody()->getContents() ?? $result->getReasonPhrase()
);
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
return new TosAccepted( false, $e->getMessage() );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
return new TosAccepted( false, $e->getMessage() );
}
}
/**
* Get the TOS endpoint URL
*
* @param string $service Name of service.
*
* @return string
*/
protected function get_tos_url( string $service ): string {
$url = $this->container->get( 'connect_server_root' ) . 'tos';
return $service ? trailingslashit( $url ) . $service : $url;
}
/**
* Get the manager endpoint URL
*
* @param string $name Resource name.
*
* @return string
*/
protected function get_manager_url( string $name = '' ): string {
$url = $this->container->get( 'connect_server_root' ) . 'google/manager';
return $name ? trailingslashit( $url ) . $name : $url;
}
/**
* Get the Google Shopping Data Integration auth endpoint URL
*
* @return string
*/
public function get_sdi_auth_endpoint(): string {
return $this->container->get( 'connect_server_root' )
. 'google/google-sdi/v1/credentials/partners/WOO_COMMERCE/merchants/'
. $this->strip_url_protocol( $this->get_site_url() )
. '/oauth/redirect:generate'
. '?merchant_id=' . $this->options->get_merchant_id();
}
/**
* Generate a descriptive name for a new account.
* Use site name if available.
*
* @return string
*/
protected function new_account_name(): string {
$site_name = get_bloginfo( 'name' );
return ! empty( $site_name ) ? $site_name : $this->default_account_name();
}
/**
* Generate a default account name based on the date.
*
* @return string
*/
protected function default_account_name(): string {
return sprintf(
/* translators: 1: current date in the format Y-m-d */
__( 'Account %1$s', 'google-listings-and-ads' ),
( new DateTime() )->format( 'Y-m-d' )
);
}
/**
* Get a timezone string from WP Settings.
*
* @return string
* @throws Exception If the DateTime instantiation fails.
*/
protected function get_site_timezone_string(): string {
/** @var WP $wp */
$wp = $this->container->get( WP::class );
$timezone = $wp->wp_timezone_string();
/** @var DateTimeUtility $datetime_util */
$datetime_util = $this->container->get( DateTimeUtility::class );
return $datetime_util->maybe_convert_tz_string( $timezone );
}
/**
* This function detects if the current account is a sub-account
* This function is cached in the MC_IS_SUBACCOUNT transient
*
* @return bool True if it's a standalone account.
*/
public function is_subaccount(): bool {
/** @var TransientsInterface $transients */
$transients = $this->container->get( TransientsInterface::class );
$is_subaccount = $transients->get( $transients::MC_IS_SUBACCOUNT );
if ( is_null( $is_subaccount ) ) {
$is_subaccount = 0;
$merchant_id = $this->options->get_merchant_id();
$accounts = $this->get_merchant_accounts();
foreach ( $accounts as $account ) {
if ( $account['id'] === $merchant_id && $account['subaccount'] ) {
$is_subaccount = 1;
}
}
$transients->set( $transients::MC_IS_SUBACCOUNT, $is_subaccount );
}
// since transients don't support booleans, we save them as 0/1 and do the conversion here
return boolval( $is_subaccount );
}
/**
* Performs a request to Google Shopping Data Integration (SDI) to get required information in order to form an auth URL.
*
* @return array An array with the JSON response from the WCS server.
* @throws NotFoundExceptionInterface When the container was not found.
* @throws ContainerExceptionInterface When an error happens while retrieving the container.
* @throws Exception When the response status is not successful.
* @see google-sdi in google/services inside WCS
*/
public function get_sdi_auth_params() {
try {
/** @var Client $client */
$client = $this->container->get( Client::class );
$result = $client->get( $this->get_sdi_auth_endpoint() );
$response = json_decode( $result->getBody()->getContents(), true );
if ( 200 !== $result->getStatusCode() ) {
do_action(
'woocommerce_gla_partner_app_auth_failure',
[
'error' => 'response',
'response' => $response,
]
);
do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
$error = $response['message'] ?? __( 'Invalid response authenticating partner app.', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
}
return $response;
} catch ( ClientExceptionInterface $e ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
throw new Exception(
$this->client_exception_message( $e, __( 'Error authenticating Google Partner APP.', 'google-listings-and-ads' ) ),
$e->getCode()
);
}
}
}
API/Google/Query/AdsAccountAccessQuery.php 0000644 00000001027 15153721356 0014413 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsAccountAccessQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsAccountAccessQuery extends AdsQuery {
/**
* AdsAccountAccessQuery constructor.
*/
public function __construct() {
parent::__construct( 'customer_user_access' );
$this->columns( [ 'customer_user_access.resource_name', 'customer_user_access.access_role' ] );
}
}
API/Google/Query/AdsAccountQuery.php 0000644 00000001010 15153721356 0013261 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsAccountQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsAccountQuery extends AdsQuery {
/**
* AdsAccountQuery constructor.
*/
public function __construct() {
parent::__construct( 'customer' );
$this->columns( [ 'customer.id', 'customer.descriptive_name', 'customer.manager', 'customer.test_account' ] );
}
}
API/Google/Query/AdsAssetGroupAssetQuery.php 0000644 00000001201 15153721356 0014763 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsAssetGroupAssetQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsAssetGroupAssetQuery extends AdsQuery {
/**
* AdsAssetGroupAssetQuery constructor.
*/
public function __construct() {
parent::__construct( 'asset_group_asset' );
$this->columns( [ 'asset.id', 'asset.name', 'asset.type', 'asset.text_asset.text', 'asset.image_asset.full_size.url', 'asset.call_to_action_asset.call_to_action', 'asset_group_asset.field_type' ] );
}
}
API/Google/Query/AdsAssetGroupQuery.php 0000644 00000001163 15153721356 0013772 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsAssetGroupQuery
*
* @since 1.12.2
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsAssetGroupQuery extends AdsQuery {
/**
* AdsAssetGroupQuery constructor.
*
* @param array $search_args List of search args, such as pageSize.
*/
public function __construct( array $search_args = [] ) {
parent::__construct( 'asset_group' );
$this->columns( [ 'asset_group.resource_name' ] );
$this->search_args = $search_args;
}
}
API/Google/Query/AdsBillingStatusQuery.php 0000644 00000001153 15153721356 0014461 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsBillingStatusQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsBillingStatusQuery extends AdsQuery {
/**
* AdsBillingStatusQuery constructor.
*/
public function __construct() {
parent::__construct( 'billing_setup' );
$this->columns(
[
'status' => 'billing_setup.status',
'start_date_time' => 'billing_setup.start_date_time',
]
);
$this->set_order( 'start_date_time', 'DESC' );
}
}
API/Google/Query/AdsCampaignBudgetQuery.php 0000644 00000000740 15153721356 0014550 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsCampaignBudgetQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsCampaignBudgetQuery extends AdsQuery {
/**
* AdsCampaignBudgetQuery constructor.
*/
public function __construct() {
parent::__construct( 'campaign' );
$this->columns( [ 'campaign.campaign_budget' ] );
}
}
API/Google/Query/AdsCampaignCriterionQuery.php 0000644 00000001052 15153721356 0015271 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsCampaignCriterionQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsCampaignCriterionQuery extends AdsQuery {
/**
* AdsCampaignCriterionQuery constructor.
*/
public function __construct() {
parent::__construct( 'campaign_criterion' );
$this->columns(
[
'campaign.id',
'campaign_criterion.location.geo_target_constant',
]
);
}
}
API/Google/Query/AdsCampaignLabelQuery.php 0000644 00000000727 15153721356 0014362 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsCampaignLabelQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsCampaignLabelQuery extends AdsQuery {
/**
* AdsCampaignLabelQuery constructor.
*/
public function __construct() {
parent::__construct( 'label' );
$this->columns(
[
'label.id',
]
);
}
}
API/Google/Query/AdsCampaignQuery.php 0000644 00000001164 15153721356 0013416 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsCampaignQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsCampaignQuery extends AdsQuery {
/**
* AdsCampaignQuery constructor.
*/
public function __construct() {
parent::__construct( 'campaign' );
$this->columns(
[
'campaign.id',
'campaign.name',
'campaign.status',
'campaign.advertising_channel_type',
'campaign.shopping_setting.feed_label',
'campaign_budget.amount_micros',
]
);
}
}
API/Google/Query/AdsCampaignReportQuery.php 0000644 00000001561 15153721356 0014613 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsCampaignReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsCampaignReportQuery extends AdsReportQuery {
/**
* Set the initial columns for this query.
*/
protected function set_initial_columns() {
$this->columns(
[
'id' => 'campaign.id',
'name' => 'campaign.name',
'status' => 'campaign.status',
'type' => 'campaign.advertising_channel_type',
]
);
}
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
public function filter( array $ids ): QueryInterface {
if ( empty( $ids ) ) {
return $this;
}
return $this->where( 'campaign.id', $ids, 'IN' );
}
}
API/Google/Query/AdsConversionActionQuery.php 0000644 00000001244 15153721356 0015161 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsConversionActionQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsConversionActionQuery extends AdsQuery {
/**
* AdsConversionActionQuery constructor.
*/
public function __construct() {
parent::__construct( 'conversion_action' );
$this->columns(
[
'id' => 'conversion_action.id',
'name' => 'conversion_action.name',
'status' => 'conversion_action.status',
'tag_snippets' => 'conversion_action.tag_snippets',
]
);
}
}
API/Google/Query/AdsProductLinkInvitationQuery.php 0000644 00000001110 15153721356 0016171 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsProductLinkInvitationQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsProductLinkInvitationQuery extends AdsQuery {
/**
* AdsProductLinkInvitationQuery constructor.
*/
public function __construct() {
parent::__construct( 'product_link_invitation' );
$this->columns( [ 'product_link_invitation.merchant_center.merchant_center_id', 'product_link_invitation.status' ] );
}
}
API/Google/Query/AdsProductReportQuery.php 0000644 00000001466 15153721356 0014520 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsProductReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class AdsProductReportQuery extends AdsReportQuery {
/**
* Set the initial columns for this query.
*/
protected function set_initial_columns() {
$this->columns(
[
'id' => 'segments.product_item_id',
'name' => 'segments.product_title',
]
);
}
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
public function filter( array $ids ): QueryInterface {
if ( empty( $ids ) ) {
return $this;
}
return $this->where( 'segments.product_item_id', $ids, 'IN' );
}
}
API/Google/Query/AdsQuery.php 0000644 00000005405 15153721356 0011760 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\SearchGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\SearchSettings;
use Google\ApiCore\ApiException;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
abstract class AdsQuery extends Query {
/**
* Client which handles the query.
*
* @var GoogleAdsClient
*/
protected $client = null;
/**
* Ads Account ID.
*
* @var int
*/
protected $id = null;
/**
* Arguments to add to the search query.
*
* Note: While we allow pageSize to be set, we do not pass it to the API.
* pageSize has been deprecated in the API since V17 and is fixed to 10000 rows.
*
* @var array
*/
protected $search_args = [];
/**
* Set the client which will handle the query.
*
* @param GoogleAdsClient $client Client instance.
* @param int $id Account ID.
*
* @return QueryInterface
* @throws InvalidProperty If the ID is empty.
*/
public function set_client( GoogleAdsClient $client, int $id ): QueryInterface {
if ( empty( $id ) ) {
throw InvalidProperty::not_null( get_class( $this ), 'id' );
}
$this->client = $client;
$this->id = $id;
return $this;
}
/**
* Get the first row from the results.
*
* @return GoogleAdsRow
* @throws ApiException When no results returned or an error occurs.
*/
public function get_result(): GoogleAdsRow {
$results = $this->get_results();
if ( $results ) {
foreach ( $results->iterateAllElements() as $row ) {
return $row;
}
}
throw new ApiException( __( 'No result from query', 'google-listings-and-ads' ), 404, '' );
}
/**
* Perform the query and save it to the results.
*
* @throws ApiException If the search call fails.
* @throws InvalidProperty If the client is not set.
*/
protected function query_results() {
if ( ! $this->client || ! $this->id ) {
throw InvalidProperty::not_null( get_class( $this ), 'client' );
}
$request = new SearchGoogleAdsRequest();
if ( ! empty( $this->search_args['pageToken'] ) ) {
$request->setPageToken( $this->search_args['pageToken'] );
}
// Allow us to get the total number of results.
$request->setSearchSettings(
new SearchSettings(
[
'return_total_results_count' => true,
]
)
);
$request->setQuery( $this->build_query() );
$request->setCustomerId( $this->id );
$this->results = $this->client->getGoogleAdsServiceClient()->search( $request );
}
}
API/Google/Query/AdsReportQuery.php 0000644 00000003713 15153721356 0013154 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
use Google\Ads\GoogleAds\V18\Resources\ShoppingPerformanceView;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
abstract class AdsReportQuery extends AdsQuery {
use ReportQueryTrait;
/**
* AdsReportQuery constructor.
* Uses the resource ShoppingPerformanceView.
*
* @param array $args Query arguments.
*/
public function __construct( array $args ) {
parent::__construct( 'shopping_performance_view' );
$this->set_initial_columns();
$this->handle_query_args( $args );
}
/**
* Add all the requested fields.
*
* @param array $fields List of fields.
*
* @return $this
*/
public function fields( array $fields ): QueryInterface {
$map = [
'clicks' => 'metrics.clicks',
'impressions' => 'metrics.impressions',
'spend' => 'metrics.cost_micros',
'sales' => 'metrics.conversions_value',
'conversions' => 'metrics.conversions',
];
$this->add_columns( array_intersect_key( $map, array_flip( $fields ) ) );
return $this;
}
/**
* Add a segment interval to the query.
*
* @param string $interval Type of interval.
*
* @return $this
*/
public function segment_interval( string $interval ): QueryInterface {
$map = [
'day' => 'segments.date',
'week' => 'segments.week',
'month' => 'segments.month',
'quarter' => 'segments.quarter',
'year' => 'segments.year',
];
if ( isset( $map[ $interval ] ) ) {
$this->add_columns( [ $interval => $map[ $interval ] ] );
}
return $this;
}
/**
* Set the initial columns for this query.
*/
abstract protected function set_initial_columns();
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
abstract public function filter( array $ids ): QueryInterface;
}
API/Google/Query/MerchantFreeListingReportQuery.php 0000644 00000001247 15153721356 0016342 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantFreeListingReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class MerchantFreeListingReportQuery extends MerchantReportQuery {
/**
* Set the initial columns for this query.
*/
protected function set_initial_columns() {}
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
public function filter( array $ids ): QueryInterface {
// No filtering available for free listings.
return $this;
}
}
API/Google/Query/MerchantProductReportQuery.php 0000644 00000001415 15153721356 0015544 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantProductReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class MerchantProductReportQuery extends MerchantReportQuery {
/**
* Set the initial columns for this query.
*/
protected function set_initial_columns() {
$this->columns(
[
'id' => 'segments.offer_id',
]
);
}
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
public function filter( array $ids ): QueryInterface {
if ( empty( $ids ) ) {
return $this;
}
return $this->where( 'segments.offer_id', $ids, 'IN' );
}
}
API/Google/Query/MerchantProductViewReportQuery.php 0000644 00000002217 15153721356 0016400 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantProductViewReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
class MerchantProductViewReportQuery extends MerchantQuery {
use ReportQueryTrait;
/**
* MerchantProductViewReportQuery constructor.
*
* @param array $args Query arguments.
*/
public function __construct( array $args ) {
parent::__construct( 'ProductView' );
$this->set_initial_columns();
$this->handle_query_args( $args );
}
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
public function filter( array $ids ): QueryInterface {
// No filtering used for product view report.
return $this;
}
/**
* Set the initial columns for this query.
*/
protected function set_initial_columns() {
$this->columns(
[
'id' => 'product_view.id',
'expiration_date' => 'product_view.expiration_date',
'status' => 'product_view.aggregated_destination_status',
]
);
}
}
API/Google/Query/MerchantQuery.php 0000644 00000004137 15153721356 0013013 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
abstract class MerchantQuery extends Query {
/**
* Client which handles the query.
*
* @var ShoppingContent
*/
protected $client = null;
/**
* Merchant Account ID.
*
* @var int
*/
protected $id = null;
/**
* Arguments to add to the search query.
*
* @var array
*/
protected $search_args = [];
/**
* Set the client which will handle the query.
*
* @param ShoppingContent $client Client instance.
* @param int $id Account ID.
*
* @return QueryInterface
*/
public function set_client( ShoppingContent $client, int $id ): QueryInterface {
$this->client = $client;
$this->id = $id;
return $this;
}
/**
* Perform the query and save it to the results.
*
* @throws GoogleException If the search call fails.
* @throws InvalidProperty If the client is not set.
*/
protected function query_results() {
if ( ! $this->client || ! $this->id ) {
throw InvalidProperty::not_null( get_class( $this ), 'client' );
}
$request = new SearchRequest();
$request->setQuery( $this->build_query() );
if ( ! empty( $this->search_args['pageSize'] ) ) {
$request->setPageSize( $this->search_args['pageSize'] );
}
if ( ! empty( $this->search_args['pageToken'] ) ) {
$request->setPageToken( $this->search_args['pageToken'] );
}
/** @var SearchResponse $this->results */
$this->results = $this->client->reports->search( $this->id, $request );
}
}
API/Google/Query/MerchantReportQuery.php 0000644 00000003444 15153721356 0014207 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantReportQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
abstract class MerchantReportQuery extends MerchantQuery {
use ReportQueryTrait;
/**
* MerchantReportQuery constructor.
*
* @param array $args Query arguments.
*/
public function __construct( array $args ) {
parent::__construct( 'MerchantPerformanceView' );
$this->set_initial_columns();
$this->handle_query_args( $args );
$this->where( 'segments.program', 'FREE_PRODUCT_LISTING' );
}
/**
* Add all the requested fields.
*
* @param array $fields List of fields.
*
* @return $this
*/
public function fields( array $fields ): QueryInterface {
$map = [
'clicks' => 'metrics.clicks',
'impressions' => 'metrics.impressions',
];
$this->add_columns( array_intersect_key( $map, array_flip( $fields ) ) );
return $this;
}
/**
* Add a segment interval to the query.
*
* @param string $interval Type of interval.
*
* @return $this
*/
public function segment_interval( string $interval ): QueryInterface {
$map = [
'day' => 'segments.date',
'week' => 'segments.week',
'month' => 'segments.month',
'quarter' => 'segments.quarter',
'year' => 'segments.year',
];
if ( isset( $map[ $interval ] ) ) {
$this->add_columns( [ $interval => $map[ $interval ] ] );
}
return $this;
}
/**
* Set the initial columns for this query.
*/
abstract protected function set_initial_columns();
/**
* Filter the query by a list of ID's.
*
* @param array $ids list of ID's to filter by.
*
* @return $this
*/
abstract public function filter( array $ids ): QueryInterface;
}
API/Google/Query/Query.php 0000644 00000017526 15153721356 0011337 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Google Ads Query Language (GAQL)
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
abstract class Query implements QueryInterface {
/**
* Resource name.
*
* @var string
*/
protected $resource;
/**
* Set of columns to retrieve in the query.
*
* @var array
*/
protected $columns = [];
/**
* Where clauses for the query.
*
* @var array
*/
protected $where = [];
/**
* Where relation for multiple clauses.
*
* @var string
*/
protected $where_relation;
/**
* Order sort attribute.
*
* @var string
*/
protected $order = 'ASC';
/**
* Column to order by.
*
* @var string
*/
protected $orderby;
/**
* The result of the query.
*
* @var mixed
*/
protected $results = null;
/**
* Query constructor.
*
* @param string $resource_name
*
* @throws InvalidQuery When the resource name is not valid.
*/
public function __construct( string $resource_name ) {
if ( ! preg_match( '/^[a-zA-Z_]+$/', $resource_name ) ) {
throw InvalidQuery::resource_name();
}
$this->resource = $resource_name;
}
/**
* Set columns to retrieve in the query.
*
* @param array $columns List of column names.
*
* @return QueryInterface
*/
public function columns( array $columns ): QueryInterface {
$this->validate_columns( $columns );
$this->columns = $columns;
return $this;
}
/**
* Add a set columns to retrieve in the query.
*
* @param array $columns List of column names.
*
* @return QueryInterface
*/
public function add_columns( array $columns ): QueryInterface {
$this->validate_columns( $columns );
$this->columns = array_merge( $this->columns, array_filter( $columns ) );
return $this;
}
/**
* Add a where clause to the query.
*
* @param string $column The column name.
* @param mixed $value The where value.
* @param string $compare The comparison to use. Valid values are =, <, >, IN, NOT IN.
*
* @return QueryInterface
*/
public function where( string $column, $value, string $compare = '=' ): QueryInterface {
$this->validate_compare( $compare );
$this->where[] = [
'column' => $column,
'value' => $value,
'compare' => $compare,
];
return $this;
}
/**
* Add a where date between clause to the query.
*
* @since 1.7.0
*
* @link https://developers.google.com/shopping-content/guides/reports/query-language/date-ranges
*
* @param string $after Start of date range. In ISO 8601(YYYY-MM-DD) format.
* @param string $before End of date range. In ISO 8601(YYYY-MM-DD) format.
*
* @return QueryInterface
*/
public function where_date_between( string $after, string $before ): QueryInterface {
return $this->where( 'segments.date', [ $after, $before ], 'BETWEEN' );
}
/**
* Set the where relation for the query.
*
* @param string $relation
*
* @return QueryInterface
*/
public function set_where_relation( string $relation ): QueryInterface {
$this->validate_where_relation( $relation );
$this->where_relation = $relation;
return $this;
}
/**
* Set ordering information for the query.
*
* @param string $column
* @param string $order
*
* @return QueryInterface
* @throws InvalidQuery When the given column is not in the list of included columns.
*/
public function set_order( string $column, string $order = 'ASC' ): QueryInterface {
if ( ! array_key_exists( $column, $this->columns ) ) {
throw InvalidQuery::invalid_order_column( $column );
}
$this->orderby = $this->columns[ $column ];
$this->order = $this->normalize_order( $order );
return $this;
}
/**
* Get the results of the query.
*
* @return mixed
*/
public function get_results() {
if ( null === $this->results ) {
$this->query_results();
}
return $this->results;
}
/**
* Perform the query and save it to the results.
*/
protected function query_results() {
$this->results = [];
}
/**
* Validate a set of columns.
*
* @param array $columns
*
* @throws InvalidQuery When one of columns in the set is not valid.
*/
protected function validate_columns( array $columns ) {
array_walk( $columns, [ $this, 'validate_column' ] );
}
/**
* Validate that a given column is using a valid name.
*
* @param string $column
*
* @throws InvalidQuery When the given column is not valid.
*/
protected function validate_column( string $column ) {
if ( ! preg_match( '/^[a-zA-Z0-9\._]+$/', $column ) ) {
throw InvalidQuery::invalid_column( $column );
}
}
/**
* Validate that a compare operator is valid.
*
* @param string $compare
*
* @throws InvalidQuery When the compare value is not valid.
*/
protected function validate_compare( string $compare ) {
switch ( $compare ) {
case '=':
case '>':
case '<':
case '!=':
case 'IN':
case 'NOT IN':
case 'BETWEEN':
case 'IS NOT NULL':
case 'CONTAINS ANY':
// These are all valid.
return;
default:
throw InvalidQuery::from_compare( $compare );
}
}
/**
* Validate that a where relation is valid.
*
* @param string $relation
*
* @throws InvalidQuery When the relation value is not valid.
*/
protected function validate_where_relation( string $relation ) {
switch ( $relation ) {
case 'AND':
case 'OR':
// These are all valid.
return;
default:
throw InvalidQuery::where_relation( $relation );
}
}
/**
* Normalize the string for the order.
*
* Converts the string to uppercase, and will return only DESC or ASC.
*
* @param string $order
*
* @return string
*/
protected function normalize_order( string $order ): string {
$order = strtoupper( $order );
return 'DESC' === $order ? $order : 'ASC';
}
/**
* Build the query and return the query string.
*
* @return string
*
* @throws InvalidQuery When the set of columns is empty.
*/
protected function build_query(): string {
if ( empty( $this->columns ) ) {
throw InvalidQuery::empty_columns();
}
$columns = join( ',', $this->columns );
$pieces = [ "SELECT {$columns} FROM {$this->resource}" ];
$pieces = array_merge( $pieces, $this->generate_where_pieces() );
if ( $this->orderby ) {
$pieces[] = "ORDER BY {$this->orderby} {$this->order}";
}
return join( ' ', $pieces );
}
/**
* Generate the pieces for the WHERE part of the query.
*
* @return string[]
*/
protected function generate_where_pieces(): array {
if ( empty( $this->where ) ) {
return [];
}
$where_pieces = [ 'WHERE' ];
foreach ( $this->where as $where ) {
$column = $where['column'];
$compare = $where['compare'];
if ( 'IN' === $compare || 'NOT_IN' === $compare || 'CONTAINS ANY' === $compare ) {
$value = sprintf(
"('%s')",
join(
"','",
array_map(
function ( $value ) {
return $this->escape( $value );
},
$where['value']
)
)
);
} elseif ( 'BETWEEN' === $compare ) {
$value = "'{$this->escape( $where['value'][0] )}' AND '{$this->escape( $where['value'][1] )}'";
} elseif ( 'IS NOT NULL' === $compare ) {
$value = '';
} else {
$value = "'{$this->escape( $where['value'] )}'";
}
if ( count( $where_pieces ) > 1 ) {
$where_pieces[] = $this->where_relation ?? 'AND';
}
$where_pieces[] = "{$column} {$compare} {$value}";
}
return $where_pieces;
}
/**
* Escape the value to a string which can be used in a query.
*
* @param mixed $value Original value to escape.
*
* @return string
*/
protected function escape( $value ): string {
if ( $value instanceof DateTime ) {
return $value->format( 'Y-m-d' );
}
if ( ! is_numeric( $value ) ) {
return (string) $value;
}
return addslashes( (string) $value );
}
}
API/Google/Query/QueryInterface.php 0000644 00000002406 15153721356 0013147 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
defined( 'ABSPATH' ) || exit;
/**
* Interface QueryInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
interface QueryInterface {
/**
* Set columns to retrieve in the query.
*
* @param array $columns List of column names.
*
* @return $this
*/
public function columns( array $columns ): QueryInterface;
/**
* Add a set columns to retrieve in the query.
*
* @param array $columns List of column names.
*
* @return $this
*/
public function add_columns( array $columns ): QueryInterface;
/**
* Set a where clause to query.
*
* @param string $column The column name.
* @param mixed $value The where value.
* @param string $compare The comparison to use. Valid values are =, <, >, IN, NOT IN.
*
* @return $this
*/
public function where( string $column, $value, string $compare = '=' ): QueryInterface;
/**
* Set the where relation for the query.
*
* @param string $relation
*
* @return QueryInterface
*/
public function set_where_relation( string $relation ): QueryInterface;
/**
* Get the results of the query.
*
* @return mixed
*/
public function get_results();
}
API/Google/Query/ReportQueryTrait.php 0000644 00000002477 15153721356 0013536 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Trait ReportQueryTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
*/
trait ReportQueryTrait {
/**
* Handle the common arguments for this query.
*
* @param array $args List of arguments which were passed to the query.
*/
protected function handle_query_args( array $args ) {
if ( ! empty( $args['fields'] ) ) {
$this->fields( $args['fields'] );
}
if ( ! empty( $args['interval'] ) ) {
$this->segment_interval( $args['interval'] );
}
if ( ! empty( $args['after'] ) && ! empty( $args['before'] ) ) {
$after = $args['after'];
$before = $args['before'];
$this->where_date_between(
$after instanceof DateTime ? $after->format( 'Y-m-d' ) : $after,
$before instanceof DateTime ? $before->format( 'Y-m-d' ) : $before
);
}
if ( ! empty( $args['ids'] ) ) {
$this->filter( $args['ids'] );
}
if ( ! empty( $args['orderby'] ) ) {
$this->set_order( $args['orderby'], $args['order'] );
}
if ( ! empty( $args['per_page'] ) ) {
$this->search_args['pageSize'] = $args['per_page'];
}
if ( ! empty( $args['next_page'] ) ) {
$this->search_args['pageToken'] = $args['next_page'];
}
}
}
API/Google/ReportTrait.php 0000644 00000003445 15153721356 0011377 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
/**
* Trait ReportTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
trait ReportTrait {
/** @var array $report_data */
private $report_data = [];
/**
* Increase report data by adding the subtotals.
*
* @param string $field Field to increase.
* @param string $index Unique index.
* @param array $data Report data.
*/
protected function increase_report_data( string $field, string $index, array $data ) {
if ( ! isset( $this->report_data[ $field ][ $index ] ) ) {
$this->report_data[ $field ][ $index ] = $data;
} elseif ( ! empty( $data['subtotals'] ) ) {
foreach ( $data['subtotals'] as $name => $subtotal ) {
$this->report_data[ $field ][ $index ]['subtotals'][ $name ] += $subtotal;
}
}
}
/**
* Initialize report totals to 0 values.
*
* @param array $fields List of field names.
*/
protected function init_report_totals( array $fields ) {
foreach ( $fields as $name ) {
$this->report_data['totals'][ $name ] = 0;
}
}
/**
* Increase report totals.
*
* @param array $data Totals data.
*/
protected function increase_report_totals( array $data ) {
foreach ( $data as $name => $total ) {
if ( ! isset( $this->report_data['totals'][ $name ] ) ) {
$this->report_data['totals'][ $name ] = $total;
} else {
$this->report_data['totals'][ $name ] += $total;
}
}
}
/**
* Remove indexes from report data to conform to schema.
*
* @param array $fields Fields to reindex.
*/
protected function remove_report_indexes( array $fields ) {
foreach ( $fields as $key ) {
if ( isset( $this->report_data[ $key ] ) ) {
$this->report_data[ $key ] = array_values( $this->report_data[ $key ] );
}
}
}
}
API/Google/Settings.php 0000644 00000030004 15153721356 0010707 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\CountryRatesCollection;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter\DBShippingSettingsAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter\WCShippingSettingsAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountTax;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountTaxTaxRule as TaxRule;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ShippingSettings;
defined( 'ABSPATH' ) || exit;
/**
* Class Settings
*
* Container used for:
* - OptionsInterface
* - ShippingRateQuery
* - ShippingTimeQuery
* - ShippingZone
* - ShoppingContent
* - TargetAudience
* - WC
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class Settings implements ContainerAwareInterface {
use ContainerAwareTrait;
use LocationIDTrait;
/**
* Return a set of formatted settings which can be used in tracking.
*
* @since 2.5.16
*
* @return array
*/
public function get_settings_for_tracking() {
$settings = $this->get_settings();
return [
'shipping_rate' => $settings['shipping_rate'] ?? '',
'offers_free_shipping' => (bool) ( $settings['offers_free_shipping'] ?? false ),
'free_shipping_threshold' => (float) ( $settings['free_shipping_threshold'] ?? 0 ),
'shipping_time' => $settings['shipping_time'] ?? '',
'tax_rate' => $settings['tax_rate'] ?? '',
'target_countries' => join( ',', $this->get_target_countries() ),
];
}
/**
* Sync the shipping settings with Google.
*/
public function sync_shipping() {
if ( ! $this->should_sync_shipping() ) {
return;
}
$settings = $this->generate_shipping_settings();
$this->get_shopping_service()->shippingsettings->update(
$this->get_merchant_id(),
$this->get_account_id(),
$settings
);
}
/**
* Whether we should synchronize settings with the Merchant Center
*
* @return bool
*/
protected function should_sync_shipping(): bool {
$shipping_rate = $this->get_settings()['shipping_rate'] ?? '';
$shipping_time = $this->get_settings()['shipping_time'] ?? '';
return in_array( $shipping_rate, [ 'flat', 'automatic' ], true ) && 'flat' === $shipping_time;
}
/**
* Whether we should get the shipping settings from the WooCommerce settings.
*
* @return bool
*
* @since 1.12.0
*/
public function should_get_shipping_rates_from_woocommerce(): bool {
return 'automatic' === ( $this->get_settings()['shipping_rate'] ?? '' );
}
/**
* Generate a ShippingSettings object for syncing the store shipping settings to Merchant Center.
*
* @return ShippingSettings
*
* @since 2.1.0
*/
protected function generate_shipping_settings(): ShippingSettings {
$times = $this->get_shipping_times();
/** @var WC $wc_proxy */
$wc_proxy = $this->container->get( WC::class );
$currency = $wc_proxy->get_woocommerce_currency();
if ( $this->should_get_shipping_rates_from_woocommerce() ) {
return new WCShippingSettingsAdapter(
[
'currency' => $currency,
'rates_collections' => $this->get_shipping_rates_collections_from_woocommerce(),
'delivery_times' => $times,
'accountId' => $this->get_account_id(),
]
);
}
return new DBShippingSettingsAdapter(
[
'currency' => $currency,
'db_rates' => $this->get_shipping_rates_from_database(),
'delivery_times' => $times,
'accountId' => $this->get_account_id(),
]
);
}
/**
* Get the current tax settings from the API.
*
* @return AccountTax
*/
public function get_taxes(): AccountTax {
return $this->get_shopping_service()->accounttax->get(
$this->get_merchant_id(),
$this->get_account_id()
);
}
/**
* Whether we should sync tax settings.
*
* This depends on the store being in the US
*
* @return bool
*/
protected function should_sync_taxes(): bool {
if ( 'US' !== $this->get_store_country() ) {
return false;
}
return 'destination' === ( $this->get_settings()['tax_rate'] ?? 'destination' );
}
/**
* Sync tax setting with Google.
*/
public function sync_taxes() {
if ( ! $this->should_sync_taxes() ) {
return;
}
$taxes = new AccountTax();
$taxes->setAccountId( $this->get_account_id() );
$tax_rule = new TaxRule();
$tax_rule->setUseGlobalRate( true );
$tax_rule->setLocationId( $this->get_state_id( $this->get_store_state() ) );
$tax_rule->setCountry( $this->get_store_country() );
$taxes->setRules( [ $tax_rule ] );
$this->get_shopping_service()->accounttax->update(
$this->get_merchant_id(),
$this->get_account_id(),
$taxes
);
}
/**
* Get shipping time data.
*
* @return array
*/
protected function get_shipping_times(): array {
static $times = null;
if ( null === $times ) {
$time_query = $this->container->get( ShippingTimeQuery::class );
$times = $time_query->get_all_shipping_times();
}
return $times;
}
/**
* Get shipping rate data.
*
* @return array
*/
protected function get_shipping_rates_from_database(): array {
$rate_query = $this->container->get( ShippingRateQuery::class );
return $rate_query->get_results();
}
/**
* Get shipping rate data from WooCommerce shipping settings.
*
* @return CountryRatesCollection[] Array of rates collections for each target country specified in settings.
*/
protected function get_shipping_rates_collections_from_woocommerce(): array {
/** @var TargetAudience $target_audience */
$target_audience = $this->container->get( TargetAudience::class );
$target_countries = $target_audience->get_target_countries();
/** @var ShippingZone $shipping_zone */
$shipping_zone = $this->container->get( ShippingZone::class );
$rates = [];
foreach ( $target_countries as $country ) {
$location_rates = $shipping_zone->get_shipping_rates_for_country( $country );
$rates[ $country ] = new CountryRatesCollection( $country, $location_rates );
}
return $rates;
}
/**
* @return OptionsInterface
*/
protected function get_options_object(): OptionsInterface {
return $this->container->get( OptionsInterface::class );
}
/**
* Get the Merchant ID
*
* @return int
*/
protected function get_merchant_id(): int {
return $this->get_options_object()->get( OptionsInterface::MERCHANT_ID );
}
/**
* Get the account ID.
*
* @return int
*/
protected function get_account_id(): int {
// todo: there are some cases where this might be different than the Merchant ID.
return $this->get_merchant_id();
}
/**
* Get the Shopping Service object.
*
* @return ShoppingContent
*/
protected function get_shopping_service(): ShoppingContent {
return $this->container->get( ShoppingContent::class );
}
/**
* Get the country for the store.
*
* @return string
*/
protected function get_store_country(): string {
return $this->container->get( WC::class )->get_base_country();
}
/**
* Get the state for the store.
*
* @return string
*/
protected function get_store_state(): string {
/** @var WC $wc */
$wc = $this->container->get( WC::class );
return $wc->get_wc_countries()->get_base_state();
}
/**
* Get the WooCommerce store physical address.
*
* @return AccountAddress
*
* @since 1.4.0
*/
public function get_store_address(): AccountAddress {
/** @var WC $wc */
$wc = $this->container->get( WC::class );
$countries = $wc->get_wc_countries();
$postal_code = ! empty( $countries->get_base_postcode() ) ? $countries->get_base_postcode() : null;
$locality = ! empty( $countries->get_base_city() ) ? $countries->get_base_city() : null;
$country = ! empty( $countries->get_base_country() ) ? $countries->get_base_country() : null;
$region = ! empty( $countries->get_base_state() ) ? $countries->get_base_state() : null;
$mc_address = new AccountAddress();
$mc_address->setPostalCode( $postal_code );
$mc_address->setLocality( $locality );
$mc_address->setCountry( $country );
if ( ! empty( $region ) && ! empty( $country ) ) {
$mc_address->setRegion( $this->maybe_get_state_name( $region, $country ) );
}
$address = ! empty( $countries->get_base_address() ) ? $countries->get_base_address() : null;
$address_2 = ! empty( $countries->get_base_address_2() ) ? $countries->get_base_address_2() : null;
$separator = ! empty( $address ) && ! empty( $address_2 ) ? "\n" : '';
$address = sprintf( '%s%s%s', $countries->get_base_address(), $separator, $countries->get_base_address_2() );
if ( ! empty( $address ) ) {
$mc_address->setStreetAddress( $address );
}
return $mc_address;
}
/**
* Check whether the address has errors
*
* @param AccountAddress $address to be validated.
*
* @return array
*/
public function wc_address_errors( AccountAddress $address ): array {
/** @var WC $wc */
$wc = $this->container->get( WC::class );
$countries = $wc->get_wc_countries();
$locale = $countries->get_country_locale();
$locale_settings = $locale[ $address->getCountry() ] ?? [];
$fields_to_validate = [
'address_1' => $address->getStreetAddress(),
'city' => $address->getLocality(),
'country' => $address->getCountry(),
'postcode' => $address->getPostalCode(),
];
return $this->validate_address( $fields_to_validate, $locale_settings );
}
/**
* Check whether the required address fields are empty
*
* @param array $address_fields to be validated.
* @param array $locale_settings locale settings
* @return array
*/
public function validate_address( array $address_fields, array $locale_settings ): array {
$errors = array_filter(
$address_fields,
function ( $field ) use ( $locale_settings, $address_fields ) {
$is_required = $locale_settings[ $field ]['required'] ?? true;
return $is_required && empty( $address_fields[ $field ] );
},
ARRAY_FILTER_USE_KEY
);
return array_keys( $errors );
}
/**
* Return a state name.
*
* @param string $state_code State code.
* @param string $country Country code.
*
* @return string
*
* @since 1.4.0
*/
protected function maybe_get_state_name( string $state_code, string $country ): string {
/** @var WC $wc */
$wc = $this->container->get( WC::class );
$states = $country ? array_filter( (array) $wc->get_wc_countries()->get_states( $country ) ) : [];
if ( ! empty( $states ) ) {
$state_code = wc_strtoupper( $state_code );
if ( isset( $states[ $state_code ] ) ) {
return $states[ $state_code ];
}
}
return $state_code;
}
/**
* Get the array of settings for the Merchant Center.
*
* @return array
*/
protected function get_settings(): array {
$settings = $this->get_options_object()->get( OptionsInterface::MERCHANT_CENTER );
return is_array( $settings ) ? $settings : [];
}
/**
* Return a list of target countries or all.
*
* @return array
*/
protected function get_target_countries(): array {
$target_audience = $this->get_options_object()->get( OptionsInterface::TARGET_AUDIENCE );
if ( isset( $target_audience['location'] ) && 'all' === $target_audience['location'] ) {
return [ 'all' ];
}
return $target_audience['countries'] ?? [];
}
}
API/Google/ShoppingContentDateTrait.php 0000644 00000001437 15153721356 0014043 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Date as ShoppingContentDate;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Trait ShoppingContentDateTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
trait ShoppingContentDateTrait {
/**
* Convert ShoppingContentDate to DateTime.
*
* @param ShoppingContentDate $date The Google date.
*
* @return DateTime|false The date converted or false if the date is invalid.
*/
protected function convert_shopping_content_date( ShoppingContentDate $date ) {
return DateTime::createFromFormat( 'Y-m-d|', "{$date->getYear()}-{$date->getMonth()}-{$date->getDay()}" );
}
}
API/Google/SiteVerification.php 0000644 00000014171 15153721356 0012365 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification as SiteVerificationService;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceResource as WebResource;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceResourceSite as WebResourceSite;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceGettokenRequest as GetTokenRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceGettokenRequestSite as GetTokenRequestSite;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class SiteVerification
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
*/
class SiteVerification implements ContainerAwareInterface, OptionsAwareInterface {
use ContainerAwareTrait;
use ExceptionTrait;
use OptionsAwareTrait;
use PluginHelper;
/** @var string */
private const VERIFICATION_METHOD = 'META';
/** @var string */
public const VERIFICATION_STATUS_VERIFIED = 'yes';
/** @var string */
public const VERIFICATION_STATUS_UNVERIFIED = 'no';
/**
* Performs the three-step process of verifying the current site:
* 1. Retrieves the meta tag with the verification token.
* 2. Enables the meta tag in the head of the store (handled by SiteVerificationMeta).
* 3. Instructs the Site Verification API to verify the meta tag.
*
* @since 1.12.0
*
* @param string $site_url Site URL to verify.
*
* @throws Exception If any step of the site verification process fails.
*/
public function verify_site( string $site_url ) {
if ( ! wc_is_valid_url( $site_url ) ) {
do_action( 'woocommerce_gla_site_verify_failure', [ 'step' => 'site-url' ] );
throw new Exception( __( 'Invalid site URL.', 'google-listings-and-ads' ) );
}
// Retrieve the meta tag with verification token.
try {
$meta_tag = $this->get_token( $site_url );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_site_verify_failure', [ 'step' => 'token' ] );
throw $e;
}
// Store the meta tag in the options table and mark as unverified.
$site_verification_options = [
'verified' => self::VERIFICATION_STATUS_UNVERIFIED,
'meta_tag' => $meta_tag,
];
$this->options->update(
OptionsInterface::SITE_VERIFICATION,
$site_verification_options
);
// Attempt verification.
try {
$this->insert( $site_url );
$site_verification_options['verified'] = self::VERIFICATION_STATUS_VERIFIED;
$this->options->update( OptionsInterface::SITE_VERIFICATION, $site_verification_options );
do_action( 'woocommerce_gla_site_verify_success', [] );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_site_verify_failure', [ 'step' => 'meta-tag' ] );
throw $e;
}
}
/**
* Get the META token for site verification.
* https://developers.google.com/site-verification/v1/webResource/getToken
*
* @param string $identifier The URL of the site to verify (including protocol).
*
* @return string The meta tag to be used for verification.
* @throws ExceptionWithResponseData When unable to retrieve meta token.
*/
protected function get_token( string $identifier ): string {
/** @var SiteVerificationService $service */
$service = $this->container->get( SiteVerificationService::class );
$post_body = new GetTokenRequest(
[
'verificationMethod' => self::VERIFICATION_METHOD,
'site' => new GetTokenRequestSite(
[
'type' => 'SITE',
'identifier' => $identifier,
]
),
]
);
try {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$response = $service->webResource->getToken( $post_body );
} catch ( GoogleServiceException $e ) {
do_action( 'woocommerce_gla_sv_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Unable to retrieve site verification token: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$e->getCode(),
null,
[ 'errors' => $errors ]
);
}
return $response->getToken();
}
/**
* Instructs the Google Site Verification API to verify site ownership
* using the META method.
*
* @param string $identifier The URL of the site to verify (including protocol).
*
* @throws ExceptionWithResponseData When unable to verify token.
*/
protected function insert( string $identifier ) {
/** @var SiteVerificationService $service */
$service = $this->container->get( SiteVerificationService::class );
$post_body = new WebResource(
[
'site' => new WebResourceSite(
[
'type' => 'SITE',
'identifier' => $identifier,
]
),
]
);
try {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$service->webResource->insert( self::VERIFICATION_METHOD, $post_body );
} catch ( GoogleServiceException $e ) {
do_action( 'woocommerce_gla_sv_client_exception', $e, __METHOD__ );
$errors = $this->get_exception_errors( $e );
throw new ExceptionWithResponseData(
/* translators: %s Error message */
sprintf( __( 'Unable to insert site verification: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$e->getCode(),
null,
[ 'errors' => $errors ]
);
}
}
}
API/MicroTrait.php 0000644 00000001362 15153721356 0007755 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API;
defined( 'ABSPATH' ) || exit;
/**
* Trait MicroTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API
*/
trait MicroTrait {
/**
* Micro units.
*
* @var integer
*/
protected static $micro = 1000000;
/**
* Convert to micro units.
*
* @param float $num Number to convert to micro units.
*
* @return int
*/
protected function to_micro( float $num ): int {
return (int) ( $num * self::$micro );
}
/**
* Convert from micro units.
*
* @param int $num Number to convert from micro units.
*
* @return float
*/
protected function from_micro( int $num ): float {
return (float) ( $num / self::$micro );
}
}
API/PermissionsTrait.php 0000644 00000000663 15153721356 0011222 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API;
defined( 'ABSPATH' ) || exit;
/**
* Trait PermissionsTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API
*/
trait PermissionsTrait {
/**
* Check whether the current user can manage woocommerce.
*
* @return bool
*/
protected function can_manage(): bool {
return current_user_can( 'manage_woocommerce' );
}
}
API/Site/Controllers/Ads/AccountController.php 0000644 00000013122 15153721356 0015256 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class AccountController extends BaseController {
/**
* Service used to access / update Ads account data.
*
* @var AccountService
*/
protected $account;
/**
* AccountController constructor.
*
* @param RESTServer $server
* @param AccountService $account
*/
public function __construct( RESTServer $server, AccountService $account ) {
parent::__construct( $server );
$this->account = $account;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'ads/accounts',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_accounts_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->create_or_link_account_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'ads/connection',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connected_ads_account_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->disconnect_ads_account_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
$this->register_route(
'ads/billing-status',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_billing_status_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
$this->register_route(
'ads/account-status',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_ads_account_has_access(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback function for the list accounts request.
*
* @return callable
*/
protected function get_accounts_callback(): callable {
return function () {
try {
return new Response( $this->account->get_accounts() );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for creating or linking an account.
*
* @return callable
*/
protected function create_or_link_account_callback(): callable {
return function ( Request $request ) {
try {
$link_id = absint( $request['id'] );
if ( $link_id ) {
$this->account->use_existing_account( $link_id );
}
$account_data = $this->account->setup_account();
return $this->prepare_item_for_response( $account_data, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the connected ads account.
*
* @return callable
*/
protected function get_connected_ads_account_callback(): callable {
return function () {
return $this->account->get_connected_account();
};
}
/**
* Get the callback function for disconnecting a merchant.
*
* @return callable
*/
protected function disconnect_ads_account_callback(): callable {
return function () {
$this->account->disconnect();
return [
'status' => 'success',
'message' => __( 'Successfully disconnected.', 'google-listings-and-ads' ),
];
};
}
/**
* Get the callback function for retrieving the billing setup status.
*
* @return callable
*/
protected function get_billing_status_callback(): callable {
return function () {
return $this->account->get_billing_status();
};
}
/**
* Get the callback function for retrieving the account access status for ads.
*
* @return callable
*/
protected function get_ads_account_has_access(): callable {
return function () {
try {
return $this->account->get_ads_account_has_access();
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'id' => [
'type' => 'number',
'description' => __( 'Google Ads Account ID.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => false,
],
'billing_url' => [
'type' => 'string',
'description' => __( 'Billing Flow URL.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'account';
}
}
API/Site/Controllers/Ads/AssetGroupController.php 0000644 00000022143 15153721356 0015761 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use WP_REST_Request as Request;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class for handling API requests related to the asset groups.
* See https://developers.google.com/google-ads/api/reference/rpc/v18/AssetGroup
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class AssetGroupController extends BaseController {
/**
* The AdsAssetGroup class.
*
* @var AdsAssetGroup $ads_asset_group
*/
protected $ads_asset_group;
/**
* AssetGroupController constructor.
*
* @param RESTServer $rest_server
* @param AdsAssetGroup $ads_asset_group
*/
public function __construct( RESTServer $rest_server, AdsAssetGroup $ads_asset_group ) {
parent::__construct( $rest_server );
$this->ads_asset_group = $ads_asset_group;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'ads/campaigns/asset-groups/(?P<id>[\d]+)',
[
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->edit_asset_group_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->edit_asset_group_params(),
],
]
);
$this->register_route(
'ads/campaigns/asset-groups',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_asset_groups_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_asset_group_params(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->create_asset_group_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_asset_group_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the schema for the asset group.
*
* @return array The asset group schema.
*/
public function get_asset_group_fields(): array {
return [
'final_url' => [
'type' => 'string',
'description' => __( 'Final URL.', 'google-listings-and-ads' ),
],
'path1' => [
'type' => 'string',
'description' => __( 'Asset Group path 1.', 'google-listings-and-ads' ),
],
'path2' => [
'type' => 'string',
'description' => __( 'Asset Group path 2.', 'google-listings-and-ads' ),
],
];
}
/**
* Get the edit asset group params params to update an asset group.
*
* @return array The edit asset group params.
*/
public function edit_asset_group_params(): array {
return array_merge(
[
'id' => [
'description' => __( 'Asset Group ID.', 'google-listings-and-ads' ),
'type' => 'integer',
'required' => true,
],
'assets' => [
'type' => 'array',
'description' => __( 'List of asset to be edited.', 'google-listings-and-ads' ),
'items' => $this->get_schema_asset(),
'default' => [],
],
],
$this->get_asset_group_fields()
);
}
/**
* Get the assets groups params.
*
* @return array
*/
public function get_asset_group_params(): array {
return [
'campaign_id' => [
'description' => __( 'Campaign ID.', 'google-listings-and-ads' ),
'type' => 'integer',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
];
}
/**
* Get Asset Groups by Campaign ID.
*
* @return callable
*/
protected function get_asset_groups_callback(): callable {
return function ( Request $request ) {
try {
$campaign_id = $request->get_param( 'campaign_id' );
return array_map(
function ( $item ) use ( $request ) {
$data = $this->prepare_item_for_response( $item, $request );
return $this->prepare_response_for_collection( $data );
},
$this->ads_asset_group->get_asset_groups_by_campaign_id( $campaign_id )
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Create asset group.
*
* @return callable
*/
public function create_asset_group_callback(): callable {
return function ( Request $request ) {
try {
$asset_group_id = $this->ads_asset_group->create_asset_group( $request->get_param( 'campaign_id' ) );
return [
'status' => 'success',
'message' => __( 'Successfully created asset group.', 'google-listings-and-ads' ),
'id' => $asset_group_id,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Edit asset group.
*
* @return callable
*/
public function edit_asset_group_callback(): callable {
return function ( Request $request ) {
try {
$asset_group_fields = array_intersect_key(
$request->get_params(),
$this->get_asset_group_fields()
);
if ( empty( $asset_group_fields ) && empty( $request->get_param( 'assets' ) ) ) {
throw new Exception( __( 'No asset group fields to update.', 'google-listings-and-ads' ) );
}
$asset_group_id = $this->ads_asset_group->edit_asset_group( $request->get_param( 'id' ), $asset_group_fields, $request->get_param( 'assets' ) );
return [
'status' => 'success',
'message' => __( 'Successfully edited asset group.', 'google-listings-and-ads' ),
'id' => $asset_group_id,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'id' => [
'type' => 'number',
'description' => __( 'Asset Group ID', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'final_url' => [
'type' => 'string',
'description' => __( 'Final URL', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'display_url_path' => [
'type' => 'array',
'description' => __( 'Text that may appear appended to the url displayed in the ad.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'assets' => [
'type' => 'array',
'description' => __( 'Asset is a part of an ad which can be shared across multiple ads. It can be an image, headlines, descriptions, etc.', 'google-listings-and-ads' ),
'items' => [
'type' => 'object',
'properties' => [
AssetFieldType::SQUARE_MARKETING_IMAGE => $this->get_schema_field_type_asset(),
AssetFieldType::MARKETING_IMAGE => $this->get_schema_field_type_asset(),
AssetFieldType::PORTRAIT_MARKETING_IMAGE => $this->get_schema_field_type_asset(),
AssetFieldType::LOGO => $this->get_schema_field_type_asset(),
AssetFieldType::BUSINESS_NAME => $this->get_schema_field_type_asset(),
AssetFieldType::HEADLINE => $this->get_schema_field_type_asset(),
AssetFieldType::DESCRIPTION => $this->get_schema_field_type_asset(),
AssetFieldType::LONG_HEADLINE => $this->get_schema_field_type_asset(),
AssetFieldType::CALL_TO_ACTION_SELECTION => $this->get_schema_field_type_asset(),
],
],
],
];
}
/**
* Get the item schema for the field type asset.
*
* @return array the field type asset schema.
*/
protected function get_schema_field_type_asset(): array {
return [
'type' => 'array',
'items' => $this->get_schema_asset(),
'required' => false,
];
}
/**
* Get the item schema for the asset.
*
* @return array
*/
protected function get_schema_asset() {
return [
'type' => 'object',
'properties' => [
'id' => [
'type' => [ 'integer', 'null' ],
'description' => __( 'Asset ID', 'google-listings-and-ads' ),
],
'content' => [
'type' => [ 'string', 'null' ],
'description' => __( 'Asset content', 'google-listings-and-ads' ),
],
'field_type' => [
'type' => 'string',
'description' => __( 'Asset field type', 'google-listings-and-ads' ),
'required' => true,
'context' => [ 'edit' ],
'enum' => [
AssetFieldType::HEADLINE,
AssetFieldType::LONG_HEADLINE,
AssetFieldType::DESCRIPTION,
AssetFieldType::BUSINESS_NAME,
AssetFieldType::MARKETING_IMAGE,
AssetFieldType::SQUARE_MARKETING_IMAGE,
AssetFieldType::LOGO,
AssetFieldType::CALL_TO_ACTION_SELECTION,
AssetFieldType::PORTRAIT_MARKETING_IMAGE,
],
],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'asset-group';
}
}
API/Site/Controllers/Ads/AssetSuggestionsController.php 0000644 00000014135 15153721356 0017201 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AssetSuggestionsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class AssetSuggestionsController
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class AssetSuggestionsController extends BaseController {
/**
* Service used to populate ads suggestions data.
*
* @var AssetSuggestionsService
*/
protected $asset_suggestions_service;
/**
* AssetSuggestionsController constructor.
*
* @param RESTServer $server
* @param AssetSuggestionsService $asset_suggestions
*/
public function __construct( RESTServer $server, AssetSuggestionsService $asset_suggestions ) {
parent::__construct( $server );
$this->asset_suggestions_service = $asset_suggestions;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'assets/suggestions',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_assets_suggestions_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_assets_suggestions_params(),
],
]
);
$this->register_route(
'assets/final-url/suggestions',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_final_url_suggestions_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
return [
'search' => [
'description' => __( 'Search for post title or term name', 'google-listings-and-ads' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
],
'per_page' => [
'description' => __( 'The number of items to be return', 'google-listings-and-ads' ),
'type' => 'number',
'default' => 30,
'sanitize_callback' => 'absint',
'minimum' => 1,
'validate_callback' => 'rest_validate_request_arg',
],
'order_by' => [
'description' => __( 'Sort retrieved items by parameter', 'google-listings-and-ads' ),
'type' => 'string',
'default' => 'title',
'sanitize_callback' => 'sanitize_text_field',
'enum' => [ 'type', 'title', 'url' ],
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Get the assets suggestions params.
*
* @return array
*/
public function get_assets_suggestions_params(): array {
return [
'id' => [
'description' => __( 'Post ID or Term ID.', 'google-listings-and-ads' ),
'type' => 'number',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
'type' => [
'description' => __( 'Type linked to the id.', 'google-listings-and-ads' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'enum' => [ 'post', 'term', 'homepage' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
];
}
/**
* Get the callback function for the assets suggestions request.
*
* @return callable
*/
protected function get_assets_suggestions_callback(): callable {
return function ( Request $request ) {
try {
$id = $request->get_param( 'id' );
$type = $request->get_param( 'type' );
return $this->asset_suggestions_service->get_assets_suggestions( $id, $type );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the list of final-url suggestions request.
*
* @return callable
*/
protected function get_final_url_suggestions_callback(): callable {
return function ( Request $request ) {
$search = $request->get_param( 'search' );
$per_page = $request->get_param( 'per_page' );
$order_by = $request->get_param( 'order_by' );
return array_map(
function ( $item ) use ( $request ) {
$data = $this->prepare_item_for_response( $item, $request );
return $this->prepare_response_for_collection( $data );
},
$this->asset_suggestions_service->get_final_url_suggestions( $search, $per_page, $order_by )
);
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'id' => [
'type' => 'number',
'description' => __( 'Post ID or Term ID', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'type' => [
'type' => 'string',
'description' => __( 'Post, term or homepage', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'enum' => [ 'post', 'term', 'homepage' ],
'readonly' => true,
],
'title' => [
'type' => 'string',
'description' => __( 'The post or term title', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'url' => [
'type' => 'string',
'description' => __( 'The URL linked to the post/term', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'asset_final_url_suggestions';
}
}
API/Site/Controllers/Ads/BudgetRecommendationController.php 0000644 00000012206 15153721356 0017763 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\BudgetRecommendationQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class BudgetRecommendationController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class BudgetRecommendationController extends BaseController implements ISO3166AwareInterface {
use CountryCodeTrait;
/**
* @var BudgetRecommendationQuery
*/
protected $budget_recommendation_query;
/**
* @var Ads
*/
protected $ads;
/**
* BudgetRecommendationController constructor.
*
* @param RESTServer $rest_server
* @param BudgetRecommendationQuery $budget_recommendation_query
* @param Ads $ads
*/
public function __construct( RESTServer $rest_server, BudgetRecommendationQuery $budget_recommendation_query, Ads $ads ) {
parent::__construct( $rest_server );
$this->budget_recommendation_query = $budget_recommendation_query;
$this->ads = $ads;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'ads/campaigns/budget-recommendation',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_budget_recommendation_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'country_codes' => [
'type' => 'array',
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_country_code_validate_callback(),
'items' => [
'type' => 'string',
],
'required' => true,
'minItems' => 1,
],
];
}
/**
* @return callable
*/
protected function get_budget_recommendation_callback(): callable {
return function ( Request $request ) {
$country_codes = $request->get_param( 'country_codes' );
$currency = $this->ads->get_ads_currency();
if ( ! $currency ) {
return new Response(
[
'message' => __( 'No currency available for the Ads account.', 'google-listings-and-ads' ),
'currency' => $currency,
'country_codes' => $country_codes,
],
400
);
}
$recommendations = $this
->budget_recommendation_query
->where( 'country', $country_codes, 'IN' )
->where( 'currency', $currency )
->get_results();
if ( ! $recommendations ) {
return new Response(
[
'message' => __( 'Cannot find any budget recommendations.', 'google-listings-and-ads' ),
'currency' => $currency,
'country_codes' => $country_codes,
],
404
);
}
$returned_recommendations = array_map(
function ( $recommendation ) {
return [
'country' => $recommendation['country'],
'daily_budget' => (int) $recommendation['daily_budget'],
];
},
$recommendations
);
return $this->prepare_item_for_response(
[
'currency' => $currency,
'recommendations' => $returned_recommendations,
],
$request
);
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'currency' => [
'type' => 'string',
'description' => __( 'The currency to use for the shipping rate.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'validate_callback' => 'rest_validate_request_arg',
],
'recommendations' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'country' => [
'type' => 'string',
'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'daily_budget' => [
'type' => 'number',
'description' => __( 'The recommended daily budget for a country.', 'google-listings-and-ads' ),
],
],
],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'budget-recommendation';
}
}
API/Site/Controllers/Ads/CampaignController.php 0000644 00000030775 15153721356 0015416 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\CampaignStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\CampaignType;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelperAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use DateTime;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class CampaignController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class CampaignController extends BaseController implements GoogleHelperAwareInterface, ISO3166AwareInterface {
use CountryCodeTrait;
/**
* @var AdsCampaign
*/
protected $ads_campaign;
/**
* CampaignController constructor.
*
* @param RESTServer $server
* @param AdsCampaign $ads_campaign
*/
public function __construct( RESTServer $server, AdsCampaign $ads_campaign ) {
parent::__construct( $server );
$this->ads_campaign = $ads_campaign;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'ads/campaigns',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_campaigns_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->create_campaign_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'ads/campaigns/(?P<id>[\d]+)',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_campaign_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->edit_campaign_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_edit_schema(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->delete_campaign_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for listing campaigns.
*
* @return callable
*/
protected function get_campaigns_callback(): callable {
return function ( Request $request ) {
try {
$exclude_removed = $request->get_param( 'exclude_removed' );
return array_map(
function ( $campaign ) use ( $request ) {
$data = $this->prepare_item_for_response( $campaign, $request );
return $this->prepare_response_for_collection( $data );
},
$this->ads_campaign->get_campaigns( $exclude_removed, true, $request->get_params() )
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for creating a campaign.
*
* @return callable
*/
protected function create_campaign_callback(): callable {
return function ( Request $request ) {
try {
$fields = array_intersect_key( $request->get_json_params(), $this->get_schema_properties() );
// Set the default value of campaign name.
if ( empty( $fields['name'] ) ) {
$current_date_time = ( new DateTime( 'now', wp_timezone() ) )->format( 'Y-m-d H:i:s' );
$fields['name'] = sprintf(
/* translators: %s: current date time. */
__( 'Campaign %s', 'google-listings-and-ads' ),
$current_date_time
);
}
$campaign = $this->ads_campaign->create_campaign( $fields );
/**
* When a campaign has been successfully created.
*
* @event gla_created_campaign
* @property int id Campaign ID.
* @property string status Campaign status, `enabled` or `paused`.
* @property string name Campaign name, generated based on date.
* @property float amount Campaign budget.
* @property string country Base target country code.
* @property string targeted_locations Additional target country codes.
* @property string source The source of the campaign creation.
*/
do_action(
'woocommerce_gla_track_event',
'created_campaign',
[
'id' => $campaign['id'],
'status' => $campaign['status'],
'name' => $campaign['name'],
'amount' => $campaign['amount'],
'country' => $campaign['country'],
'targeted_locations' => join( ',', $campaign['targeted_locations'] ),
'source' => $fields['label'] ?? '',
]
);
return $this->prepare_item_for_response( $campaign, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for listing a single campaign.
*
* @return callable
*/
protected function get_campaign_callback(): callable {
return function ( Request $request ) {
try {
$id = absint( $request['id'] );
$campaign = $this->ads_campaign->get_campaign( $id );
if ( empty( $campaign ) ) {
return new Response(
[
'message' => __( 'Campaign is not available.', 'google-listings-and-ads' ),
'id' => $id,
],
404
);
}
return $this->prepare_item_for_response( $campaign, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for editing a campaign.
*
* @return callable
*/
protected function edit_campaign_callback(): callable {
return function ( Request $request ) {
try {
$fields = array_intersect_key( $request->get_json_params(), $this->get_edit_schema() );
if ( empty( $fields ) ) {
return new Response(
[
'status' => 'invalid_data',
'message' => __( 'Invalid edit data.', 'google-listings-and-ads' ),
],
400
);
}
$campaign_id = $this->ads_campaign->edit_campaign( absint( $request['id'] ), $fields );
/**
* When a campaign has been successfully edited.
*
* @event gla_edited_campaign
* @property int id Campaign ID.
* @property string status Campaign status, `enabled` or `paused`.
* @property string name Campaign name, generated based on date.
* @property float amount Campaign budget.
*/
do_action(
'woocommerce_gla_track_event',
'edited_campaign',
array_merge(
[
'id' => $campaign_id,
],
$fields,
)
);
return [
'status' => 'success',
'message' => __( 'Successfully edited campaign.', 'google-listings-and-ads' ),
'id' => $campaign_id,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for deleting a campaign.
*
* @return callable
*/
protected function delete_campaign_callback(): callable {
return function ( Request $request ) {
try {
$deleted_id = $this->ads_campaign->delete_campaign( absint( $request['id'] ) );
/**
* When a campaign has been successfully deleted.
*
* @event gla_deleted_campaign
* @property int id Campaign ID.
*/
do_action(
'woocommerce_gla_track_event',
'deleted_campaign',
[
'id' => $deleted_id,
]
);
return [
'status' => 'success',
'message' => __( 'Successfully deleted campaign.', 'google-listings-and-ads' ),
'id' => $deleted_id,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the schema for fields we are allowed to edit.
*
* @return array
*/
protected function get_edit_schema(): array {
$allowed = [
'name',
'status',
'amount',
];
$fields = array_intersect_key( $this->get_schema_properties(), array_flip( $allowed ) );
// Unset required to allow editing individual fields.
array_walk(
$fields,
function ( &$value ) {
unset( $value['required'] );
}
);
return $fields;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
return [
'exclude_removed' => [
'description' => __( 'Exclude removed campaigns.', 'google-listings-and-ads' ),
'type' => 'boolean',
'default' => true,
'validate_callback' => 'rest_validate_request_arg',
],
'per_page' => [
'description' => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ),
'type' => 'integer',
'minimum' => 1,
'maximum' => 10000,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'id' => [
'type' => 'integer',
'description' => __( 'ID number.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'type' => 'string',
'description' => __( 'Descriptive campaign name.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => false,
],
'status' => [
'type' => 'string',
'enum' => CampaignStatus::labels(),
'description' => __( 'Campaign status.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
],
'type' => [
'type' => 'string',
'enum' => CampaignType::labels(),
'description' => __( 'Campaign type.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
],
'amount' => [
'type' => 'number',
'description' => __( 'Daily budget amount in the local currency.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
'country' => [
'type' => 'string',
'description' => __( 'Country code of sale country in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_supported_country_code_validate_callback(),
'readonly' => true,
],
'targeted_locations' => [
'type' => 'array',
'description' => __( 'The locations that an Ads campaign is targeting in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_supported_country_code_validate_callback(),
'required' => true,
'minItems' => 1,
'items' => [
'type' => 'string',
],
],
'label' => [
'type' => 'string',
'description' => __( 'The name of the label to assign to the campaign.', 'google-listings-and-ads' ),
'context' => [ 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => false,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'campaign';
}
}
API/Site/Controllers/Ads/ReportsController.php 0000644 00000015173 15153721356 0015330 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsReport;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\CampaignStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseReportsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Exception;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class ReportsController
*
* ContainerAware used for:
* - AdsReport
* - WP (in parent class)
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class ReportsController extends BaseReportsController {
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'ads/reports/programs',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_programs_report_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'ads/reports/products',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_products_report_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for the programs report request.
*
* @return callable
*/
protected function get_programs_report_callback(): callable {
return function ( Request $request ) {
try {
/** @var AdsReport $ads */
$ads = $this->container->get( AdsReport::class );
$data = $ads->get_report_data( 'campaigns', $this->prepare_query_arguments( $request ) );
return $this->prepare_item_for_response( $data, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the products report request.
*
* @return callable
*/
protected function get_products_report_callback(): callable {
return function ( Request $request ) {
try {
/** @var AdsReport $ads */
$ads = $this->container->get( AdsReport::class );
$data = $ads->get_report_data( 'products', $this->prepare_query_arguments( $request ) );
return $this->prepare_item_for_response( $data, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
$params = parent::get_collection_params();
$params['interval'] = [
'description' => __( 'Time interval to use for segments in the returned data.', 'google-listings-and-ads' ),
'type' => 'string',
'enum' => [
'day',
'week',
'month',
'quarter',
'year',
],
'validate_callback' => 'rest_validate_request_arg',
];
return $params;
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'products' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'string',
'description' => __( 'Product ID.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'name' => [
'type' => 'string',
'description' => __( 'Product name.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
],
'subtotals' => $this->get_totals_schema(),
],
],
],
'campaigns' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'integer',
'description' => __( 'ID number.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'name' => [
'type' => 'string',
'description' => __( 'Campaign name.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
],
'status' => [
'type' => 'string',
'enum' => CampaignStatus::labels(),
'description' => __( 'Campaign status.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'isConverted' => [
'type' => 'boolean',
'description' => __( 'Whether the campaign has been converted', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'subtotals' => $this->get_totals_schema(),
],
],
],
'intervals' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'interval' => [
'type' => 'string',
'description' => __( 'ID of this report segment.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'subtotals' => $this->get_totals_schema(),
],
],
],
'totals' => $this->get_totals_schema(),
'next_page' => [
'type' => 'string',
'description' => __( 'Token to retrieve the next page of results.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
];
}
/**
* Return schema for total fields.
*
* @return array
*/
protected function get_totals_schema(): array {
return [
'type' => 'object',
'properties' => [
'clicks' => [
'type' => 'integer',
'description' => __( 'Clicks.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'impressions' => [
'type' => 'integer',
'description' => __( 'Impressions.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'sales' => [
'type' => 'number',
'description' => __( 'Sales amount.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'spend' => [
'type' => 'number',
'description' => __( 'Spend amount.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'conversions' => [
'type' => 'number',
'description' => __( 'Conversions.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'reports';
}
}
API/Site/Controllers/Ads/SetupCompleteController.php 0000644 00000005045 15153721356 0016460 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class SetupCompleteController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads
*/
class SetupCompleteController extends BaseController {
use EmptySchemaPropertiesTrait;
/**
* Service used to access metrics from the Ads Account.
*
* @var MerchantMetrics
*/
protected $metrics;
/**
* SetupCompleteController constructor.
*
* @param RESTServer $server
* @param MerchantMetrics $metrics
*/
public function __construct( RESTServer $server, MerchantMetrics $metrics ) {
parent::__construct( $server );
$this->metrics = $metrics;
}
/**
* Registers the routes for the objects of the controller.
*/
public function register_routes() {
$this->register_route(
'ads/setup/complete',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_setup_complete_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback function for marking setup complete.
*
* @return callable
*/
protected function get_setup_complete_callback(): callable {
return function ( Request $request ) {
do_action( 'woocommerce_gla_ads_setup_completed' );
/**
* Ads onboarding has been successfully completed.
*
* @event gla_ads_setup_completed
* @property int campaign_count Number of campaigns for the connected Ads account.
*/
do_action(
'woocommerce_gla_track_event',
'ads_setup_completed',
[
'campaign_count' => $this->metrics->get_campaign_count(),
]
);
return new Response(
[
'status' => 'success',
'message' => __( 'Successfully marked Ads setup as completed.', 'google-listings-and-ads' ),
]
);
};
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'ads_setup_complete';
}
}
API/Site/Controllers/AttributeMapping/AttributeMappingDataController.php 0000644 00000010200 15153721356 0022475 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\AttributeMappingHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class for handling API requests for getting source and destination data for Attribute Mapping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping
*/
class AttributeMappingDataController extends BaseOptionsController {
/**
* @var AttributeMappingHelper
*/
private AttributeMappingHelper $attribute_mapping_helper;
/**
* AttributeMappingDataController constructor.
*
* @param RESTServer $server
* @param AttributeMappingHelper $attribute_mapping_helper
*/
public function __construct( RESTServer $server, AttributeMappingHelper $attribute_mapping_helper ) {
parent::__construct( $server );
$this->attribute_mapping_helper = $attribute_mapping_helper;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
/**
* GET the destination fields for Google Shopping
*/
$this->register_route(
'mc/mapping/attributes',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_mapping_attributes_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
/**
* GET for getting the source data for a specific destination
*/
$this->register_route(
'mc/mapping/sources',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_mapping_sources_read_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => [
'attribute' => [
'description' => __( 'The attribute key to get the sources.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
],
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Callback function for returning the attributes
*
* @return callable
*/
protected function get_mapping_attributes_read_callback(): callable {
return function ( Request $request ) {
try {
return $this->prepare_item_for_response( $this->get_attributes(), $request );
} catch ( Exception $e ) {
return new Response( [ 'message' => $e->getMessage() ], $e->getCode() ?: 400 );
}
};
}
/**
* Callback function for returning the sources.
*
* @return callable
*/
protected function get_mapping_sources_read_callback(): callable {
return function ( Request $request ) {
try {
$attribute = $request->get_param( 'attribute' );
return [
'data' => $this->attribute_mapping_helper->get_sources_for_attribute( $attribute ),
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'data' => [
'type' => 'array',
'description' => __( 'The list of attributes or attribute sources.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'attribute_mapping_data';
}
/**
* Attributes getter
*
* @return array The attributes available for mapping
*/
private function get_attributes(): array {
return [
'data' => $this->attribute_mapping_helper->get_attributes(),
];
}
}
API/Site/Controllers/AttributeMapping/AttributeMappingRulesController.php 0000644 00000021445 15153721356 0022733 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\AttributeMappingHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_Error;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class for handling API requests for getting source and destination data for Attribute Mapping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping
*/
class AttributeMappingRulesController extends BaseOptionsController {
/**
* @var AttributeMappingRulesQuery
*/
private AttributeMappingRulesQuery $attribute_mapping_rules_query;
/**
* @var AttributeMappingHelper
*/
private AttributeMappingHelper $attribute_mapping_helper;
/**
* AttributeMappingRulesController constructor.
*
* @param RESTServer $server
* @param AttributeMappingHelper $attribute_mapping_helper
* @param AttributeMappingRulesQuery $attribute_mapping_rules_query
*/
public function __construct( RESTServer $server, AttributeMappingHelper $attribute_mapping_helper, AttributeMappingRulesQuery $attribute_mapping_rules_query ) {
parent::__construct( $server );
$this->attribute_mapping_helper = $attribute_mapping_helper;
$this->attribute_mapping_rules_query = $attribute_mapping_rules_query;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/mapping/rules',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_rule_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->create_rule_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
$this->register_route(
'mc/mapping/rules/(?P<id>[\d]+)',
[
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->update_rule_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->delete_rule_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Callback function for getting the Attribute Mapping rules from DB
*
* @return callable
*/
protected function get_rule_callback(): callable {
return function ( Request $request ) {
try {
$page = $request->get_param( 'page' );
$per_page = $request->get_param( 'per_page' );
$this->attribute_mapping_rules_query->set_limit( $per_page );
$this->attribute_mapping_rules_query->set_offset( $per_page * ( $page - 1 ) );
$rules = $this->attribute_mapping_rules_query->get_results();
$total_rules = $this->attribute_mapping_rules_query->get_count();
$response_data = [];
foreach ( $rules as $rule ) {
$item_data = $this->prepare_item_for_response( $rule, $request );
$response_data[] = $this->prepare_response_for_collection( $item_data );
}
return new Response(
$response_data,
200,
[
'X-WP-Total' => $total_rules,
'X-WP-TotalPages' => ceil( $total_rules / $per_page ),
]
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Callback function for saving an Attribute Mapping rule in DB
*
* @return callable
*/
protected function create_rule_callback(): callable {
return function ( Request $request ) {
try {
if ( ! $this->attribute_mapping_rules_query->insert( $this->prepare_item_for_database( $request ) ) ) {
return $this->response_from_exception( new Exception( 'Unable to create the new rule.' ) );
}
$response = $this->prepare_item_for_response( $this->attribute_mapping_rules_query->get_rule( $this->attribute_mapping_rules_query->last_insert_id() ), $request );
do_action( 'woocommerce_gla_mapping_rules_change' );
return $response;
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Callback function for saving an Attribute Mapping rule in DB
*
* @return callable
*/
protected function update_rule_callback(): callable {
return function ( Request $request ) {
try {
$rule_id = $request->get_url_params()['id'];
if ( ! $this->attribute_mapping_rules_query->update( $this->prepare_item_for_database( $request ), [ 'id' => $rule_id ] ) ) {
return $this->response_from_exception( new Exception( 'Unable to update the new rule.' ) );
}
$response = $this->prepare_item_for_response( $this->attribute_mapping_rules_query->get_rule( $rule_id ), $request );
do_action( 'woocommerce_gla_mapping_rules_change' );
return $response;
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Callback function for deleting an Attribute Mapping rule in DB
*
* @return callable
*/
protected function delete_rule_callback(): callable {
return function ( Request $request ) {
try {
$rule_id = $request->get_url_params()['id'];
if ( ! $this->attribute_mapping_rules_query->delete( 'id', $rule_id ) ) {
return $this->response_from_exception( new Exception( 'Unable to delete the rule' ) );
}
do_action( 'woocommerce_gla_mapping_rules_change' );
return [
'id' => $rule_id,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema properties for the controller.
*
* @return array The Schema properties
*/
protected function get_schema_properties(): array {
return [
'id' => [
'description' => __( 'The Id for the rule.', 'google-listings-and-ads' ),
'type' => 'integer',
'validate_callback' => 'rest_validate_request_arg',
'readonly' => true,
],
'attribute' => [
'description' => __( 'The attribute value for the rule.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
'enum' => array_column( $this->attribute_mapping_helper->get_attributes(), 'id' ),
],
'source' => [
'description' => __( 'The source value for the rule.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
'category_condition_type' => [
'description' => __( 'The category condition type to apply for this rule.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
'enum' => $this->attribute_mapping_helper->get_category_condition_types(),
],
'categories' => [
'description' => __( 'List of category IDs, separated by commas.', 'google-listings-and-ads' ),
'type' => 'string',
'required' => false,
'validate_callback' => function ( $param ) {
return $this->validate_categories_param( $param );
},
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'attribute_mapping_rules';
}
/**
* @param string $categories Categories to validate
* @return bool|WP_Error True if it's validated
*
* @throw Exception when invalid categories are provided
*/
public function validate_categories_param( string $categories ) {
if ( $categories === '' ) {
return true;
}
$categories_array = explode( ',', $categories );
foreach ( $categories_array as $category ) {
if ( ! is_numeric( $category ) ) {
return new WP_Error(
'woocommerce_gla_attribute_mapping_invalid_categories_schema',
'categories should be a string of category IDs separated by commas.',
[
'categories' => $categories,
]
);
}
}
return true;
}
}
API/Site/Controllers/AttributeMapping/AttributeMappingSyncerController.php 0000644 00000006417 15153721356 0023106 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class for handling API requests for getting the current Syncing state
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping
*/
class AttributeMappingSyncerController extends BaseController implements OptionsAwareInterface {
use OptionsAwareTrait;
/**
* @var ProductSyncStats
*/
protected $sync_stats;
/**
* AttributeMappingSyncerController constructor.
*
* @param RESTServer $server
* @param ProductSyncStats $sync_stats
*/
public function __construct( RESTServer $server, ProductSyncStats $sync_stats ) {
parent::__construct( $server );
$this->sync_stats = $sync_stats;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/mapping/sync',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_sync_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Callback function for getting the Attribute Mapping Sync State
*
* @return callable
*/
protected function get_sync_callback(): callable {
return function ( Request $request ) {
try {
$state = [
'is_scheduled' => (bool) $this->sync_stats->get_count(),
'last_sync' => $this->options->get( OptionsInterface::UPDATE_ALL_PRODUCTS_LAST_SYNC ),
];
return $this->prepare_item_for_response( $state, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema properties for the controller.
*
* @return array The Schema properties
*/
protected function get_schema_properties(): array {
return [
'is_scheduled' => [
'description' => __( 'Indicates if the products are currently syncing', 'google-listings-and-ads' ),
'type' => 'boolean',
'validate_callback' => 'rest_validate_request_arg',
'readonly' => true,
'context' => [ 'view' ],
],
'last_sync' => [
'description' => __( 'Timestamp with the last sync.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'readonly' => true,
'context' => [ 'view' ],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'attribute_mapping_syncer';
}
}
API/Site/Controllers/BaseController.php 0000644 00000012256 15153721356 0014034 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\API\PermissionsTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WC_REST_Controller;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
/**
* Class BaseEndpoint
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site
*/
abstract class BaseController extends WC_REST_Controller implements Registerable {
use PluginHelper;
use PermissionsTrait;
use ResponseFromExceptionTrait;
/**
* @var RESTServer
*/
protected $server;
/**
* BaseController constructor.
*
* @param RESTServer $server
*/
public function __construct( RESTServer $server ) {
$this->server = $server;
$this->namespace = $this->get_namespace();
}
/**
* Register a service.
*/
public function register(): void {
$this->register_routes();
}
/**
* Register a single route.
*
* @param string $route The route name.
* @param array $args The arguments for the route.
*/
protected function register_route( string $route, array $args ): void {
$this->server->register_route( $this->get_namespace(), $route, $args );
}
/**
* Get the namespace for the current controller.
*
* @return string
*/
protected function get_namespace(): string {
return "wc/{$this->get_slug()}";
}
/**
* Get the callback to determine the route's permissions.
*
* @return callable
*/
protected function get_permission_callback(): callable {
return function () {
return $this->can_manage();
};
}
/**
* Prepare an item schema for sending to the API.
*
* @param array $properties Array of raw properties.
* @param string $schema_title Schema title.
*
* @return array
*/
protected function prepare_item_schema( array $properties, string $schema_title ): array {
return $this->add_additional_fields_schema(
[
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => $schema_title,
'type' => 'object',
'additionalProperties' => false,
'properties' => $properties,
]
);
}
/**
* Retrieves the item's schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema(): array {
return $this->prepare_item_schema( $this->get_schema_properties(), $this->get_schema_title() );
}
/**
* Get a callback function for returning the API schema.
*
* @return callable
*/
protected function get_api_response_schema_callback(): callable {
return function () {
return $this->get_item_schema();
};
}
/**
* Get a route name which is safe to use as a filter (removes namespace prefix).
*
* @param Request $request Request object.
*
* @return string
*/
protected function get_route_name( Request $request ): string {
$route = trim( $request->get_route(), '/' );
if ( 0 === strpos( $route, $this->get_namespace() ) ) {
$route = substr( $route, strlen( $this->get_namespace() ) );
}
return sanitize_title( $route );
}
/**
* Prepares the item for the REST response.
*
* @param mixed $item WordPress representation of the item.
* @param Request $request Request object.
*
* @return Response Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) {
$prepared = [];
$context = $request['context'] ?? 'view';
$schema = $this->get_schema_properties();
foreach ( $schema as $key => $property ) {
$item_value = $item[ $key ] ?? $property['default'] ?? null;
// Cast empty arrays to empty objects if property is supposed to be an object.
if ( is_array( $item_value ) && empty( $item_value ) && isset( $property['type'] ) && 'object' === $property['type'] ) {
$item_value = (object) [];
}
$prepared[ $key ] = $item_value;
}
$prepared = $this->add_additional_fields_to_object( $prepared, $request );
$prepared = $this->filter_response_by_context( $prepared, $context );
$prepared = apply_filters(
'woocommerce_gla_prepared_response_' . $this->get_route_name( $request ),
$prepared,
$request
);
return new Response( $prepared );
}
/**
* Prepares one item for create or update operation.
*
* @param Request $request Request object.
*
* @return array The prepared item, or WP_Error object on failure.
*/
protected function prepare_item_for_database( $request ): array {
$prepared = [];
$schema = $this->get_schema_properties();
foreach ( $schema as $key => $property ) {
if ( $property['readonly'] ?? false ) {
continue;
}
$prepared[ $key ] = $request[ $key ] ?? $property['default'] ?? null;
}
return $prepared;
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
abstract protected function get_schema_properties(): array;
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
abstract protected function get_schema_title(): string;
}
API/Site/Controllers/BaseOptionsController.php 0000644 00000001032 15153721356 0015376 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class BaseOptionsController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
abstract class BaseOptionsController extends BaseController implements OptionsAwareInterface {
use OptionsAwareTrait;
}
API/Site/Controllers/BaseReportsController.php 0000644 00000011177 15153721356 0015414 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use DateTime;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class BaseReportsController
*
* ContainerAware used for:
* - WP
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
abstract class BaseReportsController extends BaseController implements ContainerAwareInterface {
use ContainerAwareTrait;
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'after' => [
'description' => __( 'Limit response to data after a given ISO8601 compliant date.', 'google-listings-and-ads' ),
'type' => 'string',
'format' => 'date',
'default' => '-7 days',
'validate_callback' => 'rest_validate_request_arg',
],
'before' => [
'description' => __( 'Limit response to data before a given ISO8601 compliant date.', 'google-listings-and-ads' ),
'type' => 'string',
'format' => 'date',
'default' => 'now',
'validate_callback' => 'rest_validate_request_arg',
],
'ids' => [
'description' => __( 'Limit result to items with specified ids.', 'google-listings-and-ads' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'string',
],
],
'fields' => [
'description' => __( 'Limit totals to a set of fields.', 'google-listings-and-ads' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'string',
],
],
'order' => [
'description' => __( 'Order sort attribute ascending or descending.', 'google-listings-and-ads' ),
'type' => 'string',
'default' => 'desc',
'enum' => [ 'asc', 'desc' ],
'validate_callback' => 'rest_validate_request_arg',
],
'orderby' => [
'description' => __( 'Sort collection by attribute.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'per_page' => [
'description' => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ),
'type' => 'integer',
'default' => 200,
'minimum' => 1,
'maximum' => 1000,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
'next_page' => [
'description' => __( 'Token to retrieve the next page.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Maps query arguments from the REST request.
*
* @param Request $request REST Request.
* @return array
*/
protected function prepare_query_arguments( Request $request ): array {
$args = wp_parse_args(
array_intersect_key(
$request->get_query_params(),
$this->get_collection_params()
),
$request->get_default_params()
);
$this->normalize_timezones( $args );
return $args;
}
/**
* Converts input datetime parameters to local timezone.
*
* @param array $query_args Array of query arguments.
*/
protected function normalize_timezones( &$query_args ) {
/** @var WP $wp */
$wp = $this->container->get( WP::class );
$local_tz = $wp->wp_timezone();
foreach ( [ 'before', 'after' ] as $query_arg_key ) {
if ( isset( $query_args[ $query_arg_key ] ) && is_string( $query_args[ $query_arg_key ] ) ) {
// Assume that unspecified timezone is a local timezone.
$datetime = new DateTime( $query_args[ $query_arg_key ], $local_tz );
// In case timezone was forced by using +HH:MM, convert to local timezone.
$datetime->setTimezone( $local_tz );
$query_args[ $query_arg_key ] = $datetime;
} elseif ( isset( $query_args[ $query_arg_key ] ) && $query_args[ $query_arg_key ] instanceof DateTime ) {
// In case timezone is in other timezone, convert to local timezone.
$query_args[ $query_arg_key ]->setTimezone( $local_tz );
}
}
}
}
API/Site/Controllers/BatchSchemaTrait.php 0000644 00000002640 15153721356 0014260 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
defined( 'ABSPATH' ) || exit;
/**
* Trait BatchSchemaTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
trait BatchSchemaTrait {
use CountryCodeTrait;
/**
* Get the schema for a batch request.
*
* @return array
*/
public function get_item_schema(): array {
$schema = parent::get_schema_properties();
unset( $schema['country'], $schema['country_code'] );
// Context is always edit for batches.
foreach ( $schema as $key => &$value ) {
$value['context'] = [ 'edit' ];
}
$schema['country_codes'] = [
'type' => 'array',
'description' => __(
'Array of country codes in ISO 3166-1 alpha-2 format.',
'google-listings-and-ads'
),
'context' => [ 'edit' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_country_code_validate_callback(),
'minItems' => 1,
'required' => true,
'uniqueItems' => true,
'items' => [
'type' => 'string',
],
];
return $schema;
}
/**
* Get the schema for a batch DELETE request.
*
* @return array
*/
public function get_item_delete_schema(): array {
$schema = $this->get_item_schema();
unset( $schema['rate'], $schema['currency'] );
return $schema;
}
}
API/Site/Controllers/CountryCodeTrait.php 0000644 00000010052 15153721356 0014350 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\WPErrorTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelperAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\ISO3166Awareness;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\Exception\OutOfBoundsException;
use WP_REST_Request as Request;
use Exception;
use Throwable;
defined( 'ABSPATH' ) || exit;
/**
* Trait CountryCodeTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
trait CountryCodeTrait {
use GoogleHelperAwareTrait;
use ISO3166Awareness;
use WPErrorTrait;
/**
* Validate that a country is valid.
*
* @param string $country The alpha2 country code.
*
* @throws OutOfBoundsException When the country code cannot be found.
*/
protected function validate_country_code( string $country ): void {
$this->iso3166_data_provider->alpha2( $country );
}
/**
* Validate that a country or a list of countries is valid and supported,
* and also validate the data by the built-in validation of WP REST API with parameter’s schema.
*
* Since this extension's all API endpoints that use this validation function specify both
* `validate_callback` and `sanitize_callback`, this makes the built-in schema validation
* in WP REST API not to be applied. Therefore, this function calls `rest_validate_request_arg`
* first, so that the API endpoints can still benefit from the built-in schema validation.
*
* @param bool $check_supported_country Whether to check the country is supported.
* @param mixed $countries An individual string or an array of strings.
* @param Request $request The request to validate.
* @param string $param The parameter name, used in error messages.
*
* @return mixed
* @throws Exception When the country is not supported.
* @throws OutOfBoundsException When the country code cannot be found.
*/
protected function validate_country_codes( bool $check_supported_country, $countries, $request, $param ) {
$validation_result = rest_validate_request_arg( $countries, $request, $param );
if ( true !== $validation_result ) {
return $validation_result;
}
try {
// This is used for individual strings and an array of strings.
$countries = (array) $countries;
foreach ( $countries as $country ) {
$this->validate_country_code( $country );
if ( $check_supported_country ) {
$country_supported = $this->google_helper->is_country_supported( $country );
if ( ! $country_supported ) {
throw new Exception( __( 'Country is not supported', 'google-listings-and-ads' ) );
}
}
}
return true;
} catch ( Throwable $e ) {
return $this->error_from_exception(
$e,
'gla_invalid_country',
[
'status' => 400,
'country' => $countries,
]
);
}
}
/**
* Get the callback to sanitize the country code.
*
* Necessary because strtoupper() will trigger warnings when extra parameters are passed to it.
*
* @return callable
*/
protected function get_country_code_sanitize_callback(): callable {
return function ( $value ) {
return is_array( $value )
? array_map( 'strtoupper', $value )
: strtoupper( $value );
};
}
/**
* Get a callable function for validating that a provided country code is recognized
* and fulfilled the given parameter's schema.
*
* @return callable
*/
protected function get_country_code_validate_callback(): callable {
return function ( ...$args ) {
return $this->validate_country_codes( false, ...$args );
};
}
/**
* Get a callable function for validating that a provided country code is recognized, supported,
* and fulfilled the given parameter's schema..
*
* @return callable
*/
protected function get_supported_country_code_validate_callback(): callable {
return function ( ...$args ) {
return $this->validate_country_codes( true, ...$args );
};
}
}
API/Site/Controllers/DisconnectController.php 0000644 00000004353 15153721356 0015252 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class DisconnectController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
class DisconnectController extends BaseController {
use EmptySchemaPropertiesTrait;
/**
* Register rest routes with WordPress.
*/
public function register_routes() {
$this->register_route(
'connections',
[
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_disconnect_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback for disconnecting all the services.
*
* @return callable
*/
protected function get_disconnect_callback(): callable {
return function ( Request $request ) {
$endpoints = [
'ads/connection',
'mc/connection',
'google/connect',
'jetpack/connect',
'rest-api/authorize',
];
$errors = [];
$responses = [];
foreach ( $endpoints as $endpoint ) {
$response = $this->get_delete_response( $endpoint );
if ( 200 !== $response->get_status() ) {
$errors[ $response->get_matched_route() ] = $response->get_data();
} else {
$responses[ $response->get_matched_route() ] = $response->get_data();
}
}
return new Response(
[
'errors' => $errors,
'responses' => $responses,
],
empty( $errors ) ? 200 : 400
);
};
}
/**
* Run a DELETE request for a given path, and return the response.
*
* @param string $path The relative API path. Based on the shared namespace.
*
* @return Response
*/
protected function get_delete_response( string $path ): Response {
$path = ltrim( $path, '/' );
return $this->server->dispatch_request( new Request( 'DELETE', "/{$this->get_namespace()}/{$path}" ) );
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'disconnect_all_accounts';
}
}
API/Site/Controllers/EmptySchemaPropertiesTrait.php 0000644 00000000655 15153721356 0016416 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
/**
* Trait EmptySchemaPropertiesTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
trait EmptySchemaPropertiesTrait {
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [];
}
}
API/Site/Controllers/GTINMigrationController.php 0000644 00000005757 15153721356 0015605 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\GTINMigrationUtilities;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\MigrateGTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class GTINMigrationController offering API endpoint for GTIN field Migration
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
class GTINMigrationController extends BaseController {
use EmptySchemaPropertiesTrait;
use GTINMigrationUtilities;
/**
* Repository to fetch job responsible to run the migration in the background.
*
* @var JobRepository
*/
protected $job_repository;
/**
* Constructor.
*
* @param RESTServer $server
* @param JobRepository $job_repository
*/
public function __construct( RESTServer $server, JobRepository $job_repository ) {
parent::__construct( $server );
$this->job_repository = $job_repository;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'gtin-migration',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->start_migration_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_migration_status_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Callback function for scheduling GTIN migration job.
*
* @return callable
*/
protected function start_migration_callback(): callable {
return function () {
try {
$job = $this->job_repository->get( MigrateGTIN::class );
if ( ! $job->can_schedule( [ 1 ] ) ) {
return new Response(
[
'status' => 'error',
'message' => __( 'GTIN Migration cannot be scheduled.', 'google-listings-and-ads' ),
],
400
);
}
$job->schedule();
return new Response(
[
'status' => 'success',
'message' => __( 'GTIN Migration successfully started.', 'google-listings-and-ads' ),
],
200
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Callback function for getting the current migration status.
*
* @return callable
*/
protected function get_migration_status_callback(): callable {
return function () {
return new Response(
[
'status' => $this->get_gtin_migration_status(),
],
200
);
};
}
/**
* Get Schema title
*
* @return string
*/
protected function get_schema_title(): string {
return 'gtin_migration';
}
}
API/Site/Controllers/Google/AccountController.php 0000644 00000014120 15153721356 0015762 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Google
*/
class AccountController extends BaseController {
/**
* @var Connection
*/
protected $connection;
/**
* Mapping between the client page name and its path.
* The first value is also used as a default,
* and changing the order of keys/values may affect things below.
*
* @var string[]
*/
private const NEXT_PATH_MAPPING = [
'setup-mc' => '/google/setup-mc',
'setup-ads' => '/google/setup-ads',
'reconnect' => '/google/settings&subpath=/reconnect-google-account',
];
/**
* AccountController constructor.
*
* @param RESTServer $server
* @param Connection $connection
*/
public function __construct( RESTServer $server, Connection $connection ) {
parent::__construct( $server );
$this->connection = $connection;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'google/connect',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connect_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_connect_params(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_disconnect_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'google/connected',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connected_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
$this->register_route(
'google/reconnected',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_reconnected_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback function for the connection request.
*
* @return callable
*/
protected function get_connect_callback(): callable {
return function ( Request $request ) {
try {
$next = $request->get_param( 'next_page_name' );
$login_hint = $request->get_param( 'login_hint' ) ?: '';
$path = self::NEXT_PATH_MAPPING[ $next ];
return [
'url' => $this->connection->connect(
admin_url( "admin.php?page=wc-admin&path={$path}" ),
$login_hint
),
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the query params for the connection request.
*
* @return array
*/
protected function get_connect_params(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'next_page_name' => [
'description' => __( 'Indicates the next page name mapped to the redirect URL when back from Google authorization.', 'google-listings-and-ads' ),
'type' => 'string',
'default' => array_key_first( self::NEXT_PATH_MAPPING ),
'enum' => array_keys( self::NEXT_PATH_MAPPING ),
'validate_callback' => 'rest_validate_request_arg',
],
'login_hint' => [
'description' => __( 'Indicate the Google account to suggest for authorization.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'is_email',
],
];
}
/**
* Get the callback function for the disconnection request.
*
* @return callable
*/
protected function get_disconnect_callback(): callable {
return function () {
$this->connection->disconnect();
return [
'status' => 'success',
'message' => __( 'Successfully disconnected.', 'google-listings-and-ads' ),
];
};
}
/**
* Get the callback function to determine if Google is currently connected.
*
* Uses consistent properties to the Jetpack connected callback
*
* @return callable
*/
protected function get_connected_callback(): callable {
return function () {
try {
$status = $this->connection->get_status();
return [
'active' => array_key_exists( 'status', $status ) && ( 'connected' === $status['status'] ) ? 'yes' : 'no',
'email' => array_key_exists( 'email', $status ) ? $status['email'] : '',
'scope' => array_key_exists( 'scope', $status ) ? $status['scope'] : [],
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function to determine if we have access to the dependent services.
*
* @return callable
*/
protected function get_reconnected_callback(): callable {
return function () {
try {
$status = $this->connection->get_reconnect_status();
$status['active'] = array_key_exists( 'status', $status ) && ( 'connected' === $status['status'] ) ? 'yes' : 'no';
unset( $status['status'] );
return $status;
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'url' => [
'type' => 'string',
'description' => __( 'The URL for making a connection to Google.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'google_account';
}
}
API/Site/Controllers/Jetpack/AccountController.php 0000644 00000017412 15153721356 0016136 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Jetpack;
use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Jetpack
*/
class AccountController extends BaseOptionsController {
/**
* @var Manager
*/
protected $manager;
/**
* @var Middleware
*/
protected $middleware;
/**
* Retain the connected state to prevent multiple external calls to validate the token.
*
* @var bool
*/
private $jetpack_connected_state;
/**
* Mapping between the client page name and its path.
* The first value is also used as a default,
* and changing the order of keys/values may affect things below.
*
* @var string[]
*/
private const NEXT_PATH_MAPPING = [
'setup-mc' => '/google/setup-mc',
'reconnect' => '/google/settings&subpath=/reconnect-wpcom-account',
];
/**
* AccountController constructor.
*
* @param RESTServer $server
* @param Manager $manager
* @param Middleware $middleware
*/
public function __construct( RESTServer $server, Manager $manager, Middleware $middleware ) {
parent::__construct( $server );
$this->manager = $manager;
$this->middleware = $middleware;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'jetpack/connect',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connect_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_connect_params(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_disconnect_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'jetpack/connected',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connected_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback function for the connection request.
*
* @return callable
*/
protected function get_connect_callback(): callable {
return function ( Request $request ) {
// Register the site to WPCOM.
if ( $this->manager->is_connected() ) {
$result = $this->manager->reconnect();
} else {
$result = $this->manager->register();
}
if ( is_wp_error( $result ) ) {
return new Response(
[
'status' => 'error',
'message' => $result->get_error_message(),
],
400
);
}
// Get an authorization URL which will redirect back to our page.
$next = $request->get_param( 'next_page_name' );
$path = self::NEXT_PATH_MAPPING[ $next ];
$redirect = admin_url( "admin.php?page=wc-admin&path={$path}" );
$auth_url = $this->manager->get_authorization_url( null, $redirect );
// Payments flow allows redirect back to the site without showing plans. Escaping the URL preventing XSS.
$auth_url = esc_url( add_query_arg( [ 'from' => 'google-listings-and-ads' ], $auth_url ), null, 'db' );
return [
'url' => $auth_url,
];
};
}
/**
* Get the query params for the connection request.
*
* @return array
*/
protected function get_connect_params(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'next_page_name' => [
'description' => __( 'Indicates the next page name mapped to the redirect URL when back from Jetpack authorization.', 'google-listings-and-ads' ),
'type' => 'string',
'default' => array_key_first( self::NEXT_PATH_MAPPING ),
'enum' => array_keys( self::NEXT_PATH_MAPPING ),
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Get the callback function for the disconnection request.
*
* @return callable
*/
protected function get_disconnect_callback(): callable {
return function () {
$this->manager->remove_connection();
$this->options->delete( OptionsInterface::WP_TOS_ACCEPTED );
$this->options->delete( OptionsInterface::JETPACK_CONNECTED );
return [
'status' => 'success',
'message' => __( 'Successfully disconnected.', 'google-listings-and-ads' ),
];
};
}
/**
* Get the callback function to determine if Jetpack is currently connected.
*
* @return callable
*/
protected function get_connected_callback(): callable {
return function () {
if ( $this->is_jetpack_connected() && ! $this->options->get( OptionsInterface::WP_TOS_ACCEPTED ) ) {
$this->log_wp_tos_accepted();
}
// Update connection status.
$this->options->update( OptionsInterface::JETPACK_CONNECTED, $this->is_jetpack_connected() );
$user_data = $this->get_jetpack_user_data();
return [
'active' => $this->display_boolean( $this->is_jetpack_connected() ),
'owner' => $this->display_boolean( $this->is_jetpack_connection_owner() ),
'displayName' => $user_data['display_name'] ?? '',
'email' => $user_data['email'] ?? '',
];
};
}
/**
* Determine whether Jetpack is connected.
* Check if manager is active and we have a valid token.
*
* @return bool
*/
protected function is_jetpack_connected(): bool {
if ( null !== $this->jetpack_connected_state ) {
return $this->jetpack_connected_state;
}
if ( ! $this->manager->has_connected_owner() || ! $this->manager->is_connected() ) {
$this->jetpack_connected_state = false;
return false;
}
// Send an external request to validate the token.
$this->jetpack_connected_state = $this->manager->get_tokens()->validate_blog_token();
return $this->jetpack_connected_state;
}
/**
* Determine whether user is the current Jetpack connection owner.
*
* @return bool
*/
protected function is_jetpack_connection_owner(): bool {
return $this->manager->is_connection_owner();
}
/**
* Format boolean for display.
*
* @param bool $value
*
* @return string
*/
protected function display_boolean( bool $value ): string {
return $value ? 'yes' : 'no';
}
/**
* Get the wpcom user data of the current connected user.
*
* @return array
*/
protected function get_jetpack_user_data(): array {
$user_data = $this->manager->get_connected_user_data();
// adjust for $user_data returning false
return is_array( $user_data ) ? $user_data : [];
}
/**
* Log accepted TOS for WordPress.
*/
protected function log_wp_tos_accepted() {
$user = wp_get_current_user();
$this->middleware->mark_tos_accepted( 'wp-com', $user->user_email );
$this->options->update( OptionsInterface::WP_TOS_ACCEPTED, true );
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'url' => [
'type' => 'string',
'description' => __( 'The URL for making a connection to Jetpack (wordpress.com).', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'jetpack_account';
}
}
API/Site/Controllers/MerchantCenter/AccountController.php 0000644 00000017167 15153721356 0017466 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ApiNotReady;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class AccountController extends BaseController {
/**
* Service used to access / update Ads account data.
*
* @var AccountService
*/
protected $account;
/**
* AccountController constructor.
*
* @param RESTServer $server
* @param AccountService $account
*/
public function __construct( RESTServer $server, AccountService $account ) {
parent::__construct( $server );
$this->account = $account;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/accounts',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_accounts_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->setup_account_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'mc/accounts/claim-overwrite',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->overwrite_claim_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'mc/accounts/switch-url',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->switch_url_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'mc/connection',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connected_merchant_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->disconnect_merchant_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
$this->register_route(
'mc/setup',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_setup_merchant_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback function for the list accounts request.
*
* @return callable
*/
protected function get_accounts_callback(): callable {
return function ( Request $request ) {
try {
return array_map(
function ( $account ) use ( $request ) {
$data = $this->prepare_item_for_response( $account, $request );
return $this->prepare_response_for_collection( $data );
},
$this->account->get_accounts()
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback for creating or linking an account, overwriting the website claim during the claim step.
*
* @return callable
*/
protected function overwrite_claim_callback(): callable {
return $this->setup_account_callback( 'overwrite_claim' );
}
/**
* Get the callback for creating or linking an account, switching the URL during the set_id step.
*
* @return callable
*/
protected function switch_url_callback(): callable {
return $this->setup_account_callback( 'switch_url' );
}
/**
* Get the callback function for creating or linking an account.
*
* @param string $action Action to call while setting up account (default is normal setup).
* @return callable
*/
protected function setup_account_callback( string $action = 'setup_account' ): callable {
return function ( Request $request ) use ( $action ) {
try {
$account_id = absint( $request['id'] );
if ( $account_id && 'setup_account' === $action ) {
$this->account->use_existing_account_id( $account_id );
}
$account = $this->account->{$action}( $account_id );
return $this->prepare_item_for_response( $account, $request );
} catch ( ApiNotReady $e ) {
return $this->get_time_to_wait_response( $e );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the connected merchant account.
*
* @return callable
*/
protected function get_connected_merchant_callback(): callable {
return function () {
return $this->account->get_connected_status();
};
}
/**
* Get the callback function for the merchant setup status.
*
* @return callable
*/
protected function get_setup_merchant_callback(): callable {
return function () {
return $this->account->get_setup_status();
};
}
/**
* Get the callback function for disconnecting a merchant.
*
* @return callable
*/
protected function disconnect_merchant_callback(): callable {
return function () {
$this->account->disconnect();
return [
'status' => 'success',
'message' => __( 'Merchant Center account successfully disconnected.', 'google-listings-and-ads' ),
];
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'id' => [
'type' => 'number',
'description' => __( 'Merchant Center Account ID.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => false,
],
'subaccount' => [
'type' => 'boolean',
'description' => __( 'Is a MCA sub account.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'name' => [
'type' => 'string',
'description' => __( 'The Merchant Center Account name.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'required' => false,
],
'domain' => [
'type' => 'string',
'description' => __( 'The domain registered with the Merchant Center Account.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'account';
}
/**
* Return a 503 Response with Retry-After header and message.
*
* @param ApiNotReady $wait Exception containing the time to wait.
*
* @return Response
*/
private function get_time_to_wait_response( ApiNotReady $wait ): Response {
$data = $wait->get_response_data( true );
return new Response(
$data,
$wait->getCode() ?: 503,
[
'Retry-After' => $data['retry_after'],
]
);
}
}
API/Site/Controllers/MerchantCenter/AttributeMappingCategoriesController.php 0000644 00000006520 15153721356 0023346 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class for handling API requests for getting category tree in Attribute Mapping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class AttributeMappingCategoriesController extends BaseController {
/**
* AttributeMappingCategoriesController constructor.
*
* @param RESTServer $server
*/
public function __construct( RESTServer $server ) {
parent::__construct( $server );
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/mapping/categories',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_categories_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Callback function for getting the category tree
*
* @return callable
*/
protected function get_categories_callback(): callable {
return function ( Request $request ) {
try {
$cats = $this->get_category_tree();
return array_map(
function ( $cats ) use ( $request ) {
$response = $this->prepare_item_for_response( $cats, $request );
return $this->prepare_response_for_collection( $response );
},
$cats
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema properties for the controller.
*
* @return array The Schema properties
*/
protected function get_schema_properties(): array {
return [
'id' => [
'description' => __( 'The Category ID.', 'google-listings-and-ads' ),
'type' => 'integer',
'validate_callback' => 'rest_validate_request_arg',
'readonly' => true,
],
'name' => [
'description' => __( 'The category name.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'readonly' => true,
],
'parent' => [
'description' => __( 'The category parent.', 'google-listings-and-ads' ),
'type' => 'integer',
'validate_callback' => 'rest_validate_request_arg',
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'attribute_mapping_categories';
}
/**
* Function to get all the categories
*
* @return array The categories
*/
private function get_category_tree(): array {
$categories = get_categories(
[
'taxonomy' => 'product_cat',
'hide_empty' => false,
]
);
return array_map(
function ( $category ) {
return [
'id' => $category->term_id,
'name' => $category->name,
'parent' => $category->parent,
];
},
$categories
);
}
}
API/Site/Controllers/MerchantCenter/BatchShippingTrait.php 0000644 00000002273 15153721356 0017545 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Trait BatchShippingTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
trait BatchShippingTrait {
/**
* Get the callback for deleting shipping items via batch.
*
* @return callable
*/
protected function get_batch_delete_shipping_callback(): callable {
return function ( Request $request ) {
$country_codes = $request->get_param( 'country_codes' );
$responses = [];
$errors = [];
foreach ( $country_codes as $country_code ) {
$route = "/{$this->get_namespace()}/{$this->route_base}/{$country_code}";
$delete_request = new Request( 'DELETE', $route );
$response = $this->server->dispatch_request( $delete_request );
if ( 200 !== $response->get_status() ) {
$errors[] = $response->get_data();
} else {
$responses[] = $response->get_data();
}
}
return new Response(
[
'errors' => $errors,
'success' => $responses,
],
);
};
}
}
API/Site/Controllers/MerchantCenter/ConnectionController.php 0000644 00000003307 15153721356 0020160 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
defined( 'ABSPATH' ) || exit;
/**
* Class ConnectionController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ConnectionController extends BaseController {
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/connect',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_connect_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for the connection request.
*
* @return callable
*/
protected function get_connect_callback(): callable {
return function () {
return [
'url' => 'example.com',
];
};
}
/**
* Get the schema for settings endpoints.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'url' => [
'description' => __( 'Action that should be completed after connection.', 'google-listings-and-ads' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'merchant_center_connection';
}
}
API/Site/Controllers/MerchantCenter/ContactInformationController.php 0000644 00000023506 15153721356 0021665 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\ContactInformation;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\AddressUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PhoneNumber;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountBusinessInformation;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class ContactInformationController
*
* @since 1.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ContactInformationController extends BaseOptionsController {
/**
* @var ContactInformation $contact_information
*/
protected $contact_information;
/**
* @var Settings
*/
protected $settings;
/**
* @var AddressUtility
*/
protected $address_utility;
/**
* ContactInformationController constructor.
*
* @param RESTServer $server
* @param ContactInformation $contact_information
* @param Settings $settings
* @param AddressUtility $address_utility
*/
public function __construct( RESTServer $server, ContactInformation $contact_information, Settings $settings, AddressUtility $address_utility ) {
parent::__construct( $server );
$this->contact_information = $contact_information;
$this->settings = $settings;
$this->address_utility = $address_utility;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/contact-information',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_contact_information_endpoint_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_contact_information_endpoint_edit_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_update_args(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get a callback for the contact information endpoint.
*
* @return callable
*/
protected function get_contact_information_endpoint_read_callback(): callable {
return function ( Request $request ) {
try {
return $this->get_contact_information_response(
$this->contact_information->get_contact_information(),
$request
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get a callback for the edit contact information endpoint.
*
* @return callable
*/
protected function get_contact_information_endpoint_edit_callback(): callable {
return function ( Request $request ) {
try {
return $this->get_contact_information_response(
$this->contact_information->update_address_based_on_store_settings(),
$request
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the schema for contact information endpoints.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'id' => [
'type' => 'integer',
'description' => __( 'The Merchant Center account ID.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
],
'phone_number' => [
'type' => 'string',
'description' => __( 'The phone number associated with the Merchant Center account.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'phone_verification_status' => [
'type' => 'string',
'description' => __( 'The verification status of the phone number associated with the Merchant Center account.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'enum' => [ 'verified', 'unverified' ],
],
'mc_address' => [
'type' => 'object',
'description' => __( 'The address associated with the Merchant Center account.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'properties' => $this->get_address_schema(),
],
'wc_address' => [
'type' => 'object',
'description' => __( 'The WooCommerce store address.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'properties' => $this->get_address_schema(),
],
'is_mc_address_different' => [
'type' => 'boolean',
'description' => __( 'Whether the Merchant Center account address is different than the WooCommerce store address.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'wc_address_errors' => [
'type' => 'array',
'description' => __( 'The errors associated with the WooCommerce address', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
];
}
/**
* Get the schema for addresses returned by the contact information endpoints.
*
* @return array[]
*/
protected function get_address_schema(): array {
return [
'street_address' => [
'description' => __( 'Street-level part of the address.', 'google-listings-and-ads' ),
'type' => 'string',
'context' => [ 'view' ],
],
'locality' => [
'description' => __( 'City, town or commune. May also include dependent localities or sublocalities (e.g. neighborhoods or suburbs).', 'google-listings-and-ads' ),
'type' => 'string',
'context' => [ 'view' ],
],
'region' => [
'description' => __( 'Top-level administrative subdivision of the country. For example, a state like California ("CA") or a province like Quebec ("QC").', 'google-listings-and-ads' ),
'type' => 'string',
'context' => [ 'view' ],
],
'postal_code' => [
'description' => __( 'Postal code or ZIP (e.g. "94043").', 'google-listings-and-ads' ),
'type' => 'string',
'context' => [ 'view' ],
],
'country' => [
'description' => __( 'CLDR country code (e.g. "US").', 'google-listings-and-ads' ),
'type' => 'string',
'context' => [ 'view' ],
],
];
}
/**
* Get the arguments for the update endpoint.
*
* @return array
*/
public function get_update_args(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
];
}
/**
* Get the prepared REST response with Merchant Center account ID and contact information.
*
* @param AccountBusinessInformation|null $contact_information
* @param Request $request
*
* @return Response
*/
protected function get_contact_information_response( ?AccountBusinessInformation $contact_information, Request $request ): Response {
$phone_number = null;
$phone_verification_status = null;
$mc_address = null;
$wc_address = null;
$is_address_diff = false;
if ( $this->settings->get_store_address() instanceof AccountAddress ) {
$wc_address = $this->settings->get_store_address();
$is_address_diff = true;
}
if ( $contact_information instanceof AccountBusinessInformation ) {
if ( ! empty( $contact_information->getPhoneNumber() ) ) {
try {
$phone_number = PhoneNumber::cast( $contact_information->getPhoneNumber() )->get();
$phone_verification_status = strtolower( $contact_information->getPhoneVerificationStatus() );
} catch ( InvalidValue $exception ) {
// log and fail silently
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
}
}
if ( $contact_information->getAddress() instanceof AccountAddress ) {
$mc_address = $contact_information->getAddress();
$is_address_diff = true;
}
if ( null !== $mc_address && null !== $wc_address ) {
$is_address_diff = ! $this->address_utility->compare_addresses( $contact_information->getAddress(), $this->settings->get_store_address() );
}
}
$wc_address_errors = $this->settings->wc_address_errors( $wc_address );
return $this->prepare_item_for_response(
[
'id' => $this->options->get_merchant_id(),
'phone_number' => $phone_number,
'phone_verification_status' => $phone_verification_status,
'mc_address' => self::serialize_address( $mc_address ),
'wc_address' => self::serialize_address( $wc_address ),
'is_mc_address_different' => $is_address_diff,
'wc_address_errors' => $wc_address_errors,
],
$request
);
}
/**
* @param AccountAddress|null $address
*
* @return array|null
*/
protected static function serialize_address( ?AccountAddress $address ): ?array {
if ( null === $address ) {
return null;
}
return [
'street_address' => $address->getStreetAddress(),
'locality' => $address->getLocality(),
'region' => $address->getRegion(),
'postal_code' => $address->getPostalCode(),
'country' => $address->getCountry(),
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'merchant_center_contact_information';
}
}
API/Site/Controllers/MerchantCenter/IssuesController.php 0000644 00000015561 15153721356 0017341 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class IssuesController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class IssuesController extends BaseOptionsController {
/**
* @var MerchantStatuses
*/
protected $merchant_statuses;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* IssuesController constructor.
*
* @param RESTServer $server
* @param MerchantStatuses $merchant_statuses
* @param ProductHelper $product_helper
*/
public function __construct( RESTServer $server, MerchantStatuses $merchant_statuses, ProductHelper $product_helper ) {
parent::__construct( $server );
$this->merchant_statuses = $merchant_statuses;
$this->product_helper = $product_helper;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/issues(/(?P<type_filter>[a-z]+))?',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_issues_read_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Get the callback function for returning account and product issues.
*
* @return callable
*/
protected function get_issues_read_callback(): callable {
return function ( Request $request ) {
$type_filter = $request['type_filter'];
$per_page = intval( $request['per_page'] );
$page = max( 1, intval( $request['page'] ) );
try {
$results = $this->merchant_statuses->get_issues( $type_filter, $per_page, $page );
$results['page'] = $page;
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
// Replace variation IDs with parent ID (for Edit links).
foreach ( $results['issues'] as &$issue ) {
$issue = apply_filters( 'woocommerce_gla_merchant_issue_override', $issue );
if ( empty( $issue['product_id'] ) ) {
continue;
}
try {
$issue['product_id'] = $this->product_helper->maybe_swap_for_parent_id( $issue['product_id'] );
} catch ( InvalidValue $e ) {
// Don't include invalid products
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Merchant Center product ID %s not found in this WooCommerce store.', $issue['product_id'] ),
__METHOD__,
);
continue;
}
}
return $this->prepare_item_for_response( $results, $request );
};
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'issues' => [
'type' => 'array',
'description' => __( 'The issues related to the Merchant Center account.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'description' => __( 'Issue type.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'product' => [
'type' => 'string',
'description' => __( 'Affected product.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'product_id' => [
'type' => 'numeric',
'description' => __( 'The WooCommerce product ID.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'code' => [
'type' => 'string',
'description' => __( 'Internal Google code for issue.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'issue' => [
'type' => 'string',
'description' => __( 'Descriptive text of the issue.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'action' => [
'type' => 'string',
'description' => __( 'Descriptive text of action to take.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'action_url' => [
'type' => 'string',
'description' => __( 'Documentation URL for issue and/or action.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'severity' => [
'type' => 'string',
'description' => __( 'Severity level of the issue: warning or error.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'applicable_countries' => [
'type' => 'array',
'description' => __( 'Country codes of the product audience.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
],
],
],
'total' => [
'type' => 'numeric',
'context' => [ 'view' ],
'readonly' => true,
],
'page' => [
'type' => 'numeric',
'context' => [ 'view' ],
'readonly' => true,
],
'loading' => [
'type' => 'boolean',
'description' => __( 'Whether the product issues are loading.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'page' => [
'description' => __( 'Page of data to retrieve.', 'google-listings-and-ads' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
'per_page' => [
'description' => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ),
'type' => 'integer',
'default' => 0,
'minimum' => 0,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'merchant_issues';
}
}
API/Site/Controllers/MerchantCenter/PhoneVerificationController.php 0000644 00000012051 15153721356 0021471 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PhoneVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PhoneNumber;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class PhoneVerificationController
*
* @since 1.5.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class PhoneVerificationController extends BaseOptionsController {
use EmptySchemaPropertiesTrait;
/**
* @var PhoneVerification
*/
protected $phone_verification;
/**
* PhoneVerificationController constructor.
*
* @param RESTServer $server
* @param PhoneVerification $phone_verification Phone verification service.
*/
public function __construct( RESTServer $server, PhoneVerification $phone_verification ) {
parent::__construct( $server );
$this->phone_verification = $phone_verification;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$verification_method = [
'description' => __( 'Method used to verify the phone number.', 'google-listings-and-ads' ),
'enum' => [
PhoneVerification::VERIFICATION_METHOD_SMS,
PhoneVerification::VERIFICATION_METHOD_PHONE_CALL,
],
'required' => true,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
];
$this->register_route(
'/mc/phone-verification/request',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_request_phone_verification_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => [
'phone_region_code' => [
'description' => __( 'Two-letter country code (ISO 3166-1 alpha-2) for the phone number.', 'google-listings-and-ads' ),
'required' => true,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'phone_number' => [
'description' => __( 'The phone number to verify.', 'google-listings-and-ads' ),
'required' => true,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'verification_method' => $verification_method,
],
],
]
);
$this->register_route(
'/mc/phone-verification/verify',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_verify_phone_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => [
'verification_id' => [
'description' => __( 'The verification ID returned by the /request call.', 'google-listings-and-ads' ),
'required' => true,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'verification_code' => [
'description' => __( 'The verification code that was sent to the phone number for validation.', 'google-listings-and-ads' ),
'required' => true,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'verification_method' => $verification_method,
],
],
]
);
}
/**
* Get callback for requesting phone verification endpoint.
*
* @return callable
*/
protected function get_request_phone_verification_callback(): callable {
return function ( Request $request ) {
try {
$verification_id = $this->phone_verification->request_phone_verification(
$request->get_param( 'phone_region_code' ),
new PhoneNumber( $request->get_param( 'phone_number' ) ),
$request->get_param( 'verification_method' ),
);
return [
'verification_id' => $verification_id,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get callback for verifying a phone number.
*
* @return callable
*/
protected function get_verify_phone_callback(): callable {
return function ( Request $request ) {
try {
$this->phone_verification->verify_phone_number(
$request->get_param( 'verification_id' ),
$request->get_param( 'verification_code' ),
$request->get_param( 'verification_method' ),
);
return new Response( null, 204 );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema name for the controller.
*
* @return string
*/
protected function get_schema_title(): string {
return 'phone_verification';
}
}
API/Site/Controllers/MerchantCenter/PolicyComplianceCheckController.php 0000644 00000011104 15153721356 0022243 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PolicyComplianceCheck;
use Exception;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class PolicyComplianceCheckController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class PolicyComplianceCheckController extends BaseController {
use CountryCodeTrait;
/**
* The PolicyComplianceCheck object.
*
* @var PolicyComplianceCheck
*/
protected $policy_compliance_check;
/**
* PolicyComplianceCheckController constructor.
*
* @param RESTServer $server
* @param PolicyComplianceCheck $policy_compliance_check
*/
public function __construct( RESTServer $server, PolicyComplianceCheck $policy_compliance_check ) {
parent::__construct( $server );
$this->policy_compliance_check = $policy_compliance_check;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/policy_check',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_policy_check_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the allowed countries, payment gateways info, store ssl and refund return policy page for the controller.
*
* @return callable
*/
protected function get_policy_check_callback(): callable {
return function () {
try {
return new Response(
[
'allowed_countries' => $this->policy_compliance_check->is_accessible(),
'robots_restriction' => $this->policy_compliance_check->has_restriction(),
'page_not_found_error' => $this->policy_compliance_check->has_page_not_found_error(),
'page_redirects' => $this->policy_compliance_check->has_redirects(),
'payment_gateways' => $this->policy_compliance_check->has_payment_gateways(),
'store_ssl' => $this->policy_compliance_check->get_is_store_ssl(),
'refund_returns' => $this->policy_compliance_check->has_refund_return_policy_page(),
]
);
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the schema for policy compliance check endpoints.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'allowed_countries' => [
'type' => 'boolean',
'description' => __( 'The store website could be accessed or not by all users.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'robots_restriction' => [
'type' => 'boolean',
'description' => __( 'The merchant set the restrictions in robots.txt or not in the store.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'page_not_found_error' => [
'type' => 'boolean',
'description' => __( 'The sample of product landing pages leads to a 404 error.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'page_redirects' => [
'type' => 'boolean',
'description' => __( 'The sample of product landing pages have redirects through 3P domains.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'payment_gateways' => [
'type' => 'boolean',
'description' => __( 'The payment gateways associated with onboarding policy checking.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'store_ssl' => [
'type' => 'boolean',
'description' => __( 'The store ssl associated with onboarding policy checking.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'refund_returns' => [
'type' => 'boolean',
'description' => __( 'The refund returns policy associated with onboarding policy checking.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'schema' => $this->get_api_response_schema_callback(),
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'policy_check';
}
}
API/Site/Controllers/MerchantCenter/ProductFeedController.php 0000644 00000014342 15153721356 0020266 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductFeedQueryHelper;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductFeedController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ProductFeedController extends BaseController {
/**
* @var ProductFeedQueryHelper
*/
protected $query_helper;
/**
* ProductFeedController constructor.
*
* @param RESTServer $server
* @param ProductFeedQueryHelper $query_helper
*/
public function __construct( RESTServer $server, ProductFeedQueryHelper $query_helper ) {
parent::__construct( $server );
$this->query_helper = $query_helper;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/product-feed',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_product_feed_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Get the callback function for returning the product feed.
*
* @return callable
*/
protected function get_product_feed_read_callback(): callable {
return function ( Request $request ) {
try {
return [
'products' => $this->query_helper->get( $request ),
'total' => $this->query_helper->count( $request ),
'page' => $request['per_page'] > 0 && $request['page'] > 0 ? $request['page'] : 1,
];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'products' => [
'type' => 'array',
'description' => __( 'The store\'s products.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'numeric',
'description' => __( 'Product ID.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'title' => [
'type' => 'string',
'description' => __( 'Product title.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'visible' => [
'type' => 'boolean',
'description' => __( 'Whether the product is set to be visible in the Merchant Center', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'status' => [
'type' => 'string',
'description' => __( 'The current sync status of the product.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'image_url' => [
'type' => 'string',
'description' => __( 'The image url of the product.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'price' => [
'type' => 'string',
'description' => __( 'The price of the product.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'errors' => [
'type' => 'array',
'description' => __( 'Errors preventing the product from being synced to the Merchant Center.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
],
],
],
'total' => [
'type' => 'numeric',
'context' => [ 'view' ],
'readonly' => true,
],
'page' => [
'type' => 'numeric',
'context' => [ 'view' ],
'readonly' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'product_feed';
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'page' => [
'description' => __( 'Page of data to retrieve.', 'google-listings-and-ads' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
'per_page' => [
'description' => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ),
'type' => 'integer',
'default' => 0,
'minimum' => 0,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
'search' => [
'description' => __( 'Text to search for in product names.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'ids' => [
'description' => __( 'Limit result to items with specified ids (comma-separated).', 'google-listings-and-ads' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'integer',
],
],
'orderby' => [
'description' => __( 'Sort collection by attribute.', 'google-listings-and-ads' ),
'type' => 'string',
'default' => 'title',
'enum' => [ 'title', 'id', 'visible', 'status' ],
'validate_callback' => 'rest_validate_request_arg',
],
'order' => [
'description' => __( 'Order sort attribute ascending or descending.', 'google-listings-and-ads' ),
'type' => 'string',
'default' => 'ASC',
'enum' => [ 'ASC', 'DESC' ],
'validate_callback' => 'rest_validate_request_arg',
],
];
}
}
API/Site/Controllers/MerchantCenter/ProductStatisticsController.php 0000644 00000013475 15153721356 0021563 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use WP_REST_Response as Response;
use WP_REST_Request as Request;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductStatisticsController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ProductStatisticsController extends BaseOptionsController {
/**
* The MerchantProducts object.
*
* @var MerchantStatuses
*/
protected $merchant_statuses;
/**
* Helper class to count scheduled sync jobs.
*
* @var ProductSyncStats
*/
protected $sync_stats;
/**
* ProductStatisticsController constructor.
*
* @param RESTServer $server
* @param MerchantStatuses $merchant_statuses
* @param ProductSyncStats $sync_stats
*/
public function __construct( RESTServer $server, MerchantStatuses $merchant_statuses, ProductSyncStats $sync_stats ) {
parent::__construct( $server );
$this->merchant_statuses = $merchant_statuses;
$this->sync_stats = $sync_stats;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/product-statistics',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_product_statistics_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
$this->register_route(
'mc/product-statistics/refresh',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_product_statistics_refresh_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Get the callback function for returning product statistics.
*
* @return callable
*/
protected function get_product_statistics_read_callback(): callable {
return function ( Request $request ) {
return $this->get_product_status_stats( $request );
};
}
/**
* Get the callback function for getting re-calculated product statistics.
*
* @return callable
*/
protected function get_product_statistics_refresh_callback(): callable {
return function ( Request $request ) {
return $this->get_product_status_stats( $request, true );
};
}
/**
* Get the overall product status statistics array.
*
* @param Request $request
* @param bool $force_refresh True to force a refresh of the product status statistics.
*
* @return Response
*/
protected function get_product_status_stats( Request $request, bool $force_refresh = false ): Response {
try {
$response = $this->merchant_statuses->get_product_statistics( $force_refresh );
$response['scheduled_sync'] = $this->sync_stats->get_count();
return $this->prepare_item_for_response( $response, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'timestamp' => [
'type' => 'number',
'description' => __( 'Timestamp reflecting when the product status statistics were last generated.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'statistics' => [
'type' => 'object',
'description' => __( 'Merchant Center product status statistics.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
'properties' => [
'active' => [
'type' => 'integer',
'description' => __( 'Active products.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'expiring' => [
'type' => 'integer',
'description' => __( 'Expiring products.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'pending' => [
'type' => 'number',
'description' => __( 'Pending products.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'disapproved' => [
'type' => 'number',
'description' => __( 'Disapproved products.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'not_synced' => [
'type' => 'number',
'description' => __( 'Products not uploaded.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
],
],
'scheduled_sync' => [
'type' => 'number',
'description' => __( 'Amount of scheduled jobs which will sync products to Google.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'loading' => [
'type' => 'boolean',
'description' => __( 'Whether the product statistics are loading.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'error' => [
'type' => 'string',
'description' => __( 'Error message in case of failure', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
'default' => null,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'product_statistics';
}
}
API/Site/Controllers/MerchantCenter/ProductVisibilityController.php 0000644 00000013050 15153721356 0021545 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantIssueQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductVisibilityController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ProductVisibilityController extends BaseController {
use PluginHelper;
/**
* @var ProductHelper $product_helper
*/
protected $product_helper;
/**
* @var MerchantIssueQuery $issue_query
*/
protected $issue_query;
/**
* ProductVisibilityController constructor.
*
* @param RESTServer $server
* @param ProductHelper $product_helper
* @param MerchantIssueQuery $issue_query
*/
public function __construct( RESTServer $server, ProductHelper $product_helper, MerchantIssueQuery $issue_query ) {
parent::__construct( $server );
$this->product_helper = $product_helper;
$this->issue_query = $issue_query;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/product-visibility',
[
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->get_update_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_update_args(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get a callback for updating products' channel visibility.
*
* @return callable
*/
protected function get_update_callback(): callable {
return function ( Request $request ) {
$ids = $request->get_param( 'ids' );
$visible = $request->get_param( 'visible' );
$success = [];
$errors = [];
foreach ( $ids as $product_id ) {
$product_id = intval( $product_id );
if ( ! $this->change_product_visibility( $product_id, $visible ) ) {
$errors[] = $product_id;
continue;
}
if ( ! $visible ) {
$this->issue_query->delete( 'product_id', $product_id );
}
$success[] = $product_id;
}
sort( $success );
sort( $errors );
return new Response(
[
'success' => $success,
'errors' => $errors,
],
count( $errors ) ? 400 : 200
);
};
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'success' => [
'type' => 'array',
'description' => __( 'Products whose visibility was changed successfully.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'numeric',
],
],
'errors' => [
'type' => 'array',
'description' => __( 'Products whose visibility was not changed.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'numeric',
],
],
];
}
/**
* Get the arguments for the update endpoint.
*
* @return array
*/
public function get_update_args(): array {
return [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'ids' => [
'description' => __( 'IDs of the products to update.', 'google-listings-and-ads' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_slug_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'integer',
],
],
'visible' => [
'description' => __( 'New Visibility status for the specified products.', 'google-listings-and-ads' ),
'type' => 'boolean',
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'product_visibility';
}
/**
* Update a product's Merchant Center visibility setting (or parent product, for variations).
*
* @param int $product_id
* @param bool $new_visibility True for visible, false for not visible.
*
* @return bool True if the product was found and updated correctly.
*/
protected function change_product_visibility( int $product_id, bool $new_visibility ): bool {
try {
$product = $this->product_helper->get_wc_product( $product_id );
$product = $this->product_helper->maybe_swap_for_parent( $product );
// Use $product->save() instead of ProductMetaHandler to trigger MC sync.
$product->update_meta_data(
$this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY ),
$new_visibility ? ChannelVisibility::SYNC_AND_SHOW : ChannelVisibility::DONT_SYNC_AND_SHOW
);
$product->save();
return true;
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
return false;
}
}
}
API/Site/Controllers/MerchantCenter/ReportsController.php 0000644 00000012522 15153721356 0017516 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantReport;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseReportsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Exception;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class ReportsController
*
* ContainerAware used for:
* - MerchantReport
* - WP (in parent class)
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ReportsController extends BaseReportsController {
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/reports/programs',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_programs_report_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'mc/reports/products',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_products_report_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_collection_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for the programs report request.
*
* @return callable
*/
protected function get_programs_report_callback(): callable {
return function ( Request $request ) {
try {
/** @var MerchantReport $merchant */
$merchant = $this->container->get( MerchantReport::class );
$data = $merchant->get_report_data( 'free_listings', $this->prepare_query_arguments( $request ) );
return $this->prepare_item_for_response( $data, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the products report request.
*
* @return callable
*/
protected function get_products_report_callback(): callable {
return function ( Request $request ) {
try {
/** @var MerchantReport $merchant */
$merchant = $this->container->get( MerchantReport::class );
$data = $merchant->get_report_data( 'products', $this->prepare_query_arguments( $request ) );
return $this->prepare_item_for_response( $data, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params(): array {
$params = parent::get_collection_params();
$params['interval'] = [
'description' => __( 'Time interval to use for segments in the returned data.', 'google-listings-and-ads' ),
'type' => 'string',
'enum' => [
'day',
],
'validate_callback' => 'rest_validate_request_arg',
];
return $params;
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'free_listings' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'subtotals' => $this->get_totals_schema(),
],
],
],
'products' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'string',
'description' => __( 'Product ID.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'name' => [
'type' => 'string',
'description' => __( 'Product name.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
],
'subtotals' => $this->get_totals_schema(),
],
],
],
'intervals' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'interval' => [
'type' => 'string',
'description' => __( 'ID of this report segment.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'subtotals' => $this->get_totals_schema(),
],
],
],
'totals' => $this->get_totals_schema(),
'next_page' => [
'type' => 'string',
'description' => __( 'Token to retrieve the next page of results.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
];
}
/**
* Return schema for total fields.
*
* @return array
*/
protected function get_totals_schema(): array {
return [
'type' => 'object',
'properties' => [
'clicks' => [
'type' => 'integer',
'description' => __( 'Clicks.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'impressions' => [
'type' => 'integer',
'description' => __( 'Impressions.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'reports';
}
}
API/Site/Controllers/MerchantCenter/RequestReviewController.php 0000644 00000024531 15153721356 0020675 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\RequestReviewStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class RequestReviewController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class RequestReviewController extends BaseOptionsController {
/**
* @var TransientsInterface
*/
private $transients;
/**
* RequestReviewController constructor.
*
* @param RESTServer $server
* @param Middleware $middleware
* @param Merchant $merchant
* @param RequestReviewStatuses $request_review_statuses
* @param TransientsInterface $transients
*/
public function __construct( RESTServer $server, Middleware $middleware, Merchant $merchant, RequestReviewStatuses $request_review_statuses, TransientsInterface $transients ) {
parent::__construct( $server );
$this->middleware = $middleware;
$this->merchant = $merchant;
$this->request_review_statuses = $request_review_statuses;
$this->transients = $transients;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
/**
* GET information regarding the current Account Status
*/
$this->register_route(
'mc/review',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_review_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
/**
* POST a request review for the current account
*/
$this->register_route(
'mc/review',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->post_review_request_callback(),
'permission_callback' => $this->get_permission_callback(),
],
],
);
}
/**
* Get the callback function for returning the review status.
*
* @return callable
*/
protected function get_review_read_callback(): callable {
return function ( Request $request ) {
try {
return $this->prepare_item_for_response( $this->get_review_status(), $request );
} catch ( Exception $e ) {
return new Response( [ 'message' => $e->getMessage() ], $e->getCode() ?: 400 );
}
};
}
/**
* Get the callback function after requesting a review.
*
* @return callable
*/
protected function post_review_request_callback(): callable {
return function () {
try {
// getting the current account status
$account_review_status = $this->get_review_status();
// Abort if it's in cool down period
if ( $account_review_status['cooldown'] ) {
do_action(
'woocommerce_gla_request_review_failure',
[
'error' => 'cooldown',
'account_review_status' => $account_review_status,
]
);
throw new Exception( __( 'Your account is under cool down period and cannot request a new review.', 'google-listings-and-ads' ), 400 );
}
// Abort if there is no eligible region available
if ( ! count( $account_review_status['reviewEligibleRegions'] ) ) {
do_action(
'woocommerce_gla_request_review_failure',
[
'error' => 'ineligible',
'account_review_status' => $account_review_status,
]
);
throw new Exception( __( 'Your account is not eligible for a new request review.', 'google-listings-and-ads' ), 400 );
}
$this->account_request_review( $account_review_status['reviewEligibleRegions'] );
return $this->set_under_review_status();
} catch ( Exception $e ) {
/**
* Catch potential errors in any specific region API call.
*
* Notice due some inconsistencies with Google API we are not considering [Bad Request -> ...already under review...]
* as an exception. This is because we suspect that calling the API of a region is triggering other regions requests as well.
* This makes all the calls after the first to fail as they will be under review.
*
* The undesired call of this function for accounts under review is already prevented in a previous stage, so, there is no danger doing this.
*/
if ( strpos( $e->getMessage(), 'under review' ) !== false ) {
return $this->set_under_review_status();
}
return new Response( [ 'message' => $e->getMessage() ], $e->getCode() ?: 400 );
}
};
}
/**
* Set Under review Status in the cache and return the response
*
* @return Response With the Under review status
*/
private function set_under_review_status() {
$new_status = [
'issues' => [],
'cooldown' => 0,
'status' => $this->request_review_statuses::UNDER_REVIEW,
'reviewEligibleRegions' => [],
];
// Update Account status when successful response
$this->set_cached_review_status( $new_status );
return new Response( $new_status );
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'status' => [
'type' => 'string',
'description' => __( 'The status of the last review.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'cooldown' => [
'type' => 'integer',
'description' => __( 'Timestamp indicating if the user is in cool down period.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'issues' => [
'type' => 'array',
'description' => __( 'The issues related to the Merchant Center to be reviewed and addressed before approval.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'string',
],
],
'reviewEligibleRegions' => [
'type' => 'array',
'description' => __( 'The region codes in which is allowed to request a new review.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
'items' => [
'type' => 'string',
],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'merchant_account_review';
}
/**
* Save the Account Review Status data inside a transient for caching purposes.
*
* @param array $value The Account Review Status data to save in the transient
*/
private function set_cached_review_status( $value ): void {
$this->transients->set(
TransientsInterface::MC_ACCOUNT_REVIEW,
$value,
$this->request_review_statuses->get_account_review_lifetime()
);
}
/**
* Get the Account Review Status data inside a transient for caching purposes.
*
* @return null|array Returns NULL in case no data is available or an array with the Account Review Status data otherwise.
*/
private function get_cached_review_status(): ?array {
return $this->transients->get(
TransientsInterface::MC_ACCOUNT_REVIEW,
);
}
/**
* Get the Account Review Status. We attempt to get the cached version or create a request otherwise.
*
* @return null|array Returns NULL in case no data is available or an array with the Account Review Status data otherwise.
* @throws Exception If the get_account_review_status API call fails.
*/
private function get_review_status(): ?array {
$review_status = $this->get_cached_review_status();
if ( is_null( $review_status ) ) {
$response = $this->get_account_review_status();
$review_status = $this->request_review_statuses->get_statuses_from_response( $response );
$this->set_cached_review_status( $review_status );
}
return $review_status;
}
/**
* Get Account Review Status
*
* @return array the response data
* @throws Exception When there is an invalid response.
*/
public function get_account_review_status() {
try {
if ( ! $this->middleware->is_subaccount() ) {
return [];
}
$response = $this->merchant->get_account_review_status();
do_action( 'woocommerce_gla_request_review_response', $response );
return $response;
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
throw new Exception(
$e->getMessage() ?? __( 'Error getting account review status.', 'google-listings-and-ads' ),
$e->getCode()
);
}
}
/**
* Request a new account review
*
* @param array $regions Regions to request a review.
* @return array With a successful message
* @throws Exception When there is an invalid response.
*/
public function account_request_review( $regions ) {
try {
// For each region we request a new review
foreach ( $regions as $region_code => $region_types ) {
$result = $this->merchant->account_request_review( $region_code, $region_types );
if ( 200 !== $result->getStatusCode() ) {
do_action(
'woocommerce_gla_request_review_failure',
[
'error' => 'response',
'region_code' => $region_code,
'response' => $result,
]
);
do_action( 'woocommerce_gla_guzzle_invalid_response', $result, __METHOD__ );
$error = $response['message'] ?? __( 'Invalid response getting requesting a new review.', 'google-listings-and-ads' );
throw new Exception( $error, $result->getStatusCode() );
}
}
// Otherwise, return a successful message and update the account status
return [
'message' => __( 'A new review has been successfully requested', 'google-listings-and-ads' ),
];
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
throw new Exception(
$e->getMessage() ?? __( 'Error requesting a new review.', 'google-listings-and-ads' ),
$e->getCode()
);
}
}
}
API/Site/Controllers/MerchantCenter/SettingsController.php 0000644 00000012231 15153721356 0017655 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class SettingsController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class SettingsController extends BaseOptionsController {
/**
* @var ShippingZone
*/
protected $shipping_zone;
/**
* SettingsController constructor.
*
* @param RESTServer $server
* @param ShippingZone $shipping_zone
*/
public function __construct( RESTServer $server, ShippingZone $shipping_zone ) {
parent::__construct( $server );
$this->shipping_zone = $shipping_zone;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/settings',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_settings_endpoint_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->get_settings_endpoint_edit_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get a callback for the settings endpoint.
*
* @return callable
*/
protected function get_settings_endpoint_read_callback(): callable {
return function () {
$data = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
$data['shipping_rates_count'] = $this->shipping_zone->get_shipping_rates_count();
$schema = $this->get_schema_properties();
$items = [];
foreach ( $schema as $key => $property ) {
$items[ $key ] = $data[ $key ] ?? $property['default'] ?? null;
}
return $items;
};
}
/**
* Get a callback for editing the settings endpoint.
*
* @return callable
*/
protected function get_settings_endpoint_edit_callback(): callable {
return function ( Request $request ) {
$schema = $this->get_schema_properties();
$options = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
if ( ! is_array( $options ) ) {
$options = [];
}
foreach ( $schema as $key => $property ) {
if ( ! in_array( 'edit', $property['context'] ?? [], true ) ) {
continue;
}
$options[ $key ] = $request->get_param( $key ) ?? $options[ $key ] ?? $property['default'] ?? null;
}
$this->options->update( OptionsInterface::MERCHANT_CENTER, $options );
return [
'status' => 'success',
'message' => __( 'Merchant Center Settings successfully updated.', 'google-listings-and-ads' ),
'data' => $options,
];
};
}
/**
* Get the schema for settings endpoints.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'shipping_rate' => [
'type' => 'string',
'description' => __(
'Whether shipping rate is a simple flat rate or needs to be configured manually in the Merchant Center.',
'google-listings-and-ads'
),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'enum' => [
'automatic',
'flat',
'manual',
],
],
'shipping_time' => [
'type' => 'string',
'description' => __(
'Whether shipping time is a simple flat time or needs to be configured manually in the Merchant Center.',
'google-listings-and-ads'
),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'enum' => [
'flat',
'manual',
],
],
'tax_rate' => [
'type' => 'string',
'description' => __(
'Whether tax rate is destination based or need to be configured manually in the Merchant Center.',
'google-listings-and-ads'
),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'enum' => [
'destination',
'manual',
],
'default' => 'destination',
],
'shipping_rates_count' => [
'type' => 'number',
'description' => __(
'The number of shipping rates in WC ready to be used in the Merchant Center.',
'google-listings-and-ads'
),
'context' => [ 'view' ],
'validate_callback' => 'rest_validate_request_arg',
'default' => 0,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'merchant_center_settings';
}
}
API/Site/Controllers/MerchantCenter/SettingsSyncController.php 0000644 00000007063 15153721356 0020521 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\WPErrorTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class SettingsSyncController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class SettingsSyncController extends BaseController {
use EmptySchemaPropertiesTrait;
use WPErrorTrait;
/** @var Settings */
protected $settings;
/**
* SettingsSyncController constructor.
*
* @param RESTServer $server
* @param Settings $settings
*/
public function __construct( RESTServer $server, Settings $settings ) {
parent::__construct( $server );
$this->settings = $settings;
}
/**
* Registers the routes for the objects of the controller.
*/
public function register_routes() {
$this->register_route(
'mc/settings/sync',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_sync_endpoint_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback for syncing shipping.
*
* @return callable
*/
protected function get_sync_endpoint_callback(): callable {
return function ( Request $request ) {
try {
$this->settings->sync_taxes();
$this->settings->sync_shipping();
do_action( 'woocommerce_gla_mc_settings_sync' );
/**
* MerchantCenter onboarding has been successfully completed.
*
* @event gla_mc_setup_completed
* @property string shipping_rate Shipping rate setup `automatic`, `manual`, `flat`.
* @property bool offers_free_shipping Free Shipping is available.
* @property float free_shipping_threshold Minimum amount to avail of free shipping.
* @property string shipping_time Shipping time setup `flat`, `manual`.
* @property string tax_rate Tax rate setup `destination`, `manual`.
* @property string target_countries List of target countries or `all`.
*/
do_action(
'woocommerce_gla_track_event',
'mc_setup_completed',
$this->settings->get_settings_for_tracking()
);
return new Response(
[
'status' => 'success',
'message' => __( 'Successfully synchronized settings with Google.', 'google-listings-and-ads' ),
],
201
);
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
try {
$decoded = $this->json_decode_message( $e->getMessage() );
$data = [
'status' => $decoded['code'] ?? 500,
'message' => $decoded['message'] ?? '',
'data' => $decoded,
];
} catch ( Exception $e2 ) {
$data = [
'status' => 500,
];
}
return $this->error_from_exception(
$e,
'gla_setting_sync_error',
$data
);
}
};
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'settings_sync';
}
}
API/Site/Controllers/MerchantCenter/ShippingRateBatchController.php 0000644 00000010127 15153721356 0021416 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRateBatchController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ShippingRateBatchController extends ShippingRateController {
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
"{$this->route_base}/batch",
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_batch_create_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_batch_create_args_schema(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_batch_delete_shipping_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_batch_delete_args_schema(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback for creating items via batch.
*
* @return callable
*/
protected function get_batch_create_callback(): callable {
return function ( Request $request ) {
$rates = $request->get_param( 'rates' );
$responses = [];
$errors = [];
foreach ( $rates as $rate ) {
$new_request = new Request( 'POST', "/{$this->get_namespace()}/{$this->route_base}" );
$new_request->set_body_params( $rate );
$response = $this->server->dispatch_request( $new_request );
if ( 201 !== $response->get_status() ) {
$errors[] = $response->get_data();
} else {
$responses[] = $response->get_data();
}
}
return new Response(
[
'errors' => $errors,
'success' => $responses,
],
201
);
};
}
/**
* Get the callback for deleting shipping items via batch.
*
* @return callable
*
* @since 1.12.0
*/
protected function get_batch_delete_shipping_callback(): callable {
return function ( Request $request ) {
$ids = $request->get_param( 'ids' );
$responses = [];
$errors = [];
foreach ( $ids as $id ) {
$route = "/{$this->get_namespace()}/{$this->route_base}/{$id}";
$delete_request = new Request( 'DELETE', $route );
$response = $this->server->dispatch_request( $delete_request );
if ( 200 !== $response->get_status() ) {
$errors[] = $response->get_data();
} else {
$responses[] = $response->get_data();
}
}
return new Response(
[
'errors' => $errors,
'success' => $responses,
],
);
};
}
/**
* Get the argument schema for a batch create request.
*
* @return array
*
* @since 1.12.0
*/
protected function get_batch_create_args_schema(): array {
return [
'rates' => [
'type' => 'array',
'minItems' => 1,
'uniqueItems' => true,
'description' => __( 'Array of shipping rates to create.', 'google-listings-and-ads' ),
'validate_callback' => 'rest_validate_request_arg',
'items' => [
'type' => 'object',
'additionalProperties' => false,
'properties' => $this->get_schema_properties(),
],
],
];
}
/**
* Get the argument schema for a batch delete request.
*
* @return array
*
* @since 1.12.0
*/
protected function get_batch_delete_args_schema(): array {
return [
'ids' => [
'type' => 'array',
'description' => __( 'Array of unique shipping rate identification numbers.', 'google-listings-and-ads' ),
'context' => [ 'edit' ],
'minItems' => 1,
'required' => true,
'uniqueItems' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'batch_shipping_rates';
}
}
API/Site/Controllers/MerchantCenter/ShippingRateController.php 0000644 00000020133 15153721356 0020452 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\ShippingRateSchemaTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRateController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ShippingRateController extends BaseController implements ISO3166AwareInterface {
use ShippingRateSchemaTrait;
/**
* The base for routes in this controller.
*
* @var string
*/
protected $route_base = 'mc/shipping/rates';
/**
* @var ShippingRateQuery
*/
protected $query;
/**
* ShippingRateController constructor.
*
* @param RESTServer $server
* @param ShippingRateQuery $query
*/
public function __construct( RESTServer $server, ShippingRateQuery $query ) {
parent::__construct( $server );
$this->query = $query;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
$this->route_base,
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_read_all_rates_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_create_rate_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
"{$this->route_base}/(?P<id>[\d]+)",
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_read_rate_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => [ 'id' => $this->get_schema_properties()['id'] ],
],
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->get_update_rate_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_delete_rate_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => [ 'id' => $this->get_schema_properties()['id'] ],
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for returning the endpoint results.
*
* @return callable
*/
protected function get_read_all_rates_callback(): callable {
return function ( Request $request ) {
$rates = $this->get_all_shipping_rates();
return array_map(
function ( $rate ) use ( $request ) {
$response = $this->prepare_item_for_response( $rate, $request );
return $this->prepare_response_for_collection( $response );
},
$rates
);
};
}
/**
* @return callable
*/
protected function get_read_rate_callback(): callable {
return function ( Request $request ) {
$id = (string) $request->get_param( 'id' );
$rate = $this->get_shipping_rate_by_id( $id );
if ( empty( $rate ) ) {
return new Response(
[
'message' => __( 'No rate available.', 'google-listings-and-ads' ),
'id' => $id,
],
404
);
}
return $this->prepare_item_for_response( $rate, $request );
};
}
/**
* @return callable
*
* @since 1.12.0
*/
protected function get_update_rate_callback(): callable {
return function ( Request $request ) {
$id = (string) $request->get_param( 'id' );
$rate = $this->get_shipping_rate_by_id( $id );
if ( empty( $rate ) ) {
return new Response(
[
'message' => __( 'No rate found with the given ID.', 'google-listings-and-ads' ),
'id' => $id,
],
404
);
}
$data = $this->prepare_item_for_database( $request );
$this->create_query()->update(
$data,
[
'id' => $id,
]
);
return new Response( '', 204 );
};
}
/**
* Get the callback function for creating a new shipping rate.
*
* @return callable
*/
protected function get_create_rate_callback(): callable {
return function ( Request $request ) {
$shipping_rate_query = $this->create_query();
try {
$data = $this->prepare_item_for_database( $request );
$country = $data['country'];
$existing_query = $this->create_query()->where( 'country', $country );
$existing = ! empty( $existing_query->get_results() );
if ( $existing ) {
$rate_id = $existing_query->get_results()[0]['id'];
$shipping_rate_query->update( $data, [ 'id' => $rate_id ] );
} else {
$shipping_rate_query->insert( $data );
$rate_id = $shipping_rate_query->last_insert_id();
}
} catch ( InvalidQuery $e ) {
return $this->error_from_exception(
$e,
'gla_error_creating_shipping_rate',
[
'code' => 400,
'message' => $e->getMessage(),
]
);
}
// Fetch updated/inserted rate to return in response.
$rate_response = $this->prepare_item_for_response(
$this->get_shipping_rate_by_id( (string) $rate_id ),
$request
);
return new Response(
[
'status' => 'success',
'message' => sprintf(
/* translators: %s is the country code in ISO 3166-1 alpha-2 format. */
__( 'Successfully added rate for country: "%s".', 'google-listings-and-ads' ),
$country
),
'rate' => $rate_response->get_data(),
],
201
);
};
}
/**
* @return callable
*/
protected function get_delete_rate_callback(): callable {
return function ( Request $request ) {
try {
$id = (string) $request->get_param( 'id' );
$rate = $this->get_shipping_rate_by_id( $id );
if ( empty( $rate ) ) {
return new Response(
[
'message' => __( 'No rate found with the given ID.', 'google-listings-and-ads' ),
'id' => $id,
],
404
);
}
$this->create_query()->delete( 'id', $id );
return [
'status' => 'success',
'message' => __( 'Successfully deleted rate.', 'google-listings-and-ads' ),
];
} catch ( InvalidQuery $e ) {
return $this->error_from_exception(
$e,
'gla_error_deleting_shipping_rate',
[
'code' => 400,
'message' => $e->getMessage(),
]
);
}
};
}
/**
* Returns the list of all shipping rates stored in the database grouped by their respective country code.
*
* @return array Array of shipping rates grouped by country code.
*/
protected function get_all_shipping_rates(): array {
return $this->create_query()
->set_order( 'country', 'ASC' )
->get_results();
}
/**
* @param string $id
*
* @return array|null The shipping rate properties as an array or null if it doesn't exist.
*/
protected function get_shipping_rate_by_id( string $id ): ?array {
$results = $this->create_query()->where( 'id', $id )->get_results();
return ! empty( $results ) ? $results[0] : null;
}
/**
* Return a new instance of the shipping rate query object.
*
* @return ShippingRateQuery
*/
protected function create_query(): ShippingRateQuery {
return clone $this->query;
}
/**
* @return array
*/
protected function get_schema_properties(): array {
return $this->get_shipping_rate_schema();
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'shipping_rates';
}
}
API/Site/Controllers/MerchantCenter/ShippingRateSuggestionsController.php 0000644 00000007755 15153721356 0022724 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\ShippingRateSchemaTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingSuggestionService;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRateSuggestionsController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*
* @since 1.12.0
*/
class ShippingRateSuggestionsController extends BaseController implements ISO3166AwareInterface {
use ShippingRateSchemaTrait;
/**
* The base for routes in this controller.
*
* @var string
*/
protected $route_base = 'mc/shipping/rates/suggestions';
/**
* @var ShippingSuggestionService
*/
protected $shipping_suggestion;
/**
* ShippingRateSuggestionsController constructor.
*
* @param RESTServer $server
* @param ShippingSuggestionService $shipping_suggestion
*/
public function __construct( RESTServer $server, ShippingSuggestionService $shipping_suggestion ) {
parent::__construct( $server );
$this->shipping_suggestion = $shipping_suggestion;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
"{$this->route_base}",
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_suggestions_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => [
'country_codes' => [
'type' => 'array',
'description' => __( 'Array of country codes in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
'context' => [ 'edit' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_country_code_validate_callback(),
'minItems' => 1,
'required' => true,
'uniqueItems' => true,
'items' => [
'type' => 'string',
],
],
],
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for returning the endpoint results.
*
* @return callable
*/
protected function get_suggestions_callback(): callable {
return function ( Request $request ) {
$country_codes = $request->get_param( 'country_codes' );
$rates_output = [];
foreach ( $country_codes as $country_code ) {
$suggestions = $this->shipping_suggestion->get_suggestions( $country_code );
// Prepare the output.
$suggestions = array_map(
function ( $suggestion ) use ( $request ) {
$response = $this->prepare_item_for_response( $suggestion, $request );
return $this->prepare_response_for_collection( $response );
},
$suggestions
);
// Merge the suggestions for all countries into one array.
$rates_output = array_merge( $rates_output, $suggestions );
}
return $rates_output;
};
}
/**
* @return array
*/
protected function get_schema_properties(): array {
$schema = $this->get_shipping_rate_schema();
// Suggested shipping rates don't have an id.
unset( $schema['id'] );
// All properties are read-only.
return array_map(
function ( $property ) {
$property['readonly'] = true;
$property['context'] = [ 'view' ];
return $property;
},
$schema
);
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'shipping_rates_suggestions';
}
}
API/Site/Controllers/MerchantCenter/ShippingTimeBatchController.php 0000644 00000005132 15153721356 0021421 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BatchSchemaTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingTimeBatchController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ShippingTimeBatchController extends ShippingTimeController {
use BatchSchemaTrait;
use BatchShippingTrait;
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
"{$this->route_base}/batch",
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_batch_create_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_item_schema(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_batch_delete_shipping_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_item_delete_schema(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback for creating items via batch.
*
* @return callable
*/
protected function get_batch_create_callback(): callable {
return function ( Request $request ) {
$country_codes = $request->get_param( 'country_codes' );
$time = $request->get_param( 'time' );
$max_time = $request->get_param( 'max_time' );
$responses = [];
$errors = [];
foreach ( $country_codes as $country_code ) {
$new_request = new Request( 'POST', "/{$this->get_namespace()}/{$this->route_base}" );
$new_request->set_body_params(
[
'country_code' => $country_code,
'time' => $time,
'max_time' => $max_time,
]
);
$response = $this->server->dispatch_request( $new_request );
if ( 201 !== $response->get_status() ) {
$errors[] = $response->get_data();
} else {
$responses[] = $response->get_data();
}
}
return new Response(
[
'errors' => $errors,
'success' => $responses,
],
201
);
};
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'batch_shipping_times';
}
}
API/Site/Controllers/MerchantCenter/ShippingTimeController.php 0000644 00000023537 15153721356 0020470 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use WP_Error;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingTimeController
*
* ContainerAware used for:
* - ShippingTimeQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class ShippingTimeController extends BaseController implements ContainerAwareInterface, ISO3166AwareInterface {
use ContainerAwareTrait;
use CountryCodeTrait;
/**
* The base for routes in this controller.
*
* @var string
*/
protected $route_base = 'mc/shipping/times';
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
$this->route_base,
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_read_times_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_create_time_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_args_schema(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
"{$this->route_base}/(?P<country_code>\\w{2})",
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_read_time_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->get_delete_time_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for reading times.
*
* @return callable
*/
protected function get_read_times_callback(): callable {
return function ( Request $request ) {
$times = $this->get_all_shipping_times();
$items = [];
foreach ( $times as $time ) {
$data = $this->prepare_item_for_response(
[
'country_code' => $time['country'],
'time' => $time['time'],
'max_time' => $time['max_time'],
],
$request
);
$items[ $time['country'] ] = $this->prepare_response_for_collection( $data );
}
return $items;
};
}
/**
* Get the callback function for reading a single time.
*
* @return callable
*/
protected function get_read_time_callback(): callable {
return function ( Request $request ) {
$country = $request->get_param( 'country_code' );
$time = $this->get_shipping_time_for_country( $country );
if ( empty( $time ) ) {
return new Response(
[
'message' => __( 'No time available.', 'google-listings-and-ads' ),
'country' => $country,
],
404
);
}
return $this->prepare_item_for_response(
[
'country_code' => $time[0]['country'],
'time' => $time[0]['time'],
'max_time' => $time[0]['max_time'],
],
$request
);
};
}
/**
* Get the callback to crate a new time.
*
* @return callable
*/
protected function get_create_time_callback(): callable {
return function ( Request $request ) {
$query = $this->get_query_object();
$country_code = $request->get_param( 'country_code' );
$existing = ! empty( $query->where( 'country', $country_code )->get_results() );
try {
$data = [
'country' => $country_code,
'time' => $request->get_param( 'time' ),
'max_time' => $request->get_param( 'max_time' ),
];
if ( $existing ) {
$query->update(
$data,
[
'id' => $query->get_results()[0]['id'],
]
);
} else {
$query->insert( $data );
}
return new Response(
[
'status' => 'success',
'message' => sprintf(
/* translators: %s is the country code in ISO 3166-1 alpha-2 format. */
__( 'Successfully added time for country: "%s".', 'google-listings-and-ads' ),
$country_code
),
],
201
);
} catch ( InvalidQuery $e ) {
return $this->error_from_exception(
$e,
'gla_error_creating_shipping_time',
[
'code' => 400,
'message' => $e->getMessage(),
]
);
}
};
}
/**
* Get the callback function for deleting a time.
*
* @return callable
*/
protected function get_delete_time_callback(): callable {
return function ( Request $request ) {
try {
$country_code = $request->get_param( 'country_code' );
$this->get_query_object()->delete( 'country', $country_code );
return [
'status' => 'success',
'message' => sprintf(
/* translators: %s is the country code in ISO 3166-1 alpha-2 format. */
__( 'Successfully deleted the time for country: "%s".', 'google-listings-and-ads' ),
$country_code
),
];
} catch ( InvalidQuery $e ) {
return $this->error_from_exception(
$e,
'gla_error_deleting_shipping_time',
[
'code' => 400,
'message' => $e->getMessage(),
]
);
}
};
}
/**
* @return array
*/
protected function get_all_shipping_times(): array {
return $this->get_query_object()->set_limit( 100 )->get_results();
}
/**
* @param string $country
*
* @return array
*/
protected function get_shipping_time_for_country( string $country ): array {
return $this->get_query_object()->where( 'country', $country )->get_results();
}
/**
* Get the shipping time query object.
*
* @return ShippingTimeQuery
*/
protected function get_query_object(): ShippingTimeQuery {
return $this->container->get( ShippingTimeQuery::class );
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'country_code' => [
'type' => 'string',
'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_country_code_validate_callback(),
'required' => true,
],
'time' => [
'type' => 'integer',
'description' => __( 'The minimum shipping time in days.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => [ $this, 'validate_shipping_times' ],
],
'max_time' => [
'type' => 'integer',
'description' => __( 'The maximum shipping time in days.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => [ $this, 'validate_shipping_times' ],
],
];
}
/**
* Get the args schema for the controller.
*
* @return array
*/
protected function get_args_schema(): array {
$schema = $this->get_schema_properties();
$schema['time']['required'] = true;
$schema['max_time']['required'] = true;
return $schema;
}
/**
* Validate the shipping times.
*
* @param mixed $value
* @param Request $request
* @param string $param
*
* @return WP_Error|true
*/
public function validate_shipping_times( $value, $request, $param ) {
$time = $request->get_param( 'time' );
$max_time = $request->get_param( 'max_time' );
if ( rest_is_integer( $value ) === false ) {
return new WP_Error(
'rest_invalid_type',
/* translators: 1: Parameter, 2: Type name. */
sprintf( __( '%1$s is not of type %2$s.', 'google-listings-and-ads' ), $param, 'integer' ),
[ 'param' => $param ]
);
}
if ( $value < 0 ) {
return new WP_Error( 'invalid_shipping_times', __( 'Shipping times cannot be negative.', 'google-listings-and-ads' ), [ 'param' => $param ] );
}
if ( $time > $max_time ) {
return new WP_Error( 'invalid_shipping_times', __( 'The minimum shipping time cannot be greater than the maximum shipping time.', 'google-listings-and-ads' ), [ 'param' => $param ] );
}
return true;
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'shipping_times';
}
/**
* Retrieves all of the registered additional fields for a given object-type.
*
* @param string $object_type Optional. The object type.
*
* @return array Registered additional fields (if any), empty array if none or if the object type could
* not be inferred.
*/
protected function get_additional_fields( $object_type = null ): array {
$fields = parent::get_additional_fields( $object_type );
$fields['country'] = [
'schema' => [
'type' => 'string',
'description' => __( 'Country in which the shipping time applies.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'get_callback' => function ( $fields ) {
return $this->iso3166_data_provider->alpha2( $fields['country_code'] )['name'];
},
];
return $fields;
}
}
API/Site/Controllers/MerchantCenter/SupportedCountriesController.php 0000644 00000010101 15153721356 0021730 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class SupportedCountriesController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
class SupportedCountriesController extends BaseController {
use CountryCodeTrait;
use EmptySchemaPropertiesTrait;
/**
* The WC proxy object.
*
* @var WC
*/
protected $wc;
/**
* @var GoogleHelper
*/
protected $google_helper;
/**
* SupportedCountriesController constructor.
*
* @param RESTServer $server
* @param WC $wc
* @param GoogleHelper $google_helper
*/
public function __construct( RESTServer $server, WC $wc, GoogleHelper $google_helper ) {
parent::__construct( $server );
$this->wc = $wc;
$this->google_helper = $google_helper;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/countries',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_countries_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_query_args(),
],
]
);
}
/**
* Get the callback function for returning supported countries.
*
* @return callable
*/
protected function get_countries_callback(): callable {
return function ( Request $request ) {
$return = [
'countries' => $this->get_supported_countries( $request ),
];
if ( $request->get_param( 'continents' ) ) {
$return['continents'] = $this->get_supported_continents();
}
return $return;
};
}
/**
* Get the array of supported countries.
*
* @return array
*/
protected function get_supported_countries(): array {
$all_countries = $this->wc->get_countries();
$mc_countries = $this->google_helper->get_mc_supported_countries_currencies();
$supported = [];
foreach ( $mc_countries as $country => $currency ) {
if ( ! array_key_exists( $country, $all_countries ) ) {
continue;
}
$supported[ $country ] = [
'name' => $all_countries[ $country ],
'currency' => $currency,
];
}
uasort(
$supported,
function ( $a, $b ) {
return $a['name'] <=> $b['name'];
}
);
return $supported;
}
/**
* Get the array of supported continents.
*
* @return array
*/
protected function get_supported_continents(): array {
$all_continents = $this->wc->get_continents();
foreach ( $all_continents as $continent_code => $continent ) {
$supported_countries_of_continent = $this->google_helper->get_supported_countries_from_continent( $continent_code );
if ( empty( $supported_countries_of_continent ) ) {
unset( $all_continents[ $continent_code ] );
} else {
$all_continents[ $continent_code ]['countries'] = array_values( $supported_countries_of_continent );
}
}
return $all_continents;
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'supported_countries';
}
/**
* Get the arguments for the query endpoint.
*
* @return array
*/
protected function get_query_args(): array {
return [
'continents' => [
'description' => __( 'Include continents data if set to true.', 'google-listings-and-ads' ),
'type' => 'boolean',
'validate_callback' => 'rest_validate_request_arg',
],
];
}
}
API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php 0000644 00000007025 15153721356 0022357 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateSyncableProductsCount;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class SyncableProductsCountController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class SyncableProductsCountController extends BaseOptionsController {
/**
* @var JobRepository
*/
protected $job_repository;
/**
* SyncableProductsCountController constructor.
*
* @param RESTServer $server
* @param JobRepository $job_repository
*/
public function __construct( RESTServer $server, JobRepository $job_repository ) {
parent::__construct( $server );
$this->job_repository = $job_repository;
}
/**
* Registers the routes for the objects of the controller.
*/
public function register_routes() {
$this->register_route(
'mc/syncable-products-count',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_syncable_products_count_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->update_syncable_products_count_callback(),
'permission_callback' => $this->get_permission_callback(),
],
]
);
}
/**
* Get the callback function for marking setup complete.
*
* @return callable
*/
protected function get_syncable_products_count_callback(): callable {
return function ( Request $request ) {
$response = [
'count' => null,
];
$count = $this->options->get( OptionsInterface::SYNCABLE_PRODUCTS_COUNT );
if ( isset( $count ) ) {
$response['count'] = (int) $count;
}
return $this->prepare_item_for_response( $response, $request );
};
}
/**
* Get the callback for syncing shipping.
*
* @return callable
*/
protected function update_syncable_products_count_callback(): callable {
return function ( Request $request ) {
$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT );
$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA );
$job = $this->job_repository->get( UpdateSyncableProductsCount::class );
$job->schedule();
return new Response(
[
'status' => 'success',
'message' => __( 'Successfully scheduled a job to update the number of syncable products.', 'google-listings-and-ads' ),
],
201
);
};
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'count' => [
'type' => 'number',
'description' => __( 'The number of products that are ready to be synced to Google.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'syncable_products_count';
}
}
API/Site/Controllers/MerchantCenter/TargetAudienceController.php 0000644 00000020277 15153721356 0020752 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Locale;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use function wp_get_available_translations;
defined( 'ABSPATH' ) || exit;
/**
* Class TargetAudienceController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
*/
class TargetAudienceController extends BaseOptionsController implements ISO3166AwareInterface {
use CountryCodeTrait;
/**
* The WP proxy object.
*
* @var WP
*/
protected $wp;
/**
* @var ShippingZone
*/
protected $shipping_zone;
/**
* @var WC
*/
protected $wc;
/**
* @var GoogleHelper
*/
protected $google_helper;
/**
* TargetAudienceController constructor.
*
* @param RESTServer $server
* @param WP $wp
* @param WC $wc
* @param ShippingZone $shipping_zone
* @param GoogleHelper $google_helper
*/
public function __construct( RESTServer $server, WP $wp, WC $wc, ShippingZone $shipping_zone, GoogleHelper $google_helper ) {
parent::__construct( $server );
$this->wp = $wp;
$this->wc = $wc;
$this->shipping_zone = $shipping_zone;
$this->google_helper = $google_helper;
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
$this->register_route(
'mc/target_audience',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_read_audience_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_update_audience_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
$this->register_route(
'mc/target_audience/suggestions',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_suggest_audience_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for reading the target audience data.
*
* @return callable
*/
protected function get_read_audience_callback(): callable {
return function ( Request $request ) {
return $this->prepare_item_for_response( $this->get_target_audience_option(), $request );
};
}
/**
* Get the callback function for suggesting the target audience data.
*
* @return callable
*
* @since 1.9.0
*/
protected function get_suggest_audience_callback(): callable {
return function ( Request $request ) {
return $this->prepare_item_for_response( $this->get_target_audience_suggestion(), $request );
};
}
/**
* Get the callback function for updating the target audience data.
*
* @return callable
*/
protected function get_update_audience_callback(): callable {
return function ( Request $request ) {
$data = $this->prepare_item_for_database( $request );
$this->update_target_audience_option( $data );
$this->prepare_item_for_response( $data, $request );
return new Response(
[
'status' => 'success',
'message' => __( 'Successfully updated the Target Audience settings.', 'google-listings-and-ads' ),
],
201
);
};
}
/**
* Retrieves all of the registered additional fields for a given object-type.
*
* @param string $object_type Optional. The object type.
*
* @return array Registered additional fields (if any), empty array if none or if the object type could
* not be inferred.
*/
protected function get_additional_fields( $object_type = null ): array {
$fields = parent::get_additional_fields( $object_type );
// Fields are expected to be an array with a 'get_callback' callable that returns the field value.
$fields['locale'] = [
'schema' => [
'type' => 'string',
'description' => __( 'The locale for the site.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'get_callback' => function () {
return $this->wp->get_locale();
},
];
$fields['language'] = [
'schema' => [
'type' => 'string',
'description' => __( 'The language to use for product listings.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'get_callback' => $this->get_language_callback(),
];
return $fields;
}
/**
* Get the option data for the target audience.
*
* @return array
*/
protected function get_target_audience_option(): array {
return $this->options->get( OptionsInterface::TARGET_AUDIENCE, [] );
}
/**
* Get the suggested values for the target audience option.
*
* @return string[]
*
* @since 1.9.0
*/
protected function get_target_audience_suggestion(): array {
$countries = $this->shipping_zone->get_shipping_countries();
$base_country = $this->wc->get_base_country();
// Add WooCommerce store country if it's supported and not already in the list.
if ( ! in_array( $base_country, $countries, true ) && $this->google_helper->is_country_supported( $base_country ) ) {
$countries[] = $base_country;
}
return [
'location' => 'selected',
'countries' => $countries,
];
}
/**
* Update the option data for the target audience.
*
* @param array $data
*
* @return bool
*/
protected function update_target_audience_option( array $data ): bool {
return $this->options->update( OptionsInterface::TARGET_AUDIENCE, $data );
}
/**
* Get the item schema for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'location' => [
'type' => 'string',
'description' => __( 'Location where products will be shown.', 'google-listings-and-ads' ),
'context' => [ 'edit', 'view' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
'enum' => [
'all',
'selected',
],
],
'countries' => [
'type' => 'array',
'description' => __(
'Array of country codes in ISO 3166-1 alpha-2 format.',
'google-listings-and-ads'
),
'context' => [ 'edit', 'view' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_country_code_validate_callback(),
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'target_audience';
}
/**
* Get the callback to provide the language in use for the site.
*
* @return callable
*/
protected function get_language_callback(): callable {
$locale = $this->wp->get_locale();
// Default to using the Locale class if it is available.
if ( class_exists( Locale::class ) ) {
return function () use ( $locale ): string {
return Locale::getDisplayLanguage( $locale, $locale );
};
}
return function () use ( $locale ): string {
// en_US isn't provided by the translations API.
if ( 'en_US' === $locale ) {
return 'English';
}
require_once ABSPATH . 'wp-admin/includes/translation-install.php';
return wp_get_available_translations()[ $locale ]['native_name'] ?? $locale;
};
}
}
API/Site/Controllers/ResponseFromExceptionTrait.php 0000644 00000001641 15153721356 0016417 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Exception;
use WP_REST_Response as Response;
/**
* Trait ResponseFromExceptionTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*
* @since 1.5.0
*/
trait ResponseFromExceptionTrait {
/**
* Get REST response from an exception.
*
* @param Exception $exception
*
* @return Response
*/
protected function response_from_exception( Exception $exception ): Response {
$code = $exception->getCode();
$status = $code && is_numeric( $code ) ? $code : 400;
if ( $exception instanceof ExceptionWithResponseData ) {
return new Response( $exception->get_response_data( true ), $status );
}
return new Response( [ 'message' => $exception->getMessage() ], $status );
}
}
API/Site/Controllers/RestAPI/AuthController.php 0000644 00000014217 15153721356 0015331 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\RestAPI;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\OAuthService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
defined( 'ABSPATH' ) || exit;
/**
* Class AuthController
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\RestAPI
*
* @since 2.8.0
*/
class AuthController extends BaseController {
/**
* @var OAuthService
*/
protected $oauth_service;
/**
* @var AccountService
*/
protected $account_service;
/**
* Mapping between the client page name and its path.
* The first value is also used as a default,
* and changing the order of keys/values may affect things below.
*
* @var string[]
*/
private const NEXT_PATH_MAPPING = [
'setup-mc' => '/google/setup-mc',
'settings' => '/google/settings',
];
/**
* AuthController constructor.
*
* @param RESTServer $server
* @param OAuthService $oauth_service
* @param AccountService $account_service
*/
public function __construct( RESTServer $server, OAuthService $oauth_service, AccountService $account_service ) {
parent::__construct( $server );
$this->oauth_service = $oauth_service;
$this->account_service = $account_service;
}
/**
* Registers the routes for the objects of the controller.
*/
public function register_routes() {
$this->register_route(
'rest-api/authorize',
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_authorize_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_auth_params(),
],
[
'methods' => TransportMethods::DELETABLE,
'callback' => $this->delete_authorize_callback(),
'permission_callback' => $this->get_permission_callback(),
],
[
'methods' => TransportMethods::EDITABLE,
'callback' => $this->get_update_authorize_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_update_authorize_params(),
],
'schema' => $this->get_api_response_schema_callback(),
]
);
}
/**
* Get the callback function for the authorization request.
*
* @return callable
*/
protected function get_authorize_callback(): callable {
return function ( Request $request ) {
try {
$next = $request->get_param( 'next_page_name' );
$path = self::NEXT_PATH_MAPPING[ $next ];
$auth_url = $this->oauth_service->get_auth_url( $path );
$response = [
'auth_url' => $auth_url,
];
return $this->prepare_item_for_response( $response, $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the delete authorization request.
*
* @return callable
*/
protected function delete_authorize_callback(): callable {
return function ( Request $request ) {
try {
$this->oauth_service->revoke_wpcom_api_auth();
return $this->prepare_item_for_response( [], $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the callback function for the update authorize request.
*
* @return callable
*/
protected function get_update_authorize_callback(): callable {
return function ( Request $request ) {
try {
$this->account_service->update_wpcom_api_authorization( $request['status'], $request['nonce'] );
return [ 'status' => $request['status'] ];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the query params for the authorize request.
*
* @return array
*/
protected function get_auth_params(): array {
return [
'next_page_name' => [
'description' => __( 'Indicates the next page name mapped to the redirect URL when redirected back from Google WPCOM App authorization.', 'google-listings-and-ads' ),
'type' => 'string',
'default' => array_key_first( self::NEXT_PATH_MAPPING ),
'enum' => array_keys( self::NEXT_PATH_MAPPING ),
'validate_callback' => 'rest_validate_request_arg',
],
];
}
/**
* Get the query params for the update authorize request.
*
* @return array
*/
protected function get_update_authorize_params(): array {
return [
'status' => [
'description' => __( 'The status of the merchant granting access to Google\'s WPCOM app', 'google-listings-and-ads' ),
'type' => 'string',
'enum' => OAuthService::ALLOWED_STATUSES,
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
'nonce' => [
'description' => __( 'The nonce provided by Google in the URL query parameter when Google redirects back to merchant\'s site', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
];
}
/**
* Get the item schema properties for the controller.
*
* @return array
*/
protected function get_schema_properties(): array {
return [
'auth_url' => [
'type' => 'string',
'description' => __( 'The authorization URL for granting access to Google WPCOM App.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
],
'status' => [
'type' => 'string',
'description' => __( 'The status of the merchant granting access to Google\'s WPCOM app', 'google-listings-and-ads' ),
'enum' => OAuthService::ALLOWED_STATUSES,
'context' => [ 'view' ],
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'rest_api_authorize';
}
}
API/Site/Controllers/ShippingRateSchemaTrait.php 0000644 00000004706 15153721356 0015641 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingRate;
defined( 'ABSPATH' ) || exit;
/**
* Trait ShippingRateSchemaTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*
* @since 1.12.0
*/
trait ShippingRateSchemaTrait {
use CountryCodeTrait;
/**
* @return array
*/
protected function get_shipping_rate_schema(): array {
return [
'id' => [
'type' => 'number',
'description' => __( 'The shipping rate unique identification number.', 'google-listings-and-ads' ),
'context' => [ 'view' ],
'readonly' => true,
],
'country' => [
'type' => 'string',
'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'sanitize_callback' => $this->get_country_code_sanitize_callback(),
'validate_callback' => $this->get_country_code_validate_callback(),
'required' => true,
],
'currency' => [
'type' => 'string',
'description' => __( 'The currency to use for the shipping rate.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'default' => 'USD', // todo: default to store currency.
],
'rate' => [
'type' => 'number',
'minimum' => 0,
'description' => __( 'The shipping rate.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
'options' => [
'type' => 'object',
'additionalProperties' => false,
'description' => __( 'Array of options for the shipping method.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
'default' => [],
'properties' => [
'free_shipping_threshold' => [
'type' => 'number',
'minimum' => 0,
'description' => __( 'Minimum price eligible for free shipping.', 'google-listings-and-ads' ),
'context' => [ 'view', 'edit' ],
'validate_callback' => 'rest_validate_request_arg',
],
],
],
];
}
}
API/Site/Controllers/TourController.php 0000644 00000011061 15153721356 0014104 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class for handling API requests for getting and update the tour visualizations.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
*/
class TourController extends BaseOptionsController {
/**
* Constructor.
*
* @param RESTServer $server
*/
public function __construct( RESTServer $server ) {
parent::__construct( $server );
}
/**
* Register rest routes with WordPress.
*/
public function register_routes(): void {
/**
* GET The tour visualizations
*/
$this->register_route(
"/tours/(?P<id>{$this->get_tour_id_regex()})",
[
[
'methods' => TransportMethods::READABLE,
'callback' => $this->get_tours_read_callback(),
'permission_callback' => $this->get_permission_callback(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
/**
* POST Update the tour visualizations
*/
$this->register_route(
'/tours',
[
[
'methods' => TransportMethods::CREATABLE,
'callback' => $this->get_tours_create_callback(),
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_schema_properties(),
],
'schema' => $this->get_api_response_schema_callback(),
],
);
}
/**
* Callback function for returning the tours
*
* @return callable
*/
protected function get_tours_read_callback(): callable {
return function ( Request $request ) {
try {
$tour_id = $request->get_url_params()['id'];
return $this->prepare_item_for_response( $this->get_tour( $tour_id ), $request );
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Callback function for saving the Tours
*
* @return callable
*/
protected function get_tours_create_callback(): callable {
return function ( Request $request ) {
try {
$tour_id = $request->get_param( 'id' );
$tours = $this->get_tours();
$tours[ $tour_id ] = $this->prepare_item_for_database( $request );
if ( $this->options->update( OptionsInterface::TOURS, $tours ) ) {
return new Response(
[
'status' => 'success',
'message' => __( 'Successfully updated the tour.', 'google-listings-and-ads' ),
],
200
);
} else {
throw new Exception( __( 'Unable to updated the tour.', 'google-listings-and-ads' ), 400 );
}
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
/**
* Get the tours
*
* @return array|null The tours saved in databse
*/
private function get_tours(): ?array {
return $this->options->get( OptionsInterface::TOURS );
}
/**
* Get the tour by Id
*
* @param string $tour_id The tour ID
* @return array The tour
* @throws Exception In case the tour is not found.
*/
private function get_tour( string $tour_id ): array {
$tours = $this->get_tours();
if ( ! isset( $tours[ $tour_id ] ) ) {
throw new Exception( __( 'Tour not found', 'google-listings-and-ads' ), 404 );
}
return $tours[ $tour_id ];
}
/**
* Get the item schema properties for the controller.
*
* @return array The Schema properties
*/
protected function get_schema_properties(): array {
return [
'id' => [
'description' => __( 'The Id for the tour.', 'google-listings-and-ads' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
'pattern' => "^{$this->get_tour_id_regex()}$",
],
'checked' => [
'description' => __( 'Whether the tour was checked.', 'google-listings-and-ads' ),
'type' => 'boolean',
'validate_callback' => 'rest_validate_request_arg',
'required' => true,
],
];
}
/**
* Get the item schema name for the controller.
*
* Used for building the API response schema.
*
* @return string
*/
protected function get_schema_title(): string {
return 'tours';
}
/**
* Get the regex used for the Tour ID
*
* @return string The regex
*/
private function get_tour_id_regex(): string {
return '[a-zA-z0-9-_]+';
}
}
API/Site/RESTControllers.php 0000644 00000002612 15153721356 0011607 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
/**
* Class RESTControllers
*
* Container used for:
* - classes tagged with 'rest_controller'
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site
*/
class RESTControllers implements ContainerAwareInterface, Service, Registerable {
use ContainerAwareTrait;
use ValidateInterface;
/**
* Register a service.
*/
public function register(): void {
add_action(
'rest_api_init',
function () {
$this->register_controllers();
}
);
}
/**
* Register our individual rest controllers.
*/
protected function register_controllers(): void {
/** @var BaseController[] $controllers */
$controllers = $this->container->get( 'rest_controller' );
foreach ( $controllers as $controller ) {
$this->validate_instanceof( $controller, BaseController::class );
$controller->register();
}
}
}
API/TransportMethods.php 0000644 00000001500 15153721356 0011212 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API;
/**
* Interface TransportMethods
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\API
*/
interface TransportMethods {
/**
* Alias for GET transport method.
*
* @var string
*/
public const READABLE = 'GET';
/**
* Alias for POST transport method.
*
* @var string
*/
public const CREATABLE = 'POST';
/**
* Alias for POST, PUT, PATCH transport methods together.
*
* @var string
*/
public const EDITABLE = 'POST, PUT, PATCH';
/**
* Alias for DELETE transport method.
*
* @var string
*/
public const DELETABLE = 'DELETE';
/**
* Alias for GET, POST, PUT, PATCH & DELETE transport methods together.
*
* @var string
*/
public const ALLMETHODS = 'GET, POST, PUT, PATCH, DELETE';
}
API/WP/NotificationsService.php 0000644 00000013630 15153721356 0012361 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\WP;
use Automattic\Jetpack\Connection\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Jetpack_Options;
defined( 'ABSPATH' ) || exit;
/**
* Class NotificationsService
* This class implements a service to Notify a partner about Shop Data Updates
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\WP
*/
class NotificationsService implements Service, OptionsAwareInterface {
use OptionsAwareTrait;
// List of Topics to be used.
public const TOPIC_PRODUCT_CREATED = 'product.create';
public const TOPIC_PRODUCT_DELETED = 'product.delete';
public const TOPIC_PRODUCT_UPDATED = 'product.update';
public const TOPIC_COUPON_CREATED = 'coupon.create';
public const TOPIC_COUPON_DELETED = 'coupon.delete';
public const TOPIC_COUPON_UPDATED = 'coupon.update';
public const TOPIC_SHIPPING_UPDATED = 'shipping.update';
public const TOPIC_SETTINGS_UPDATED = 'settings.update';
// Constant used to get all the allowed topics
public const ALLOWED_TOPICS = [
self::TOPIC_PRODUCT_CREATED,
self::TOPIC_PRODUCT_DELETED,
self::TOPIC_PRODUCT_UPDATED,
self::TOPIC_COUPON_CREATED,
self::TOPIC_COUPON_DELETED,
self::TOPIC_COUPON_UPDATED,
self::TOPIC_SHIPPING_UPDATED,
self::TOPIC_SETTINGS_UPDATED,
];
/**
* The url to send the notification
*
* @var string $notification_url
*/
private $notification_url;
/**
* The Merchant center service
*
* @var MerchantCenterService $merchant_center
*/
public MerchantCenterService $merchant_center;
/**
* The AccountService service
*
* @var AccountService $account_service
*/
public AccountService $account_service;
/**
* NotificationsService constructor
*
* @param MerchantCenterService $merchant_center
* @param AccountService $account_service
*/
public function __construct( MerchantCenterService $merchant_center, AccountService $account_service ) {
$blog_id = Jetpack_Options::get_option( 'id' );
$this->merchant_center = $merchant_center;
$this->account_service = $account_service;
$this->notification_url = "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/partners/google/notifications";
}
/**
* Calls the Notification endpoint in WPCOM.
* https://public-api.wordpress.com/wpcom/v2/sites/{site}/partners/google/notifications
*
* @param string $topic The topic to use in the notification.
* @param int|null $item_id The item ID to notify. It can be null for topics that doesn't need Item ID
* @param array $data Optional data to send in the request.
* @return bool True is the notification is successful. False otherwise.
*/
public function notify( string $topic, $item_id = null, $data = [] ): bool {
/**
* Allow users to disable the notification request.
*
* @since 2.8.0
*
* @param bool $value The current filter value. True by default.
* @param int $item_id The item_id for the notification.
* @param string $topic The topic for the notification.
*/
if ( ! apply_filters( 'woocommerce_gla_notify', $this->is_ready() && in_array( $topic, self::ALLOWED_TOPICS, true ), $item_id, $topic ) ) {
$this->notification_error( $topic, 'Notification was not sent because the Notification Service is not ready or the topic is not valid.', $item_id );
return false;
}
$remote_args = [
'method' => 'POST',
'timeout' => 30,
'headers' => [
'x-woocommerce-topic' => $topic,
'Content-Type' => 'application/json',
],
'body' => array_merge( $data, [ 'item_id' => $item_id ] ),
'url' => $this->get_notification_url(),
];
$response = $this->do_request( $remote_args );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 400 ) {
$error = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_body( $response );
$this->notification_error( $topic, $error, $item_id );
return false;
}
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Notification - Item ID: %s - Topic: %s - Data %s', $item_id, $topic, wp_json_encode( $data ) ),
__METHOD__
);
return true;
}
/**
* Logs an error.
*
* @param string $topic
* @param string $error
* @param int|null $item_id
*/
private function notification_error( string $topic, string $error, $item_id = null ): void {
do_action(
'woocommerce_gla_error',
sprintf( 'Error sending notification for Item ID %s with topic %s. %s', $item_id, $topic, $error ),
__METHOD__
);
}
/**
* Performs a Remote Request
*
* @param array $args
* @return array|\WP_Error
*/
protected function do_request( array $args ) {
return Client::remote_request( $args, wp_json_encode( $args['body'] ) );
}
/**
* Get the route
*
* @return string The route.
*/
public function get_notification_url(): string {
return $this->notification_url;
}
/**
* If the Notifications are ready
* This happens when the WPCOM API is Authorized and the feature is enabled.
*
* @param bool $with_health_check If true. Performs a remote request to WPCOM API to get the status.
* @return bool
*/
public function is_ready( bool $with_health_check = true ): bool {
return $this->options->is_wpcom_api_authorized() && $this->is_enabled() && $this->merchant_center->is_ready_for_syncing() && ( $with_health_check === false || $this->account_service->is_wpcom_api_status_healthy() );
}
/**
* If the Notifications are enabled
*
* @return bool
*/
public function is_enabled(): bool {
return apply_filters( 'woocommerce_gla_notifications_enabled', true );
}
}
API/WP/OAuthService.php 0000644 00000020470 15153721356 0010570 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities as UtilitiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Deactivateable;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\Jetpack;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerExceptionInterface;
use Jetpack_Options;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class OAuthService
* This class implements a service to handle WordPress.com OAuth.
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\API\WP
*/
class OAuthService implements Service, OptionsAwareInterface, Deactivateable, ContainerAwareInterface {
use OptionsAwareTrait;
use UtilitiesTrait;
use ContainerAwareTrait;
public const WPCOM_API_URL = 'https://public-api.wordpress.com';
public const AUTH_URL = '/oauth2/authorize';
public const RESPONSE_TYPE = 'code';
public const SCOPE = 'wc-partner-access';
public const STATUS_APPROVED = 'approved';
public const STATUS_DISAPPROVED = 'disapproved';
public const STATUS_ERROR = 'error';
public const ALLOWED_STATUSES = [
self::STATUS_APPROVED,
self::STATUS_DISAPPROVED,
self::STATUS_ERROR,
];
/**
* Returns WordPress.com OAuth authorization URL.
* https://developer.wordpress.com/docs/oauth2/
*
* The full auth URL example:
*
* https://public-api.wordpress.com/oauth2/authorize?
* client_id=CLIENT_ID&
* redirect_uri=PARTNER_REDIRECT_URL&
* response_type=code&
* blog=BLOD_ID&
* scope=wc-partner-access&
* state=URL_SAFE_BASE64_ENCODED_STRING
*
* State is a URL safe base64 encoded string.
* E.g.
* state=bm9uY2UtMTIzJnJlZGlyZWN0X3VybD1odHRwcyUzQSUyRiUyRm1lcmNoYW50LXNpdGUuZXhhbXBsZS5jb20lMkZ3cC1hZG1pbiUyRmFkbWluLnBocCUzRnBhZ2UlM0R3Yy1hZG1pbiUyNnBhdGglM0QlMkZnb29nbGUlMkZzZXR1cC1tYw
*
* The decoded content of state is a URL query string where the value of its parameter "store_url" is being URL encoded.
* E.g.
* nonce=nonce-123&store_url=https%3A%2F%2Fmerchant-site.example.com%2Fwp-admin%2Fadmin.php%3Fpage%3Dwc-admin%26path%3D%2Fgoogle%2Fsetup-mc
*
* where its URL decoded version is:
* nonce=nonce-123&store_url=https://merchant-site.example.com/wp-admin/admin.php?page=wc-admin&path=/google/setup-mc
*
* @param string $path A URL parameter for the path within GL&A page, which will be added in the merchant redirect URL.
*
* @return string Auth URL.
* @throws ContainerExceptionInterface When get_data_from_google throws an exception.
*/
public function get_auth_url( string $path ): string {
$google_data = $this->get_data_from_google();
$store_url = urlencode_deep( admin_url( "admin.php?page=wc-admin&path={$path}" ) );
$state = $this->base64url_encode(
build_query(
[
'nonce' => $google_data['nonce'],
'store_url' => $store_url,
]
)
);
$auth_url = esc_url_raw(
add_query_arg(
[
'blog' => Jetpack_Options::get_option( 'id' ),
'client_id' => $google_data['client_id'],
'redirect_uri' => $google_data['redirect_uri'],
'response_type' => self::RESPONSE_TYPE,
'scope' => self::SCOPE,
'state' => $state,
],
$this->get_wpcom_api_url( self::AUTH_URL )
)
);
return $auth_url;
}
/**
* Get a WPCOM REST API URl concatenating the endpoint with the API Domain
*
* @param string $endpoint The endpoint to get the URL for
*
* @return string The WPCOM endpoint with the domain.
*/
protected function get_wpcom_api_url( string $endpoint ): string {
return self::WPCOM_API_URL . $endpoint;
}
/**
* Calls an API by Google via WCS to get required information in order to form an auth URL.
*
* @return array{client_id: string, redirect_uri: string, nonce: string} An associative array contains required information that is retrived from Google.
* client_id: Google's WPCOM app client ID, will be used to form the authorization URL.
* redirect_uri: A Google's URL that will be redirected to when the merchant approve the app access. Note that it needs to be matched with the Google WPCOM app client settings.
* nonce: A string returned by Google that we will put it in the auth URL and the redirect_uri. Google will use it to verify the call.
* @throws ContainerExceptionInterface When get_sdi_auth_params throws an exception.
*/
protected function get_data_from_google(): array {
/** @var Middleware $middleware */
$middleware = $this->container->get( Middleware::class );
$response = $middleware->get_sdi_auth_params();
$nonce = $response['nonce'];
$this->options->update( OptionsInterface::GOOGLE_WPCOM_AUTH_NONCE, $nonce );
return [
'client_id' => $response['clientId'],
'redirect_uri' => $response['redirectUri'],
'nonce' => $nonce,
];
}
/**
* Perform a remote request for revoking OAuth access for the current user.
*
* @return string The body of the response
* @throws Exception If the remote request fails.
*/
public function revoke_wpcom_api_auth(): string {
$args = [
'method' => 'DELETE',
'timeout' => 30,
'url' => $this->get_wpcom_api_url( '/wpcom/v2/sites/' . Jetpack_Options::get_option( 'id' ) . '/wc/partners/google/revoke-token' ),
'user_id' => get_current_user_id(),
];
$request = $this->container->get( Jetpack::class )->remote_request( $args );
if ( is_wp_error( $request ) ) {
/**
* When the WPCOM token has been revoked with errors.
*
* @event revoke_wpcom_api_authorization
* @property int status The status of the request.
* @property string error The error message.
* @property int|null blog_id The blog ID.
*/
do_action(
'woocommerce_gla_track_event',
'revoke_wpcom_api_authorization',
[
'status' => 400,
'error' => $request->get_error_message(),
'blog_id' => Jetpack_Options::get_option( 'id' ),
]
);
throw new Exception( $request->get_error_message(), 400 );
} else {
$body = wp_remote_retrieve_body( $request );
$status = wp_remote_retrieve_response_code( $request );
if ( ! $status || $status !== 200 ) {
$data = json_decode( $body, true );
$message = $data['message'] ?? 'Error revoking access to WPCOM.';
/**
*
* When the WPCOM token has been revoked with errors.
*
* @event revoke_wpcom_api_authorization
* @property int status The status of the request.
* @property string error The error message.
* @property int|null blog_id The blog ID.
*/
do_action(
'woocommerce_gla_track_event',
'revoke_wpcom_api_authorization',
[
'status' => $status,
'error' => $message,
'blog_id' => Jetpack_Options::get_option( 'id' ),
]
);
throw new Exception( $message, $status );
}
/**
* When the WPCOM token has been revoked successfully.
*
* @event revoke_wpcom_api_authorization
* @property int status The status of the request.
* @property int|null blog_id The blog ID.
*/
do_action(
'woocommerce_gla_track_event',
'revoke_wpcom_api_authorization',
[
'status' => 200,
'blog_id' => Jetpack_Options::get_option( 'id' ),
]
);
$this->container->get( AccountService::class )->reset_wpcom_api_authorization_data();
return $body;
}
}
/**
* Deactivate the service.
*
* Revoke token on deactivation.
*/
public function deactivate(): void {
// Try to revoke the token on deactivation. If no token is available, it will throw an exception which we can ignore.
try {
$this->revoke_wpcom_api_auth();
} catch ( Exception $e ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Error revoking the WPCOM token: %s', $e->getMessage() ),
__METHOD__
);
}
}
}
ActionScheduler/ActionScheduler.php 0000644 00000014733 15153721356 0013425 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler;
use ActionScheduler as ActionSchedulerCore;
use ActionScheduler_Action;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* ActionScheduler service class.
*
* Acts as a wrapper for ActionScheduler's public functions.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler
*/
class ActionScheduler implements ActionSchedulerInterface, Service {
use PluginHelper;
/**
* @var AsyncActionRunner
*/
protected $async_runner;
/**
* ActionScheduler constructor.
*
* @param AsyncActionRunner $async_runner
*/
public function __construct( AsyncActionRunner $async_runner ) {
$this->async_runner = $async_runner;
}
/**
* Schedule an action to run once at some time in the future
*
* @param int $timestamp When the job will run.
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function schedule_single( int $timestamp, string $hook, $args = [] ): int {
return as_schedule_single_action( $timestamp, $hook, $args, $this->get_slug() );
}
/**
* Schedule an action to run now i.e. in the next available batch.
*
* This differs from async actions by having a scheduled time rather than being set for '0000-00-00 00:00:00'.
* We could use an async action instead but they can't be viewed easily in the admin area
* because the table is sorted by schedule date.
*
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function schedule_immediate( string $hook, $args = [] ): int {
return as_schedule_single_action( gmdate( 'U' ) - 1, $hook, $args, $this->get_slug() );
}
/**
* Schedule a recurring action to run now (i.e. in the next available batch), and in the given intervals.
*
* @param int $timestamp When the job will run.
* @param int $interval_in_seconds How long to wait between runs.
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function schedule_recurring( int $timestamp, int $interval_in_seconds, string $hook, $args = [] ): int {
return as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args, $this->get_slug() );
}
/**
* Schedule an action that recurs on a cron-like schedule.
*
* @param int $timestamp The first instance of the action will be scheduled to run at a time
* calculated after this timestamp matching the cron expression. This
* can be used to delay the first instance of the action.
* @param string $schedule A cron-link schedule string
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*
* @see https://en.wikipedia.org/wiki/Cron
* * * * * * *
* ┬ ┬ ┬ ┬ ┬ ┬
* | | | | | |
* | | | | | + year [optional]
* | | | | +----- day of week (0 - 7) (Sunday=0 or 7)
* | | | +---------- month (1 - 12)
* | | +--------------- day of month (1 - 31)
* | +-------------------- hour (0 - 23)
* +------------------------- min (0 - 59)
*/
public function schedule_cron( int $timestamp, string $schedule, string $hook, $args = [] ): int {
return as_schedule_cron_action( $timestamp, $schedule, $hook, $args, $this->get_slug() );
}
/**
* Enqueue an action to run one time, as soon as possible
*
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function enqueue_async_action( string $hook, $args = [] ): int {
$this->async_runner->attach_shutdown_hook();
return $this->schedule_immediate( $hook, $args );
}
/**
* Check if there is an existing action in the queue with a given hook and args combination.
*
* An action in the queue could be pending, in-progress or async. If the action is pending for a time in
* future, currently being run, or an async action sitting in the queue waiting to be processed, boolean
* true will be returned. Or there may be no async, in-progress or pending action for this hook, in which
* case, boolean false will be the return value.
*
* @param string $hook
* @param array|null $args
*
* @return bool True if there is a pending scheduled, async or in-progress action in the queue or false if there is no matching action.
*/
public function has_scheduled_action( string $hook, $args = [] ): bool {
return ( false !== as_next_scheduled_action( $hook, $args, $this->get_slug() ) );
}
/**
* Search for scheduled actions.
*
* @param array|null $args See as_get_scheduled_actions() for possible arguments.
* @param string $return_format OBJECT, ARRAY_A, or ids.
*
* @return array
*/
public function search( $args = [], $return_format = OBJECT ): array {
$args['group'] = $this->get_slug();
return as_get_scheduled_actions( $args, $return_format );
}
/**
* Cancel the next scheduled instance of an action with a matching hook (and optionally matching args).
*
* Any recurring actions with a matching hook should also be cancelled, not just the next scheduled action.
*
* @param string $hook The hook that the job will trigger.
* @param array|null $args Args that would have been passed to the job.
*
* @return int The scheduled action ID if a scheduled action was found.
*
* @throws ActionSchedulerException If no matching action found.
*/
public function cancel( string $hook, $args = [] ) {
$action_id = as_unschedule_action( $hook, $args, $this->get_slug() );
if ( null === $action_id ) {
throw ActionSchedulerException::action_not_found( $hook );
}
return $action_id;
}
/**
* Retrieve an action.
*
* @param int $action_id Action ID.
*
* @return ActionScheduler_Action
*
* @since 1.7.0
*/
public function fetch_action( int $action_id ): ActionScheduler_Action {
return ActionSchedulerCore::store()->fetch_action( $action_id );
}
}
ActionScheduler/ActionSchedulerException.php 0000644 00000001567 15153721356 0015305 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use LogicException;
defined( 'ABSPATH' ) || exit;
/**
* Class ActionSchedulerException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler
*/
class ActionSchedulerException extends LogicException implements GoogleListingsAndAdsException {
/**
* Create a new exception instance for when a job item is not found.
*
* @param string $action Action name
*
* @return ActionSchedulerException
*/
public static function action_not_found( string $action ): ActionSchedulerException {
return new static(
sprintf(
/* translators: %s: the action name */
__( 'No action matching %s was found.', 'google-listings-and-ads' ),
$action
)
);
}
}
ActionScheduler/ActionSchedulerInterface.php 0000644 00000012431 15153721356 0015237 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler;
use ActionScheduler_Action;
defined( 'ABSPATH' ) || exit;
/**
* Interface ActionSchedulerInterface
*
* Acts as a wrapper for ActionScheduler's public functions.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler
*/
interface ActionSchedulerInterface {
public const STATUS_COMPLETE = 'complete';
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'in-progress';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
/**
* Schedule an action to run once at some time in the future
*
* @param int $timestamp When the job will run.
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function schedule_single( int $timestamp, string $hook, $args = [] ): int;
/**
* Schedule an action to run now i.e. in the next available batch.
*
* This differs from async actions by having a scheduled time rather than being set for '0000-00-00 00:00:00'.
* We could use an async action instead but they can't be viewed easily in the admin area
* because the table is sorted by schedule date.
*
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function schedule_immediate( string $hook, $args = [] ): int;
/**
* Schedule a recurring action to run now (i.e. in the next available batch), and in the given intervals.
*
* @param int $timestamp When the job will run.
* @param int $interval_in_seconds How long to wait between runs.
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function schedule_recurring( int $timestamp, int $interval_in_seconds, string $hook, $args = [] ): int;
/**
* Schedule an action that recurs on a cron-like schedule.
*
* @param int $timestamp The first instance of the action will be scheduled to run at a time
* calculated after this timestamp matching the cron expression. This
* can be used to delay the first instance of the action.
* @param string $schedule A cron-link schedule string
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*
* @see https://en.wikipedia.org/wiki/Cron
* * * * * * *
* ┬ ┬ ┬ ┬ ┬ ┬
* | | | | | |
* | | | | | + year [optional]
* | | | | +----- day of week (0 - 7) (Sunday=0 or 7)
* | | | +---------- month (1 - 12)
* | | +--------------- day of month (1 - 31)
* | +-------------------- hour (0 - 23)
* +------------------------- min (0 - 59)
*/
public function schedule_cron( int $timestamp, string $schedule, string $hook, $args = [] ): int;
/**
* Enqueue an action to run one time, as soon as possible
*
* @param string $hook The hook to trigger.
* @param array|null $args Arguments to pass when the hook triggers.
*
* @return int The action ID.
*/
public function enqueue_async_action( string $hook, $args = [] ): int;
/**
* Check if there is an existing action in the queue with a given hook and args combination.
*
* An action in the queue could be pending, in-progress or async. If the action is pending for a time in
* future, currently being run, or an async action sitting in the queue waiting to be processed, boolean
* true will be returned. Or there may be no async, in-progress or pending action for this hook, in which
* case, boolean false will be the return value.
*
* @param string $hook
* @param array|null $args
*
* @return bool True if there is a pending scheduled, async or in-progress action in the queue or false if there is no matching action.
*/
public function has_scheduled_action( string $hook, $args = [] ): bool;
/**
* Search for scheduled actions.
*
* @param array|null $args See as_get_scheduled_actions() for possible arguments.
* @param string $return_format OBJECT, ARRAY_A, or ids.
*
* @return array
*/
public function search( $args = [], $return_format = OBJECT ): array;
/**
* Cancel the next scheduled instance of an action with a matching hook (and optionally matching args).
*
* Any recurring actions with a matching hook should also be cancelled, not just the next scheduled action.
*
* @param string $hook The hook that the job will trigger.
* @param array|null $args Args that would have been passed to the job.
*
* @return int The scheduled action ID if a scheduled action was found.
*
* @throws ActionSchedulerException If no matching action found.
*/
public function cancel( string $hook, $args = [] );
/**
* Retrieve an action.
*
* @param int $action_id Action ID.
*
* @return ActionScheduler_Action
*
* @since 1.7.0
*/
public function fetch_action( int $action_id ): ActionScheduler_Action;
}
ActionScheduler/AsyncActionRunner.php 0000644 00000004353 15153721356 0013753 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler;
use ActionScheduler_AsyncRequest_QueueRunner as QueueRunnerAsyncRequest;
use ActionScheduler_Lock;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class AsyncActionRunner
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler
*/
class AsyncActionRunner implements Service {
/**
* Whether the shutdown hook has been attached.
*
* @var bool
*/
protected $has_attached_shutdown_hook = false;
/**
* @var QueueRunnerAsyncRequest
*/
protected $async_request;
/**
* @var ActionScheduler_Lock
*/
protected $locker;
/**
* AsyncActionRunner constructor.
*
* @param QueueRunnerAsyncRequest $async_request
* @param ActionScheduler_Lock $locker
*/
public function __construct( QueueRunnerAsyncRequest $async_request, ActionScheduler_Lock $locker ) {
$this->async_request = $async_request;
$this->locker = $locker;
}
/**
* Attach async runner shutdown hook before ActionScheduler shutdown hook.
*
* The shutdown hook should only be attached if an async event has been created in the current request.
* The hook is only attached if it hasn't already been attached.
*
* @see ActionScheduler_QueueRunner::hook_dispatch_async_request
*/
public function attach_shutdown_hook() {
if ( $this->has_attached_shutdown_hook ) {
return;
}
$this->has_attached_shutdown_hook = true;
add_action( 'shutdown', [ $this, 'maybe_dispatch_async_request' ], 9 );
}
/**
* Dispatches an async queue runner request if various conditions are met.
*
* Note: This is a temporary solution. In the future (probably ActionScheduler 3.2) we should use the filter
* added in https://github.com/woocommerce/action-scheduler/pull/628.
*/
public function maybe_dispatch_async_request() {
if ( is_admin() ) {
// ActionScheduler will dispatch an async runner request on it's own.
return;
}
if ( $this->locker->is_locked( 'async-request-runner' ) ) {
// An async runner request has already occurred in the last 60 seconds.
return;
}
$this->locker->set( 'async-request-runner' );
$this->async_request->maybe_dispatch();
}
}
Admin/Admin.php 0000644 00000025344 15153721356 0007355 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox\MetaBoxInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AdminScriptWithBuiltDependenciesAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AdminStyleAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\Asset;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\ViewFactory;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray;
use Automattic\WooCommerce\GoogleListingsAndAds\View\ViewException;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\Admin\PageController;
/**
* Class Admin
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Pages
*/
class Admin implements OptionsAwareInterface, Registerable, Service {
use OptionsAwareTrait;
use PluginHelper;
/**
* @var AssetsHandlerInterface
*/
protected $assets_handler;
/**
* @var ViewFactory
*/
protected $view_factory;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var AdsService
*/
protected $ads;
/**
* Admin constructor.
*
* @param AssetsHandlerInterface $assets_handler
* @param ViewFactory $view_factory
* @param MerchantCenterService $merchant_center
* @param AdsService $ads
*/
public function __construct( AssetsHandlerInterface $assets_handler, ViewFactory $view_factory, MerchantCenterService $merchant_center, AdsService $ads ) {
$this->assets_handler = $assets_handler;
$this->view_factory = $view_factory;
$this->merchant_center = $merchant_center;
$this->ads = $ads;
}
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_enqueue_scripts',
function () {
if ( PageController::is_admin_page() ) {
// Enqueue the required JavaScript scripts and CSS styles of the Media library.
wp_enqueue_media();
}
$assets = $this->get_assets();
$this->assets_handler->register_many( $assets );
$this->assets_handler->enqueue_many( $assets );
}
);
add_action(
"plugin_action_links_{$this->get_plugin_basename()}",
function ( $links ) {
return $this->add_plugin_links( $links );
}
);
add_action(
'wp_default_scripts',
function ( $scripts ) {
$this->inject_fast_refresh_for_dev( $scripts );
},
20
);
add_action( 'admin_init', [ $this, 'privacy_policy' ] );
}
/**
* Return an array of assets.
*
* @return Asset[]
*/
protected function get_assets(): array {
$wc_admin_condition = function () {
return PageController::is_admin_page();
};
$assets[] = ( new AdminScriptWithBuiltDependenciesAsset(
'google-listings-and-ads',
'js/build/index',
"{$this->get_root_dir()}/js/build/index.asset.php",
new BuiltScriptDependencyArray(
[
'dependencies' => [],
'version' => (string) filemtime( "{$this->get_root_dir()}/js/build/index.js" ),
]
),
$wc_admin_condition
) )->add_inline_script(
'glaData',
[
'slug' => $this->get_slug(),
'mcSetupComplete' => $this->merchant_center->is_setup_complete(),
'mcSupportedCountry' => $this->merchant_center->is_store_country_supported(),
'mcSupportedLanguage' => $this->merchant_center->is_language_supported(),
'adsCampaignConvertStatus' => $this->options->get( OptionsInterface::CAMPAIGN_CONVERT_STATUS ),
'adsSetupComplete' => $this->ads->is_setup_complete(),
'enableReports' => $this->enableReports(),
'dateFormat' => get_option( 'date_format' ),
'timeFormat' => get_option( 'time_format' ),
'siteLogoUrl' => wp_get_attachment_image_url( get_theme_mod( 'custom_logo' ), 'full' ),
'initialWpData' => [
'version' => $this->get_version(),
'mcId' => $this->options->get_merchant_id() ?: null,
'adsId' => $this->options->get_ads_id() ?: null,
],
]
);
$assets[] = ( new AdminStyleAsset(
'google-listings-and-ads-css',
'/js/build/index',
defined( 'WC_ADMIN_PLUGIN_FILE' ) ? [ 'wc-admin-app' ] : [],
(string) filemtime( "{$this->get_root_dir()}/js/build/index.css" ),
$wc_admin_condition
) );
$product_condition = function () {
$screen = get_current_screen();
return ( null !== $screen && 'product' === $screen->id );
};
$assets[] = ( new AdminScriptWithBuiltDependenciesAsset(
'gla-product-attributes',
'js/build/product-attributes',
"{$this->get_root_dir()}/js/build/product-attributes.asset.php",
new BuiltScriptDependencyArray(
[
'dependencies' => [],
'version' => (string) filemtime( "{$this->get_root_dir()}/js/build/product-attributes.js" ),
]
),
$product_condition
) )->add_inline_script(
'glaProductData',
[
'applicableProductTypes' => ProductSyncer::get_supported_product_types(),
]
);
$assets[] = ( new AdminStyleAsset(
'gla-product-attributes-css',
'js/build/product-attributes',
[],
'',
$product_condition
) );
return $assets;
}
/**
* Adds links to the plugin's row in the "Plugins" wp-admin page.
*
* @see https://codex.wordpress.org/Plugin_API/Filter_Reference/plugin_action_links_(plugin_file_name)
* @param array $links The existing list of links that will be rendered.
*/
protected function add_plugin_links( $links ): array {
$plugin_links = [];
// Display settings url if setup is complete otherwise link to get started page
if ( $this->merchant_center->is_setup_complete() ) {
$plugin_links[] = sprintf(
'<a href="%1$s">%2$s</a>',
esc_attr( $this->get_settings_url() ),
esc_html__( 'Settings', 'google-listings-and-ads' )
);
} else {
$plugin_links[] = sprintf(
'<a href="%1$s">%2$s</a>',
esc_attr( $this->get_start_url() ),
esc_html__( 'Get Started', 'google-listings-and-ads' )
);
}
$plugin_links[] = sprintf(
'<a href="%1$s">%2$s</a>',
esc_attr( $this->get_documentation_url() ),
esc_html__( 'Documentation', 'google-listings-and-ads' )
);
// Add new links to the beginning
return array_merge( $plugin_links, $links );
}
/**
* Adds a meta box.
*
* @param MetaBoxInterface $meta_box
*/
public function add_meta_box( MetaBoxInterface $meta_box ) {
add_filter(
"postbox_classes_{$meta_box->get_screen()}_{$meta_box->get_id()}",
function ( array $classes ) use ( $meta_box ) {
return array_merge( $classes, $meta_box->get_classes() );
}
);
add_meta_box(
$meta_box->get_id(),
$meta_box->get_title(),
$meta_box->get_callback(),
$meta_box->get_screen(),
$meta_box->get_context(),
$meta_box->get_priority(),
$meta_box->get_callback_args()
);
}
/**
* @param string $view Name of the view
* @param array $context_variables Array of variables to pass to the view
*
* @return string The rendered view
*
* @throws ViewException If the view doesn't exist or can't be loaded.
*/
public function get_view( string $view, array $context_variables = [] ): string {
return $this->view_factory->create( $view )
->render( $context_variables );
}
/**
* Only show reports if we enable it through a snippet.
*
* @return bool Whether reports should be enabled .
*/
protected function enableReports(): bool {
return apply_filters( 'woocommerce_gla_enable_reports', true );
}
/**
* Add suggested privacy policy content
*
* @return void
*/
public function privacy_policy() {
$policy_text = sprintf(
/* translators: 1) HTML anchor open tag 2) HTML anchor closing tag */
esc_html__( 'By using this extension, you may be storing personal data or sharing data with an external service. %1$sLearn more about what data is collected by Google and what you may want to include in your privacy policy%2$s.', 'google-listings-and-ads' ),
'<a href="https://support.google.com/adspolicy/answer/54817" target="_blank">',
'</a>'
);
// As the extension doesn't offer suggested privacy policy text, the button to copy it is hidden.
$content = '
<p class="privacy-policy-tutorial">' . $policy_text . '</p>
<style>#privacy-settings-accordion-block-google-listings-ads .privacy-settings-accordion-actions { display: none }</style>';
wp_add_privacy_policy_content( 'Google for WooCommerce', wpautop( $content, false ) );
}
/**
* This method is ONLY used during development.
*
* The runtime.js file is created when the front-end is developed in Fast Refresh mode
* and must be loaded together to enable the mode.
*
* When Gutenberg is not installed or not activated, the react dependency will not have
* the 'wp-react-refresh-entry' handle, so here injects the Fast Refresh scripts we built.
*
* The Fast Refresh also needs the development version of React and ReactDOM.
* They will be replaced if the SCRIPT_DEBUG flag is not enabled.
*
* @param WP_Scripts $scripts WP_Scripts instance.
*/
private function inject_fast_refresh_for_dev( $scripts ) {
$runtime_path = "{$this->get_root_dir()}/js/build/runtime.js";
if ( ! file_exists( $runtime_path ) ) {
return;
}
$react_script = $scripts->query( 'react', 'registered' );
if ( ! $react_script ) {
return;
}
if ( ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) {
$react_dom_script = $scripts->query( 'react-dom', 'registered' );
$react_dom_script->src = str_replace( '.min', '', $react_dom_script->src );
$react_script->src = str_replace( '.min', '', $react_script->src );
}
$plugin_url = $this->get_plugin_url();
$scripts->add(
'gla-webpack-runtime',
"{$plugin_url}/js/build/runtime.js",
[],
(string) filemtime( $runtime_path )
);
$react_script->deps[] = 'gla-webpack-runtime';
if ( ! in_array( 'wp-react-refresh-entry', $react_script->deps, true ) ) {
$scripts->add(
'wp-react-refresh-runtime',
"{$plugin_url}/js/build-dev/react-refresh-runtime.js",
[]
);
$scripts->add(
'wp-react-refresh-entry',
"{$plugin_url}/js/build-dev/react-refresh-entry.js",
[ 'wp-react-refresh-runtime' ]
);
$react_script->deps[] = 'wp-react-refresh-entry';
}
}
}
Admin/BulkEdit/BulkEditInitializer.php 0000644 00000002112 15153721356 0013723 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Class BulkEditInitializer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit
*/
class BulkEditInitializer implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action( 'save_post', [ $this, 'bulk_edit_hook' ], 10, 2 );
}
/**
* Offers a way to hook into save post without causing an infinite loop
* when bulk saving info.
*
* @since 3.0.0
* @param int $post_id Post ID being saved.
* @param WP_Post $post Post object being saved.
*/
public function bulk_edit_hook( int $post_id, WP_Post $post ): void {
remove_action( 'save_post', [ $this, 'bulk_edit_hook' ] );
do_action( 'bulk_edit_save_post', $post_id, $post );
add_action( 'save_post', [ $this, 'bulk_edit_hook' ], 10, 2 );
}
}
Admin/BulkEdit/BulkEditInterface.php 0000644 00000002131 15153721356 0013341 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use WP_Post;
defined( 'ABSPATH' ) || exit;
interface BulkEditInterface extends Service, Conditional {
public const SCREEN_COUPON = 'shop_coupon';
/**
* Function that renders view of custom bulk edit fields.
*
* @param string $column_name Column being shown.
* @param string $post_type Post type being shown.
*/
public function render_view( string $column_name, string $post_type );
/**
* The screen or screens on which to show the box (such as a post type, 'link', or 'comment').
*
* Default is the current screen.
*
* @return string
*/
public function get_screen(): string;
/**
* Handle the bulk edit submission.
*
* @param int $post_id Post ID being saved.
* @param WP_Post $post Post object being saved.
*
* @return int $post_id
*/
public function handle_submission( int $post_id, WP_Post $post ): int;
}
Admin/BulkEdit/CouponBulkEdit.php 0000644 00000010604 15153721356 0012710 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\ViewHelperTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use WC_Coupon;
defined( 'ABSPATH' ) || exit;
/**
* Class CouponBulkEdit
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit
*/
class CouponBulkEdit implements BulkEditInterface, Registerable {
use AdminConditional;
use ViewHelperTrait;
protected const VIEW_PATH = 'views/bulk-edit/shop_coupon.php';
protected const TARGET_COLUMN = 'usage';
/**
* @var CouponMetaHandler
*/
protected $meta_handler;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var TargetAudience
*/
protected $target_audience;
/**
* CouponBulkEdit constructor.
*
* @param CouponMetaHandler $meta_handler
* @param MerchantCenterService $merchant_center
* @param TargetAudience $target_audience
*/
public function __construct( CouponMetaHandler $meta_handler, MerchantCenterService $merchant_center, TargetAudience $target_audience ) {
$this->meta_handler = $meta_handler;
$this->merchant_center = $merchant_center;
$this->target_audience = $target_audience;
}
/**
* Register a service.
*/
public function register(): void {
add_action( 'bulk_edit_custom_box', [ $this, 'render_view' ], 10, 2 );
add_action( 'bulk_edit_save_post', [ $this, 'handle_submission' ], 10, 2 );
}
/**
* The screen on which to show the bulk edit view.
*
* @return string
*/
public function get_screen(): string {
return self::SCREEN_COUPON;
}
/**
* Render the coupon bulk edit view.
*
* @param string $column_name Column being shown.
* @param string $post_type Post type being shown.
*/
public function render_view( $column_name, $post_type ) {
if ( $this->get_screen() !== $post_type || self::TARGET_COLUMN !== $column_name ) {
return;
}
if ( ! $this->merchant_center->is_setup_complete() ) {
return;
}
$target_country = $this->target_audience->get_main_target_country();
if ( ! $this->merchant_center->is_promotion_supported_country( $target_country ) ) {
return;
}
include path_join( dirname( __DIR__, 3 ), self::VIEW_PATH );
}
/**
* Handle the coupon bulk edit submission.
*
* @param int $post_id Post ID being saved.
* @param object $post Post object being saved.
*
* @return int $post_id
*/
public function handle_submission( int $post_id, $post ): int {
$request_data = $this->request_data();
// If this is an autosave, our form has not been submitted, so we don't want to do anything.
if ( Constants::is_true( 'DOING_AUTOSAVE' ) ) {
return $post_id;
}
// Don't save revisions and autosaves.
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || $this->get_screen() !== $post->post_type || ! current_user_can( 'edit_post', $post_id ) ) {
return $post_id;
}
// Check nonce.
if ( ! isset( $request_data['woocommerce_gla_bulk_edit'] ) || ! wp_verify_nonce( $request_data['woocommerce_gla_bulk_edit_nonce'], 'woocommerce_gla_bulk_edit_nonce' ) ) {
return $post_id;
}
if ( ! empty( $request_data['change_channel_visibility'] ) ) {
// Get the coupon and save.
$coupon = new WC_Coupon( $post_id );
$visibility =
ChannelVisibility::cast( sanitize_key( $request_data['change_channel_visibility'] ) );
if ( $this->meta_handler->get_visibility( $coupon ) !== $visibility ) {
$this->meta_handler->update_visibility( $coupon, $visibility );
do_action( 'woocommerce_gla_bulk_update_coupon', $post_id );
}
}
return $post_id;
}
/**
* Get the current request data ($_REQUEST superglobal).
* This method is added to ease unit testing.
*
* @return array The $_REQUEST superglobal.
*/
protected function request_data(): array {
// Nonce must be verified manually.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $_REQUEST;
}
}
Admin/Input/BooleanSelect.php 0000644 00000001466 15153721356 0012142 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Class BooleanSelect
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class BooleanSelect extends Select {
/**
* @return array
*/
public function get_options(): array {
return [
'' => __( 'Default', 'google-listings-and-ads' ),
'yes' => __( 'Yes', 'google-listings-and-ads' ),
'no' => __( 'No', 'google-listings-and-ads' ),
];
}
/**
* Return the data used for the input's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = parent::get_view_data();
if ( is_bool( $view_data['value'] ) ) {
$view_data['value'] = wc_bool_to_string( $view_data['value'] );
}
return $view_data;
}
}
Admin/Input/DateTime.php 0000644 00000004266 15153721356 0011120 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
use DateTimeZone;
use Exception;
use WC_DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Class DateTime
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*
* @since 1.5.0
*/
class DateTime extends Input {
/**
* DateTime constructor.
*/
public function __construct() {
parent::__construct( 'datetime', 'google-listings-and-ads/product-date-time-field' );
}
/**
* Return the data used for the input's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = parent::get_view_data();
if ( ! empty( $this->get_value() ) ) {
try {
// Display the time in site's local timezone.
$datetime = new WC_DateTime( $this->get_value(), new DateTimeZone( 'UTC' ) );
$datetime->setTimezone( new DateTimeZone( $this->get_local_tz_string() ) );
$view_data['value'] = $datetime->format( 'Y-m-d H:i:s' );
$view_data['date'] = $datetime->format( 'Y-m-d' );
$view_data['time'] = $datetime->format( 'H:i' );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
$view_data['value'] = '';
$view_data['date'] = '';
$view_data['time'] = '';
}
}
return $view_data;
}
/**
* Set the form's data.
*
* @param mixed $data
*
* @return void
*/
public function set_data( $data ): void {
if ( is_array( $data ) ) {
if ( ! empty( $data['date'] ) ) {
$date = $data['date'] ?? '';
$time = $data['time'] ?? '';
$data = sprintf( '%s%s', $date, $time );
} else {
$data = '';
}
}
if ( ! empty( $data ) ) {
try {
// Store the time in UTC.
$datetime = new WC_DateTime( $data, new DateTimeZone( $this->get_local_tz_string() ) );
$datetime->setTimezone( new DateTimeZone( 'UTC' ) );
$data = (string) $datetime;
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
$data = '';
}
}
parent::set_data( $data );
}
/**
* Get site's local timezone string from WordPress settings.
*
* @return string
*/
protected function get_local_tz_string(): string {
return wp_timezone_string();
}
}
Admin/Input/Form.php 0000644 00000011107 15153721356 0010317 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class Form
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class Form implements FormInterface {
use ValidateInterface;
/**
* @var string
*/
protected $name = '';
/**
* @var mixed
*/
protected $data;
/**
* @var FormInterface[]
*/
protected $children = [];
/**
* @var FormInterface
*/
protected $parent;
/**
* @var bool
*/
protected $is_submitted = false;
/**
* Form constructor.
*
* @param mixed $data
*/
public function __construct( $data = null ) {
$this->set_data( $data );
}
/**
* @return string
*/
public function get_name(): string {
return $this->name;
}
/**
* @param string $name
*
* @return FormInterface
*/
public function set_name( string $name ): FormInterface {
$this->name = $name;
return $this;
}
/**
* @return FormInterface[]
*/
public function get_children(): array {
return $this->children;
}
/**
* Add a child form.
*
* @param FormInterface $form
*
* @return FormInterface
*
* @throws FormException If form is already submitted.
*/
public function add( FormInterface $form ): FormInterface {
if ( $this->is_submitted ) {
throw FormException::cannot_modify_submitted();
}
$this->children[ $form->get_name() ] = $form;
$form->set_parent( $this );
return $this;
}
/**
* Remove a child with the given name from the form's children.
*
* @param string $name
*
* @return FormInterface
*
* @throws FormException If form is already submitted.
*/
public function remove( string $name ): FormInterface {
if ( $this->is_submitted ) {
throw FormException::cannot_modify_submitted();
}
if ( isset( $this->children[ $name ] ) ) {
$this->children[ $name ]->set_parent( null );
unset( $this->children[ $name ] );
}
return $this;
}
/**
* Whether the form contains a child with the given name.
*
* @param string $name
*
* @return bool
*/
public function has( string $name ): bool {
return isset( $this->children[ $name ] );
}
/**
* @param FormInterface|null $form
*
* @return void
*/
public function set_parent( ?FormInterface $form ): void {
$this->parent = $form;
}
/**
* @return FormInterface|null
*/
public function get_parent(): ?FormInterface {
return $this->parent;
}
/**
* Return the form's data.
*
* @return mixed
*/
public function get_data() {
return $this->data;
}
/**
* Set the form's data.
*
* @param mixed $data
*
* @return void
*/
public function set_data( $data ): void {
if ( is_array( $data ) && ! empty( $this->children ) ) {
$this->data = $this->map_children_data( $data );
} else {
if ( is_string( $data ) ) {
$data = trim( $data );
}
$this->data = $data;
}
}
/**
* Maps the data to each child and returns the mapped data.
*
* @param array $data
*
* @return array
*/
protected function map_children_data( array $data ): array {
$children_data = [];
foreach ( $data as $key => $datum ) {
if ( isset( $this->children[ $key ] ) ) {
$this->children[ $key ]->set_data( $datum );
$children_data[ $key ] = $this->children[ $key ]->get_data();
}
}
return $children_data;
}
/**
* Submit the form.
*
* @param array $submitted_data
*/
public function submit( array $submitted_data = [] ): void {
// todo: add form validation
if ( ! $this->is_submitted ) {
$this->is_submitted = true;
$this->set_data( $submitted_data );
}
}
/**
* Return the data used for the form's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = [
'name' => $this->get_view_name(),
'is_root' => $this->is_root(),
'children' => [],
];
foreach ( $this->get_children() as $index => $form ) {
$view_data['children'][ $index ] = $form->get_view_data();
}
return $view_data;
}
/**
* Return the name used for the form's view.
*
* @return string
*/
public function get_view_name(): string {
return $this->is_root() ? sprintf( 'gla_%s', $this->get_name() ) : sprintf( '%s[%s]', $this->get_parent()->get_view_name(), $this->get_name() );
}
/**
* Whether this is the root form (i.e. has no parents).
*
* @return bool
*/
public function is_root(): bool {
return null === $this->parent;
}
/**
* Whether the form has been already submitted.
*
* @return bool
*/
public function is_submitted(): bool {
return $this->is_submitted;
}
}
Admin/Input/FormException.php 0000644 00000001234 15153721356 0012176 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class FormException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class FormException extends Exception implements GoogleListingsAndAdsException {
/**
* Return a new instance of the exception when a submitted form is being modified.
*
* @return static
*/
public static function cannot_modify_submitted(): FormException {
return new static( 'You cannot modify a submitted form.' );
}
}
Admin/Input/FormInterface.php 0000644 00000004170 15153721356 0012142 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Interface FormInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
interface FormInterface {
/**
* Return the form's data.
*
* @return mixed
*/
public function get_data();
/**
* Set the form's data.
*
* @param mixed $data
*
* @return void
*/
public function set_data( $data ): void;
/**
* Return the form name.
*
* @return string
*/
public function get_name(): string;
/**
* Set the form's name.
*
* @param string $name
*
* @return FormInterface
*/
public function set_name( string $name ): FormInterface;
/**
* Submit the form.
*
* @param array $submitted_data
*
* @return void
*/
public function submit( array $submitted_data = [] ): void;
/**
* Return the data used for the form's view.
*
* @return array
*/
public function get_view_data(): array;
/**
* Return the name used for the form's view.
*
* @return string
*/
public function get_view_name(): string;
/**
* @return FormInterface[]
*/
public function get_children(): array;
/**
* Add a child form.
*
* @param FormInterface $form
*
* @return FormInterface
*/
public function add( FormInterface $form ): FormInterface;
/**
* Remove a child with the given name from the form's children.
*
* @param string $name
*
* @return FormInterface
*/
public function remove( string $name ): FormInterface;
/**
* Whether the form contains a child with the given name.
*
* @param string $name
*
* @return bool
*/
public function has( string $name ): bool;
/**
* @param FormInterface|null $form
*
* @return void
*/
public function set_parent( ?FormInterface $form ): void;
/**
* @return FormInterface|null
*/
public function get_parent(): ?FormInterface;
/**
* If this is the root form (i.e. has no parents)
*
* @return bool
*/
public function is_root(): bool;
/**
* Whether the form has been already submitted.
*
* @return bool
*/
public function is_submitted(): bool;
}
Admin/Input/Input.php 0000644 00000012644 15153721356 0010522 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class Input
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class Input extends Form implements InputInterface {
use PluginHelper;
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $type;
/**
* @var string
*/
protected $block_name;
/**
* @var array
*/
protected $block_attributes = [];
/**
* @var string
*/
protected $label;
/**
* @var string
*/
protected $description;
/**
* @var mixed
*/
protected $value;
/**
* @var bool
*/
protected $is_readonly = false;
/**
* @var bool
*/
protected $is_hidden = false;
/**
* Input constructor.
*
* @param string $type
* @param string $block_name The name of a generic product block in WooCommerce core or a custom block in this extension.
*/
public function __construct( string $type, string $block_name ) {
$this->type = $type;
$this->block_name = $block_name;
parent::__construct();
}
/**
* @return string|null
*/
public function get_id(): ?string {
return $this->id;
}
/**
* @return string
*/
public function get_type(): string {
return $this->type;
}
/**
* @return string|null
*/
public function get_label(): ?string {
return $this->label;
}
/**
* @return string|null
*/
public function get_description(): ?string {
return $this->description;
}
/**
* @return mixed
*/
public function get_value() {
return $this->get_data();
}
/**
* @param string|null $id
*
* @return InputInterface
*/
public function set_id( ?string $id ): InputInterface {
$this->id = $id;
return $this;
}
/**
* @param string|null $label
*
* @return InputInterface
*/
public function set_label( ?string $label ): InputInterface {
$this->label = $label;
return $this;
}
/**
* @param string|null $description
*
* @return InputInterface
*/
public function set_description( ?string $description ): InputInterface {
$this->description = $description;
return $this;
}
/**
* @param mixed $value
*
* @return InputInterface
*/
public function set_value( $value ): InputInterface {
$this->set_data( $value );
return $this;
}
/**
* @return bool
*/
public function is_readonly(): bool {
return $this->is_readonly;
}
/**
* @param bool $value
*
* @return InputInterface
*/
public function set_readonly( bool $value ): InputInterface {
$this->is_readonly = $value;
return $this;
}
/**
* @param bool $value
*
* @return InputInterface
*/
public function set_hidden( bool $value ): InputInterface {
$this->is_hidden = $value;
return $this;
}
/**
* @return bool
*/
public function is_hidden(): bool {
return $this->is_hidden;
}
/**
* Return the data used for the input's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = [
'id' => $this->get_view_id(),
'type' => $this->get_type(),
'label' => $this->get_label(),
'value' => $this->get_value(),
'description' => $this->get_description(),
'desc_tip' => true,
];
if ( $this->is_readonly ) {
$view_data['custom_attributes'] = [
'readonly' => 'readonly',
];
}
return array_merge( parent::get_view_data(), $view_data );
}
/**
* Return the id used for the input's view.
*
* @return string
*/
public function get_view_id(): string {
$parent = $this->get_parent();
if ( $parent instanceof InputInterface ) {
return sprintf( '%s_%s', $parent->get_view_id(), $this->get_id() );
} elseif ( $parent instanceof FormInterface ) {
return sprintf( '%s_%s', $parent->get_view_name(), $this->get_id() );
}
return sprintf( 'gla_%s', $this->get_name() );
}
/**
* Return the name of a generic product block in WooCommerce core or a custom block in this extension.
*
* @return string
*/
public function get_block_name(): string {
return $this->block_name;
}
/**
* Add or update a block attribute used for block config.
*
* @param string $key The attribute key defined in the corresponding block.json
* @param mixed $value The attribute value defined in the corresponding block.json
*
* @return InputInterface
*/
public function set_block_attribute( string $key, $value ): InputInterface {
$this->block_attributes[ $key ] = $value;
return $this;
}
/**
* Return the attributes of block config used for the input's view within the Product Block Editor.
*
* @return array
*/
public function get_block_attributes(): array {
$meta_key = $this->prefix_meta_key( $this->get_id() );
$block_attributes = array_merge(
[
'property' => "meta_data.{$meta_key}",
'label' => $this->get_label(),
'tooltip' => $this->get_description(),
],
$this->block_attributes
);
// Set boolean disabled property only if it's needed.
if ( $this->is_readonly() ) {
$block_attributes['disabled'] = true;
}
return $block_attributes;
}
/**
* Return the config used for the input's block within the Product Block Editor.
*
* @return array
*/
public function get_block_config(): array {
$id = $this->get_id();
return [
'id' => "google-listings-and-ads-product-attributes-{$id}",
'blockName' => $this->get_block_name(),
'attributes' => $this->get_block_attributes(),
];
}
}
Admin/Input/InputInterface.php 0000644 00000004171 15153721356 0012337 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Interface InputInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
interface InputInterface extends FormInterface {
/**
* @return string|null
*/
public function get_id(): ?string;
/**
* @param string|null $id
*
* @return InputInterface
*/
public function set_id( ?string $id ): InputInterface;
/**
* @return string
*/
public function get_type(): string;
/**
* @return string|null
*/
public function get_label(): ?string;
/**
* @param string|null $label
*
* @return InputInterface
*/
public function set_label( ?string $label ): InputInterface;
/**
* @return string|null
*/
public function get_description(): ?string;
/**
* @param string|null $description
*
* @return InputInterface
*/
public function set_description( ?string $description ): InputInterface;
/**
* @return mixed
*/
public function get_value();
/**
* @param mixed $value
*
* @return InputInterface
*/
public function set_value( $value ): InputInterface;
/**
* Return the id used for the input's view.
*
* @return string
*/
public function get_view_id(): string;
/**
* Return the name of a generic product block in WooCommerce core or a custom block in this extension.
*
* @return string
*/
public function get_block_name(): string;
/**
* Add or update a block attribute used for block config.
*
* @param string $key The attribute key defined in the corresponding block.json
* @param mixed $value The attribute value defined in the corresponding block.json
*
* @return InputInterface
*/
public function set_block_attribute( string $key, $value ): InputInterface;
/**
* Return the attributes of block config used for the input's view within the Product Block Editor.
*
* @return array
*/
public function get_block_attributes(): array;
/**
* Return the block config used for the input's view within the Product Block Editor.
*
* @return array
*/
public function get_block_config(): array;
}
Admin/Input/Integer.php 0000644 00000001253 15153721356 0011012 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Class Integer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class Integer extends Input {
/**
* Integer constructor.
*/
public function __construct() {
// Ideally, it should use the 'woocommerce/product-number-field' block
// but the block doesn't support integer validation. Therefore, it uses
// the text field block to work around it.
parent::__construct( 'integer', 'woocommerce/product-text-field' );
$this->set_block_attribute(
'pattern',
[
'value' => '0|[1-9]\d*',
]
);
}
}
Admin/Input/Select.php 0000644 00000002666 15153721356 0010645 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Class Select
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class Select extends Input {
/**
* @var array
*/
protected $options = [];
/**
* Select constructor.
*/
public function __construct() {
parent::__construct( 'select', 'google-listings-and-ads/product-select-field' );
}
/**
* @return array
*/
public function get_options(): array {
return $this->options;
}
/**
* @param array $options
*
* @return $this
*/
public function set_options( array $options ): Select {
$this->options = $options;
return $this;
}
/**
* Return the data used for the input's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = parent::get_view_data();
$view_data['options'] = $this->get_options();
// add custom class
$view_data['class'] = 'select short';
return $view_data;
}
/**
* Return the attributes of block config used for the input's view within the Product Block Editor.
*
* @return array
*/
public function get_block_attributes(): array {
$options = [];
foreach ( $this->get_options() as $key => $value ) {
$options[] = [
'label' => $value,
'value' => $key,
];
}
$this->set_block_attribute( 'options', $options );
return parent::get_block_attributes();
}
}
Admin/Input/SelectWithTextInput.php 0000644 00000010500 15153721356 0013350 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Class Select
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class SelectWithTextInput extends Input {
public const CUSTOM_INPUT_KEY = '_gla_custom_value';
public const SELECT_INPUT_KEY = '_gla_select';
/**
* SelectWithTextInput constructor.
*/
public function __construct() {
$select_input = ( new Select() )->set_id( self::SELECT_INPUT_KEY )
->set_name( self::SELECT_INPUT_KEY );
$this->add( $select_input );
$custom_input = ( new Text() )->set_id( self::CUSTOM_INPUT_KEY )
->set_label( __( 'Enter your value', 'google-listings-and-ads' ) )
->set_name( self::CUSTOM_INPUT_KEY );
$this->add( $custom_input );
parent::__construct( 'select-with-text-input', 'google-listings-and-ads/product-select-with-text-field' );
}
/**
* @return array
*/
public function get_options(): array {
return $this->get_select_input()->get_options();
}
/**
* @param array $options
*
* @return $this
*/
public function set_options( array $options ): SelectWithTextInput {
$this->get_select_input()->set_options( $options );
return $this;
}
/**
* @param string|null $label
*
* @return InputInterface
*/
public function set_label( ?string $label ): InputInterface {
$this->get_select_input()->set_label( $label );
return parent::set_label( $label );
}
/**
* @param string|null $description
*
* @return InputInterface
*/
public function set_description( ?string $description ): InputInterface {
$this->get_select_input()->set_description( $description );
return parent::set_description( $description );
}
/**
* @return Select
*/
protected function get_select_input(): Select {
return $this->children[ self::SELECT_INPUT_KEY ];
}
/**
* @return Text
*/
protected function get_custom_input(): Text {
return $this->children[ self::CUSTOM_INPUT_KEY ];
}
/**
* Return the data used for the input's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = parent::get_view_data();
$select_input = $view_data['children'][ self::SELECT_INPUT_KEY ];
$custom_input = $view_data['children'][ self::CUSTOM_INPUT_KEY ];
// add custom classes
$view_data['gla_wrapper_class'] = $view_data['gla_wrapper_class'] ?? '';
$view_data['gla_wrapper_class'] .= ' select-with-text-input';
$custom_input['wrapper_class'] = 'custom-input';
// add custom value option
$select_input['options'][ self::CUSTOM_INPUT_KEY ] = __( 'Enter a custom value', 'google-listings-and-ads' );
if ( $this->is_readonly ) {
$select_input['custom_attributes'] = [
'disabled' => 'disabled',
];
$custom_input['custom_attributes'] = [
'readonly' => 'readonly',
];
}
$view_data['children'][ self::CUSTOM_INPUT_KEY ] = $custom_input;
$view_data['children'][ self::SELECT_INPUT_KEY ] = $select_input;
return $view_data;
}
/**
* Set the form's data.
*
* @param mixed $data
*
* @return void
*/
public function set_data( $data ): void {
if ( empty( $data ) ) {
$this->get_select_input()->set_data( null );
$this->get_custom_input()->set_data( null );
return;
}
$select_value = is_array( $data ) ? $data[ self::SELECT_INPUT_KEY ] ?? '' : $data;
$custom_value = is_array( $data ) ? $data[ self::CUSTOM_INPUT_KEY ] ?? '' : $data;
if ( ! isset( $this->get_options()[ $select_value ] ) ) {
$this->get_select_input()->set_data( self::CUSTOM_INPUT_KEY );
$this->get_custom_input()->set_data( $custom_value );
$this->data = $custom_value;
} else {
$this->get_select_input()->set_data( $select_value );
$this->data = $select_value;
}
}
/**
* Return the attributes of block config used for the input's view within the Product Block Editor.
*
* @return array
*/
public function get_block_attributes(): array {
$options = [];
foreach ( $this->get_options() as $key => $value ) {
$options[] = [
'label' => $value,
'value' => $key,
];
}
$options[] = [
'label' => __( 'Enter a custom value', 'google-listings-and-ads' ),
'value' => self::CUSTOM_INPUT_KEY,
];
$this->set_block_attribute( 'options', $options );
$this->set_block_attribute( 'customInputValue', self::CUSTOM_INPUT_KEY );
return parent::get_block_attributes();
}
}
Admin/Input/Text.php 0000644 00000000606 15153721356 0010342 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input;
defined( 'ABSPATH' ) || exit;
/**
* Class Text
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input
*/
class Text extends Input {
/**
* Text constructor.
*/
public function __construct() {
parent::__construct( 'text', 'woocommerce/product-text-field' );
}
}
Admin/MetaBox/AbstractMetaBox.php 0000644 00000007644 15153721356 0012712 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\ViewHelperTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\View\ViewException;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractMetaBox
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox
*/
abstract class AbstractMetaBox implements MetaBoxInterface {
use ViewHelperTrait;
protected const VIEW_PATH = 'meta-box';
/**
* @var Admin
*/
protected $admin;
/**
* AbstractMetaBox constructor.
*
* @param Admin $admin
*/
protected function __construct( Admin $admin ) {
$this->admin = $admin;
}
/**
* The context within the screen where the box should display. Available contexts vary from screen to
* screen. Post edit screen contexts include 'normal', 'side', and 'advanced'. Comments screen contexts
* include 'normal' and 'side'. Menus meta boxes (accordion sections) all use the 'side' context.
*
* Global default is 'advanced'.
*
* @return string
*/
public function get_context(): string {
return self::CONTEXT_ADVANCED;
}
/***
* The priority within the context where the box should show.
*
* Accepts 'high', 'core', 'default', or 'low'. Default 'default'.
*
* @return string
*/
public function get_priority(): string {
return self::PRIORITY_DEFAULT;
}
/**
* Data that should be set as the $args property of the box array (which is the second parameter passed to your callback).
*
* @return array
*/
public function get_callback_args(): array {
return [];
}
/**
* Returns an array of CSS classes to apply to the box.
*
* @return array
*/
public function get_classes(): array {
return [];
}
/**
* Function that fills the box with the desired content.
*
* The function should echo its output.
*
* @return callable
*/
public function get_callback(): callable {
return [ $this, 'handle_callback' ];
}
/**
* Called by WordPress when rendering the meta box.
*
* The function should echo its output.
*
* @param WP_Post $post The WordPress post object the box is loaded for.
* @param array $data Array of box data passed to the callback by WordPress.
*
* @return void
*
* @throws ViewException If the meta box view can't be rendered.
*/
public function handle_callback( WP_Post $post, array $data ): void {
$args = $data['args'] ?? [];
$context = $this->get_view_context( $post, $args );
echo wp_kses( $this->render( $context ), $this->get_allowed_html_form_tags() );
}
/**
* Render the meta box.
*
* The view templates need to be placed under 'views/meta-box' and named
* using the meta box ID specified by the `get_id` method.
*
* @param array $context Optional. Contextual information to use while
* rendering. Defaults to an empty array.
*
* @return string Rendered result.
*
* @throws ViewException If the view doesn't exist or can't be loaded.
*
* @see self::get_id To see and modify the view file name.
*/
public function render( array $context = [] ): string {
$view_path = path_join( self::VIEW_PATH, $this->get_id() );
return $this->admin->get_view( $view_path, $context );
}
/**
* Appends a prefix to the given field ID and returns it.
*
* @param string $field_id
*
* @return string
*
* @since 1.1.0
*/
protected function prefix_field_id( string $field_id ): string {
$box_id = $this->prefix_id( $this->get_id() );
return "{$box_id}_{$field_id}";
}
/**
* Returns an array of variables to be used in the view.
*
* @param WP_Post $post The WordPress post object the box is loaded for.
* @param array $args Array of data passed to the callback. Defined by `get_callback_args`.
*
* @return array
*/
abstract protected function get_view_context( WP_Post $post, array $args ): array;
}
Admin/MetaBox/ChannelVisibilityMetaBox.php 0000644 00000013006 15153721356 0014554 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use WC_Product;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Class ChannelVisibilityMetaBox
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox
*/
class ChannelVisibilityMetaBox extends SubmittableMetaBox {
use PluginHelper;
/**
* @var ProductMetaHandler
*/
protected $meta_handler;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* ChannelVisibilityMetaBox constructor.
*
* @param Admin $admin
* @param ProductMetaHandler $meta_handler
* @param ProductHelper $product_helper
* @param MerchantCenterService $merchant_center
*/
public function __construct( Admin $admin, ProductMetaHandler $meta_handler, ProductHelper $product_helper, MerchantCenterService $merchant_center ) {
$this->meta_handler = $meta_handler;
$this->product_helper = $product_helper;
$this->merchant_center = $merchant_center;
parent::__construct( $admin );
}
/**
* Meta box ID (used in the 'id' attribute for the meta box).
*
* @return string
*/
public function get_id(): string {
return 'channel_visibility';
}
/**
* Title of the meta box.
*
* @return string
*/
public function get_title(): string {
return __( 'Channel visibility', 'google-listings-and-ads' );
}
/**
* The screen on which to show the box (such as a post type, 'link', or 'comment').
*
* Default is the current screen.
*
* @return string
*/
public function get_screen(): string {
return self::SCREEN_PRODUCT;
}
/**
* The context within the screen where the box should display. Available contexts vary from screen to
* screen. Post edit screen contexts include 'normal', 'side', and 'advanced'. Comments screen contexts
* include 'normal' and 'side'. Menus meta boxes (accordion sections) all use the 'side' context.
*
* Global default is 'advanced'.
*
* @return string
*/
public function get_context(): string {
return self::CONTEXT_SIDE;
}
/**
* Returns an array of CSS classes to apply to the box.
*
* @return array
*/
public function get_classes(): array {
return [ 'gla_meta_box' ];
}
/**
* Returns an array of variables to be used in the view.
*
* @param WP_Post $post The WordPress post object the box is loaded for.
* @param array $args Array of data passed to the callback. Defined by `get_callback_args`.
*
* @return array
*/
protected function get_view_context( WP_Post $post, array $args ): array {
$product_id = absint( $post->ID );
$product = $this->product_helper->get_wc_product( $product_id );
return [
'field_id' => $this->get_visibility_field_id(),
'product_id' => $product_id,
'product' => $product,
'channel_visibility' => $this->product_helper->get_channel_visibility( $product ),
'sync_status' => $this->meta_handler->get_sync_status( $product ),
'issues' => $this->product_helper->get_validation_errors( $product ),
'is_setup_complete' => $this->merchant_center->is_setup_complete(),
'get_started_url' => $this->get_start_url(),
];
}
/**
* Register a service.
*/
public function register(): void {
add_action( 'woocommerce_new_product', [ $this, 'handle_submission' ], 10, 2 );
add_action( 'woocommerce_update_product', [ $this, 'handle_submission' ], 10, 2 );
}
/**
* @param int $product_id
* @param WC_Product $product
*/
public function handle_submission( int $product_id, WC_Product $product ) {
/**
* Array of `true` values for each product IDs already handled by this method. Used to prevent double submission.
*
* @var bool[] $already_updated
*/
static $already_updated = [];
$field_id = $this->get_visibility_field_id();
// phpcs:disable WordPress.Security.NonceVerification
// nonce is verified by self::verify_nonce
if ( ! $this->verify_nonce() || ! isset( $_POST[ $field_id ] ) || isset( $already_updated[ $product_id ] ) ) {
return;
}
// only update the value for supported product types
if ( ! in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true ) ) {
return;
}
try {
$visibility = empty( $_POST[ $field_id ] ) ?
ChannelVisibility::cast( ChannelVisibility::SYNC_AND_SHOW ) :
ChannelVisibility::cast( sanitize_key( $_POST[ $field_id ] ) );
// phpcs:enable WordPress.Security.NonceVerification
$this->meta_handler->update_visibility( $product, $visibility );
$already_updated[ $product_id ] = true;
} catch ( InvalidValue $exception ) {
// silently log the exception and do not set the product's visibility if an invalid visibility value is sent.
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
}
}
/**
* @return string
*
* @since 1.1.0
*/
protected function get_visibility_field_id(): string {
return $this->prefix_field_id( 'visibility' );
}
}
Admin/MetaBox/CouponChannelVisibilityMetaBox.php 0000644 00000014274 15153721356 0015750 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use WC_Coupon;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Class CouponChannelVisibilityMetaBox
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox
*/
class CouponChannelVisibilityMetaBox extends SubmittableMetaBox {
/**
* @var CouponMetaHandler
*/
protected $meta_handler;
/**
* @var CouponHelper
*/
protected $coupon_helper;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var TargetAudience
*/
protected $target_audience;
/**
* CouponChannelVisibilityMetaBox constructor.
*
* @param Admin $admin
* @param CouponMetaHandler $meta_handler
* @param CouponHelper $coupon_helper
* @param MerchantCenterService $merchant_center
* @param TargetAudience $target_audience
*/
public function __construct( Admin $admin, CouponMetaHandler $meta_handler, CouponHelper $coupon_helper, MerchantCenterService $merchant_center, TargetAudience $target_audience ) {
$this->meta_handler = $meta_handler;
$this->coupon_helper = $coupon_helper;
$this->merchant_center = $merchant_center;
$this->target_audience = $target_audience;
parent::__construct( $admin );
}
/**
* Meta box ID (used in the 'id' attribute for the meta box).
*
* @return string
*/
public function get_id(): string {
return 'coupon_channel_visibility';
}
/**
* Title of the meta box.
*
* @return string
*/
public function get_title(): string {
return __( 'Channel visibility', 'google-listings-and-ads' );
}
/**
* The screen on which to show the box (such as a post type, 'link', or 'comment').
*
* Default is the current screen.
*
* @return string
*/
public function get_screen(): string {
return self::SCREEN_COUPON;
}
/**
* The context within the screen where the box should display. Available contexts vary from screen to
* screen. Post edit screen contexts include 'normal', 'side', and 'advanced'. Comments screen contexts
* include 'normal' and 'side'. Menus meta boxes (accordion sections) all use the 'side' context.
*
* Global default is 'advanced'.
*
* @return string
*/
public function get_context(): string {
return self::CONTEXT_SIDE;
}
/**
* Returns an array of CSS classes to apply to the box.
*
* @return array
*/
public function get_classes(): array {
$shown_types = array_map(
function ( string $coupon_type ) {
return "show_if_{$coupon_type}";
},
CouponSyncer::get_supported_coupon_types()
);
$hidden_types = array_map(
function ( string $coupon_type ) {
return "hide_if_{$coupon_type}";
},
CouponSyncer::get_hidden_coupon_types()
);
return array_merge( $shown_types, $hidden_types );
}
/**
* Returns an array of variables to be used in the view.
*
* @param WP_Post $post The WordPress post object the box is loaded for.
* @param array $args Array of data passed to the callback. Defined by `get_callback_args`.
*
* @return array
*/
protected function get_view_context( WP_Post $post, array $args ): array {
$coupon_id = absint( $post->ID );
$coupon = $this->coupon_helper->get_wc_coupon( $coupon_id );
$target_country = $this->target_audience->get_main_target_country();
return [
'field_id' => $this->get_visibility_field_id(),
'coupon_id' => $coupon_id,
'coupon' => $coupon,
'channel_visibility' => $this->coupon_helper->get_channel_visibility( $coupon ),
'sync_status' => $this->meta_handler->get_sync_status( $coupon ),
'issues' => $this->coupon_helper->get_validation_errors( $coupon ),
'is_setup_complete' => $this->merchant_center->is_setup_complete(),
'is_channel_supported' => $this->merchant_center->is_promotion_supported_country( $target_country ),
'get_started_url' => $this->get_start_url(),
];
}
/**
* Register a service.
*/
public function register(): void {
add_action( 'woocommerce_new_coupon', [ $this, 'handle_submission' ], 10, 2 );
add_action( 'woocommerce_update_coupon', [ $this, 'handle_submission' ], 10, 2 );
}
/**
* @param int $coupon_id
* @param WC_Coupon $coupon
*/
public function handle_submission( int $coupon_id, WC_Coupon $coupon ) {
/**
* Array of `true` values for each coupon IDs already handled by this method. Used to prevent double submission.
*
* @var bool[] $already_updated
*/
static $already_updated = [];
$field_id = $this->get_visibility_field_id();
// phpcs:disable WordPress.Security.NonceVerification
// nonce is verified by self::verify_nonce
if ( ! $this->verify_nonce() || ! isset( $_POST[ $field_id ] ) || isset( $already_updated[ $coupon_id ] ) ) {
return;
}
// Only update the value for supported coupon types
if ( ! CouponSyncer::is_coupon_supported( $coupon ) ) {
return;
}
try {
$visibility = empty( $_POST[ $field_id ] ) ?
ChannelVisibility::cast( ChannelVisibility::DONT_SYNC_AND_SHOW ) :
ChannelVisibility::cast( sanitize_key( $_POST[ $field_id ] ) );
// phpcs:enable WordPress.Security.NonceVerification
$this->meta_handler->update_visibility( $coupon, $visibility );
$already_updated[ $coupon_id ] = true;
} catch ( InvalidValue $exception ) {
// silently log the exception and do not set the coupon's visibility if an invalid visibility value is sent.
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
}
}
/**
* @return string
*
* @since 1.1.0
*/
protected function get_visibility_field_id(): string {
return $this->prefix_field_id( 'visibility' );
}
}
Admin/MetaBox/MetaBoxInitializer.php 0000644 00000002543 15153721356 0013423 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class MetaBoxInitializer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox
*/
class MetaBoxInitializer implements Service, Registerable, Conditional {
use AdminConditional;
/**
* @var Admin
*/
protected $admin;
/**
* @var MetaBoxInterface[]
*/
protected $meta_boxes;
/**
* MetaBoxInitializer constructor.
*
* @param Admin $admin
* @param MetaBoxInterface[] $meta_boxes
*/
public function __construct( Admin $admin, array $meta_boxes ) {
$this->admin = $admin;
$this->meta_boxes = $meta_boxes;
}
/**
* Register a service.
*/
public function register(): void {
add_action( 'add_meta_boxes', [ $this, 'register_meta_boxes' ] );
}
/**
* Registers the meta boxes.
*/
public function register_meta_boxes() {
array_walk( $this->meta_boxes, [ $this->admin, 'add_meta_box' ] );
}
}
Admin/MetaBox/MetaBoxInterface.php 0000644 00000004405 15153721356 0013037 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Renderable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
interface MetaBoxInterface extends Renderable, Service {
public const SCREEN_PRODUCT = 'product';
public const SCREEN_COUPON = 'shop_coupon';
public const CONTEXT_NORMAL = 'normal';
public const CONTEXT_SIDE = 'side';
public const CONTEXT_ADVANCED = 'advanced';
public const PRIORITY_DEFAULT = 'default';
public const PRIORITY_LOW = 'low';
public const PRIORITY_CORE = 'core';
public const PRIORITY_HIGH = 'high';
/**
* Meta box ID (used in the 'id' attribute for the meta box).
*
* @return string
*/
public function get_id(): string;
/**
* Title of the meta box.
*
* @return string
*/
public function get_title(): string;
/**
* Function that fills the box with the desired content.
*
* The function should echo its output.
*
* @return callable
*/
public function get_callback(): callable;
/**
* The screen or screens on which to show the box (such as a post type, 'link', or 'comment').
*
* Default is the current screen.
*
* @return string
*/
public function get_screen(): string;
/**
* The context within the screen where the box should display. Available contexts vary from screen to
* screen. Post edit screen contexts include 'normal', 'side', and 'advanced'. Comments screen contexts
* include 'normal' and 'side'. Menus meta boxes (accordion sections) all use the 'side' context.
*
* Global default is 'advanced'.
*
* @return string
*/
public function get_context(): string;
/***
* The priority within the context where the box should show.
*
* Accepts 'high', 'core', 'default', or 'low'. Default 'default'.
*
* @return string
*/
public function get_priority(): string;
/**
* Data that should be set as the $args property of the box array (which is the second parameter passed to your callback).
*
* @return array
*/
public function get_callback_args(): array;
/**
* Returns an array of CSS classes to apply to the box.
*
* @return array
*/
public function get_classes(): array;
}
Admin/MetaBox/SubmittableMetaBox.php 0000644 00000001336 15153721356 0013412 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
defined( 'ABSPATH' ) || exit;
/**
* Class SubmittableMetaBox
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox
*/
abstract class SubmittableMetaBox extends AbstractMetaBox implements Registerable {
/**
* Verifies the WooCommerce meta box nonce.
*
* @return bool True is nonce is provided and valid, false otherwise.
*/
protected function verify_nonce(): bool {
return ! empty( $_POST['woocommerce_meta_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['woocommerce_meta_nonce'] ), 'woocommerce_save_data' );
}
}
Admin/Product/Attributes/AttributesForm.php 0000644 00000017455 15153721356 0015051 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Form;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\FormException;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\InputInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\SelectWithTextInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\GTINInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\WithValueOptionsInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class AttributesForm
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
*/
class AttributesForm extends Form {
use ValidateInterface;
/**
* @var string[]
*/
protected $attribute_types = [];
/**
* AttributesForm constructor.
*
* @param string[] $attribute_types The names of the attribute classes extending AttributeInterface.
* @param array $data
*/
public function __construct( array $attribute_types, array $data = [] ) {
foreach ( $attribute_types as $attribute_type ) {
$this->add_attribute( $attribute_type );
}
parent::__construct( $data );
}
/**
* Return the data used for the input's view.
*
* @return array
*/
public function get_view_data(): array {
$view_data = parent::get_view_data();
// add classes to hide/display attributes based on product type
foreach ( $view_data['children'] as $index => $input ) {
if ( ! isset( $this->attribute_types[ $index ] ) ) {
continue;
}
$attribute_type = $this->attribute_types[ $index ];
$attribute_product_types = self::get_attribute_product_types( $attribute_type );
$hidden_types = $attribute_product_types['hidden'];
$visible_types = $attribute_product_types['visible'];
$input['gla_wrapper_class'] = $input['gla_wrapper_class'] ?? '';
if ( ! empty( $visible_types ) ) {
$input['gla_wrapper_class'] .= ' show_if_' . join( ' show_if_', $visible_types );
}
if ( ! empty( $hidden_types ) ) {
$input['gla_wrapper_class'] .= ' hide_if_' . join( ' hide_if_', $hidden_types );
}
$view_data['children'][ $index ] = $input;
}
return $view_data;
}
/**
* Get the hidden and visible types of an attribute's applicable product types.
*
* @param string $attribute_type The name of an attribute class extending AttributeInterface.
*
* @return array
*/
public static function get_attribute_product_types( string $attribute_type ): array {
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
$applicable_product_types = call_user_func( [ $attribute_type, 'get_applicable_product_types' ] );
/**
* This filter is documented in AttributeManager::map_attribute_types
*
* @see AttributeManager::map_attribute_types
*/
$applicable_product_types = apply_filters( "woocommerce_gla_attribute_applicable_product_types_{$attribute_id}", $applicable_product_types, $attribute_type );
/**
* Filters the list of product types to hide the attribute for.
*/
$hidden_product_types = apply_filters( "woocommerce_gla_attribute_hidden_product_types_{$attribute_id}", [] );
$visible_product_types = array_diff( $applicable_product_types, $hidden_product_types );
return [
'hidden' => $hidden_product_types,
'visible' => $visible_product_types,
];
}
/**
* @param InputInterface $input
* @param AttributeInterface $attribute
*
* @return InputInterface
*/
public static function init_input( InputInterface $input, AttributeInterface $attribute ) {
$input->set_id( $attribute::get_id() )
->set_name( $attribute::get_id() );
$value_options = [];
if ( $attribute instanceof WithValueOptionsInterface ) {
$value_options = $attribute::get_value_options();
}
$value_options = apply_filters( "woocommerce_gla_product_attribute_value_options_{$attribute::get_id()}", $value_options );
if ( ! empty( $value_options ) ) {
if ( ! $input instanceof Select && ! $input instanceof SelectWithTextInput ) {
$new_input = new SelectWithTextInput();
$new_input->set_label( $input->get_label() )
->set_description( $input->get_description() );
// When GTIN uses the SelectWithTextInput field, copy the readonly/hidden attributes from the GTINInput field.
if ( $input->name === 'gtin' ) {
$gtin_input = new GTINInput();
$new_input->set_hidden( $gtin_input->is_hidden() );
$new_input->set_readonly( $gtin_input->is_readonly() );
}
return self::init_input( $new_input, $attribute );
}
// add a 'default' value option
$value_options = [ '' => __( 'Default', 'google-listings-and-ads' ) ] + $value_options;
$input->set_options( $value_options );
}
return $input;
}
/**
* Add an attribute to the form
*
* @param string $attribute_type The name of an attribute class extending AttributeInterface.
* @param string|null $input_type The name of an input class extending InputInterface to use for attribute input.
*
* @return AttributesForm
*
* @throws InvalidValue If the attribute type is invalid or an invalid input type is specified for the attribute.
* @throws FormException If form is already submitted.
*/
public function add_attribute( string $attribute_type, ?string $input_type = null ): AttributesForm {
$this->validate_interface( $attribute_type, AttributeInterface::class );
// use the attribute's default input type if none provided.
if ( empty( $input_type ) ) {
$input_type = call_user_func( [ $attribute_type, 'get_input_type' ] );
}
$this->validate_interface( $input_type, InputInterface::class );
$attribute_input = self::init_input( new $input_type(), new $attribute_type() );
if ( ! $attribute_input->is_hidden() ) {
$this->add( $attribute_input );
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
$this->attribute_types[ $attribute_id ] = $attribute_type;
}
return $this;
}
/**
* Remove an attribute from the form
*
* @param string $attribute_type The name of an attribute class extending AttributeInterface.
*
* @return AttributesForm
*
* @throws InvalidValue If the attribute type is invalid or an invalid input type is specified for the attribute.
* @throws FormException If form is already submitted.
*/
public function remove_attribute( string $attribute_type ): AttributesForm {
$this->validate_interface( $attribute_type, AttributeInterface::class );
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
unset( $this->attribute_types[ $attribute_id ] );
$this->remove( $attribute_id );
return $this;
}
/**
* Sets the input type for the given attribute.
*
* @param string $attribute_type The name of an attribute class extending AttributeInterface.
* @param string $input_type The name of an input class extending InputInterface to use for attribute input.
*
* @return $this
*
* @throws FormException If form is already submitted.
*/
public function set_attribute_input( string $attribute_type, string $input_type ): AttributesForm {
if ( $this->is_submitted ) {
throw FormException::cannot_modify_submitted();
}
$this->validate_interface( $attribute_type, AttributeInterface::class );
$this->validate_interface( $input_type, InputInterface::class );
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
if ( $this->has( $attribute_id ) ) {
$this->children[ $attribute_id ] = self::init_input( new $input_type(), new $attribute_type() );
}
return $this;
}
}
Admin/Product/Attributes/AttributesTab.php 0000644 00000011704 15153721356 0014643 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class AttributesTab
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
*/
class AttributesTab implements Service, Registerable, Conditional {
use AdminConditional;
use AttributesTrait;
/**
* @var Admin
*/
protected $admin;
/**
* @var AttributeManager
*/
protected $attribute_manager;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* AttributesTab constructor.
*
* @param Admin $admin
* @param AttributeManager $attribute_manager
* @param MerchantCenterService $merchant_center
*/
public function __construct( Admin $admin, AttributeManager $attribute_manager, MerchantCenterService $merchant_center ) {
$this->admin = $admin;
$this->attribute_manager = $attribute_manager;
$this->merchant_center = $merchant_center;
}
/**
* Register a service.
*/
public function register(): void {
// Register the hooks only if Merchant Center is set up.
if ( ! $this->merchant_center->is_setup_complete() ) {
return;
}
add_action(
'woocommerce_new_product',
function ( int $product_id, WC_Product $product ) {
$this->handle_update_product( $product );
},
10,
2
);
add_action(
'woocommerce_update_product',
function ( int $product_id, WC_Product $product ) {
$this->handle_update_product( $product );
},
10,
2
);
add_action(
'woocommerce_product_data_tabs',
function ( array $tabs ) {
return $this->add_tab( $tabs );
}
);
add_action(
'woocommerce_product_data_panels',
function () {
$this->render_panel();
}
);
}
/**
* Adds the Google for WooCommerce tab to the WooCommerce product data box.
*
* @param array $tabs The current product data tabs.
*
* @return array An array with product tabs with the Yoast SEO tab added.
*/
private function add_tab( array $tabs ): array {
$tabs['gla_attributes'] = [
'label' => 'Google for WooCommerce',
'class' => 'gla',
'target' => 'gla_attributes',
];
return $tabs;
}
/**
* Render the product attributes tab.
*/
private function render_panel() {
$product = wc_get_product( get_the_ID() );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->admin->get_view( 'attributes/tab-panel', [ 'form' => $this->get_form( $product )->get_view_data() ] );
}
/**
* Handle form submission and update the product attributes.
*
* @param WC_Product $product
*/
private function handle_update_product( WC_Product $product ) {
/**
* Array of `true` values for each product IDs already handled by this method. Used to prevent double submission.
*
* @var bool[] $already_updated
*/
static $already_updated = [];
if ( isset( $already_updated[ $product->get_id() ] ) ) {
return;
}
$form = $this->get_form( $product );
$form_view_data = $form->get_view_data();
// phpcs:disable WordPress.Security.NonceVerification
if ( empty( $_POST[ $form_view_data['name'] ] ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$submitted_data = (array) wc_clean( wp_unslash( $_POST[ $form_view_data['name'] ] ) );
// phpcs:enable WordPress.Security.NonceVerification
$form->submit( $submitted_data );
$this->update_data( $product, $form->get_data() );
$already_updated[ $product->get_id() ] = true;
}
/**
* @param WC_Product $product
*
* @return AttributesForm
*/
protected function get_form( WC_Product $product ): AttributesForm {
$attribute_types = $this->attribute_manager->get_attribute_types_for_product_types( $this->get_applicable_product_types() );
$form = new AttributesForm( $attribute_types, $this->attribute_manager->get_all_values( $product ) );
$form->set_name( 'attributes' );
return $form;
}
/**
* @param WC_Product $product
* @param array $data
*
* @return void
*/
protected function update_data( WC_Product $product, array $data ): void {
foreach ( $this->attribute_manager->get_attribute_types_for_product( $product ) as $attribute_id => $attribute_type ) {
if ( isset( $data[ $attribute_id ] ) ) {
$this->attribute_manager->update( $product, new $attribute_type( $data[ $attribute_id ] ) );
}
}
}
}
Admin/Product/Attributes/AttributesTrait.php 0000644 00000001172 15153721356 0015216 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;
/**
* Trait AttributesTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
*/
trait AttributesTrait {
/**
* Return an array of WooCommerce product types that the Google for WooCommerce tab can be displayed for.
*
* @return array of WooCommerce product types (e.g. 'simple', 'variable', etc.)
*/
protected function get_applicable_product_types(): array {
return apply_filters( 'woocommerce_gla_attributes_tab_applicable_product_types', [ 'simple', 'variable' ] );
}
}
Admin/Product/Attributes/Input/AdultInput.php 0000644 00000001267 15153721356 0015261 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\BooleanSelect;
defined( 'ABSPATH' ) || exit;
/**
* Class Adult
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class AdultInput extends BooleanSelect {
/**
* AdultInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Adult content', 'google-listings-and-ads' ) );
$this->set_description( __( 'Whether the product contains nudity or sexually suggestive content', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/AgeGroupInput.php 0000644 00000001211 15153721356 0015706 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
defined( 'ABSPATH' ) || exit;
/**
* Class AgeGroup
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class AgeGroupInput extends Select {
/**
* AgeGroupInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Age Group', 'google-listings-and-ads' ) );
$this->set_description( __( 'Target age group of the item.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/AttributeInputInterface.php 0000644 00000001433 15153721356 0017767 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
defined( 'ABSPATH' ) || exit;
/**
* Class AttributeInputInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input
*
* @since 1.5.0
*/
interface AttributeInputInterface {
/**
* Returns a name for the attribute input.
*
* @return string
*/
public static function get_name(): string;
/**
* Returns a short description for the attribute input.
*
* @return string
*/
public static function get_description(): string;
/**
* Returns the input class used for the attribute input.
*
* Must be an instance of `InputInterface`.
*
* @return string
*/
public static function get_input_type(): string;
}
Admin/Product/Attributes/Input/AvailabilityDateInput.php 0000644 00000001426 15153721356 0017415 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Class AvailabilityDate
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class AvailabilityDateInput extends DateTime {
/**
* AvailabilityDateInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Availability Date', 'google-listings-and-ads' ) );
$this->set_description( __( 'The date a preordered or backordered product becomes available for delivery. Required if product availability is preorder or backorder', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/BrandInput.php 0000644 00000001160 15153721356 0015226 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
defined( 'ABSPATH' ) || exit;
/**
* Class Brand
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class BrandInput extends Text {
/**
* BrandInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Brand', 'google-listings-and-ads' ) );
$this->set_description( __( 'Brand of the product.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/ColorInput.php 0000644 00000001160 15153721356 0015256 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
defined( 'ABSPATH' ) || exit;
/**
* Class Color
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class ColorInput extends Text {
/**
* ColorInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Color', 'google-listings-and-ads' ) );
$this->set_description( __( 'Color of the product.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/ConditionInput.php 0000644 00000001216 15153721356 0016130 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
defined( 'ABSPATH' ) || exit;
/**
* Class Condition
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class ConditionInput extends Select {
/**
* ConditionInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Condition', 'google-listings-and-ads' ) );
$this->set_description( __( 'Condition or state of the item.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/GTINInput.php 0000644 00000003125 15153721356 0014744 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\GTINMigrationUtilities;
defined( 'ABSPATH' ) || exit;
/**
* Class GTIN
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class GTINInput extends Text {
use GTINMigrationUtilities;
/**
* GTINInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Global Trade Item Number (GTIN)', 'google-listings-and-ads' ) );
$this->set_description( __( 'Global Trade Item Number (GTIN) for your item. These identifiers include UPC (in North America), EAN (in Europe), JAN (in Japan), and ISBN (for books)', 'google-listings-and-ads' ) );
$this->set_field_visibility();
}
/**
* Controls the inputs visibility based on the WooCommerce version and the
* initial version of Google for WooCommerce at the time of installation.
*
* @since 2.9.0
* @return void
*/
public function set_field_visibility(): void {
if ( $this->is_gtin_available_in_core() ) {
// For versions after the GTIN changes are published. Hide the GTIN field from G4W tab. Otherwise, set as readonly.
if ( $this->should_hide_gtin() ) {
$this->set_hidden( true );
} else {
$this->set_readonly( true );
$this->set_description( __( 'The Global Trade Item Number (GTIN) for your item can now be entered on the "Inventory" tab', 'google-listings-and-ads' ) );
}
}
}
}
Admin/Product/Attributes/Input/GenderInput.php 0000644 00000001221 15153721356 0015402 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
defined( 'ABSPATH' ) || exit;
/**
* Class Gender
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class GenderInput extends Select {
/**
* GenderInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Gender', 'google-listings-and-ads' ) );
$this->set_description( __( 'The gender for which your product is intended.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/IsBundleInput.php 0000644 00000001377 15153721356 0015717 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\BooleanSelect;
defined( 'ABSPATH' ) || exit;
/**
* Class IsBundle
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class IsBundleInput extends BooleanSelect {
/**
* IsBundleInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Is Bundle?', 'google-listings-and-ads' ) );
$this->set_description( __( 'Whether the item is a bundle of products. A bundle is a custom grouping of different products sold by a merchant for a single price.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/MPNInput.php 0000644 00000001254 15153721356 0014636 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
defined( 'ABSPATH' ) || exit;
/**
* Class MPN
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class MPNInput extends Text {
/**
* MPNInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Manufacturer Part Number (MPN)', 'google-listings-and-ads' ) );
$this->set_description( __( 'This code uniquely identifies the product to its manufacturer.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/MaterialInput.php 0000644 00000001216 15153721356 0015740 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
defined( 'ABSPATH' ) || exit;
/**
* Class Material
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class MaterialInput extends Text {
/**
* MaterialInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Material', 'google-listings-and-ads' ) );
$this->set_description( __( 'The material of which the item is made.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/MultipackInput.php 0000644 00000001500 15153721356 0016127 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Integer;
defined( 'ABSPATH' ) || exit;
/**
* Class Multipack
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class MultipackInput extends Integer {
/**
* MultipackInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Multipack', 'google-listings-and-ads' ) );
$this->set_description( __( 'The number of identical products in a multipack. Use this attribute to indicate that you\'ve grouped multiple identical products for sale as one item.', 'google-listings-and-ads' ) );
$this->set_block_attribute( 'min', [ 'value' => 0 ] );
}
}
Admin/Product/Attributes/Input/PatternInput.php 0000644 00000001211 15153721356 0015612 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
defined( 'ABSPATH' ) || exit;
/**
* Class Pattern
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class PatternInput extends Text {
/**
* PatternInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Pattern', 'google-listings-and-ads' ) );
$this->set_description( __( 'The item\'s pattern (e.g. polka dots).', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/SizeInput.php 0000644 00000001153 15153721357 0015115 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
defined( 'ABSPATH' ) || exit;
/**
* Class Size
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class SizeInput extends Text {
/**
* SizeInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Size', 'google-listings-and-ads' ) );
$this->set_description( __( 'Size of the product.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/SizeSystemInput.php 0000644 00000001271 15153721357 0016323 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
defined( 'ABSPATH' ) || exit;
/**
* Class SizeSystem
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class SizeSystemInput extends Select {
/**
* SizeSystemInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Size system', 'google-listings-and-ads' ) );
$this->set_description( __( 'System in which the size is specified. Recommended for apparel items.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/Input/SizeTypeInput.php 0000644 00000001237 15153721357 0015762 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
defined( 'ABSPATH' ) || exit;
/**
* Class SizeType
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class SizeTypeInput extends Select {
/**
* SizeTypeInput constructor.
*/
public function __construct() {
parent::__construct();
$this->set_label( __( 'Size type', 'google-listings-and-ads' ) );
$this->set_description( __( 'The cut of the item. Recommended for apparel items.', 'google-listings-and-ads' ) );
}
}
Admin/Product/Attributes/VariationsAttributes.php 0000644 00000012114 15153721357 0016251 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Form;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use WC_Product_Variation;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Class VariationsAttributes
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
*/
class VariationsAttributes implements Service, Registerable, Conditional {
use AdminConditional;
/**
* @var Admin
*/
protected $admin;
/**
* @var AttributeManager
*/
protected $attribute_manager;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* VariationsAttributes constructor.
*
* @param Admin $admin
* @param AttributeManager $attribute_manager
* @param MerchantCenterService $merchant_center
*/
public function __construct( Admin $admin, AttributeManager $attribute_manager, MerchantCenterService $merchant_center ) {
$this->admin = $admin;
$this->attribute_manager = $attribute_manager;
$this->merchant_center = $merchant_center;
}
/**
* Register a service.
*/
public function register(): void {
// Register the hooks only if Merchant Center is set up.
if ( ! $this->merchant_center->is_setup_complete() ) {
return;
}
add_action(
'woocommerce_product_after_variable_attributes',
function ( int $variation_index, array $variation_data, WP_Post $variation ) {
$this->render_attributes_form( $variation_index, $variation );
},
90,
3
);
add_action(
'woocommerce_save_product_variation',
function ( int $variation_id, int $variation_index ) {
$this->handle_save_variation( $variation_id, $variation_index );
},
10,
2
);
}
/**
* Render the attributes form for variations.
*
* @param int $variation_index Position in the loop.
* @param WP_Post $variation Post data.
*/
private function render_attributes_form( int $variation_index, WP_Post $variation ) {
/**
* @var WC_Product_Variation $product
*/
$product = wc_get_product( $variation->ID );
$data = $this->get_form( $product, $variation_index )->get_view_data();
// Do not render the form if it doesn't contain any child attributes.
$attributes = reset( $data['children'] );
if ( empty( $data['children'] ) || empty( $attributes['children'] ) ) {
return;
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->admin->get_view( 'attributes/variations-form', $data );
}
/**
* Handle form submission and update the product attributes.
*
* @param int $variation_id
* @param int $variation_index
*/
private function handle_save_variation( int $variation_id, int $variation_index ) {
/**
* @var WC_Product_Variation $variation
*/
$variation = wc_get_product( $variation_id );
$form = $this->get_form( $variation, $variation_index );
$form_view_data = $form->get_view_data();
// phpcs:disable WordPress.Security.NonceVerification
if ( empty( $_POST[ $form_view_data['name'] ] ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$submitted_data = (array) wc_clean( wp_unslash( $_POST[ $form_view_data['name'] ] ) );
// phpcs:enable WordPress.Security.NonceVerification
$form->submit( $submitted_data );
$form_data = $form->get_data();
if ( ! empty( $form_data[ $variation_index ] ) ) {
$this->update_data( $variation, $form_data[ $variation_index ] );
}
}
/**
* @param WC_Product_Variation $variation
* @param int $variation_index
*
* @return Form
*/
protected function get_form( WC_Product_Variation $variation, int $variation_index ): Form {
$attribute_types = $this->attribute_manager->get_attribute_types_for_product( $variation );
$attribute_form = new AttributesForm( $attribute_types );
$attribute_form->set_name( (string) $variation_index );
$form = new Form();
$form->set_name( 'variation_attributes' )
->add( $attribute_form )
->set_data( [ (string) $variation_index => $this->attribute_manager->get_all_values( $variation ) ] );
return $form;
}
/**
* @param WC_Product_Variation $variation
* @param array $data
*
* @return void
*/
protected function update_data( WC_Product_Variation $variation, array $data ): void {
foreach ( $this->attribute_manager->get_attribute_types_for_product( $variation ) as $attribute_id => $attribute_type ) {
if ( isset( $data[ $attribute_id ] ) ) {
$this->attribute_manager->update( $variation, new $attribute_type( $data[ $attribute_id ] ) );
}
}
}
}
Admin/Product/ChannelVisibilityBlock.php 0000644 00000012043 15153721357 0014331 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use WC_Data;
use WC_Product;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
defined( 'ABSPATH' ) || exit;
/**
* Class ChannelVisibilityBlock
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product
*/
class ChannelVisibilityBlock implements Service, Registerable {
public const PROPERTY = 'google_listings_and_ads__channel_visibility';
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* ChannelVisibilityBlock constructor.
*
* @param ProductHelper $product_helper
* @param MerchantCenterService $merchant_center
*/
public function __construct( ProductHelper $product_helper, MerchantCenterService $merchant_center ) {
$this->product_helper = $product_helper;
$this->merchant_center = $merchant_center;
}
/**
* Register hooks for querying and updating product via REST APIs.
*/
public function register(): void {
if ( ! $this->merchant_center->is_setup_complete() ) {
return;
}
// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php#L182-L192
add_filter( 'woocommerce_rest_prepare_product_object', [ $this, 'prepare_data' ], 10, 2 );
// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-crud-controller.php#L200-L207
// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-crud-controller.php#L247-L254
add_action( 'woocommerce_rest_insert_product_object', [ $this, 'update_data' ], 10, 2 );
}
/**
* Get channel visibility data from the given product and add it to the given response.
*
* @param Response $response Response to be added channel visibility data.
* @param WC_Product|WC_Data $product WooCommerce product to get data.
*
* @return Response
*/
public function prepare_data( Response $response, WC_Data $product ): Response {
if ( ! $product instanceof WC_Product ) {
return $response;
}
$response->data[ self::PROPERTY ] = [
'is_visible' => $product->is_visible(),
'channel_visibility' => $this->product_helper->get_channel_visibility( $product ),
'sync_status' => $this->product_helper->get_sync_status( $product ),
'issues' => $this->product_helper->get_validation_errors( $product ),
];
return $response;
}
/**
* Get channel visibility data from the given request and update it to the given product.
*
* @param WC_Product|WC_Data $product WooCommerce product to be updated.
* @param Request $request Response to get the channel visibility data.
*/
public function update_data( WC_Data $product, Request $request ): void {
if ( ! $product instanceof WC_Product || ! in_array( $product->get_type(), $this->get_visible_product_types(), true ) ) {
return;
}
$params = $request->get_params();
if ( ! isset( $params[ self::PROPERTY ] ) ) {
return;
}
$channel_visibility = $params[ self::PROPERTY ]['channel_visibility'];
if ( $channel_visibility !== $this->product_helper->get_channel_visibility( $product ) ) {
$this->product_helper->update_channel_visibility( $product, $channel_visibility );
}
}
/**
* Return the visible product types to control the hidden condition of the channel visibility block
* in the Product Block Editor.
*
* @return array
*/
public function get_visible_product_types(): array {
return array_diff( ProductSyncer::get_supported_product_types(), [ 'variation' ] );
}
/**
* Return the config used for the input's block within the Product Block Editor.
*
* @return array
*/
public function get_block_config(): array {
$options = [];
foreach ( ChannelVisibility::get_value_options() as $key => $value ) {
$options[] = [
'label' => $value,
'value' => $key,
];
}
$attributes = [
'property' => self::PROPERTY,
'options' => $options,
'valueOfSync' => ChannelVisibility::SYNC_AND_SHOW,
'valueOfDontSync' => ChannelVisibility::DONT_SYNC_AND_SHOW,
'statusOfSynced' => SyncStatus::SYNCED,
'statusOfHasErrors' => SyncStatus::HAS_ERRORS,
];
return [
'id' => 'google-listings-and-ads-product-channel-visibility',
'blockName' => 'google-listings-and-ads/product-channel-visibility',
'attributes' => $attributes,
];
}
}
Admin/ProductBlocksService.php 0000644 00000031341 15153721357 0012417 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\AttributesForm;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\AttributesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\ChannelVisibilityBlock;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AdminScriptWithBuiltDependenciesAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AdminStyleAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\BlockRegistry;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SectionInterface;
use Automattic\WooCommerce\Admin\PageController;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductBlocksService
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin
*/
class ProductBlocksService implements Service, Registerable, Conditional {
use AttributesTrait;
use PluginHelper;
/**
* @var AssetsHandlerInterface
*/
protected $assets_handler;
/**
* @var AttributeManager
*/
protected $attribute_manager;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var ChannelVisibilityBlock
*/
protected $channel_visibility_block;
/**
* @var string[]
*/
protected const CUSTOM_BLOCKS = [
'product-onboarding-prompt',
'product-channel-visibility',
'product-date-time-field',
'product-select-field',
'product-select-with-text-field',
];
/**
* ProductBlocksService constructor.
*
* @param AssetsHandlerInterface $assets_handler
* @param ChannelVisibilityBlock $channel_visibility_block
* @param AttributeManager $attribute_manager
* @param MerchantCenterService $merchant_center
*/
public function __construct( AssetsHandlerInterface $assets_handler, ChannelVisibilityBlock $channel_visibility_block, AttributeManager $attribute_manager, MerchantCenterService $merchant_center ) {
$this->assets_handler = $assets_handler;
$this->attribute_manager = $attribute_manager;
$this->merchant_center = $merchant_center;
$this->channel_visibility_block = $channel_visibility_block;
}
/**
* Return whether this service is needed to be registered.
*
* @return bool Whether this service is needed to be registered.
*/
public static function is_needed(): bool {
// compatibility-code "WC >= 8.6" -- The Block Template API used requires at least WooCommerce 8.6
return version_compare( WC_VERSION, '8.6', '>=' );
}
/**
* Register a service.
*/
public function register(): void {
if ( PageController::is_admin_page() ) {
add_action( 'init', [ $this, 'hook_init' ] );
}
// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/AbstractProductFormTemplate.php#L19
$template_area = 'product-form';
// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.php#L19
// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/src/Internal/Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.php#L19
$block_id = 'general';
add_action(
"woocommerce_block_template_area_{$template_area}_after_add_block_{$block_id}",
[ $this, 'hook_block_template' ]
);
}
/**
* Action hanlder for the 'init' hook.
*/
public function hook_init(): void {
$build_path = "{$this->get_root_dir()}/js/build";
$uri = 'js/build/blocks';
$this->register_custom_blocks( BlockRegistry::get_instance(), $build_path, $uri, self::CUSTOM_BLOCKS );
}
/**
* Action hanlder for the "woocommerce_block_template_area_{$template_area}_after_add_block_{$block_id}" hook.
*
* @param BlockInterface $block The block just added to get its root template to add this extension's group and blocks.
*/
public function hook_block_template( BlockInterface $block ): void {
/** @var Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\ProductFormTemplateInterface */
$template = $block->get_root_template();
$is_variation_template = $this->is_variation_template( $block );
// Please note that the simple, variable, grouped, and external product types
// use the same product block template 'simple-product'. Their dynamic hidden
// conditions are added below.
if ( 'simple-product' !== $template->get_id() && ! $is_variation_template ) {
return;
}
/** @var Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\GroupInterface */
$group = $template->add_group(
[
'id' => 'google-listings-and-ads-group',
'order' => 100,
'attributes' => [
'title' => __( 'Google for WooCommerce', 'google-listings-and-ads' ),
],
]
);
$visible_product_types = ProductSyncer::get_supported_product_types();
if ( $is_variation_template ) {
// The property of `editedProduct.type` doesn't exist in the variation product.
// The condition returned from `get_hide_condition` won't work, so it uses 'true' directly.
if ( ! in_array( 'variation', $visible_product_types, true ) ) {
$group->add_hide_condition( 'true' );
}
} else {
$group->add_hide_condition( $this->get_hide_condition( $visible_product_types ) );
}
if ( ! $this->merchant_center->is_setup_complete() ) {
$group->add_block(
[
'id' => 'google-listings-and-ads-product-onboarding-prompt',
'blockName' => 'google-listings-and-ads/product-onboarding-prompt',
'attributes' => [
'startUrl' => $this->get_start_url(),
],
]
);
return;
}
/** @var SectionInterface */
$channel_visibility_section = $group->add_section(
[
'id' => 'google-listings-and-ads-channel-visibility-section',
'order' => 1,
'attributes' => [
'title' => __( 'Channel visibility', 'google-listings-and-ads' ),
],
]
);
if ( ! $is_variation_template ) {
$this->add_channel_visibility_block( $channel_visibility_section );
}
// Add the hidden condition to the channel visibility section because it only has one block.
$visible_product_types = $this->channel_visibility_block->get_visible_product_types();
$channel_visibility_section->add_hide_condition( $this->get_hide_condition( $visible_product_types ) );
/** @var SectionInterface */
$product_attributes_section = $group->add_section(
[
'id' => 'google-listings-and-ads-product-attributes-section',
'order' => 2,
'attributes' => [
'title' => __( 'Product attributes', 'google-listings-and-ads' ),
],
]
);
$this->add_product_attribute_blocks( $product_attributes_section );
}
/**
* Register the custom blocks and their assets.
*
* @param BlockRegistry $block_registry BlockRegistry instance getting from Woo Core for registering custom blocks.
* @param string $build_path The absolute path to the build directory of the assets.
* @param string $uri The script URI of the custom blocks.
* @param string[] $custom_blocks The directory names of each custom block under the build path.
*/
public function register_custom_blocks( BlockRegistry $block_registry, string $build_path, string $uri, array $custom_blocks ): void {
foreach ( $custom_blocks as $custom_block ) {
$block_json_file = "{$build_path}/{$custom_block}/block.json";
if ( ! file_exists( $block_json_file ) ) {
continue;
}
$block_registry->register_block_type_from_metadata( $block_json_file );
}
$assets[] = new AdminScriptWithBuiltDependenciesAsset(
'google-listings-and-ads-product-blocks',
$uri,
"{$build_path}/blocks.asset.php",
new BuiltScriptDependencyArray(
[
'dependencies' => [],
'version' => (string) filemtime( "{$build_path}/blocks.js" ),
]
)
);
$assets[] = new AdminStyleAsset(
'google-listings-and-ads-product-blocks-css',
$uri,
[],
(string) filemtime( "{$build_path}/blocks.css" )
);
$this->assets_handler->register_many( $assets );
$this->assets_handler->enqueue_many( $assets );
}
/**
* Add the channel visibility block to the given section block.
*
* @param SectionInterface $section The section block to add the channel visibility block
*/
private function add_channel_visibility_block( SectionInterface $section ): void {
$section->add_block( $this->channel_visibility_block->get_block_config() );
}
/**
* Add product attribute blocks to the given section block.
*
* @param SectionInterface $section The section block to add product attribute blocks
*/
private function add_product_attribute_blocks( SectionInterface $section ): void {
$is_variation_template = $this->is_variation_template( $section );
$product_types = $is_variation_template ? [ 'variation' ] : $this->get_applicable_product_types();
$attribute_types = $this->attribute_manager->get_attribute_types_for_product_types( $product_types );
foreach ( $attribute_types as $attribute_type ) {
$input_type = call_user_func( [ $attribute_type, 'get_input_type' ] );
$input = AttributesForm::init_input( new $input_type(), new $attribute_type() );
// Avoid to render Inputs that are defined as hidden in the Input.
// i.e We don't render GTIN for new WC versions anymore.
if ( $input->is_hidden() ) {
continue;
}
if ( $is_variation_template ) {
// When editing a variation, its product type on the frontend side won't be changed dynamically.
// In addition, the property of `editedProduct.type` doesn't exist in the variation product.
// Therefore, instead of using the ProductTemplates API `add_hide_condition` to conditionally
// hide attributes, it doesn't add invisible attribute blocks from the beginning.
if ( $this->is_visible_for_variation( $attribute_type ) ) {
$section->add_block( $input->get_block_config() );
}
} else {
$visible_product_types = AttributesForm::get_attribute_product_types( $attribute_type )['visible'];
// When editing a simple, variable, grouped, or external product, its product type on the
// frontend side can be changed dynamically. So, it needs to use the ProductTemplates API
// `add_hide_condition` to conditionally hide attributes.
/** @var BlockInterface */
$block = $section->add_block( $input->get_block_config() );
$block->add_hide_condition( $this->get_hide_condition( $visible_product_types ) );
}
}
}
/**
* Determine if the product block template of the given block is the variation template.
*
* @param BlockInterface $block The block to be checked
*
* @return boolean
*/
private function is_variation_template( BlockInterface $block ): bool {
return 'product-variation' === $block->get_root_template()->get_id();
}
/**
* Determine if the given attribute is visible for variation product after applying related filters.
*
* @param string $attribute_type An attribute class extending AttributeInterface
*
* @return bool
*/
private function is_visible_for_variation( string $attribute_type ): bool {
$attribute_product_types = AttributesForm::get_attribute_product_types( $attribute_type );
return in_array( 'variation', $attribute_product_types['visible'], true );
}
/**
* Get the expression of the hide condition to a block based on the visible product types.
* e.g. "editedProduct.type !== 'simple' && ! editedProduct.parent_id > 0"
*
* The hide condition is a JavaScript-like expression that will be evaluated on the client to determine if the block should be hidden.
* See [@woocommerce/expression-evaluation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/expression-evaluation/README.md) for more details.
*
* @param array $visible_product_types The visible product types to be converted to a hidden condition
*
* @return string
*/
public function get_hide_condition( array $visible_product_types ): string {
$conditions = array_map(
function ( $type ) {
return "editedProduct.type !== '{$type}'";
},
$visible_product_types
);
return implode( ' && ', $conditions ) ?: 'true';
}
}
Admin/Redirect.php 0000644 00000011655 15153721357 0010067 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Activateable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Dashboard;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\GetStarted;
use Automattic\WooCommerce\Admin\PageController;
/**
* Class Redirect
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Admin
*/
class Redirect implements Activateable, Service, Registerable, OptionsAwareInterface, MerchantCenterAwareInterface {
use MerchantCenterAwareTrait;
use OptionsAwareTrait;
protected const OPTION = OptionsInterface::REDIRECT_TO_ONBOARDING;
public const PATHS = [
'dashboard' => Dashboard::PATH,
'get_started' => GetStarted::PATH,
];
/**
* @var WP
*/
protected $wp;
/**
* Redirect constructor.
*
* @param WP $wp
*/
public function __construct( WP $wp ) {
$this->wp = $wp;
}
/**
* Register a service.
*
* @return void
*/
public function register(): void {
add_action(
'admin_init',
function () {
$this->maybe_redirect();
}
);
}
/**
* Activate a service.
*
* @return void
*/
public function activate(): void {
// Do not take any action if activated in a REST request (via wc-admin).
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
if (
// Only redirect to onboarding when activated on its own. Either with a link...
( isset( $_GET['action'] ) && 'activate' === $_GET['action'] ) // phpcs:ignore WordPress.Security.NonceVerification
// ...or with a bulk action.
|| ( isset( $_POST['checked'] ) && is_array( $_POST['checked'] ) && 1 === count( $_POST['checked'] ) ) // phpcs:ignore WordPress.Security.NonceVerification
) {
$this->options->update( self::OPTION, 'yes' );
}
}
/**
* Checks if merchant should be redirected to the onboarding page if it is not.
*
* @return void
*/
public function maybe_redirect() {
if ( $this->wp->wp_doing_ajax() ) {
return;
}
// Maybe redirect to onboarding after activation
if ( 'yes' === $this->options->get( self::OPTION ) ) {
return $this->maybe_redirect_after_activation();
}
// If setup ISNT complete then redirect from dashboard to onboarding
if ( ! $this->merchant_center->is_setup_complete() && $this->is_current_wc_admin_page( self::PATHS['dashboard'] ) ) {
return $this->redirect_to( self::PATHS['get_started'] );
}
// If setup IS complete then redirect from onboarding to dashboard
if ( $this->merchant_center->is_setup_complete() && $this->is_current_wc_admin_page( self::PATHS['get_started'] ) ) {
return $this->redirect_to( self::PATHS['dashboard'] );
}
return false;
}
/**
* Checks if merchant should be redirected to the onboarding page after extension activation.
*
* @return bool True if the redirection should have happened
*/
protected function maybe_redirect_after_activation(): bool {
// Do not redirect if setup is already complete
if ( $this->merchant_center->is_setup_complete() ) {
$this->options->update( self::OPTION, 'no' );
return false;
}
// if we are on the get started page don't redirect again
if ( $this->is_current_wc_admin_page( self::PATHS['get_started'] ) ) {
$this->options->update( self::OPTION, 'no' );
return false;
}
// Redirect if setup is not complete
$this->redirect_to( self::PATHS['get_started'] );
return true;
}
/**
* Utility function to immediately redirect to a given WC Admin path.
* Note that this function immediately ends the execution.
*
* @param string $path The WC Admin path to redirect to
*
* @return void
*/
public function redirect_to( $path ): void {
// If we are already on this path, do nothing.
if ( $this->is_current_wc_admin_page( $path ) ) {
return;
}
$params = [
'page' => PageController::PAGE_ROOT,
'path' => $path,
];
wp_safe_redirect( admin_url( add_query_arg( $params, 'admin.php' ) ) );
exit();
}
/**
* Check if the current WC Admin page matches the given path.
*
* @param string $path The path to check.
*
* @return bool
*/
public function is_current_wc_admin_page( $path ): bool {
$params = [
'page' => PageController::PAGE_ROOT,
'path' => $path,
];
return 2 === count( array_intersect_assoc( $_GET, $params ) ); // phpcs:disable WordPress.Security.NonceVerification.Recommended
}
}
Ads/AccountService.php 0000644 00000026533 15153721357 0010723 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsConversionAction;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\BillingSetupStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountService
*
* Container used to access:
* - Ads
* - AdsConversionAction
* - Connection
* - Merchant
* - MerchantAccountState
* - Middleware
* - TransientsInterface
*
* @since 1.11.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Ads
*/
class AccountService implements ContainerAwareInterface, OptionsAwareInterface, Service {
use ContainerAwareTrait;
use OptionsAwareTrait;
/**
* @var AdsAccountState
*/
protected $state;
/**
* AccountService constructor.
*
* @param AdsAccountState $state
*/
public function __construct( AdsAccountState $state ) {
$this->state = $state;
}
/**
* Get Ads accounts associated with the connected Google account.
*
* @return array
* @throws Exception When an API error occurs.
*/
public function get_accounts(): array {
return $this->container->get( Ads::class )->get_ads_accounts();
}
/**
* Get the connected ads account.
*
* @return array
*/
public function get_connected_account(): array {
$id = $this->options->get_ads_id();
$status = [
'id' => $id,
'currency' => $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ),
'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ) ), ENT_QUOTES ),
'status' => $id ? 'connected' : 'disconnected',
];
$incomplete = $this->state->last_incomplete_step();
if ( ! empty( $incomplete ) ) {
$status['status'] = 'incomplete';
$status['step'] = $incomplete;
}
$status += $this->state->get_step_data( 'set_id' );
return $status;
}
/**
* Use an existing Ads account. Mark the 'set_id' step as done and sets the Ads ID.
*
* @param int $account_id The Ads account ID to use.
*
* @throws Exception If there is already an Ads account ID.
*/
public function use_existing_account( int $account_id ) {
$ads_id = $this->options->get_ads_id();
if ( $ads_id && $ads_id !== $account_id ) {
throw new Exception(
/* translators: 1: is a numeric account ID */
sprintf( __( 'Ads account %1$d already connected.', 'google-listings-and-ads' ), $ads_id )
);
}
$state = $this->state->get();
// Don't do anything if this step was already finished.
if ( AdsAccountState::STEP_DONE === $state['set_id']['status'] ) {
return;
}
$this->container->get( Middleware::class )->link_ads_account( $account_id );
// Skip billing setup flow when using an existing account.
$state['set_id']['status'] = AdsAccountState::STEP_DONE;
$state['billing']['status'] = AdsAccountState::STEP_DONE;
$this->state->update( $state );
}
/**
* Performs the steps necessary to setup an ads account.
* Should always resume up at the last pending or unfinished step.
* If the Ads account has already been created, the ID is simply returned.
*
* @return array The newly created (or pre-existing) Ads ID.
* @throws Exception If an error occurs during any step.
*/
public function setup_account(): array {
$state = $this->state->get();
$ads_id = $this->options->get_ads_id();
$account = [ 'id' => $ads_id ];
foreach ( $state as $name => &$step ) {
if ( AdsAccountState::STEP_DONE === $step['status'] ) {
continue;
}
try {
switch ( $name ) {
case 'set_id':
// Just in case, don't create another Ads ID.
if ( ! empty( $ads_id ) ) {
break;
}
$account = $this->container->get( Middleware::class )->create_ads_account();
$step['data']['sub_account'] = true;
$step['data']['created_timestamp'] = time();
break;
case 'billing':
$this->check_billing_status( $account );
break;
case 'conversion_action':
$this->create_conversion_action();
break;
case 'link_merchant':
// Continue to next step if the MC account is not connected yet.
if ( ! $this->options->get_merchant_id() ) {
// Save step as pending and continue the foreach loop with `continue 2`.
$state[ $name ]['status'] = AdsAccountState::STEP_PENDING;
$this->state->update( $state );
continue 2;
}
$this->link_merchant_account();
break;
case 'account_access':
$this->check_ads_account_has_access();
break;
default:
throw new Exception(
/* translators: 1: is a string representing an unknown step name */
sprintf( __( 'Unknown ads account creation step %1$s', 'google-listings-and-ads' ), $name )
);
}
$step['status'] = AdsAccountState::STEP_DONE;
$step['message'] = '';
$this->state->update( $state );
} catch ( Exception $e ) {
$step['status'] = AdsAccountState::STEP_ERROR;
$step['message'] = $e->getMessage();
$this->state->update( $state );
throw $e;
}
}
return $account;
}
/**
* Gets the billing setup status and returns a setup URL if available.
*
* @return array
*/
public function get_billing_status(): array {
$status = $this->container->get( Ads::class )->get_billing_status();
if ( BillingSetupStatus::APPROVED === $status ) {
$this->state->complete_step( 'billing' );
return [ 'status' => $status ];
}
$billing_url = $this->options->get( OptionsInterface::ADS_BILLING_URL );
// Check if user has provided the access and ocid is present.
$connection_status = $this->container->get( Connection::class )->get_status();
$email = $connection_status['email'] ?? '';
$has_access = $this->container->get( Ads::class )->has_access( $email );
$ocid = $this->options->get( OptionsInterface::ADS_ACCOUNT_OCID, null );
// Link directly to the payment page if the customer already has access.
if ( $has_access ) {
$billing_url = add_query_arg(
[
'ocid' => $ocid ?: 0,
],
'https://ads.google.com/aw/signup/payment'
);
}
return [
'status' => $status,
'billing_url' => $billing_url,
];
}
/**
* Check if the Ads account has access.
*
* @throws ExceptionWithResponseData If the account doesn't have access.
*/
private function check_ads_account_has_access() {
$access_status = $this->get_ads_account_has_access();
if ( ! $access_status['has_access'] ) {
throw new ExceptionWithResponseData(
__( 'Account must be accepted before completing setup.', 'google-listings-and-ads' ),
428,
null,
$access_status
);
}
}
/**
* Gets the Ads account access status.
*
* @return array {
* Returns the access status, last completed account setup step,
* and invite link if available.
*
* @type bool $has_access Whether the customer has access to the account.
* @type string $step The last completed setup step for the Ads account.
* @type string $invite_link The URL to the invite link.
* }
*/
public function get_ads_account_has_access() {
$has_access = false;
// Check if an Ads ID is present.
if ( $this->options->get_ads_id() ) {
$connection_status = $this->container->get( Connection::class )->get_status();
$email = $connection_status['email'] ?? '';
}
// If no email, means google account is not connected.
if ( ! empty( $email ) ) {
$has_access = $this->container->get( Ads::class )->has_access( $email );
}
// If we have access, complete the step so that it won't be called next time.
if ( $has_access ) {
$this->state->complete_step( 'account_access' );
}
return [
'has_access' => $has_access,
'step' => $this->state->last_incomplete_step(),
'invite_link' => $this->options->get( OptionsInterface::ADS_BILLING_URL, '' ),
];
}
/**
* Disconnect Ads account
*/
public function disconnect() {
$this->options->delete( OptionsInterface::ADS_ACCOUNT_CURRENCY );
$this->options->delete( OptionsInterface::ADS_ACCOUNT_OCID );
$this->options->delete( OptionsInterface::ADS_ACCOUNT_STATE );
$this->options->delete( OptionsInterface::ADS_BILLING_URL );
$this->options->delete( OptionsInterface::ADS_CONVERSION_ACTION );
$this->options->delete( OptionsInterface::ADS_ID );
$this->options->delete( OptionsInterface::ADS_SETUP_COMPLETED_AT );
$this->options->delete( OptionsInterface::CAMPAIGN_CONVERT_STATUS );
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );
}
/**
* Confirm the billing flow has been completed.
*
* @param array $account Account details.
*
* @throws ExceptionWithResponseData If this step hasn't been completed yet.
*/
private function check_billing_status( array $account ) {
$status = BillingSetupStatus::UNKNOWN;
// Only check billing status if we haven't just created the account.
if ( empty( $account['billing_url'] ) ) {
$status = $this->container->get( Ads::class )->get_billing_status();
}
if ( BillingSetupStatus::APPROVED !== $status ) {
throw new ExceptionWithResponseData(
__( 'Billing setup must be completed.', 'google-listings-and-ads' ),
428,
null,
[
'billing_url' => $this->options->get( OptionsInterface::ADS_BILLING_URL ),
'billing_status' => $status,
]
);
}
}
/**
* Get the callback function for linking a merchant account.
*
* @throws Exception When the ads account hasn't been set yet.
*/
private function link_merchant_account() {
if ( ! $this->options->get_ads_id() ) {
throw new Exception( 'An Ads account must be connected' );
}
$mc_state = $this->container->get( MerchantAccountState::class );
// Create link for Merchant and accept it in Ads.
$waiting_acceptance = $this->container->get( Merchant::class )->link_ads_id( $this->options->get_ads_id() );
if ( $waiting_acceptance ) {
$this->container->get( Ads::class )->accept_merchant_link( $this->options->get_merchant_id() );
}
$mc_state->complete_step( 'link_ads' );
}
/**
* Create the generic GLA conversion action and store the details as an option.
*
* @throws Exception If the conversion action can't be created.
*/
private function create_conversion_action(): void {
$action = $this->container->get( AdsConversionAction::class )->create_conversion_action();
$this->options->update( OptionsInterface::ADS_CONVERSION_ACTION, $action );
}
}
Ads/AdsAwareInterface.php 0000644 00000000563 15153721357 0011311 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Ads;
defined( 'ABSPATH' ) || exit;
/**
* Interface AdsAwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Ads
*/
interface AdsAwareInterface {
/**
* @param AdsService $ads_service
*/
public function set_ads_object( AdsService $ads_service ): void;
}
Ads/AdsAwareTrait.php 0000644 00000000743 15153721357 0010474 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Ads;
defined( 'ABSPATH' ) || exit;
/**
* Trait AdsAwareTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Ads
*/
trait AdsAwareTrait {
/**
* The AdsService object.
*
* @var AdsService
*/
protected $ads_service;
/**
* @param AdsService $ads_service
*/
public function set_ads_object( AdsService $ads_service ): void {
$this->ads_service = $ads_service;
}
}
Ads/AdsService.php 0000644 00000003740 15153721357 0010031 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsService
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Ads
*/
class AdsService implements OptionsAwareInterface, Service {
use OptionsAwareTrait;
/** @var AdsAccountState */
protected $account_state;
/**
* AdsService constructor.
*
* @since 1.11.0
*
* @param AdsAccountState $account_state
*/
public function __construct( AdsAccountState $account_state ) {
$this->account_state = $account_state;
}
/**
* Determine whether Ads setup has been started.
*
* @since 1.11.0
* @return bool
*/
public function is_setup_started(): bool {
return $this->account_state->last_incomplete_step() !== '' && ! $this->is_setup_complete();
}
/**
* Determine whether Ads setup has completed.
*
* @return bool
*/
public function is_setup_complete(): bool {
return boolval( $this->options->get( OptionsInterface::ADS_SETUP_COMPLETED_AT, false ) );
}
/**
* Determine whether Ads has connected.
*
* @return bool
*/
public function is_connected(): bool {
$google_connected = boolval( $this->options->get( OptionsInterface::GOOGLE_CONNECTED, false ) );
return $google_connected && $this->is_setup_complete();
}
/**
* Determine whether the Ads account is connected, even when pending billing.
*
* @return bool
*/
public function connected_account(): bool {
$id = $this->options->get_ads_id();
$last_step = $this->account_state->last_incomplete_step();
return $id && ( $last_step === '' || $last_step === 'billing' );
}
}
Ads/AssetSuggestionsService.php 0000644 00000057466 15153721357 0012652 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ArrayUtil;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ImageUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\DimensionUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroupAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Exception;
use WP_Query;
use wpdb;
use DOMDocument;
/**
* Class AssetSuggestionsService
*
* Suggest assets and possible final URLs.
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Ads
*/
class AssetSuggestionsService implements Service {
/**
* WP Proxy
*
* @var WP
*/
protected WP $wp;
/**
* WC Proxy
*
* @var WC
*/
protected WC $wc;
/**
* Image utilities.
*
* @var ImageUtility
*/
protected ImageUtility $image_utility;
/**
* The AdsAssetGroupAsset class.
*
* @var AdsAssetGroupAsset
*/
protected $asset_group_asset;
/**
* WordPress database access abstraction class.
*
* @var wpdb
*/
protected $wpdb;
/**
* Image requirements.
*/
protected const IMAGE_REQUIREMENTS = [
self::MARKETING_IMAGE_KEY => [
'minimum' => [ 600, 314 ],
'recommended' => [ 1200, 628 ],
'max_qty' => 8,
],
self::SQUARE_MARKETING_IMAGE_KEY => [
'minimum' => [ 300, 300 ],
'recommended' => [ 1200, 1200 ],
'max_qty' => 8,
],
self::PORTRAIT_MARKETING_IMAGE_KEY => [
'minimum' => [ 480, 600 ],
'recommended' => [ 960, 1200 ],
'max_qty' => 4,
],
self::LOGO_IMAGE_KEY => [
'minimum' => [ 128, 128 ],
'recommended' => [ 1200, 1200 ],
'max_qty' => 20,
],
];
/**
* Default maximum marketing images.
*/
protected const DEFAULT_MAXIMUM_MARKETING_IMAGES = 20;
/**
* The subsize key for the square marketing image.
*/
protected const SQUARE_MARKETING_IMAGE_KEY = 'gla_square_marketing_asset';
/**
* The subsize key for the marketing image.
*/
protected const MARKETING_IMAGE_KEY = 'gla_marketing_asset';
/**
* The subsize key for the portrait marketing image.
*/
protected const PORTRAIT_MARKETING_IMAGE_KEY = 'gla_portrait_marketing_asset';
/**
* The subsize key for the logo image.
*/
protected const LOGO_IMAGE_KEY = 'gla_logo_asset';
/**
* The homepage key ID.
*/
protected const HOMEPAGE_KEY_ID = 0;
/**
* AssetSuggestionsService constructor.
*
* @param WP $wp WP Proxy.
* @param WC $wc WC Proxy.
* @param ImageUtility $image_utility Image utility.
* @param wpdb $wpdb WordPress database access abstraction class.
* @param AdsAssetGroupAsset $asset_group_asset The AdsAssetGroupAsset class.
*/
public function __construct( WP $wp, WC $wc, ImageUtility $image_utility, wpdb $wpdb, AdsAssetGroupAsset $asset_group_asset ) {
$this->wp = $wp;
$this->wc = $wc;
$this->wpdb = $wpdb;
$this->image_utility = $image_utility;
$this->asset_group_asset = $asset_group_asset;
}
/**
* Get WP and other campaigns' assets from the specific post or term.
*
* @param int $id Post ID, Term ID or self::HOMEPAGE_KEY_ID if it's the homepage.
* @param string $type Only possible values are post, term and homepage.
*/
public function get_assets_suggestions( int $id, string $type ): array {
$asset_group_assets = $this->get_asset_group_asset_suggestions( $id, $type );
if ( ! empty( $asset_group_assets ) ) {
return $asset_group_assets;
}
return $this->get_wp_assets( $id, $type );
}
/**
* Get URL for a specific post or term.
*
* @param int $id Post ID, Term ID or self::HOMEPAGE_KEY_ID
* @param string $type Only possible values are post, term and homepage.
*
* @return string The URL.
* @throws Exception If the ID is invalid.
*/
protected function get_url( int $id, string $type ): string {
if ( $type === 'post' ) {
$url = get_permalink( $id );
} elseif ( $type === 'term' ) {
$url = get_term_link( $id );
} else {
$url = get_bloginfo( 'url' );
}
if ( is_wp_error( $url ) || empty( $url ) ) {
throw new Exception(
/* translators: 1: is an integer representing an unknown Term ID */
sprintf( __( 'Invalid Term ID or Post ID or site url %1$d', 'google-listings-and-ads' ), $id )
);
}
return $url;
}
/**
* Get other campaigns' assets from the specific url.
*
* @param int $id Post or Term ID.
* @param string $type Only possible values are post or term.
*/
protected function get_asset_group_asset_suggestions( int $id, string $type ): array {
$final_url = $this->get_url( $id, $type );
// Suggest the assets from the first asset group if exists.
$asset_group_assets = $this->asset_group_asset->get_assets_by_final_url( $final_url, true );
if ( empty( $asset_group_assets ) ) {
return [];
}
return array_merge( $this->get_suggestions_common_fields( [] ), [ 'final_url' => $final_url ], $asset_group_assets );
}
/**
* Get assets from specific post or term.
*
* @param int $id Post or Term ID, or self::HOMEPAGE_KEY_ID.
* @param string $type Only possible values are post or term.
*
* @return array All assets available for specific term, post or homepage.
* @throws Exception If the ID is invalid.
*/
protected function get_wp_assets( int $id, string $type ): array {
if ( $type === 'post' ) {
return $this->get_post_assets( $id );
} elseif ( $type === 'term' ) {
return $this->get_term_assets( $id );
} else {
return $this->get_homepage_assets();
}
}
/**
* Get assets from the homepage.
*
* @return array Assets available for the homepage.
* @throws Exception If the homepage id is invalid.
*/
protected function get_homepage_assets(): array {
$home_page = $this->wp->get_static_homepage();
// Static homepage.
if ( $home_page ) {
return $this->get_post_assets( $home_page->ID );
}
// Get images from the latest posts.
$posts = $this->wp->get_posts( [] );
$inserted_images_ids = array_map( [ $this, 'get_html_inserted_images' ], array_column( $posts, 'post_content' ) );
$ids = array_merge( $this->get_post_image_attachments( [ 'post_parent__in' => array_column( $posts, 'ID' ) ] ), ...$inserted_images_ids );
$marketing_images = $this->get_url_attachments_by_ids( $ids );
// Non static homepage.
return array_merge(
[
AssetFieldType::HEADLINE => [ __( 'Homepage', 'google-listings-and-ads' ) ],
AssetFieldType::LONG_HEADLINE => [ get_bloginfo( 'name' ) . ': ' . __( 'Homepage', 'google-listings-and-ads' ) ],
AssetFieldType::DESCRIPTION => ArrayUtil::remove_empty_values( [ __( 'Homepage', 'google-listings-and-ads' ), get_bloginfo( 'description' ) ] ),
'display_url_path' => [],
'final_url' => get_bloginfo( 'url' ),
],
$this->get_suggestions_common_fields( $marketing_images )
);
}
/**
* Get assets from specific post.
*
* @param int $id Post ID.
*
* @return array All assets for specific post.
* @throws Exception If the Post ID is invalid.
*/
protected function get_post_assets( int $id ): array {
$post = get_post( $id );
if ( ! $post || $post->post_status === 'trash' ) {
throw new Exception(
/* translators: 1: is an integer representing an unknown Post ID */
sprintf( __( 'Invalid Post ID %1$d', 'google-listings-and-ads' ), $id )
);
}
$attachments_ids = $this->get_post_image_attachments(
[
'post_parent' => $id,
]
);
if ( $id === wc_get_page_id( 'shop' ) ) {
$attachments_ids = [ ...$attachments_ids, ...$this->get_shop_attachments() ];
}
if ( $post->post_type === 'product' || $post->post_type === 'product_variation' ) {
$product = $this->wc->maybe_get_product( $id );
$attachments_ids = [ ...$attachments_ids, ...$product->get_gallery_image_ids() ];
}
$attachments_ids = [ ...$attachments_ids, ...$this->get_gallery_images_ids( $id ), ...$this->get_html_inserted_images( $post->post_content ), get_post_thumbnail_id( $id ) ];
$marketing_images = $this->get_url_attachments_by_ids( $attachments_ids );
$long_headline = get_bloginfo( 'name' ) . ': ' . $post->post_title;
return array_merge(
[
AssetFieldType::HEADLINE => [ $post->post_title ],
AssetFieldType::LONG_HEADLINE => [ $long_headline ],
AssetFieldType::DESCRIPTION => ArrayUtil::remove_empty_values( [ $post->post_excerpt, get_bloginfo( 'description' ) ] ),
'display_url_path' => [ $post->post_name ],
'final_url' => get_permalink( $id ),
],
$this->get_suggestions_common_fields( $marketing_images )
);
}
/**
* Get assets from specific term.
*
* @param int $id Term ID.
*
* @return array All assets for specific term.
* @throws Exception If the Term ID is invalid.
*/
protected function get_term_assets( int $id ): array {
$term = get_term( $id );
if ( ! $term ) {
throw new Exception(
/* translators: 1: is an integer representing an unknown Term ID */
sprintf( __( 'Invalid Term ID %1$d', 'google-listings-and-ads' ), $id )
);
}
$posts_assigned_to_term = $this->get_posts_assigned_to_a_term( $term->term_id, $term->taxonomy );
$posts_ids_assigned_to_term = [];
$attachments_ids = [];
foreach ( $posts_assigned_to_term as $post ) {
$attachments_ids[] = get_post_thumbnail_id( $post->ID );
$posts_ids_assigned_to_term[] = $post->ID;
}
if ( count( $posts_assigned_to_term ) ) {
$attachments_ids = [ ...$this->get_post_image_attachments( [ 'post_parent__in' => $posts_ids_assigned_to_term ] ), ...$attachments_ids ];
}
$marketing_images = $this->get_url_attachments_by_ids( $attachments_ids );
return array_merge(
[
AssetFieldType::HEADLINE => [ $term->name ],
AssetFieldType::LONG_HEADLINE => [ get_bloginfo( 'name' ) . ': ' . $term->name ],
AssetFieldType::DESCRIPTION => ArrayUtil::remove_empty_values( [ wp_strip_all_tags( $term->description ), get_bloginfo( 'description' ) ] ),
'display_url_path' => [ $term->slug ],
'final_url' => get_term_link( $term->term_id ),
],
$this->get_suggestions_common_fields( $marketing_images )
);
}
/**
* Get inserted images from HTML.
*
* @param string $html HTML string.
*
* @return array Array of image IDs.
*/
protected function get_html_inserted_images( string $html ): array {
if ( empty( $html ) ) {
return [];
}
// Malformed HTML can cause DOMDocument to throw warnings. With the below line, we can suppress them and work only with the HTML that has been parsed.
libxml_use_internal_errors( true );
$dom = new DOMDocument();
if ( $dom->loadHTML( $html ) ) {
$images = $dom->getElementsByTagName( 'img' );
$images_ids = [];
$pattern = '/-\d+x\d+\.(jpg|jpeg|png)$/i';
foreach ( $images as $image ) {
$url_unscaled = preg_replace(
$pattern,
'.${1}',
$image->getAttribute( 'src' ),
);
$image_id = attachment_url_to_postid( $url_unscaled );
// Look for scaled image if the original image is not found.
if ( $image_id === 0 ) {
$url_scaled = preg_replace(
$pattern,
'-scaled.${1}',
$image->getAttribute( 'src' ),
);
$image_id = attachment_url_to_postid( $url_scaled );
}
if ( $image_id > 0 ) {
$images_ids[] = $image_id;
}
}
}
return $images_ids;
}
/**
* Get logo images urls.
*
* @return array Logo images urls.
*/
protected function get_logo_images(): array {
$logo_images = $this->get_url_attachments_by_ids( [ get_theme_mod( 'custom_logo' ) ], [ self::LOGO_IMAGE_KEY ] );
return $logo_images[ self::LOGO_IMAGE_KEY ] ?? [];
}
/**
* Get posts linked to a specific term.
*
* @param int $term_id Term ID.
* @param string $taxonomy_name Taxonomy name.
*
* @return array List of posts assigned to the term.
*/
protected function get_posts_assigned_to_a_term( int $term_id, string $taxonomy_name ): array {
$args = [
'post_type' => 'any',
'numberposts' => self::DEFAULT_MAXIMUM_MARKETING_IMAGES,
'tax_query' => [
[
'taxonomy' => $taxonomy_name,
'terms' => $term_id,
'field' => 'term_id',
'include_children' => false,
],
],
];
return $this->wp->get_posts( $args );
}
/**
* Get attachments related to the shop page.
*
* @return array Shop attachments.
*/
protected function get_shop_attachments(): array {
return $this->get_post_image_attachments(
[
'post_parent__in' => $this->get_shop_products(),
]
);
}
/**
*
* Get products that will be use to offer image assets.
*
* @param array $args See WP_Query::parse_query() for all available arguments.
* @return array Shop products.
*/
protected function get_shop_products( array $args = [] ): array {
$defaults = [
'post_type' => 'product',
'numberposts' => self::DEFAULT_MAXIMUM_MARKETING_IMAGES,
'fields' => 'ids',
];
$args = wp_parse_args( $args, $defaults );
return $this->wp->get_posts( $args );
}
/**
* Get gallery images ids.
*
* @param int $post_id Post ID that contains the gallery.
*
* @return array List of gallery images ids.
*/
protected function get_gallery_images_ids( int $post_id ): array {
$gallery = get_post_gallery( $post_id, false );
if ( ! $gallery || ! isset( $gallery['ids'] ) ) {
return [];
}
return explode( ',', $gallery['ids'] );
}
/**
* Get unique attachments ids converted to int values.
*
* @param array $ids Attachments ids.
* @param int $maximum_images Maximum number of images to return.
*
* @return array List of unique attachments ids converted to int values.
*/
protected function prepare_image_ids( array $ids, int $maximum_images = self::DEFAULT_MAXIMUM_MARKETING_IMAGES ): array {
$ids = array_unique( ArrayUtil::remove_empty_values( $ids ) );
$ids = array_map( 'intval', $ids );
return array_slice( $ids, 0, $maximum_images );
}
/**
* Get URL for each attachment using an array of attachment ids and a list of subsizes.
*
* @param array $ids Attachments ids.
* @param array $size_keys Image subsize keys.
* @param int $maximum_images Maximum number of images to return.
*
* @return array A list of attachments urls.
*/
protected function get_url_attachments_by_ids( array $ids, array $size_keys = [ self::SQUARE_MARKETING_IMAGE_KEY, self::MARKETING_IMAGE_KEY, self::PORTRAIT_MARKETING_IMAGE_KEY ], $maximum_images = self::DEFAULT_MAXIMUM_MARKETING_IMAGES ): array {
$ids = $this->prepare_image_ids( $ids, $maximum_images );
$marketing_images = [];
foreach ( $ids as $id ) {
$metadata = wp_get_attachment_metadata( $id );
if ( ! $metadata ) {
continue;
}
foreach ( $size_keys as $size_key ) {
if ( count( $marketing_images[ $size_key ] ?? [] ) >= self::IMAGE_REQUIREMENTS[ $size_key ]['max_qty'] ) {
continue;
}
$minimum_size = new DimensionUtility( ...self::IMAGE_REQUIREMENTS[ $size_key ]['minimum'] );
$recommended_size = new DimensionUtility( ...self::IMAGE_REQUIREMENTS[ $size_key ]['recommended'] );
$image_size = new DimensionUtility( $metadata['width'], $metadata['height'] );
$suggested_size = $this->image_utility->recommend_size( $image_size, $recommended_size, $minimum_size );
// If the original size matches the suggested size with a precision of +-1px.
if ( $suggested_size && $suggested_size->equals( $image_size ) ) {
$marketing_images[ $size_key ][] = wp_get_attachment_url( $id );
} elseif ( isset( $metadata['sizes'][ $size_key ] ) ) {
// use the sub size.
$marketing_images[ $size_key ][] = wp_get_attachment_image_url( $id, $size_key );
} elseif ( $suggested_size && $this->image_utility->maybe_add_subsize_image( $id, $size_key, $suggested_size ) ) {
// use the resized image.
$marketing_images[ $size_key ][] = wp_get_attachment_image_url( $id, $size_key );
}
}
}
return $marketing_images;
}
/**
* Get Attachmets for specific posts.
*
* @param array $args See WP_Query::parse_query() for all available arguments.
*
* @return array List of attachments
*/
protected function get_post_image_attachments( array $args = [] ): array {
$defaults = [
'post_type' => 'attachment',
'post_mime_type' => [ 'image/jpeg', 'image/png', 'image/jpg' ],
'fields' => 'ids',
'numberposts' => self::DEFAULT_MAXIMUM_MARKETING_IMAGES,
];
$args = wp_parse_args( $args, $defaults );
return $this->wp->get_posts( $args );
}
/**
* Get posts that can be used to suggest assets
*
* @param string $search The search query.
* @param int $per_page Number of items per page.
* @param int $offset Used in the get_posts query.
*
* @return array formatted post suggestions
*/
protected function get_post_suggestions( string $search, int $per_page, int $offset = 0 ): array {
if ( $per_page <= 0 ) {
return [];
}
$post_suggestions = [];
$excluded_post_types = [ 'attachment' ];
$post_types = $this->wp->get_post_types(
[
'exclude_from_search' => false,
'public' => true,
]
);
// Exclude attachment post_type
$filtered_post_types = array_diff( $post_types, $excluded_post_types );
$args = [
'post_type' => $filtered_post_types,
'posts_per_page' => $per_page,
'post_status' => 'publish',
'search_title' => $search,
'offset' => $offset,
'suppress_filters' => false,
];
add_filter( 'posts_where', [ $this, 'title_filter' ], 10, 2 );
$posts = $this->wp->get_posts( $args );
remove_filter( 'posts_where', [ $this, 'title_filter' ] );
foreach ( $posts as $post ) {
$post_suggestions[] = $this->format_final_url_response( $post->ID, 'post', $post->post_title, get_permalink( $post->ID ) );
}
return $post_suggestions;
}
/**
* Filter for the posts_where hook, adds WHERE clause to search
* for the 'search_title' parameter in the post titles (when present).
*
* @param string $where The WHERE clause of the query.
* @param WP_Query $wp_query The WP_Query instance (passed by reference).
*
* @return string The updated WHERE clause.
*/
public function title_filter( string $where, WP_Query $wp_query ): string {
$search_title = $wp_query->get( 'search_title' );
if ( $search_title ) {
$title_search = '%' . $this->wpdb->esc_like( $search_title ) . '%';
$where .= $this->wpdb->prepare( " AND `{$this->wpdb->posts}`.`post_title` LIKE %s", $title_search ); // phpcs:ignore WordPress.DB.PreparedSQL
}
return $where;
}
/**
* Get terms that can be used to suggest assets
*
* @param string $search The search query
* @param int $per_page Number of items per page
*
* @return array formatted terms suggestions
*/
protected function get_terms_suggestions( string $search, int $per_page ): array {
$terms_suggestions = [];
// get_terms evaluates $per_page_terms = 0 as a falsy, therefore it will not add the LIMIT clausure returning all the results.
// See: https://github.com/WordPress/WordPress/blob/abe134c2090e84080adc46187884201a4badd649/wp-includes/class-wp-term-query.php#L868
if ( $per_page <= 0 ) {
return [];
}
// Get all taxonomies that are public, show_in_menu = true helps to exclude taxonomies such as "product_shipping_class".
$taxonomies = $this->wp->get_taxonomies(
[
'public' => true,
'show_in_menu' => true,
],
);
$terms = $this->wp->get_terms(
[
'taxonomy' => $taxonomies,
'hide_empty' => false,
'number' => $per_page,
'name__like' => $search,
]
);
foreach ( $terms as $term ) {
$terms_suggestions[] = $this->format_final_url_response( $term->term_id, 'term', $term->name, get_term_link( $term->term_id, $term->taxonomy ) );
}
return $terms_suggestions;
}
/**
* Return a list of final urls that can be used to suggest assets.
*
* @param string $search The search query
* @param int $per_page Number of items per page
* @param string $order_by Order by: type, title, url
*
* @return array final urls with their title, id & type.
*/
public function get_final_url_suggestions( string $search = '', int $per_page = 30, string $order_by = 'title' ): array {
if ( empty( $search ) ) {
return $this->get_defaults_final_url_suggestions();
}
$homepage = [];
// If the search query contains the word "homepage" add the homepage to the results.
if ( strpos( 'homepage', strtolower( $search ) ) !== false ) {
$homepage[] = $this->get_homepage_final_url();
--$per_page;
}
// Split possible results between posts and terms.
$per_page_posts = (int) ceil( $per_page / 2 );
$posts = $this->get_post_suggestions( $search, $per_page_posts );
// Try to get more results using the terms
$per_page_terms = $per_page - count( $posts );
$terms = $this->get_terms_suggestions( $search, $per_page_terms );
$pending_results = $per_page - count( $posts ) - count( $terms );
$more_results = [];
// Try to get more results using posts
if ( $pending_results > 0 && count( $posts ) === $per_page_posts ) {
$more_results = $this->get_post_suggestions( $search, $pending_results, $per_page_posts );
}
$result = array_merge( $homepage, $posts, $terms, $more_results );
return $this->sort_results( $result, $order_by );
}
/**
* Get the final url for the homepage.
*
* @return array final url for the homepage.
*/
protected function get_homepage_final_url(): array {
return $this->format_final_url_response( self::HOMEPAGE_KEY_ID, 'homepage', __( 'Homepage', 'google-listings-and-ads' ), get_bloginfo( 'url' ) );
}
/**
* Get defaults final urls suggestions.
*
* @return array default final urls.
*/
protected function get_defaults_final_url_suggestions(): array {
$defaults = [ $this->get_homepage_final_url() ];
$shop_page = $this->wp->get_shop_page();
if ( $shop_page ) {
$defaults[] = $this->format_final_url_response( $shop_page->ID, 'post', $shop_page->post_title, get_permalink( $shop_page->ID ) );
}
return $defaults;
}
/**
* Order suggestions alphabetically
*
* @param array $results Results as an associative array
* @param string $field Sort by a specific field
*
* @return array response sorted alphabetically
*/
protected function sort_results( array $results, string $field ): array {
usort(
$results,
function ( $a, $b ) use ( $field ) {
return strcmp( strtolower( (string) $a[ $field ] ), strtolower( (string) $b[ $field ] ) );
}
);
return $results;
}
/**
* Return an assotiave array with the page suggestion response format.
*
* @param int $id post id, term id or self::HOMEPAGE_KEY_ID.
* @param string $type post|term
* @param string $title page|term title
* @param string $url page|term url
*
* @return array response formated.
*/
protected function format_final_url_response( int $id, string $type, string $title, string $url ): array {
return [
'id' => $id,
'type' => $type,
'title' => $title,
'url' => $url,
];
}
/**
* Get the suggested common fieds.
*
* @param array $marketing_images Marketing images.
*
* @return array Suggested common fields.
*/
protected function get_suggestions_common_fields( array $marketing_images ): array {
return [
AssetFieldType::LOGO => $this->get_logo_images(),
AssetFieldType::BUSINESS_NAME => get_bloginfo( 'name' ),
AssetFieldType::SQUARE_MARKETING_IMAGE => $marketing_images[ self::SQUARE_MARKETING_IMAGE_KEY ] ?? [],
AssetFieldType::MARKETING_IMAGE => $marketing_images [ self::MARKETING_IMAGE_KEY ] ?? [],
AssetFieldType::PORTRAIT_MARKETING_IMAGE => $marketing_images [ self::PORTRAIT_MARKETING_IMAGE_KEY ] ?? [],
AssetFieldType::CALL_TO_ACTION_SELECTION => null,
];
}
}
Assets/AdminAssetHelper.php 0000644 00000001066 15153721357 0011723 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
defined( 'ABSPATH' ) || exit;
/**
* Trait AdminAssetHelper
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
trait AdminAssetHelper {
/**
* Get the enqueue action to use.
*
* @return string
*/
protected function get_enqueue_action(): string {
return 'admin_enqueue_scripts';
}
/**
* Get the dequeue action to use.
*
* @return string
*/
protected function get_dequeue_action(): string {
return 'admin_print_scripts';
}
}
Assets/AdminScriptAsset.php 0000644 00000001012 15153721357 0011737 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
/**
* Class AdminScriptAsset
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
class AdminScriptAsset extends ScriptAsset {
use AdminAssetHelper;
/**
* Get the condition callback to run when enqueuing the asset.
*
* The asset will only be enqueued if the callback returns true.
*
* @return bool
*/
public function can_enqueue(): bool {
return is_admin() && parent::can_enqueue();
}
}
Assets/AdminScriptWithBuiltDependenciesAsset.php 0000644 00000001317 15153721357 0016112 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
defined( 'ABSPATH' ) || exit;
/**
* Construct a ScriptAsset loading the dependencies from a generated file.
* Uses the AdminAssetHelper trait to enqueue the scripts for backend use.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
class AdminScriptWithBuiltDependenciesAsset extends ScriptWithBuiltDependenciesAsset {
use AdminAssetHelper;
/**
* Get the condition callback to run when enqueuing the asset.
*
* The asset will only be enqueued if the callback returns true.
*
* @return bool
*/
public function can_enqueue(): bool {
return is_admin() && parent::can_enqueue();
}
}
Assets/AdminStyleAsset.php 0000644 00000001007 15153721357 0011577 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
/**
* Class AdminStyleAsset
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
class AdminStyleAsset extends StyleAsset {
use AdminAssetHelper;
/**
* Get the condition callback to run when enqueuing the asset.
*
* The asset will only be enqueued if the callback returns true.
*
* @return bool
*/
public function can_enqueue(): bool {
return is_admin() && parent::can_enqueue();
}
}
Assets/Asset.php 0000644 00000001267 15153721357 0007615 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
/**
* Asset interface.
*
* An asset is something that can be enqueued by WordPress.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
interface Asset extends Registerable {
/**
* Enqueue the asset within WordPress.
*/
public function enqueue(): void;
/**
* Dequeue the asset within WordPress.
*/
public function dequeue(): void;
/**
* Get the handle of the asset. The handle serves as the ID within WordPress.
*
* @return string
*/
public function get_handle(): string;
}
Assets/AssetsHandler.php 0000644 00000007201 15153721357 0011270 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidAsset;
/**
* Class AssetsHandler
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
final class AssetsHandler implements AssetsHandlerInterface {
/**
* Assets known to this asset handler.
*
* @var Asset[]
*/
private $assets = [];
/**
* Register a single asset.
*
* @param Asset $asset Asset to register.
*/
public function register( Asset $asset ): void {
$this->validate_handle_not_exists( $asset->get_handle() );
$this->assets[ $asset->get_handle() ] = $asset;
$asset->register();
}
/**
* Register multiple assets.
*
* @param Asset[] $assets Array of assets to register.
*/
public function register_many( array $assets ): void {
foreach ( $assets as $asset ) {
$this->register( $asset );
}
}
/**
* Enqueue a single asset.
*
* @param Asset $asset Asset to enqueue.
*
* @throws InvalidAsset If the passed-in asset is not valid.
*
* @see AssetsHandlerInterface::register To register assets.
* @see AssetsHandlerInterface::register_many To register multiple assets.
*/
public function enqueue( Asset $asset ): void {
$this->enqueue_handle( $asset->get_handle() );
}
/**
* Enqueue multiple assets.
*
* @param Asset[] $assets Array of assets to enqueue.
*
* @throws InvalidAsset If any of the passed-in assets are not valid.
*
* @see AssetsHandlerInterface::register To register assets.
* @see AssetsHandlerInterface::register_many To register multiple assets.
*/
public function enqueue_many( array $assets ): void {
foreach ( $assets as $asset ) {
$this->enqueue( $asset );
}
}
/**
* Enqueue a single asset based on its handle.
*
* @param string $handle Handle of the asset to enqueue.
*
* @throws InvalidAsset If the passed-in asset handle is not valid.
*/
public function enqueue_handle( string $handle ): void {
$this->validate_handle_exists( $handle );
$this->assets[ $handle ]->enqueue();
}
/**
* Enqueue multiple assets based on their handles.
*
* @param string[] $handles Array of asset handles to enqueue.
*
* @throws InvalidAsset If any of the passed-in asset handles are not valid.
*/
public function enqueue_many_handles( array $handles ): void {
foreach ( $handles as $handle ) {
$this->enqueue_handle( $handle );
}
}
/**
* Dequeue a single asset based on its handle.
*
* @param string $handle Handle of the asset to enqueue.
*
* @throws InvalidAsset If the passed-in asset handle is not valid.
*/
public function dequeue_handle( string $handle ): void {
$this->validate_handle_exists( $handle );
$this->assets[ $handle ]->dequeue();
}
/**
* Enqueue all assets known to this asset handler.
*/
public function enqueue_all(): void {
foreach ( $this->assets as $asset_object ) {
$asset_object->enqueue();
}
}
/**
* Validate that a given asset handle is known to the object.
*
* @param string $handle The asset handle to validate.
*
* @throws InvalidAsset When the asset handle is unknown to the object.
*/
protected function validate_handle_exists( string $handle ): void {
if ( ! array_key_exists( $handle, $this->assets ) ) {
throw InvalidAsset::invalid_handle( $handle );
}
}
/**
* Validate that a given asset handle does not already exist.
*
* @param string $handle
*
* @throws InvalidAsset When the handle exists.
*/
protected function validate_handle_not_exists( string $handle ): void {
if ( array_key_exists( $handle, $this->assets ) ) {
throw InvalidAsset::handle_exists( $handle );
}
}
}
Assets/AssetsHandlerInterface.php 0000644 00000004227 15153721357 0013116 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidAsset;
/**
* Interface AssetsHandlerInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
interface AssetsHandlerInterface {
/**
* Register a single asset.
*
* @param Asset $asset Asset to register.
*/
public function register( Asset $asset ): void;
/**
* Register multiple assets.
*
* @param Asset[] $assets Array of assets to register.
*/
public function register_many( array $assets ): void;
/**
* Enqueue a single asset.
*
* @param Asset $asset Asset to enqueue.
*
* @throws InvalidAsset If the passed-in asset is not valid.
*
* @see AssetsHandlerInterface::register To register assets.
* @see AssetsHandlerInterface::register_many To register multiple assets.
*/
public function enqueue( Asset $asset ): void;
/**
* Enqueue multiple assets.
*
* @param Asset[] $assets Array of assets to enqueue.
*
* @throws InvalidAsset If any of the passed-in assets are not valid.
*
* @see AssetsHandlerInterface::register To register assets.
* @see AssetsHandlerInterface::register_many To register multiple assets.
*/
public function enqueue_many( array $assets ): void;
/**
* Enqueue a single asset based on its handle.
*
* @param string $handle Handle of the asset to enqueue.
*
* @throws InvalidAsset If the passed-in asset handle is not valid.
*/
public function enqueue_handle( string $handle ): void;
/**
* Enqueue multiple assets based on their handles.
*
* @param string[] $handles Array of asset handles to enqueue.
*
* @throws InvalidAsset If any of the passed-in asset handles are not valid.
*/
public function enqueue_many_handles( array $handles ): void;
/**
* Dequeue a single asset based on its handle.
*
* @param string $handle Handle of the asset to enqueue.
*
* @throws InvalidAsset If the passed-in asset handle is not valid.
*/
public function dequeue_handle( string $handle ): void;
/**
* Enqueue all assets known to this asset handler.
*/
public function enqueue_all(): void;
}
Assets/BaseAsset.php 0000644 00000016354 15153721357 0010413 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
/**
* Class BaseAsset
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
abstract class BaseAsset implements Asset {
use PluginHelper;
/**
* The file extension for the source.
*
* @var string
*/
protected $file_extension;
/**
* Priority for registering an asset.
*
* @var int
*/
protected $registration_priority = 1;
/**
* Priority for enqueuing an asset.
*
* @var int
*/
protected $enqueue_priority = 10;
/**
* Priority for dequeuing an asset.
*
* @var int
*/
protected $dequeue_priority = 50;
/**
* The asset handle.
*
* @var string
*/
protected $handle;
/**
* The full URI to the asset.
*
* @var string
*/
protected $uri;
/**
* Array of dependencies for the asset.
*
* @var array
*/
protected $dependencies = [];
/**
* The version string for the asset.
*
* @var string
*/
protected $version;
/**
* @var callable
*/
protected $enqueue_condition_callback;
/**
* BaseAsset constructor.
*
* @param string $file_extension The asset file extension.
* @param string $handle The asset handle.
* @param string $uri The URI for the asset.
* @param array $dependencies (Optional) Any dependencies the asset has.
* @param string $version (Optional) A version string for the asset. Will default to
* the plugin version if not set.
* @param callable|null $enqueue_condition_callback (Optional) The asset is always enqueued if this callback
* returns true or isn't set.
*/
public function __construct(
string $file_extension,
string $handle,
string $uri,
array $dependencies = [],
string $version = '',
?callable $enqueue_condition_callback = null
) {
$this->file_extension = $file_extension;
$this->handle = $handle;
$this->uri = $this->get_uri_from_path( $uri );
$this->dependencies = $dependencies;
$this->version = $version ?: $this->get_version();
$this->enqueue_condition_callback = $enqueue_condition_callback;
}
/**
* Get the handle of the asset. The handle serves as the ID within WordPress.
*
* @return string
*/
public function get_handle(): string {
return $this->handle;
}
/**
* Get the URI for the asset.
*
* @return string
*/
public function get_uri(): string {
return $this->uri;
}
/**
* Get the condition callback to run when enqueuing the asset.
*
* The asset will only be enqueued if the callback returns true.
*
* @return bool
*/
public function can_enqueue(): bool {
if ( is_null( $this->enqueue_condition_callback ) ) {
return true;
}
return (bool) call_user_func( $this->enqueue_condition_callback, $this );
}
/**
* Enqueue the asset within WordPress.
*/
public function enqueue(): void {
if ( ! $this->can_enqueue() ) {
return;
}
$this->defer_action(
$this->get_enqueue_action(),
$this->get_enqueue_callback(),
$this->enqueue_priority
);
}
/**
* Dequeue the asset within WordPress.
*/
public function dequeue(): void {
$this->defer_action(
$this->get_dequeue_action(),
$this->get_dequeue_callback(),
$this->dequeue_priority
);
}
/**
* Register a service.
*/
public function register(): void {
$this->defer_action(
$this->get_register_action(),
$this->get_register_callback(),
$this->registration_priority
);
}
/**
* Get the register action to use.
*
* @since 0.1.0
*
* @return string Register action to use.
*/
protected function get_register_action(): string {
return $this->get_enqueue_action();
}
/**
* Get the enqueue action to use.
*
* @return string
*/
protected function get_enqueue_action(): string {
return 'wp_enqueue_scripts';
}
/**
* Get the dequeue action to use.
*
* @return string
*/
protected function get_dequeue_action(): string {
return 'wp_print_scripts';
}
/**
* Add a callable to an action, or run it immediately if the action has already fired.
*
* @param string $action
* @param callable $callback
* @param int $priority
*/
protected function defer_action( string $action, callable $callback, int $priority = 10 ): void {
if ( did_action( $action ) ) {
try {
$callback();
} catch ( InvalidAsset $exception ) {
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
}
return;
}
add_action( $action, $callback, $priority );
}
/**
* Convert a file path to a URI for a source.
*
* @param string $path The source file path.
*
* @return string
*/
protected function get_uri_from_path( string $path ): string {
$path = $this->normalize_source_path( $path );
$path = str_replace( $this->get_root_dir(), '', $path );
return $this->get_plugin_url( $path );
}
/**
* Normalize a source path with a given file extension.
*
* @param string $path The path to normalize.
*
* @return string
*/
protected function normalize_source_path( string $path ): string {
$path = ltrim( $path, '/' );
$path = $this->maybe_add_extension( $path );
$path = "{$this->get_root_dir()}/{$path}";
return $this->maybe_add_minimized_extension( $path );
}
/**
* Possibly add an extension to a path.
*
* @param string $path Path where an extension may be needed.
*
* @return string
*/
protected function maybe_add_extension( string $path ): string {
$detected_extension = pathinfo( $path, PATHINFO_EXTENSION );
if ( $this->file_extension !== $detected_extension ) {
$path .= ".{$this->file_extension}";
}
return $path;
}
/**
* Possibly add a minimized extension to a path.
*
* @param string $path Path where a minimized extension may be needed.
*
* @return string
* @throws InvalidAsset When no asset can be found.
*/
protected function maybe_add_minimized_extension( string $path ): string {
$minimized_path = str_replace( ".{$this->file_extension}", ".min.{$this->file_extension}", $path );
// Validate that at least one version of the file exists.
$path_readable = is_readable( $path );
$minimized_readable = is_readable( $minimized_path );
if ( ! $path_readable && ! $minimized_readable ) {
throw InvalidAsset::invalid_path( $path );
}
// If we only have one available, return the available one no matter what.
if ( ! $minimized_readable ) {
return $path;
} elseif ( ! $path_readable ) {
return $minimized_path;
}
return defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? $path : $minimized_path;
}
/**
* Get the register callback to use.
*
* @return callable
*/
abstract protected function get_register_callback(): callable;
/**
* Get the enqueue callback to use.
*
* @return callable
*/
abstract protected function get_enqueue_callback(): callable;
/**
* Get the dequeue callback to use.
*
* @return callable
*/
abstract protected function get_dequeue_callback(): callable;
}
Assets/ScriptAsset.php 0000644 00000007724 15153721357 0011006 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidAsset;
/**
* Class ScriptAsset
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
class ScriptAsset extends BaseAsset {
/**
* Whether the script should be printed in the footer.
*
* @var bool
*/
protected $in_footer = false;
/**
* Array of localizations to add to the script.
*
* @var array
*/
protected $localizations = [];
/**
* Array of inline scripts to pass generic data from PHP to JavaScript with JSON format.
*
* @var array
*/
protected $inline_scripts = [];
/**
* ScriptAsset constructor.
*
* @param string $handle The asset handle.
* @param string $uri The URI for the asset.
* @param array $dependencies (Optional) Any dependencies the asset has.
* @param string $version (Optional) A version string for the asset. Will default to
* the plugin version if not set.
* @param callable|null $enqueue_condition_callback (Optional) The asset is always enqueued if this callback
* returns true or isn't set.
* @param bool $in_footer (Optional) Whether the script should be printed in the
* footer. Defaults to false.
*/
public function __construct(
string $handle,
string $uri,
array $dependencies = [],
string $version = '',
?callable $enqueue_condition_callback = null,
bool $in_footer = false
) {
$this->in_footer = $in_footer;
parent::__construct( 'js', $handle, $uri, $dependencies, $version, $enqueue_condition_callback );
}
/**
* Add a localization to the script.
*
* @param string $object_name The object name.
* @param array $data Array of data for the object.
*
* @return $this
*/
public function add_localization( string $object_name, array $data ): ScriptAsset {
$this->localizations[ $object_name ] = $data;
return $this;
}
/**
* Add a inline script to pass generic data from PHP to JavaScript.
*
* @param string $variable_name The global JavaScript variable name.
* @param array $data Array of data to be encoded to JSON format.
*
* @return $this
*/
public function add_inline_script( string $variable_name, array $data ): ScriptAsset {
$this->inline_scripts[ $variable_name ] = $data;
return $this;
}
/**
* Get the register callback to use.
*
* @return callable
*/
protected function get_register_callback(): callable {
return function () {
if ( wp_script_is( $this->handle, 'registered' ) ) {
return;
}
wp_register_script(
$this->handle,
$this->uri,
$this->dependencies,
$this->version,
$this->in_footer
);
};
}
/**
* Get the enqueue callback to use.
*
* @return callable
*/
protected function get_enqueue_callback(): callable {
return function () {
if ( ! wp_script_is( $this->handle, 'registered' ) ) {
throw InvalidAsset::asset_not_registered( $this->handle );
}
foreach ( $this->localizations as $object_name => $data_array ) {
wp_localize_script( $this->handle, $object_name, $data_array );
}
foreach ( $this->inline_scripts as $variable_name => $data_array ) {
$inline_script = "var $variable_name = " . wp_json_encode( $data_array );
wp_add_inline_script( $this->handle, $inline_script, 'before' );
}
wp_enqueue_script( $this->handle );
if ( in_array( 'wp-i18n', $this->dependencies, true ) ) {
wp_set_script_translations( $this->handle, 'google-listings-and-ads' );
}
};
}
/**
* Get the dequeue callback to use.
*
* @return callable
*/
protected function get_dequeue_callback(): callable {
return function () {
wp_dequeue_script( $this->handle );
};
}
}
Assets/ScriptWithBuiltDependenciesAsset.php 0000644 00000004756 15153721357 0015153 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray as DependencyArray;
use Throwable;
defined( 'ABSPATH' ) || exit;
/**
* Construct a ScriptAsset loading the dependencies from a generated file.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
class ScriptWithBuiltDependenciesAsset extends ScriptAsset {
/**
* ScriptWithBuiltDependenciesAsset constructor.
*
* @param string $handle The script handle.
* @param string $uri The URI for the script.
* @param string $build_dependency_path Path to the generated dependency file.
* @param DependencyArray $fallback_dependency_data Fallback dependencies (if the generated file is not readable).
* @param callable|null $enqueue_condition_callback (Optional) The asset is always enqueued if this callback
* returns true or isn't set.
* @param bool $in_footer Whether to enqueue the script before </body> instead of in
* the <head> (default: true).
*/
public function __construct(
string $handle,
string $uri,
string $build_dependency_path,
DependencyArray $fallback_dependency_data,
?callable $enqueue_condition_callback = null,
bool $in_footer = true
) {
$dependency_data = $this->get_dependency_data( $build_dependency_path, $fallback_dependency_data );
parent::__construct(
$handle,
$uri,
$dependency_data->get_dependencies(),
$dependency_data->get_version(),
$enqueue_condition_callback,
$in_footer
);
}
/**
* Get usable dependency data from an asset path or from the fallback.
*
* @param string $build_dependency_path
* @param DependencyArray $fallback
*
* @return DependencyArray
*/
protected function get_dependency_data( string $build_dependency_path, DependencyArray $fallback ): DependencyArray {
try {
if ( ! is_readable( $build_dependency_path ) ) {
return $fallback;
}
// Reason of exclusion: These files are being loaded manually in the function call with no user input
// nosemgrep: audit.php.lang.security.file.inclusion-arg
return new DependencyArray( include $build_dependency_path );
} catch ( Throwable $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
return $fallback;
}
}
}
Assets/StyleAsset.php 0000644 00000005167 15153721357 0010641 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Assets;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidAsset;
/**
* Class StyleAsset
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Assets
*/
class StyleAsset extends BaseAsset {
/**
* The media for which this stylesheet has been defined.
*
* @var string
*/
protected $media;
/**
* StyleAsset constructor.
*
* @param string $handle The asset handle.
* @param string $uri The URI for the asset.
* @param array $dependencies (Optional) Any dependencies the asset has.
* @param string $version (Optional) A version string for the asset. Will default to the plugin version
* if not set.
* @param callable|null $enqueue_condition_callback (Optional) The asset is always enqueued if this callback
* returns true or isn't set.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media
* queries like '(orientation: portrait)' and '(max-width: 640px)'.
*/
public function __construct(
string $handle,
string $uri,
array $dependencies = [],
string $version = '',
?callable $enqueue_condition_callback = null,
string $media = 'all'
) {
$this->media = $media;
parent::__construct( 'css', $handle, $uri, $dependencies, $version, $enqueue_condition_callback );
}
/**
* Get the register callback to use.
*
* @return callable
*/
protected function get_register_callback(): callable {
return function () {
if ( wp_style_is( $this->handle, 'registered' ) ) {
return;
}
wp_register_style(
$this->handle,
$this->uri,
$this->dependencies,
$this->version,
$this->media
);
};
}
/**
* Get the enqueue callback to use.
*
* @return callable
*/
protected function get_enqueue_callback(): callable {
return function () {
if ( ! wp_style_is( $this->handle, 'registered' ) ) {
throw InvalidAsset::asset_not_registered( $this->handle );
}
wp_enqueue_style( $this->handle );
};
}
/**
* Get the dequeue callback to use.
*
* @return callable
*/
protected function get_dequeue_callback(): callable {
return function () {
wp_dequeue_style( $this->handle );
};
}
}
ConnectionTest.php 0000644 00000150306 15153721357 0010232 0 ustar 00 <?php
// phpcs:ignoreFile
/**
* Main plugin class.
*
* @package connection-test
*/
namespace Automattic\WooCommerce\GoogleListingsAndAds;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\GTINMigrationUtilities;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\CleanupProductsJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\MigrateGTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\BatchProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
use Jetpack_Options;
use WP_REST_Request as Request;
/**
* Main class for Connection Test.
*/
class ConnectionTest implements ContainerAwareInterface, Service, Registerable {
use ContainerAwareTrait;
use GTINMigrationUtilities;
use PluginHelper;
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function() {
$this->register_admin_menu();
}
);
add_action(
'admin_init',
function() {
$this->handle_actions();
}
);
}
/**
* Store response from an API request.
*
* @var string
*/
protected $response = '';
/**
* Store response from the integration status API request.
*
* @var string
*/
protected $integration_status_response = [];
/**
* Add menu entries
*/
protected function register_admin_menu() {
if ( apply_filters( 'woocommerce_gla_enable_connection_test', false ) ) {
add_menu_page(
'Connection Test',
'Connection Test',
'manage_woocommerce',
'connection-test-admin-page',
function () {
$this->render_admin_page();
}
);
} else {
add_submenu_page(
'',
'Connection Test',
'Connection Test',
'manage_woocommerce',
'connection-test-admin-page',
function () {
$this->render_admin_page();
}
);
}
}
/**
* Render the admin page.
*/
protected function render_admin_page() {
/** @var OptionsInterface $options */
$options = $this->container->get( OptionsInterface::class );
/** @var Manager $manager */
$manager = $this->container->get( Manager::class );
$blog_token = $manager->get_tokens()->get_access_token();
$user_token = $manager->get_tokens()->get_access_token( get_current_user_id() );
$user_data = $manager->get_connected_user_data( get_current_user_id() );
$url = admin_url( 'admin.php?page=connection-test-admin-page' );
if ( ! empty( $_GET['google-mc'] ) && 'connected' === $_GET['google-mc'] ) {
$this->response .= 'Google Account connected successfully.';
}
if ( ! empty( $_GET['google-manager'] ) && 'connected' === $_GET['google-manager'] ) {
$this->response .= 'Successfully connected a Google Manager account.';
}
if ( ! empty( $_GET['google'] ) && 'failed' === $_GET['google'] ) {
$this->response .= 'Failed to connect to Google.';
}
?>
<div class="wrap">
<h2>Connection Test</h2>
<p>Google for WooCommerce connection testing page used for debugging purposes. Debug responses are output at the top of the page.</p>
<hr />
<?php if ( ! empty( $this->response ) ) { ?>
<div style="padding: 10px 20px; background: #e1e1e1;">
<h2 class="title">Response</h2>
<pre style="
overflow: auto;
word-break: normal !important;
word-wrap: normal !important;
white-space: pre !important;"
><?php echo wp_kses_post( $this->response ); ?></pre>
</div>
<?php } ?>
<h2 class="title">WooCommerce Connect Server</h2>
<table class="form-table" role="presentation">
<tr>
<th><label>WCS Server:</label></th>
<td>
<p>
<code><?php echo $this->container->get( 'connect_server_root' ); ?></code>
</p>
</td>
</tr>
<tr>
<th>Test WCS Connection:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-test' ], $url ), 'wcs-test' ) ); ?>">Test</a>
</p>
</td>
</tr>
<?php if ( $blog_token ) { ?>
<tr>
<th>Test Authenticated WCS Request:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-auth-test' ], $url ), 'wcs-auth-test' ) ); ?>">Test Authenticated Request</a>
</p>
</td>
</tr>
<?php } ?>
</table>
<hr />
<h2 class="title">WordPress.com</h2>
<table class="form-table" role="presentation">
<?php if ( $blog_token ) { ?>
<tr>
<th><label>Site ID:</label></th>
<td>
<p>
<code><?php echo Jetpack_Options::get_option( 'id' ); ?></code>
</p>
</td>
</tr>
<?php } ?>
<?php if ( $user_token ) { ?>
<tr>
<th><label>User ID:</label></th>
<td>
<p>
<code><?php echo $user_data['ID']; ?></code>
</p>
</td>
</tr>
<?php } elseif ( $blog_token ) { ?>
<tr>
<th><label>User:</label></th>
<td><p>Connected with another user account</p></td>
</tr>
<?php } ?>
<tr>
<th>Connection Status:</th>
<td>
<?php if ( ! $blog_token ) { ?>
<p><a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'connect' ], $url ), 'connect' ) ); ?>">Connect to WordPress.com</a></p>
<?php } else { ?>
<p><a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wp-status' ], $url ), 'wp-status' ) ); ?>">WordPress.com Connection Status</a></p>
<?php } ?>
</td>
</tr>
<?php if ( $blog_token && ! $options->get( OptionsInterface::JETPACK_CONNECTED ) ) { ?>
<tr>
<th>Reconnect WordPress.com:</th>
<td>
<p><a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'connect' ], $url ), 'connect' ) ); ?>">Reconnect to WordPress.com</a></p>
</td>
</tr>
<?php } ?>
</table>
<hr />
<?php if ( $blog_token ) { ?>
<h2 class="title">Google Account</h2>
<table class="form-table" role="presentation">
<tr>
<th>Connect:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-mc' ], $url ), 'wcs-google-mc' ) ); ?>">Connect Google Account</a>
</p>
</td>
</tr>
<tr>
<th>Disconnect:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-mc-disconnect' ], $url ), 'wcs-google-mc-disconnect' ) ); ?>">Disconnect Google Account</a>
</p>
</td>
</tr>
<tr>
<th>Get Status:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-mc-status' ], $url ), 'wcs-google-mc-status' ) ); ?>">Google Account Status</a>
</p>
</td>
</tr>
</table>
<hr />
<h2 class="title">Merchant Center</h2>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Get Merchant Center ID(s):</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-mc-id' ], $url ), 'wcs-google-mc-id' ) ); ?>">Get Merchant Center ID(s)</a>
</p>
</td>
</tr>
<tr>
<th>Merchant ID:</th>
<td>
<p>
<input name="merchant_id" type="text" value="<?php echo ! empty( $_GET['merchant_id'] ) ? intval( $_GET['merchant_id'] ) : ''; ?>" />
<button class="button">Send proxied request to Google Merchant Center</button>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-google-mc-proxy' ); ?>
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-google-mc-proxy" type="hidden" />
</form>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>MC Account Setup:</th>
<td>
<p>
<label title="Use a live site!">
Site URL <input name="site_url" type="text" style="width:14em; font-size:.9em" value="<?php echo esc_url( ! empty( $_GET['site_url'] ) ? $_GET['site_url'] : $this->get_site_url() ); ?>" />
</label>
<label title="To simulate linking with an external site">
MC ID <input name="account_id" type="text" style="width:8em; font-size:.9em" value="<?php echo ! empty( $_GET['account_id'] ) ? intval( $_GET['account_id'] ) : ''; ?>" />
</label>
<button class="button">MC Account Setup (I & II)</button>
</p>
<?php
$mc_account_state = $this->container->get( MerchantAccountState::class )->get( false );
$merchant_id = $this->container->get( OptionsInterface::class )->get_merchant_id();
if ( ! empty( $mc_account_state ) ) :
?>
<p class="description" style="font-style: italic">
( Merchant Center account status -- ID: <?php
echo $merchant_id; ?> ||
<?php foreach ( $mc_account_state as $name => $step ) : ?>
<?php echo $name . ':' . $step['status']; ?>
<?php endforeach; ?>
)
</p>
<?php endif; ?>
<p class="description">
Begins/continues four-step account-setup sequence: creation, verification, linking, claiming.
</p>
<p class="description">Claim overwrite performed with <a href="#overwrite">Claim Overwrite button</a>.
</p>
<p class="description">
If no MC ID is provided, then a sub-account will be created under our MCA.
</p>
<p class="description">
Adds <em>gla_merchant_id</em> to site options.
</p>
</td>
</tr>
<tr>
<th>Check MC Status:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-accounts-check' ], $url ), 'wcs-google-accounts-check' ) ); ?>">MC Connection Status</a>
</p>
</td>
</tr>
<tr>
<th>Disconnect MC:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( array( 'action' => 'wcs-google-accounts-delete' ), $url ), 'wcs-google-accounts-delete' ) ); ?>">MC Disconnect</a>
</p>
</td>
</tr>
<tr>
<th><a id="overwrite"></a>Claim Overwrite:</th>
<td>
<p>
<a class="button" href="<?php
echo esc_url( wp_nonce_url(
add_query_arg(
[
'action' => 'wcs-google-mc-claim-overwrite',
'account_id' => ($_GET['account_id'] ?? false) ?: $merchant_id,
],
$url
),
'wcs-google-mc-claim-overwrite' )
); ?>" <?php echo ( ($_GET['account_id'] ?? false) || $merchant_id ) ? '' : 'disabled="disabled" title="Missing account ID"' ?>>Claim Overwrite</a>
</p>
</td>
</tr>
<tr>
<th><a id="switch"></a>Switch URL:</th>
<td>
<p>
<a class="button" href="<?php
echo esc_url( wp_nonce_url(
add_query_arg(
[
'action' => 'wcs-google-mc-switch-url',
'site_url' => $_GET['site_url'] ?? $this->get_site_url(),
'account_id' => ($_GET['account_id'] ?? false) ?: $merchant_id,
]
),
'wcs-google-mc-switch-url'
) ); ?>" <?php echo ( ($_GET['account_id'] ?? false) || $merchant_id ) ? '' : 'disabled="disabled" title="Missing account ID"' ?>>Switch URL</a>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-google-mc-setup' ); ?>
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-google-mc-setup" type="hidden" />
</form>
<details>
<summary><strong>More Merchant Center</strong></summary>
<p class="description">For single-step development testing, not used for normal account setup flow.</p>
<table class="form-table" role="presentation">
<tr>
<th>Link Site to MCA:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-sv-link' ], $url ), 'wcs-google-sv-link' ) ); ?>">Link Site to MCA</a>
</p>
</td>
</tr>
<tr>
<th>Claim Website:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-accounts-claim' ], $url ), 'wcs-google-accounts-claim' ) ); ?>">Claim website</a>
</p>
</td>
</tr>
<tr>
<th>Clear Status Cache:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'clear-mc-status-cache' ], $url ), 'clear-mc-status-cache' ) ); ?>">Clear</a>
</p>
</td>
</tr>
</table>
</details>
<br>
<hr />
<h2 class="title">Google Ads</h2>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Get Customers:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-ads-customers-lib' ], $url ), 'wcs-ads-customers-lib' ) ); ?>">Get Customers from Google Ads</a>
</p>
</td>
</tr>
<tr>
<th>Get Campaigns:</th>
<td>
<p>
<label>
Customer ID <input name="customer_id" type="text" value="<?php echo ! empty( $_GET['customer_id'] ) ? intval( $_GET['customer_id'] ) : ''; ?>" />
</label>
<button class="button">Get Campaigns from Google Ads</button>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-ads-campaign-lib' ); ?>
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-ads-campaign-lib" type="hidden" />
</form>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Ads Account Setup:</th>
<td>
<p>
<label>
Ads ID <input name="ads_id" type="text" value="" />
</label>
<button class="button">Setup an existing account or create a new one</button>
</p>
<?php
$ads_account_state = $this->container->get( AdsAccountState::class )->get( false );
if ( ! empty( $ads_account_state ) ) :
?>
<p class="description" style="font-style: italic">
( Ads account status -- ID: <?php echo $this->container->get( OptionsInterface::class )->get( OptionsInterface::ADS_ID ); ?> ||
<?php foreach ( $ads_account_state as $name => $step ) : ?>
<?php echo $name . ':' . $step['status']; ?>
<?php endforeach; ?>
)
</p>
<?php
$conversion_action = $options->get( OptionsInterface::ADS_CONVERSION_ACTION );
if ( ! empty( $conversion_action ) && is_array( $conversion_action ) ) :
?>
<p class="description" style="font-style: italic">
( Conversion Action --
<?php foreach ( $conversion_action as $name => $value ) : ?>
<?php echo "{$name} : \"{$value}\""; ?>
<?php endforeach; ?>
)
</p>
<?php endif; ?>
<br/>
<?php endif; ?>
<p class="description">
Begins/continues a multistep account-setup sequence.
If no Ads ID is provided, then a sub-account will be created under our manager account.
Adds <em>gla_ads_id</em> to site options.
<h4>Create account steps:</h4>
create account >
direct user to billing flow >
link to merchant account >
create conversion action
<h4>Link account steps:</h4>
link to manager account >
link to merchant account >
create conversion action
</p>
</td>
</tr>
<tr>
<th>Check Ads Status:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-google-ads-check' ], $url ), 'wcs-google-ads-check' ) ); ?>">Ads Connection Status</a>
</p>
</td>
</tr>
<tr>
<th>Disconnect Ads:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( array( 'action' => 'wcs-google-ads-disconnect' ), $url ), 'wcs-google-ads-disconnect' ) ); ?>">Ads Disconnect</a>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-google-ads-setup' ); ?>
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-google-ads-setup" type="hidden" />
</form>
<hr />
<h2 class="title">Terms of Service</h2>
<table class="form-table" role="presentation">
<tr>
<th>Accept Merchant Center ToS:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-accept-tos' ], $url ), 'wcs-accept-tos' ) ); ?>">Accept ToS for Google</a>
</p>
</td>
</tr>
<tr>
<th>Get Latest Merchant Center ToS:</th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'wcs-check-tos' ], $url ), 'wcs-check-tos' ) ); ?>">Get latest ToS for Google</a>
</p>
</td>
</tr>
</table>
<hr />
<h2 class="title">Product Sync</h2>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Sync Product:</th>
<td>
<p>
<label>
Product ID <input name="product_id" type="text" value="<?php echo ! empty( $_GET['product_id'] ) ? intval( $_GET['product_id'] ) : ''; ?>" />
</label>
<label for="async-sync-product">Async?</label>
<input id="async-sync-product" name="async" value=1 type="checkbox" <?php echo ! empty( $_GET['async'] ) ? 'checked' : ''; ?> />
<button class="button">Sync Product with Google Merchant Center</button>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-sync-product' ); ?>
<input name="merchant_id" type="hidden" value="<?php echo ! empty( $_GET['merchant_id'] ) ? intval( $_GET['merchant_id'] ) : ''; ?>" />
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-sync-product" type="hidden" />
</form>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Sync All Products:</th>
<td>
<p>
<label for="async-sync-all-products">Async?</label>
<input id="async-sync-all-products" name="async" value=1 type="checkbox" <?php echo ! empty( $_GET['async'] ) ? 'checked' : ''; ?> />
<button class="button">Sync All Products with Google Merchant Center</button>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-sync-all-products' ); ?>
<input name="merchant_id" type="hidden" value="<?php echo ! empty( $_GET['merchant_id'] ) ? intval( $_GET['merchant_id'] ) : ''; ?>" />
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-sync-all-products" type="hidden" />
</form>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Delete All Synced Products:</th>
<td>
<p>
<label for="async-delete-synced-products">Async?</label>
<input id="async-delete-synced-products" name="async" value=1 type="checkbox" <?php echo ! empty( $_GET['async'] ) ? 'checked' : ''; ?> />
<button class="button">Delete All Synced Products from Google Merchant Center
</button>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-delete-synced-products' ); ?>
<input name="merchant_id" type="hidden" value="<?php echo ! empty( $_GET['merchant_id'] ) ? intval( $_GET['merchant_id'] ) : ''; ?>" />
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-delete-synced-products" type="hidden" />
</form>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th>Cleanup All Products:</th>
<td>
<p>
<label for="async-cleanup-products">Async?</label>
<input id="async-cleanup-products" name="async" value=1 type="checkbox" <?php echo ! empty( $_GET['async'] ) ? 'checked' : ''; ?> />
<button class="button">Cleanup All Products
</button>
</p>
</td>
</tr>
</table>
<?php wp_nonce_field( 'wcs-cleanup-products' ); ?>
<input name="merchant_id" type="hidden" value="<?php echo ! empty( $_GET['merchant_id'] ) ? intval( $_GET['merchant_id'] ) : ''; ?>" />
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="wcs-cleanup-products" type="hidden" />
</form>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th><label>GTIN Migration:</label></th>
<td>
<p>
<code><?php echo $this->get_gtin_migration_status(); ?></code>
</p>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'migrate-gtin' ], $url ), 'migrate-gtin' ) ); ?>">Start GTIN Migration</a>
</p>
</td>
</tr>
</table>
</form>
<?php } ?>
<hr />
<?php if ( $blog_token ) { ?>
<?php
$options = $this->container->get( OptionsInterface::class );
$wp_api_status = $options->get( OptionsInterface::WPCOM_REST_API_STATUS );
$notification_service = new NotificationsService( $this->container->get( MerchantCenterService::class ), $this->container->get( AccountService::class ) );
$notification_service->set_options_object( $options );
?>
<h2 class="title">Partner API Pull Integration</h2>
<form action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" method="GET">
<table class="form-table" role="presentation">
<tr>
<th><label>Notification Service Enabled:</label></th>
<td>
<p>
<code><?php echo $notification_service->is_enabled() ? 'yes' : 'no' ?></code>
</p>
</td>
</tr>
<tr>
<th><label>Notification Service Ready:</label></th>
<td>
<p>
<code><?php echo $notification_service->is_ready() ? 'yes' : 'no' ?></code>
</p>
</td>
</tr>
<tr>
<th><label>WPCOM REST API Status:</label></th>
<td>
<p>
<code><?php echo $wp_api_status ?? 'NOT SET'; ?></code>
<?php if ( $wp_api_status === 'approved' ) { ?> <a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( array( 'action' => 'disconnect-wp-api' ), $url ), 'disconnect-wp-api' ) ); ?>">Disconnect</a> <?php } ?>
</p>
</td>
</tr>
<tr>
<th>Send partner notification request to WPCOM:</th>
<td>
<p>
<label>
Product/Coupon ID <input name="item_id" type="text" value="<?php echo ! empty( $_GET['item_id'] ) ? intval( $_GET['item_id'] ) : ''; ?>" />
</label>
<br />
<br />
<label>
Topic
<select name="topic">
<option value="product.create" <?php echo (! isset( $_GET['topic'] ) || $_GET['topic'] === 'product.create') ? "selected" : "" ?>>product.create</option>
<option value="product.delete" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'product.delete' ? "selected" : ""?>>product.delete</option>
<option value="product.update" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'product.update' ? "selected" : ""?>>product.update</option>
<option value="coupon.create" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'coupon.create' ? "selected" : ""?>>coupon.create</option>
<option value="coupon.delete" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'coupon.delete' ? "selected" : ""?>>coupon.delete</option>
<option value="coupon.update" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'coupon.update' ? "selected" : ""?>>coupon.update</option>
<option value="shipping.update" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'shipping.update' ? "selected" : ""?>>shipping.update</option>
<option value="settings.update" <?php echo isset( $_GET['topic'] ) && $_GET['topic'] === 'settings.update' ? "selected" : ""?>>settings.update</option>
</select>
</label>
<button class="button">Send Notification</button>
</p>
</td>
</tr>
<tr>
<th><label>API Pull Integration Status:</label></th>
<td>
<p>
<a class="button" href="<?php echo esc_url( wp_nonce_url( add_query_arg( [ 'action' => 'partner-integration-status' ], $url ), 'partner-integration-status' ) ); ?>">Get API Pull Integration Status</a>
</p>
</td>
</tr>
<?php if ( isset( $this->integration_status_response['site'] ) || isset( $this->integration_status_response['errors'] ) ) { ?>
<tr>
<th><label>Site:</label></th>
<td>
<p>
<code><?php echo $this->integration_status_response['site'] ?? ''; ?></code>
</p>
</td>
</tr>
<tr>
<th><label>Jetpack Connection Health:</label></th>
<td>
<p>
<code><?php echo isset( $this->integration_status_response['is_healthy'] ) && $this->integration_status_response['is_healthy'] === true ? 'Healthy' : 'Unhealthy'; ?></code>
</p>
</td>
</tr>
<tr>
<th><label>Last Jetpack Contact:</label></th>
<td>
<p>
<code><?php echo isset( $this->integration_status_response['last_jetpack_contact'] ) ? date( 'Y-m-d H:i:s', $this->integration_status_response['last_jetpack_contact'] ) : '-'; ?></code>
</p>
</td>
</tr>
<tr>
<th><label>WC REST API Health:</label></th>
<td>
<p>
<code><?php echo isset( $this->integration_status_response['is_wc_rest_api_healthy'] ) && $this->integration_status_response['is_wc_rest_api_healthy'] === true ? 'Healthy' : 'Unhealthy'; ?></code>
</p>
</td>
</tr>
<tr>
<th><label>Google token health:</label></th>
<td>
<p>
<code><?php echo isset( $this->integration_status_response['is_partner_token_healthy'] ) && $this->integration_status_response['is_partner_token_healthy'] === true ? 'Connected' : 'Disconnected'; ?></code>
</p>
</td>
</tr>
<tr>
<th><label>Errors:</label></th>
<td>
<p>
<code><?php echo isset( $this->integration_status_response['errors'] ) ? wp_kses_post( wp_json_encode( $this->integration_status_response['errors'] ) ) ?? '' : '-'; ?></code>
</p>
</td>
</tr>
<?php } ?>
</table>
<?php wp_nonce_field( 'partner-notification' ); ?>
<input name="page" value="connection-test-admin-page" type="hidden" />
<input name="action" value="partner-notification" type="hidden" />
</form>
<?php } ?>
</div>
<?php
}
/**
* Handle actions.
*/
protected function handle_actions() {
if ( ! isset( $_GET['page'], $_GET['action'] ) || 'connection-test-admin-page' !== $_GET['page'] ) {
return;
}
/** @var Manager $manager */
$manager = $this->container->get( Manager::class );
if ( 'connect' === $_GET['action'] && check_admin_referer( 'connect' ) ) {
// Register the site to WPCOM.
if ( $manager->is_connected() ) {
$result = $manager->reconnect();
} else {
$result = $manager->register();
}
if ( is_wp_error( $result ) ) {
$this->response .= $result->get_error_message();
return;
}
// Get an authorization URL which will redirect back to our page.
$redirect = admin_url( 'admin.php?page=connection-test-admin-page' );
$auth_url = $manager->get_authorization_url( null, $redirect );
// Payments flow allows redirect back to the site without showing plans.
$auth_url = add_query_arg( [ 'from' => 'google-listings-and-ads' ], $auth_url );
// Using wp_redirect intentionally because we're redirecting outside.
wp_redirect( $auth_url ); // phpcs:ignore WordPress.Security.SafeRedirect
exit;
}
if ( 'disconnect' === $_GET['action'] && check_admin_referer( 'disconnect' ) ) {
$manager->remove_connection();
$plugin = $manager->get_plugin();
if ( $plugin && ! $plugin->is_only() ) {
$connected_plugins = $manager->get_connected_plugins();
$this->response = 'Cannot disconnect WordPress.com connection as there are other plugins using it: ';
$this->response .= implode( ', ', array_keys( $connected_plugins ) ) . "\n";
$this->response .= 'Please disconnect the connection using the Jetpack plugin.';
return;
} else {
$redirect = admin_url( 'admin.php?page=connection-test-admin-page' );
wp_safe_redirect( $redirect );
exit;
}
}
if ( 'wp-status' === $_GET['action'] && check_admin_referer( 'wp-status' ) ) {
$request = new Request( 'GET', '/wc/gla/jetpack/connected' );
$this->send_rest_request( $request );
/** @var OptionsInterface $options */
$options = $this->container->get( OptionsInterface::class );
$this->response .= "\n\n" . 'Saved Connection option = ' . ( $options->get( OptionsInterface::JETPACK_CONNECTED ) ? 'connected' : 'disconnected' );
$this->response .= "\n\n" . 'Connected plugins: ' . implode( ', ', array_column( $manager->get_connected_plugins(), 'name' ) ) . "\n";
}
if ( 'wcs-test' === $_GET['action'] && check_admin_referer( 'wcs-test' ) ) {
$url = $this->get_connect_server_url();
$this->response = 'GET ' . $url . "\n";
$response = wp_remote_get( $url );
if ( is_wp_error( $response ) ) {
$this->response .= $response->get_error_message();
return;
}
$this->response .= wp_remote_retrieve_body( $response );
}
if ( 'partner-notification' === $_GET['action'] && check_admin_referer( 'partner-notification' ) ) {
if ( ! isset( $_GET['topic'] ) ) {
$this->response .= "\n Topic is required.";
return;
}
$item = $_GET['item_id'] ?? null;
$topic = $_GET['topic'];
$mc = $this->container->get( MerchantCenterService::class );
/** @var OptionsInterface $options */
$options = $this->container->get( OptionsInterface::class );
$service = new NotificationsService( $mc, $this->container->get( AccountService::class ) );
$service->set_options_object( $options );
if ( $service->notify( $topic, $item ) ) {
$this->response .= "\n Notification success. Item: " . $item . " - Topic: " . $topic;
} else {
$this->response .= "\n Notification failed. Item: " . $item . " - Topic: " . $topic;
}
return;
}
if ( 'partner-integration-status' === $_GET['action'] && check_admin_referer( 'partner-integration-status' ) ) {
$integration_status_args = [
'method' => 'GET',
'timeout' => 30,
'url' => 'https://public-api.wordpress.com/wpcom/v2/sites/' . Jetpack_Options::get_option( 'id' ) . '/wc/partners/google/remote-site-status',
'user_id' => get_current_user_id(),
];
$integration_remote_request_response = Client::remote_request( $integration_status_args, null );
if ( is_wp_error( $integration_remote_request_response ) ) {
$this->response .= $integration_remote_request_response->get_error_message();
} else {
$this->integration_status_response = json_decode( wp_remote_retrieve_body( $integration_remote_request_response ), true ) ?? [];
// If the merchant isn't connected to the Google App, it's not necessary to display an error indicating that the partner token isn't associated.
if ( ! $this->integration_status_response['is_partner_token_healthy'] && isset( $this->integration_status_response['errors'] ['rest_api_partner_token']['error_code'] ) && $this->integration_status_response['errors'] ['rest_api_partner_token']['error_code'] === 'wpcom_partner_token_not_associated' ) {
unset( $this->integration_status_response['errors'] ['rest_api_partner_token'] );
}
if ( json_last_error() || ! isset( $this->integration_status_response['site'] ) ) {
$this->response .= wp_remote_retrieve_body( $integration_remote_request_response );
}
}
}
if ( 'disconnect-wp-api' === $_GET['action'] && check_admin_referer( 'disconnect-wp-api' ) ) {
$request = new Request( 'DELETE', '/wc/gla/rest-api/authorize' );
$this->send_rest_request( $request );
}
if ( 'wcs-auth-test' === $_GET['action'] && check_admin_referer( 'wcs-auth-test' ) ) {
$url = trailingslashit( $this->get_connect_server_url() ) . 'connection/test';
$args = [
'headers' => [ 'Authorization' => $this->get_auth_header() ],
];
$this->response = 'GET ' . $url . "\n" . var_export( $args, true ) . "\n";
$response = wp_remote_get( $url, $args );
if ( is_wp_error( $response ) ) {
$this->response .= $response->get_error_message();
return;
}
$this->response .= wp_remote_retrieve_body( $response );
}
if ( 'wcs-google-manager' === $_GET['action'] && check_admin_referer( 'wcs-google-manager' ) ) {
if ( empty( $_GET['manager_id'] ) ) {
$this->response .= 'Manager ID must be set';
return;
}
$id = absint( $_GET['manager_id'] );
$url = trailingslashit( $this->get_connect_server_url() ) . 'google/connection/google-manager';
$args = [
'headers' => [ 'Authorization' => $this->get_auth_header() ],
'body' => [
'returnUrl' => admin_url( 'admin.php?page=connection-test-admin-page' ),
'managerId' => $id,
'countries' => 'US,CA',
],
];
$this->response = 'POST ' . $url . "\n" . var_export( $args, true ) . "\n";
$response = wp_remote_post( $url, $args );
if ( is_wp_error( $response ) ) {
$this->response .= $response->get_error_message();
return;
}
$this->response .= wp_remote_retrieve_body( $response );
$json = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $json && isset( $json['oauthUrl'] ) ) {
wp_redirect( $json['oauthUrl'] ); // phpcs:ignore WordPress.Security.SafeRedirect
exit;
}
}
if ( 'wcs-google-ads-setup' === $_GET['action'] && check_admin_referer( 'wcs-google-ads-setup' ) ) {
$request = new Request( 'POST', '/wc/gla/ads/accounts' );
if ( is_numeric( $_GET['ads_id'] ?? false ) ) {
$request->set_body_params( [ 'id' => absint( $_GET['ads_id'] ) ] );
}
$this->send_rest_request( $request );
}
if ( 'wcs-google-ads-check' === $_GET['action'] && check_admin_referer( 'wcs-google-ads-check' ) ) {
$request = new Request( 'GET', '/wc/gla/ads/connection' );
$this->send_rest_request( $request );
}
if ( 'wcs-google-ads-disconnect' === $_GET['action'] && check_admin_referer( 'wcs-google-ads-disconnect' ) ) {
$request = new Request( 'DELETE', '/wc/gla/ads/connection' );
$this->send_rest_request( $request );
}
if ( 'wcs-google-mc' === $_GET['action'] && check_admin_referer( 'wcs-google-mc' ) ) {
/** @var Connection $connection */
$connection = $this->container->get( Connection::class );
$redirect_url = $connection->connect( admin_url( 'admin.php?page=connection-test-admin-page' ) );
if ( ! empty( $redirect_url ) ) {
wp_redirect( $redirect_url ); // phpcs:ignore WordPress.Security.SafeRedirect
exit;
}
}
if ( 'wcs-google-mc-disconnect' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-disconnect' ) ) {
/** @var Connection $connection */
$connection = $this->container->get( Connection::class );
$response = $connection->disconnect();
$this->response .= $response;
}
if ( 'wcs-google-sv-link' === $_GET['action'] && check_admin_referer( 'wcs-google-sv-link' ) ) {
try {
if ( $this->container->get( Middleware::class )->link_merchant_to_mca() ) {
$this->response .= "Linked merchant to MCA\n";
}
} catch ( \Exception $e ) {
$this->response .= $e->getMessage();
}
}
if ( 'wcs-google-mc-setup' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-setup' ) ) {
add_filter(
'woocommerce_gla_site_url',
function( $url ) {
return isset( $_GET['site_url'] ) ? esc_url_raw( $_GET['site_url'] ) : $url;
}
);
$request = new Request( 'POST', '/wc/gla/mc/accounts' );
if ( is_numeric( $_GET['account_id'] ?? false ) ) {
$request->set_body_params( [ 'id' => $_GET['account_id'] ] );
}
$this->send_rest_request( $request );
}
if ( 'wcs-google-mc-claim-overwrite' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-claim-overwrite' ) ) {
$request = new Request( 'POST', '/wc/gla/mc/accounts/claim-overwrite' );
if ( is_numeric( $_GET['account_id'] ?? false ) ) {
$request->set_body_params( [ 'id' => $_GET['account_id'] ] );
}
$this->send_rest_request( $request );
}
if ( 'wcs-google-mc-switch-url' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-switch-url' ) ) {
$request = new Request( 'POST', '/wc/gla/mc/accounts/switch-url' );
if ( is_numeric( $_GET['account_id'] ?? false ) ) {
$request->set_body_params( [ 'id' => $_GET['account_id'] ] );
}
$this->send_rest_request( $request );
}
if ( 'clear-mc-status-cache' === $_GET['action'] && check_admin_referer( 'clear-mc-status-cache' ) ) {
$this->container->get( MerchantStatuses::class )->clear_cache();
$this->response .= 'Merchant Center statuses transient successfully deleted.';
}
if ( 'wcs-google-accounts-check' === $_GET['action'] && check_admin_referer( 'wcs-google-accounts-check' ) ) {
$request = new Request( 'GET', '/wc/gla/mc/connection' );
$this->send_rest_request( $request );
}
if ( 'wcs-google-accounts-delete' === $_GET['action'] && check_admin_referer( 'wcs-google-accounts-delete' ) ) {
$request = new Request( 'DELETE', '/wc/gla/mc/connection' );
$this->send_rest_request( $request );
}
if ( 'wcs-google-accounts-claim' === $_GET['action'] && check_admin_referer( 'wcs-google-accounts-claim' ) ) {
add_filter(
'woocommerce_gla_site_url',
function ( $url ) {
return isset( $_GET['site_url'] ) ? esc_url_raw( $_GET['site_url'] ) : $url;
}
);
try {
$this->container->get( Merchant::class )->claimwebsite();
$this->response .= 'Website claimed';
} catch ( \Exception $e ) {
$this->response .= 'Error: ' . $e->getMessage();
}
}
if ( 'wcs-google-mc-status' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-status' ) ) {
$url = trailingslashit( $this->get_connect_server_url() ) . 'google/connection/google-mc';
$args = [
'headers' => [ 'Authorization' => $this->get_auth_header() ],
'method' => 'GET',
];
$this->response = 'GET ' . $url . "\n" . var_export( $args, true ) . "\n";
$response = wp_remote_get( $url, $args );
if ( is_wp_error( $response ) ) {
$this->response .= $response->get_error_message();
return;
}
$this->response .= wp_remote_retrieve_body( $response );
}
if ( 'wcs-google-mc-id' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-id' ) ) {
try {
$this->response = 'Proxied request > get merchant ID' . "\n";
foreach ( $this->container->get( Middleware::class )->get_merchant_accounts() as $account ) {
$this->response .= sprintf(
"Merchant ID: %s%s\n",
$account['id'],
$account['subaccount'] ? ' (IS a subaccount)' : ''
);
$_GET['merchant_id'] = $account['id'];
}
} catch ( \Exception $e ) {
$this->response .= $e->getMessage();
}
}
if ( 'wcs-google-mc-proxy' === $_GET['action'] && check_admin_referer( 'wcs-google-mc-proxy' ) ) {
/** @var Merchant $merchant */
$merchant = $this->container->get( Merchant::class );
/** @var OptionsInterface $options */
$options = $this->container->get( OptionsInterface::class );
if ( empty( $options->get_merchant_id() ) ) {
$this->response .= 'Please enter a Merchant ID';
return;
}
$this->response = "Proxied request > get products for merchant {$options->get_merchant_id()}\n";
$products = $merchant->get_products();
if ( empty( $products ) ) {
$this->response .= 'No products found';
}
foreach ( $products as $product ) {
$this->response .= "{$product->getId()} {$product->getTitle()}\n";
}
}
if ( 'wcs-ads-customers-lib' === $_GET['action'] && check_admin_referer( 'wcs-ads-customers-lib' ) ) {
try {
$accounts = $this->container->get( Ads::class )->get_ads_accounts();
$this->response .= 'Total accounts: ' . count( $accounts ) . "\n";
foreach ( $accounts as $account ) {
$this->response .= sprintf( "%d : %s\n", $account['id'], $account['name'] );
$_GET['customer_id'] = $account['id'];
}
} catch ( \Exception $e ) {
$this->response .= 'Error: ' . $e->getMessage();
}
}
if ( 'wcs-ads-campaign-lib' === $_GET['action'] && check_admin_referer( 'wcs-ads-campaign-lib' ) ) {
try {
/** @var AdsCampaign $ads_campaign */
$ads_campaign = $this->container->get( AdsCampaign::class );
/** @var OptionsInterface $options */
$options = $this->container->get( OptionsInterface::class );
$this->response = "Proxied request > get ad campaigns {$options->get_ads_id()}\n";
$campaigns = $ads_campaign->get_campaigns();
if ( empty( $campaigns ) ) {
$this->response .= 'No campaigns found';
} else {
$this->response .= 'Total campaigns: ' . count( $campaigns ) . "\n";
foreach ( $campaigns as $campaign ) {
$this->response .= print_r( $campaign, true ) . "\n";
}
}
} catch ( \Exception $e ) {
$this->response .= 'Error: ' . $e->getMessage();
}
}
if ( 'wcs-accept-tos' === $_GET['action'] && check_admin_referer( 'wcs-accept-tos' ) ) {
$result = $this->container->get( Middleware::class )->mark_tos_accepted( 'google-mc', 'john.doe@example.com' );
$this->response .= sprintf(
'Attempting to accept Tos. Successful? %s<br>Response body: %s',
$result->accepted() ? 'Yes' : 'No',
$result->message()
);
}
if ( 'wcs-check-tos' === $_GET['action'] && check_admin_referer( 'wcs-check-tos' ) ) {
$accepted = $this->container->get( Middleware::class )->check_tos_accepted( 'google-mc' );
$this->response .= sprintf(
'Tos Accepted? %s<br>Response body: %s',
$accepted->accepted() ? 'Yes' : 'No',
$accepted->message()
);
}
if ( 'wcs-sync-product' === $_GET['action'] && check_admin_referer( 'wcs-sync-product' ) ) {
if ( empty( $_GET['product_id'] ) ) {
$this->response .= 'Please enter a Product ID';
return;
}
$id = absint( $_GET['product_id'] );
$product = wc_get_product( $id );
if ( $product instanceof \WC_Product ) {
if ( empty( $_GET['async'] ) ) {
/** @var ProductSyncer $product_syncer */
$product_syncer = $this->container->get( ProductSyncer::class );
try {
$result = $product_syncer->update( [ $product ] );
$this->response .= sprintf( '%s products successfully submitted to Google.', count( $result->get_products() ) ) . "\n";
if ( ! empty( $result->get_errors() ) ) {
$this->response .= sprintf( 'There were %s errors:', count( $result->get_errors() ) ) . "\n";
foreach ( $result->get_errors() as $invalid_product ) {
$this->response .= sprintf( "%s:\n%s", $invalid_product->get_wc_product_id(), implode( "\n", $invalid_product->get_errors() ) ) . "\n";
}
}
} catch ( ProductSyncerException $exception ) {
$this->response = 'Error submitting product to Google: ' . $exception->getMessage();
}
} else {
// schedule a job
/** @var UpdateProducts $update_job */
$update_job = $this->container->get( JobRepository::class )->get( UpdateProducts::class );
$update_job->schedule( [ [ $product->get_id() ] ] );
$this->response = 'Successfully scheduled a job to sync the product ' . $product->get_id();
}
} else {
$this->response = 'Invalid product ID provided: ' . $id;
}
}
if ( 'wcs-sync-all-products' === $_GET['action'] && check_admin_referer( 'wcs-sync-all-products' ) ) {
if ( empty( $_GET['async'] ) ) {
/** @var ProductSyncer $product_syncer */
$product_syncer = $this->container->get( ProductSyncer::class );
/** @var ProductRepository $product_repository */
$product_repository = $this->container->get( ProductRepository::class );
try {
$products = $product_repository->find_sync_ready_products()->get();
$result = $product_syncer->update( $products );
$this->response .= sprintf( '%s products successfully submitted to Google.', count( $result->get_products() ) ) . "\n";
if ( ! empty( $result->get_errors() ) ) {
$this->response .= sprintf( 'There were %s errors:', count( $result->get_errors() ) ) . "\n";
foreach ( $result->get_errors() as $invalid_product ) {
$this->response .= sprintf( "%s:\n%s", $invalid_product->get_wc_product_id(), implode( "\n", $invalid_product->get_errors() ) ) . "\n";
}
}
} catch ( ProductSyncerException $exception ) {
$this->response = 'Error submitting products to Google: ' . $exception->getMessage();
}
} else {
// schedule a job
/** @var UpdateAllProducts $update_job */
$update_job = $this->container->get( JobRepository::class )->get( UpdateAllProducts::class );
$update_job->schedule();
$this->response = 'Successfully scheduled a job to sync all products!';
}
}
if ( 'wcs-delete-synced-products' === $_GET['action'] && check_admin_referer( 'wcs-delete-synced-products' ) ) {
if ( empty( $_GET['async'] ) ) {
/** @var ProductSyncer $product_syncer */
$product_syncer = $this->container->get( ProductSyncer::class );
/** @var ProductRepository $product_repository */
$product_repository = $this->container->get( ProductRepository::class );
try {
$products = $product_repository->find_synced_products();
$result = $product_syncer->delete( $products );
$this->response .= sprintf( '%s synced products deleted from Google.', count( $result->get_products() ) ) . "\n";
if ( ! empty( $result->get_errors() ) ) {
$this->response .= sprintf( 'There were %s errors:', count( $result->get_errors() ) ) . "\n";
foreach ( $result->get_errors() as $invalid_product ) {
$this->response .= sprintf( "%s:\n%s", $invalid_product->get_wc_product_id(), implode( "\n", $invalid_product->get_errors() ) ) . "\n";
}
}
} catch ( ProductSyncerException $exception ) {
$this->response = 'Error deleting products from Google: ' . $exception->getMessage();
}
} else {
// schedule a job
/** @var DeleteAllProducts $delete_job */
$delete_job = $this->container->get( JobRepository::class )->get( DeleteAllProducts::class );
$delete_job->schedule();
$this->response = 'Successfully scheduled a job to delete all synced products!';
}
}
if ( 'wcs-cleanup-products' === $_GET['action'] && check_admin_referer( 'wcs-cleanup-products' ) ) {
if ( empty( $_GET['async'] ) ) {
/** @var ProductSyncer $product_syncer */
$product_syncer = $this->container->get( ProductSyncer::class );
/** @var ProductRepository $product_repository */
$product_repository = $this->container->get( ProductRepository::class );
/** @var BatchProductHelper $batch_product_helper */
$batch_product_helper = $this->container->get( BatchProductHelper::class );
try {
$products = $product_repository->find_synced_products();
$stale_entries = $batch_product_helper->generate_stale_products_request_entries( $products );
$result = $product_syncer->delete_by_batch_requests( $stale_entries );
$this->response .= sprintf( '%s products cleaned up.', count( $result->get_products() ) ) . "\n";
if ( ! empty( $result->get_errors() ) ) {
$this->response .= sprintf( 'There were %s errors:', count( $result->get_errors() ) ) . "\n";
foreach ( $result->get_errors() as $invalid_product ) {
$this->response .= sprintf( "%s:\n%s", $invalid_product->get_wc_product_id(), implode( "\n", $invalid_product->get_errors() ) ) . "\n";
}
}
} catch ( ProductSyncerException $exception ) {
$this->response = 'Error cleaning up products: ' . $exception->getMessage();
}
} else {
// schedule a job
/** @var CleanupProductsJob $delete_job */
$delete_job = $this->container->get( JobRepository::class )->get( CleanupProductsJob::class );
$delete_job->schedule();
$this->response = 'Successfully scheduled a job to cleanup all products!';
}
}
if ( 'migrate-gtin' === $_GET['action'] && check_admin_referer( 'migrate-gtin' ) ) {
/** @var MigrateGTIN $job */
$job = $this->container->get( JobRepository::class )->get( MigrateGTIN::class );
$job->schedule();
$this->response = 'Successfully scheduled a job to migrate GTIN';
}
}
/**
* Retrieve an authorization header containing a Jetpack token.
*
* @return string Authorization header.
*/
private function get_auth_header(): string {
/** @var Manager $manager */
$manager = $this->container->get( Manager::class );
$token = $manager->get_tokens()->get_access_token();
[ $token_key, $token_secret ] = explode( '.', $token->secret );
$token_key = sprintf( '%s:%d:%d', $token_key, defined( 'JETPACK__API_VERSION' ) ? JETPACK__API_VERSION : 1, $token->external_user_id );
$time_diff = (int) Jetpack_Options::get_option( 'time_diff' );
$timestamp = time() + $time_diff;
$nonce = wp_generate_password( 10, false );
$normalized_request_string = join( "\n", [ $token_key, $timestamp, $nonce ] ) . "\n";
$signature = base64_encode( hash_hmac( 'sha1', $normalized_request_string, $token_secret, true ) );
$auth = [
'token' => $token_key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'signature' => $signature,
];
$header_pieces = [];
foreach ( $auth as $key => $value ) {
$header_pieces[] = sprintf( '%s="%s"', $key, $value );
}
return 'X_JP_Auth ' . join( ' ', $header_pieces );
}
/**
* Send a REST API request and add the response to our buffer.
*/
private function send_rest_request( Request $request ) {
$response = rest_do_request( $request );
$server = rest_get_server();
$data = $server->response_to_data( $response, false );
$json = wp_json_encode( $data, JSON_PRETTY_PRINT );
$this->response .= 'Request: ' . $request->get_method() . ' ' . $request->get_route() . PHP_EOL;
$this->response .= 'Status: ' . $response->get_status() . PHP_EOL;
$this->response .= 'Response: ' . $json;
return $data;
}
}
Coupon/CouponHelper.php 0000644 00000027474 15153721357 0011152 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\HelperNotificationInterface;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
/**
* Class CouponHelper
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
*/
class CouponHelper implements Service, HelperNotificationInterface {
use PluginHelper;
/**
*
* @var CouponMetaHandler
*/
protected $meta_handler;
/**
*
* @var WC
*/
protected $wc;
/**
*
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* CouponHelper constructor.
*
* @param CouponMetaHandler $meta_handler
* @param WC $wc
* @param MerchantCenterService $merchant_center
*/
public function __construct(
CouponMetaHandler $meta_handler,
WC $wc,
MerchantCenterService $merchant_center
) {
$this->meta_handler = $meta_handler;
$this->wc = $wc;
$this->merchant_center = $merchant_center;
}
/**
* Mark the item as notified.
*
* @param WC_Coupon $coupon
*
* @return void
*/
public function mark_as_notified( $coupon ): void {
$this->meta_handler->update_synced_at( $coupon, time() );
$this->meta_handler->update_sync_status( $coupon, SyncStatus::SYNCED );
$this->update_empty_visibility( $coupon );
}
/**
* Mark a coupon as synced. This function accepts nullable $google_id,
* which guarantees version compatibility for Alpha, Beta and stable verison promtoion APIs.
*
* @param WC_Coupon $coupon
* @param string|null $google_id
* @param string $target_country
*/
public function mark_as_synced(
WC_Coupon $coupon,
?string $google_id,
string $target_country
) {
$this->meta_handler->update_synced_at( $coupon, time() );
$this->meta_handler->update_sync_status( $coupon, SyncStatus::SYNCED );
$this->update_empty_visibility( $coupon );
// merge and update all google ids
$current_google_ids = $this->meta_handler->get_google_ids( $coupon );
$current_google_ids = ! empty( $current_google_ids ) ? $current_google_ids : [];
$google_ids = array_unique(
array_merge(
$current_google_ids,
[
$target_country => $google_id,
]
)
);
$this->meta_handler->update_google_ids( $coupon, $google_ids );
}
/**
*
* @param WC_Coupon $coupon
*/
public function mark_as_unsynced( $coupon ): void {
$this->meta_handler->delete_synced_at( $coupon );
$this->meta_handler->update_sync_status( $coupon, SyncStatus::NOT_SYNCED );
$this->meta_handler->delete_google_ids( $coupon );
$this->meta_handler->delete_errors( $coupon );
$this->meta_handler->delete_failed_sync_attempts( $coupon );
$this->meta_handler->delete_sync_failed_at( $coupon );
}
/**
*
* @param WC_Coupon $coupon
* @param string $target_country
*/
public function remove_google_id_by_country( WC_Coupon $coupon, string $target_country ) {
$google_ids = $this->meta_handler->get_google_ids( $coupon );
if ( empty( $google_ids ) ) {
return;
}
unset( $google_ids[ $target_country ] );
if ( ! empty( $google_ids ) ) {
$this->meta_handler->update_google_ids( $coupon, $google_ids );
} else {
// if there are no Google IDs left then this coupon is no longer considered "synced"
$this->mark_as_unsynced( $coupon );
}
}
/**
* Marks a WooCommerce coupon as invalid and stores the errors in a meta data key.
*
* @param WC_Coupon $coupon
* @param InvalidCouponEntry[] $errors
*/
public function mark_as_invalid( WC_Coupon $coupon, array $errors ) {
// bail if no errors exist
if ( empty( $errors ) ) {
return;
}
$this->meta_handler->update_errors( $coupon, $errors );
$this->meta_handler->update_sync_status( $coupon, SyncStatus::HAS_ERRORS );
$this->update_empty_visibility( $coupon );
// TODO: Update failed sync attempts count in case of internal errors
}
/**
* Marks a WooCommerce coupon as pending synchronization.
*
* @param WC_Coupon $coupon
*/
public function mark_as_pending( WC_Coupon $coupon ) {
$this->meta_handler->update_sync_status( $coupon, SyncStatus::PENDING );
$this->meta_handler->delete_errors( $coupon );
}
/**
* Update empty (NOT EXIST) visibility meta values to SYNC_AND_SHOW.
*
* @param WC_Coupon $coupon
*/
protected function update_empty_visibility( WC_Coupon $coupon ): void {
$visibility = $this->meta_handler->get_visibility( $coupon );
if ( empty( $visibility ) ) {
$this->meta_handler->update_visibility(
$coupon,
ChannelVisibility::SYNC_AND_SHOW
);
}
}
/**
*
* @param WC_Coupon $coupon
*
* @return string[]|null An array of Google IDs stored for each WooCommerce coupon
*/
public function get_synced_google_ids( WC_Coupon $coupon ): ?array {
return $this->meta_handler->get_google_ids( $coupon );
}
/**
* Get WooCommerce coupon
*
* @param int $coupon_id
*
* @return WC_Coupon
*
* @throws InvalidValue If the given ID doesn't reference a valid coupon.
*/
public function get_wc_coupon( int $coupon_id ): WC_Coupon {
$coupon = $this->wc->maybe_get_coupon( $coupon_id );
if ( ! $coupon instanceof WC_Coupon ) {
throw InvalidValue::not_valid_coupon_id( $coupon_id );
}
return $coupon;
}
/**
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function is_coupon_synced( WC_Coupon $coupon ): bool {
$synced_at = $this->meta_handler->get_synced_at( $coupon );
$google_ids = $this->meta_handler->get_google_ids( $coupon );
return ! empty( $synced_at ) && ! empty( $google_ids );
}
/**
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function is_sync_ready( WC_Coupon $coupon ): bool {
return ( ChannelVisibility::SYNC_AND_SHOW ===
$this->get_channel_visibility( $coupon ) ) &&
( CouponSyncer::is_coupon_supported( $coupon ) ) &&
( ! $coupon->get_virtual() );
}
/**
* Whether the sync has failed repeatedly for the coupon within the given timeframe.
*
* @param WC_Coupon $coupon
*
* @return bool
*
* @see CouponSyncer::FAILURE_THRESHOLD The number of failed attempts allowed per timeframe
* @see CouponSyncer::FAILURE_THRESHOLD_WINDOW The specified timeframe
*/
public function is_sync_failed_recently( WC_Coupon $coupon ): bool {
$failed_attempts = $this->meta_handler->get_failed_sync_attempts(
$coupon
);
$failed_at = $this->meta_handler->get_sync_failed_at( $coupon );
// if it has failed more times than the specified threshold AND if syncing it has failed within the specified window
return $failed_attempts > CouponSyncer::FAILURE_THRESHOLD &&
$failed_at >
strtotime( sprintf( '-%s', CouponSyncer::FAILURE_THRESHOLD_WINDOW ) );
}
/**
*
* @param WC_Coupon $coupon
*
* @return string
*/
public function get_channel_visibility( WC_Coupon $coupon ): string {
$visibility = $this->meta_handler->get_visibility( $coupon );
if ( empty( $visibility ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Channel visibility forced to "%s" for visibility unknown (Post ID: %s).',
ChannelVisibility::DONT_SYNC_AND_SHOW,
$coupon->get_id()
),
__METHOD__
);
return ChannelVisibility::DONT_SYNC_AND_SHOW;
}
return $visibility;
}
/**
* Return a string indicating sync status based on several factors.
*
* @param WC_Coupon $coupon
*
* @return string|null
*/
public function get_sync_status( WC_Coupon $coupon ): ?string {
return $this->meta_handler->get_sync_status( $coupon );
}
/**
* Return the string indicating the coupon status as reported by the Merchant Center.
*
* @param WC_Coupon $coupon
*
* @return string|null
*/
public function get_mc_status( WC_Coupon $coupon ): ?string {
try {
return $this->meta_handler->get_mc_status( $coupon );
} catch ( InvalidValue $exception ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Coupon status returned null for invalid coupon (ID: %s).',
$coupon->get_id()
),
__METHOD__
);
return null;
}
}
/**
* Get validation errors for a specific coupon.
* Combines errors for variable coupons, which have a variation-indexed array of errors.
*
* @param WC_Coupon $coupon
*
* @return array
*/
public function get_validation_errors( WC_Coupon $coupon ): array {
$errors = $this->meta_handler->get_errors( $coupon ) ?: [];
$first_key = array_key_first( $errors );
if ( ! empty( $errors ) && is_array( $errors[ $first_key ] ) ) {
$errors = array_unique( array_merge( ...$errors ) );
}
return $errors;
}
/**
* Indicates if a coupon is ready for sending Notifications.
* A coupon is ready to send notifications if its sync ready and the post status is publish.
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function is_ready_to_notify( WC_Coupon $coupon ): bool {
$is_ready = $this->is_sync_ready( $coupon ) && $coupon->get_status() === 'publish';
/**
* Allow users to filter if a coupon is ready to notify.
*
* @since 2.8.0
*
* @param bool $value The current filter value.
* @param WC_Coupon $coupon The coupon for the notification.
*/
return apply_filters( 'woocommerce_gla_coupon_is_ready_to_notify', $is_ready, $coupon );
}
/**
* Indicates if a coupon was already notified about its creation.
* Notice we consider synced coupons in MC as notified for creation.
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function has_notified_creation( WC_Coupon $coupon ): bool {
$valid_has_notified_creation_statuses = [
NotificationStatus::NOTIFICATION_CREATED,
NotificationStatus::NOTIFICATION_UPDATED,
NotificationStatus::NOTIFICATION_PENDING_UPDATE,
NotificationStatus::NOTIFICATION_PENDING_DELETE,
];
return in_array(
$this->meta_handler->get_notification_status( $coupon ),
$valid_has_notified_creation_statuses,
true
) || $this->is_coupon_synced( $coupon );
}
/**
* Set the notification status for a WooCommerce coupon.
*
* @param WC_Coupon $coupon
* @param string $status
*/
public function set_notification_status( $coupon, $status ): void {
$this->meta_handler->update_notification_status( $coupon, $status );
}
/**
* Indicates if a coupon is ready for sending a create Notification.
* A coupon is ready to send create notifications if is ready to notify and has not sent create notification yet.
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function should_trigger_create_notification( $coupon ): bool {
return $this->is_ready_to_notify( $coupon ) && ! $this->has_notified_creation( $coupon );
}
/**
* Indicates if a coupon is ready for sending an update Notification.
* A coupon is ready to send update notifications if is ready to notify and has sent create notification already.
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function should_trigger_update_notification( $coupon ): bool {
return $this->is_ready_to_notify( $coupon ) && $this->has_notified_creation( $coupon );
}
/**
* Indicates if a coupon is ready for sending a delete Notification.
* A coupon is ready to send delete notifications if it is not ready to notify and has sent create notification already.
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public function should_trigger_delete_notification( $coupon ): bool {
return ! $this->is_ready_to_notify( $coupon ) && $this->has_notified_creation( $coupon );
}
}
Coupon/CouponMetaHandler.php 0000644 00000014542 15153721357 0012107 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidMeta;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use BadMethodCallException;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
/**
* Class CouponMetaHandler
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
*
* @method update_synced_at( WC_Coupon $coupon, $value )
* @method delete_synced_at( WC_Coupon $coupon )
* @method get_synced_at( WC_Coupon $coupon ): int|null
* @method update_google_ids( WC_Coupon $coupon, array $value )
* @method delete_google_ids( WC_Coupon $coupon )
* @method get_google_ids( WC_Coupon $coupon ): array|null
* @method update_visibility( WC_Coupon $coupon, $value )
* @method delete_visibility( WC_Coupon $coupon )
* @method get_visibility( WC_Coupon $coupon ): string|null
* @method update_errors( WC_Coupon $coupon, array $value )
* @method delete_errors( WC_Coupon $coupon )
* @method get_errors( WC_Coupon $coupon ): array|null
* @method update_failed_sync_attempts( WC_Coupon $coupon, int $value )
* @method delete_failed_sync_attempts( WC_Coupon $coupon )
* @method get_failed_sync_attempts( WC_Coupon $coupon ): int|null
* @method update_sync_failed_at( WC_Coupon $coupon, int $value )
* @method delete_sync_failed_at( WC_Coupon $coupon )
* @method get_sync_failed_at( WC_Coupon $coupon ): int|null
* @method update_sync_status( WC_Coupon $coupon, string $value )
* @method delete_sync_status( WC_Coupon $coupon )
* @method get_sync_status( WC_Coupon $coupon ): string|null
* @method update_mc_status( WC_Coupon $coupon, string $value )
* @method delete_mc_status( WC_Coupon $coupon )
* @method get_mc_status( WC_Coupon $coupon ): string|null
* @method update_notification_status( WC_Coupon $coupon, string $value )
* @method delete_notification_status( WC_Coupon $coupon )
* @method get_notification_status( WC_Coupon $coupon ): string|null
*/
class CouponMetaHandler implements Service {
use PluginHelper;
public const KEY_SYNCED_AT = 'synced_at';
public const KEY_GOOGLE_IDS = 'google_ids';
public const KEY_VISIBILITY = 'visibility';
public const KEY_ERRORS = 'errors';
public const KEY_FAILED_SYNC_ATTEMPTS = 'failed_sync_attempts';
public const KEY_SYNC_FAILED_AT = 'sync_failed_at';
public const KEY_SYNC_STATUS = 'sync_status';
public const KEY_MC_STATUS = 'mc_status';
public const KEY_NOTIFICATION_STATUS = 'notification_status';
protected const TYPES = [
self::KEY_SYNCED_AT => 'int',
self::KEY_GOOGLE_IDS => 'array',
self::KEY_VISIBILITY => 'string',
self::KEY_ERRORS => 'array',
self::KEY_FAILED_SYNC_ATTEMPTS => 'int',
self::KEY_SYNC_FAILED_AT => 'int',
self::KEY_SYNC_STATUS => 'string',
self::KEY_MC_STATUS => 'string',
self::KEY_NOTIFICATION_STATUS => 'string',
];
/**
*
* @param string $name
* @param mixed $arguments
*
* @return mixed
*
* @throws BadMethodCallException If the method that's called doesn't exist.
* @throws InvalidMeta If the meta key is invalid.
*/
public function __call( string $name, $arguments ) {
$found_matches = preg_match( '/^([a-z]+)_([\w\d]+)$/i', $name, $matches );
if ( ! $found_matches ) {
throw new BadMethodCallException(
sprintf(
'The method %s does not exist in class CouponMetaHandler',
$name
)
);
}
[
$function_name,
$method,
$key
] = $matches;
// validate the method
if ( ! in_array(
$method,
[
'update',
'delete',
'get',
],
true
) ) {
throw new BadMethodCallException(
sprintf(
'The method %s does not exist in class CouponMetaHandler',
$function_name
)
);
}
// set the value as the third argument if method is `update`
if ( 'update' === $method ) {
$arguments[2] = $arguments[1];
}
// set the key as the second argument
$arguments[1] = $key;
return call_user_func_array(
[
$this,
$method,
],
$arguments
);
}
/**
*
* @param WC_Coupon $coupon
* @param string $key
* @param mixed $value
*
* @throws InvalidMeta If the meta key is invalid.
*/
public function update( WC_Coupon $coupon, string $key, $value ) {
self::validate_meta_key( $key );
if ( isset( self::TYPES[ $key ] ) ) {
if ( in_array(
self::TYPES[ $key ],
[
'bool',
'boolean',
],
true
) ) {
$value = wc_bool_to_string( $value );
} else {
settype( $value, self::TYPES[ $key ] );
}
}
$coupon->update_meta_data( $this->prefix_meta_key( $key ), $value );
$coupon->save_meta_data();
}
/**
*
* @param WC_Coupon $coupon
* @param string $key
*
* @throws InvalidMeta If the meta key is invalid.
*/
public function delete( WC_Coupon $coupon, string $key ) {
self::validate_meta_key( $key );
$coupon->delete_meta_data( $this->prefix_meta_key( $key ) );
$coupon->save_meta_data();
}
/**
*
* @param WC_Coupon $coupon
* @param string $key
*
* @return mixed The value, or null if the meta key doesn't exist.
*
* @throws InvalidMeta If the meta key is invalid.
*/
public function get( WC_Coupon $coupon, string $key ) {
self::validate_meta_key( $key );
$value = null;
if ( $coupon->meta_exists( $this->prefix_meta_key( $key ) ) ) {
$value = $coupon->get_meta( $this->prefix_meta_key( $key ), true );
if ( isset( self::TYPES[ $key ] ) &&
in_array(
self::TYPES[ $key ],
[
'bool',
'boolean',
],
true
) ) {
$value = wc_string_to_bool( $value );
}
}
return $value;
}
/**
*
* @param string $key
*
* @throws InvalidMeta If the meta key is invalid.
*/
protected static function validate_meta_key( string $key ) {
if ( ! self::is_meta_key_valid( $key ) ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Coupon meta key is invalid: %s', $key ),
__METHOD__
);
throw InvalidMeta::invalid_key( $key );
}
}
/**
*
* @param string $key
*
* @return bool Whether the meta key is valid.
*/
public static function is_meta_key_valid( string $key ): bool {
return isset( self::TYPES[ $key ] );
}
/**
* Returns all available meta keys.
*
* @return array
*/
public static function get_all_meta_keys(): array {
return array_keys( self::TYPES );
}
}
Coupon/CouponSyncer.php 0000644 00000031061 15153721357 0011161 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\DeleteCouponEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GooglePromotionService;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\InvalidCouponEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Exception;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
/**
* Class CouponSyncer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
*/
class CouponSyncer implements Service {
public const FAILURE_THRESHOLD = 5;
// Number of failed attempts allowed per FAILURE_THRESHOLD_WINDOW
public const FAILURE_THRESHOLD_WINDOW = '3 hours';
/**
*
* @var GooglePromotionService
*/
protected $google_service;
/**
*
* @var CouponHelper
*/
protected $coupon_helper;
/**
*
* @var ValidatorInterface
*/
protected $validator;
/**
*
* @var MerchantCenterService
*/
protected $merchant_center;
/**
*
* @var WC
*/
protected $wc;
/**
* @var TargetAudience
*/
protected $target_audience;
/**
* CouponSyncer constructor.
*
* @param GooglePromotionService $google_service
* @param CouponHelper $coupon_helper
* @param ValidatorInterface $validator
* @param MerchantCenterService $merchant_center
* @param TargetAudience $target_audience
* @param WC $wc
*/
public function __construct(
GooglePromotionService $google_service,
CouponHelper $coupon_helper,
ValidatorInterface $validator,
MerchantCenterService $merchant_center,
TargetAudience $target_audience,
WC $wc
) {
$this->google_service = $google_service;
$this->coupon_helper = $coupon_helper;
$this->validator = $validator;
$this->merchant_center = $merchant_center;
$this->target_audience = $target_audience;
$this->wc = $wc;
}
/**
* Submit a WooCommerce coupon to Google Merchant Center.
*
* @param WC_Coupon $coupon
*
* @throws CouponSyncerException If there are any errors while syncing coupon with Google Merchant Center.
*/
public function update( WC_Coupon $coupon ) {
$this->validate_merchant_center_setup();
if ( ! $this->coupon_helper->is_sync_ready( $coupon ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Skipping coupon (ID: %s) because it is not ready to be synced.',
$coupon->get_id()
),
__METHOD__
);
return;
}
$target_country = $this->target_audience->get_main_target_country();
if ( ! $this->merchant_center->is_promotion_supported_country( $target_country ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Skipping coupon (ID: %s) because it is not supported in main target country %s.',
$coupon->get_id(),
$target_country
),
__METHOD__
);
return;
}
$adapted_coupon = new WCCouponAdapter(
[
'wc_coupon' => $coupon,
'targetCountry' => $target_country,
]
);
$validation_result = $this->validate_coupon( $adapted_coupon );
if ( $validation_result instanceof InvalidCouponEntry ) {
$this->coupon_helper->mark_as_invalid(
$coupon,
$validation_result->get_errors()
);
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Skipping coupon (ID: %s) because it does not pass validation: %s',
$coupon->get_id(),
wp_json_encode( $validation_result )
),
__METHOD__
);
return;
}
try {
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Start to upload coupon (ID: %s) as promotion structure: %s',
$coupon->get_id(),
wp_json_encode( $adapted_coupon )
),
__METHOD__
);
$response = $this->google_service->create( $adapted_coupon );
$this->coupon_helper->mark_as_synced(
$coupon,
$response->getId(),
$target_country
);
do_action( 'woocommerce_gla_updated_coupon', $adapted_coupon );
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Submitted promotion:\n%s",
wp_json_encode( $adapted_coupon )
),
__METHOD__
);
} catch ( GoogleException $google_exception ) {
$invalid_promotion = new InvalidCouponEntry(
$coupon->get_id(),
[
$google_exception->getCode() => $google_exception->getMessage(),
],
$target_country
);
$this->coupon_helper->mark_as_invalid(
$coupon,
$invalid_promotion->get_errors()
);
$this->handle_update_errors( [ $invalid_promotion ] );
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Promotion failed to sync with Merchant Center:\n%s",
wp_json_encode( $invalid_promotion )
),
__METHOD__
);
} catch ( Exception $exception ) {
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
throw new CouponSyncerException(
sprintf(
'Error updating Google promotion: %s',
$exception->getMessage()
),
0,
$exception
);
}
}
/**
*
* @param WCCouponAdapter $coupon
*
* @return InvalidCouponEntry|true
*/
protected function validate_coupon( WCCouponAdapter $coupon ) {
$violations = $this->validator->validate( $coupon );
if ( 0 !== count( $violations ) ) {
$invalid_promotion = new InvalidCouponEntry(
$coupon->get_wc_coupon_id()
);
$invalid_promotion->map_validation_violations( $violations );
return $invalid_promotion;
}
return true;
}
/**
* Delete a WooCommerce coupon from Google Merchant Center.
*
* @param DeleteCouponEntry $coupon
*
* @throws CouponSyncerException If there are any errors while deleting coupon from Google Merchant Center.
*/
public function delete( DeleteCouponEntry $coupon ) {
$this->validate_merchant_center_setup();
$deleted_promotions = [];
$invalid_promotions = [];
$synced_google_ids = $coupon->get_synced_google_ids();
$wc_coupon = $this->wc->maybe_get_coupon(
$coupon->get_wc_coupon_id()
);
$wc_coupon_exist = $wc_coupon instanceof WC_Coupon;
foreach ( $synced_google_ids as $target_country => $google_id ) {
try {
$adapted_coupon = $coupon->get_google_promotion();
$adapted_coupon->setTargetCountry( $target_country );
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Start to delete coupon (ID: %s) as promotion structure: %s',
$coupon->get_wc_coupon_id(),
wp_json_encode( $adapted_coupon )
),
__METHOD__
);
// DeleteCouponEntry is generated with promotion effective date expired
// when WC coupon is able to be deleted.
// To soft-delete the promotion from Google side,
// we will update Google promotion with expired effective date.
$response = $this->google_service->create( $adapted_coupon );
array_push( $deleted_promotions, $response );
if ( $wc_coupon_exist ) {
$this->coupon_helper->remove_google_id_by_country(
$wc_coupon,
$target_country
);
}
} catch ( GoogleException $google_exception ) {
array_push(
$invalid_promotions,
new InvalidCouponEntry(
$coupon->get_wc_coupon_id(),
[
$google_exception->getCode() => $google_exception->getMessage(),
],
$target_country,
$google_id
)
);
} catch ( Exception $exception ) {
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
throw new CouponSyncerException(
sprintf(
'Error deleting Google promotion: %s',
$exception->getMessage()
),
0,
$exception
);
}
}
if ( ! empty( $invalid_promotions ) ) {
$this->handle_delete_errors( $invalid_promotions );
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Failed to delete %s promotions from Merchant Center:\n%s",
count( $invalid_promotions ),
wp_json_encode( $invalid_promotions )
),
__METHOD__
);
} elseif ( $wc_coupon_exist ) {
$this->coupon_helper->mark_as_unsynced( $wc_coupon );
}
do_action(
'woocommerce_gla_deleted_promotions',
$deleted_promotions,
$invalid_promotions
);
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Deleted %s promoitons:\n%s",
count( $deleted_promotions ),
wp_json_encode( $deleted_promotions )
),
__METHOD__
);
}
/**
* Return whether coupon is supported as visible on Google.
*
* @param WC_Coupon $coupon
*
* @return bool
*/
public static function is_coupon_supported( WC_Coupon $coupon ): bool {
if ( $coupon->get_virtual() ) {
return false;
}
if ( ! empty( $coupon->get_email_restrictions() ) ) {
return false;
}
if ( ! empty( $coupon->get_exclude_sale_items() ) &&
$coupon->get_exclude_sale_items() ) {
return false;
}
return true;
}
/**
* Return the list of supported coupon types.
*
* @return array
*/
public static function get_supported_coupon_types(): array {
return (array) apply_filters(
'woocommerce_gla_supported_coupon_types',
[ 'percent', 'fixed_cart', 'fixed_product' ]
);
}
/**
* Return the list of coupon types we will hide functionality for (default none).
*
* @since 1.2.0
*
* @return array
*/
public static function get_hidden_coupon_types(): array {
return (array) apply_filters( 'woocommerce_gla_hidden_coupon_types', [] );
}
/**
*
* @param InvalidCouponEntry[] $invalid_coupons
*/
protected function handle_update_errors( array $invalid_coupons ) {
// Get a coupon id to country mappings.
$internal_error_coupon_ids = [];
foreach ( $invalid_coupons as $invalid_coupon ) {
if ( $invalid_coupon->has_error(
GooglePromotionService::INTERNAL_ERROR_CODE
) ) {
$coupon_id = $invalid_coupon->get_wc_coupon_id();
$internal_error_coupon_ids[ $coupon_id ] = $invalid_coupon->get_target_country();
}
}
if ( ! empty( $internal_error_coupon_ids ) &&
apply_filters(
'woocommerce_gla_coupons_update_retry_on_failure',
true,
$internal_error_coupon_ids
) ) {
do_action(
'woocommerce_gla_retry_update_coupons',
$internal_error_coupon_ids
);
do_action(
'woocommerce_gla_error',
sprintf(
'Internal API errors while submitting the following coupons: %s',
join( ', ', $internal_error_coupon_ids )
),
__METHOD__
);
}
}
/**
*
* @param BatchInvalidCouponEntry[] $invalid_coupons
*/
protected function handle_delete_errors( array $invalid_coupons ) {
// Get all wc coupon id to google id mappings that have internal errors.
$internal_error_coupon_ids = [];
foreach ( $invalid_coupons as $invalid_coupon ) {
if ( $invalid_coupon->has_error(
GooglePromotionService::INTERNAL_ERROR_CODE
) ) {
$coupon_id = $invalid_coupon->get_wc_coupon_id();
$internal_error_coupon_ids[ $coupon_id ] = $invalid_coupon->get_google_promotion_id();
}
}
if ( ! empty( $internal_error_coupon_ids ) &&
apply_filters(
'woocommerce_gla_coupons_delete_retry_on_failure',
true,
$internal_error_coupon_ids
) ) {
do_action(
'woocommerce_gla_retry_delete_coupons',
$internal_error_coupon_ids
);
do_action(
'woocommerce_gla_error',
sprintf(
'Internal API errors while deleting the following coupons: %s',
join( ', ', $internal_error_coupon_ids )
),
__METHOD__
);
}
}
/**
* Validates whether Merchant Center is connected and ready for pushing data.
*
* @throws CouponSyncerException If Google Merchant Center is not set up and connected or is not ready for pushing data.
*/
protected function validate_merchant_center_setup(): void {
if ( ! $this->merchant_center->is_ready_for_syncing() ) {
do_action(
'woocommerce_gla_error',
'Cannot sync any coupons before setting up Google Merchant Center.',
__METHOD__
);
throw new CouponSyncerException(
__(
'Google Merchant Center has not been set up correctly. Please review your configuration.',
'google-listings-and-ads'
)
);
}
if ( ! $this->merchant_center->should_push() ) {
do_action(
'woocommerce_gla_error',
'Cannot push any coupons because they are being fetched automatically.',
__METHOD__
);
throw new CouponSyncerException(
__(
'Pushing Coupons will not run if the automatic data fetching is enabled. Please review your configuration in Google Listing and Ads settings.',
'google-listings-and-ads'
)
);
}
}
}
Coupon/CouponSyncerException.php 0000644 00000000655 15153721357 0013045 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class CouponSyncerException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
*/
class CouponSyncerException extends Exception implements GoogleListingsAndAdsException {
}
Coupon/SyncerHooks.php 0000644 00000032770 15153721357 0011011 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\DeleteCouponEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteCoupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\CouponNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateCoupon;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
/**
* Class SyncerHooks
*
* Hooks to various WooCommerce and WordPress actions to provide automatic coupon sync functionality.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
*/
class SyncerHooks implements Service, Registerable {
use PluginHelper;
protected const SCHEDULE_TYPE_UPDATE = 'update';
protected const SCHEDULE_TYPE_DELETE = 'delete';
/**
* Array of strings mapped to coupon IDs indicating that they have been already
* scheduled for update or delete during current request.
* Used to avoid scheduling
* duplicate jobs.
*
* @var string[]
*/
protected $already_scheduled = [];
/**
*
* @var DeleteCouponEntry[][]
*/
protected $delete_requests_map;
/**
*
* @var CouponHelper
*/
protected $coupon_helper;
/**
* @var JobRepository
*/
protected $job_repository;
/**
*
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var NotificationsService
*/
protected $notifications_service;
/**
*
* @var WC
*/
protected $wc;
/**
* WP Proxy
*
* @var WP
*/
protected WP $wp;
/**
* SyncerHooks constructor.
*
* @param CouponHelper $coupon_helper
* @param JobRepository $job_repository
* @param MerchantCenterService $merchant_center
* @param NotificationsService $notifications_service
* @param WC $wc
* @param WP $wp
*/
public function __construct(
CouponHelper $coupon_helper,
JobRepository $job_repository,
MerchantCenterService $merchant_center,
NotificationsService $notifications_service,
WC $wc,
WP $wp
) {
$this->coupon_helper = $coupon_helper;
$this->job_repository = $job_repository;
$this->merchant_center = $merchant_center;
$this->notifications_service = $notifications_service;
$this->wc = $wc;
$this->wp = $wp;
}
/**
* Register a service.
*/
public function register(): void {
// only register the hooks if Merchant Center is set up correctly.
if ( ! $this->merchant_center->is_ready_for_syncing() ) {
return;
}
// when a coupon is added / updated, schedule a update job.
add_action( 'woocommerce_new_coupon', [ $this, 'update_by_id' ], 90, 2 );
add_action( 'woocommerce_update_coupon', [ $this, 'update_by_id' ], 90, 2 );
add_action( 'woocommerce_gla_bulk_update_coupon', [ $this, 'update_by_id' ], 90 );
// when a coupon is trashed or removed, schedule a delete job.
add_action( 'wp_trash_post', [ $this, 'pre_delete' ], 90 );
add_action( 'before_delete_post', [ $this, 'pre_delete' ], 90 );
add_action( 'trashed_post', [ $this, 'delete_by_id' ], 90 );
add_action( 'deleted_post', [ $this, 'delete_by_id' ], 90 );
add_action( 'woocommerce_delete_coupon', [ $this, 'delete_by_id' ], 90, 2 );
add_action( 'woocommerce_trash_coupon', [ $this, 'delete_by_id' ], 90, 2 );
// when a coupon is restored from trash, schedule a update job.
add_action( 'untrashed_post', [ $this, 'update_by_id' ], 90 );
// Update coupons when object terms get updated.
add_action( 'set_object_terms', [ $this, 'maybe_update_by_id_when_terms_updated' ], 90, 6 );
}
/**
* Update a coupon by the ID
*
* @param int $coupon_id
*/
public function update_by_id( int $coupon_id ) {
$coupon = $this->wc->maybe_get_coupon( $coupon_id );
if ( $coupon instanceof WC_Coupon ) {
$this->handle_update_coupon( $coupon );
}
}
/**
* Update a coupon by the ID when the terms get updated.
*
* @param int $object_id The object ID.
* @param array $terms An array of object term IDs or slugs.
* @param array $tt_ids An array of term taxonomy IDs.
* @param string $taxonomy The taxonomy slug.
* @param bool $append Whether to append new terms to the old terms.
* @param array $old_tt_ids Old array of term taxonomy IDs.
*/
public function maybe_update_by_id_when_terms_updated( int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids ) {
$this->handle_update_coupon_when_product_brands_updated( $taxonomy, $tt_ids, $old_tt_ids );
}
/**
* Delete a coupon by the ID
*
* @param int $coupon_id
*/
public function delete_by_id( int $coupon_id ) {
$this->handle_delete_coupon( $coupon_id );
}
/**
* Pre Delete a coupon by the ID
*
* @param int $coupon_id
*/
public function pre_delete( int $coupon_id ) {
$this->handle_pre_delete_coupon( $coupon_id );
}
/**
* Handle updating of a coupon.
*
* @param WC_Coupon $coupon
* The coupon being saved.
*
* @return void
*/
protected function handle_update_coupon( WC_Coupon $coupon ) {
$coupon_id = $coupon->get_id();
if ( $this->notifications_service->is_ready() ) {
$this->handle_update_coupon_notification( $coupon );
}
// Schedule an update job if product sync is enabled.
if ( $this->coupon_helper->is_sync_ready( $coupon ) ) {
$this->coupon_helper->mark_as_pending( $coupon );
$this->job_repository->get( UpdateCoupon::class )->schedule(
[
[ $coupon_id ],
]
);
} elseif ( $this->coupon_helper->is_coupon_synced( $coupon ) ) {
// Delete the coupon from Google Merchant Center if it's already synced BUT it is not sync ready after the edit.
$coupon_to_delete = new DeleteCouponEntry(
$coupon_id,
$this->get_coupon_to_delete( $coupon ),
$this->coupon_helper->get_synced_google_ids( $coupon )
);
$this->job_repository->get( DeleteCoupon::class )->schedule(
[
$coupon_to_delete,
]
);
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Deleting coupon (ID: %s) from Google Merchant Center because it is not ready to be synced.',
$coupon->get_id()
),
__METHOD__
);
} else {
$this->coupon_helper->mark_as_unsynced( $coupon );
}
}
/**
* Create request entries for the coupon (containing its Google ID),
* so we can schedule a delete job when it is actually trashed / deleted.
*
* @param int $coupon_id
*/
protected function handle_pre_delete_coupon( int $coupon_id ) {
$coupon = $this->wc->maybe_get_coupon( $coupon_id );
if ( $coupon instanceof WC_Coupon &&
$this->coupon_helper->is_coupon_synced( $coupon ) ) {
$this->delete_requests_map[ $coupon_id ] = new DeleteCouponEntry(
$coupon_id,
$this->get_coupon_to_delete( $coupon ),
$this->coupon_helper->get_synced_google_ids( $coupon )
);
}
}
/**
* @param WC_Coupon $coupon
*
* @return WCCouponAdapter
*/
protected function get_coupon_to_delete( WC_Coupon $coupon ): WCCouponAdapter {
$adapted_coupon_to_delete = new WCCouponAdapter(
[
'wc_coupon' => $coupon,
]
);
// Promotion stored in Google can only be soft-deleted to keep historical records.
// Instead of 'delete', we update the promotion with effective dates expired.
// Here we reset an expiring date based on WooCommerce coupon source.
$adapted_coupon_to_delete->disable_promotion( $coupon );
return $adapted_coupon_to_delete;
}
/**
* Handle deleting of a coupon.
*
* @param int $coupon_id
*/
protected function handle_delete_coupon( int $coupon_id ) {
if ( $this->notifications_service->is_ready() ) {
$this->maybe_send_delete_notification( $coupon_id );
}
if ( ! isset( $this->delete_requests_map[ $coupon_id ] ) ) {
return;
}
$coupon_to_delete = $this->delete_requests_map[ $coupon_id ];
if ( ! empty( $coupon_to_delete->get_synced_google_ids() ) &&
! $this->is_already_scheduled_to_delete( $coupon_id ) ) {
$this->job_repository->get( DeleteCoupon::class )->schedule(
[
$coupon_to_delete,
]
);
$this->set_already_scheduled_to_delete( $coupon_id );
}
}
/**
* Send the notification for coupon deletion
*
* @since 2.8.0
* @param int $coupon_id
*/
protected function maybe_send_delete_notification( int $coupon_id ): void {
$coupon = $this->wc->maybe_get_coupon( $coupon_id );
if ( $coupon instanceof WC_Coupon && $this->coupon_helper->should_trigger_delete_notification( $coupon ) ) {
$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_DELETE );
$this->job_repository->get( CouponNotificationJob::class )->schedule(
[
'item_id' => $coupon->get_id(),
'topic' => NotificationsService::TOPIC_COUPON_DELETED,
]
);
}
}
/**
*
* @param int $coupon_id
* @param string $schedule_type
*
* @return bool
*/
protected function is_already_scheduled(
int $coupon_id,
string $schedule_type
): bool {
return isset( $this->already_scheduled[ $coupon_id ] ) &&
$this->already_scheduled[ $coupon_id ] === $schedule_type;
}
/**
*
* @param int $coupon_id
*
* @return bool
*/
protected function is_already_scheduled_to_update( int $coupon_id ): bool {
return $this->is_already_scheduled(
$coupon_id,
self::SCHEDULE_TYPE_UPDATE
);
}
/**
*
* @param int $coupon_id
*
* @return bool
*/
protected function is_already_scheduled_to_delete( int $coupon_id ): bool {
return $this->is_already_scheduled(
$coupon_id,
self::SCHEDULE_TYPE_DELETE
);
}
/**
*
* @param int $coupon_id
* @param string $schedule_type
*
* @return void
*/
protected function set_already_scheduled(
int $coupon_id,
string $schedule_type
): void {
$this->already_scheduled[ $coupon_id ] = $schedule_type;
}
/**
*
* @param int $coupon_id
*
* @return void
*/
protected function set_already_scheduled_to_update( int $coupon_id ): void {
$this->set_already_scheduled( $coupon_id, self::SCHEDULE_TYPE_UPDATE );
}
/**
*
* @param int $coupon_id
*
* @return void
*/
protected function set_already_scheduled_to_delete( int $coupon_id ): void {
$this->set_already_scheduled( $coupon_id, self::SCHEDULE_TYPE_DELETE );
}
/**
* Schedules notifications for an updated coupon
*
* @param WC_Coupon $coupon
*/
protected function handle_update_coupon_notification( WC_Coupon $coupon ) {
if ( $this->coupon_helper->should_trigger_create_notification( $coupon ) ) {
$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_CREATE );
$this->job_repository->get( CouponNotificationJob::class )->schedule(
[
'item_id' => $coupon->get_id(),
'topic' => NotificationsService::TOPIC_COUPON_CREATED,
]
);
} elseif ( $this->coupon_helper->should_trigger_update_notification( $coupon ) ) {
$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_UPDATE );
$this->job_repository->get( CouponNotificationJob::class )->schedule(
[
'item_id' => $coupon->get_id(),
'topic' => NotificationsService::TOPIC_COUPON_UPDATED,
]
);
} elseif ( $this->coupon_helper->should_trigger_delete_notification( $coupon ) ) {
$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_DELETE );
$this->job_repository->get( CouponNotificationJob::class )->schedule(
[
'item_id' => $coupon->get_id(),
'topic' => NotificationsService::TOPIC_COUPON_DELETED,
]
);
}
}
/**
* If product to brands relationship is updated, update the coupons that are related to the brands.
*
* @param string $taxonomy The taxonomy slug.
* @param array $tt_ids An array of term taxonomy IDs.
* @param array $old_tt_ids Old array of term taxonomy IDs.
*/
protected function handle_update_coupon_when_product_brands_updated( string $taxonomy, array $tt_ids, array $old_tt_ids ) {
if ( 'product_brand' !== $taxonomy ) {
return;
}
// Convert term taxonomy IDs to integers.
$tt_ids = array_map( 'intval', $tt_ids );
$old_tt_ids = array_map( 'intval', $old_tt_ids );
// Find the difference between the new and old term taxonomy IDs.
$diff1 = array_diff( $tt_ids, $old_tt_ids );
$diff2 = array_diff( $old_tt_ids, $tt_ids );
$diff = array_merge( $diff1, $diff2 );
if ( empty( $diff ) ) {
return;
}
// Serialize the diff to use in the meta query.
// This is needed because the meta value is serialized.
$serialized_diff = maybe_serialize( $diff );
$args = [
'post_type' => 'shop_coupon',
'meta_query' => [
'relation' => 'OR',
[
'key' => 'product_brands',
'value' => $serialized_diff,
'compare' => 'LIKE',
],
[
'key' => 'exclude_product_brands',
'value' => $serialized_diff,
'compare' => 'LIKE',
],
],
];
// Get coupon posts based on the above query args.
$posts = $this->wp->get_posts( $args );
if ( empty( $posts ) ) {
return;
}
foreach ( $posts as $post ) {
$this->update_by_id( $post->ID );
}
}
}
Coupon/WCCouponAdapter.php 0000644 00000032241 15153721357 0011531 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\Validatable;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\PriceAmount as GooglePriceAmount;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\TimePeriod as GoogleTimePeriod;
use DateInterval;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use WC_DateTime;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
/**
* Class WCCouponAdapter
*
* This class adapts the WooCommerce coupon class to the Google's Promotion class by mapping their attributes.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
*/
class WCCouponAdapter extends GooglePromotion implements Validatable {
use PluginHelper;
public const CHANNEL_ONLINE = 'ONLINE';
public const PRODUCT_APPLICABILITY_ALL_PRODUCTS = 'ALL_PRODUCTS';
public const PRODUCT_APPLICABILITY_SPECIFIC_PRODUCTS = 'SPECIFIC_PRODUCTS';
public const OFFER_TYPE_GENERIC_CODE = 'GENERIC_CODE';
public const PROMOTION_DESTINATION_ADS = 'Shopping_ads';
public const PROMOTION_DESTINATION_FREE_LISTING = 'Free_listings';
public const WC_DISCOUNT_TYPE_PERCENT = 'percent';
public const WC_DISCOUNT_TYPE_FIXED_CART = 'fixed_cart';
public const WC_DISCOUNT_TYPE_FIXED_PRODUCT = 'fixed_product';
public const COUPON_VALUE_TYPE_MONEY_OFF = 'MONEY_OFF';
public const COUPON_VALUE_TYPE_PERCENT_OFF = 'PERCENT_OFF';
protected const DATE_TIME_FORMAT = 'Y-m-d h:i:sa';
public const COUNTRIES_WITH_FREE_SHIPPING_DESTINATION = [ 'BR', 'IT', 'ES', 'JP', 'NL', 'KR', 'US' ];
/**
*
* @var int wc_coupon_id
*/
protected $wc_coupon_id;
/**
* Initialize this object's properties from an array.
*
* @param array $properties Used to seed this object's properties.
*
* @return void
*
* @throws InvalidValue When a WooCommerce coupon is not provided or it is invalid.
*/
public function mapTypes( $properties ) {
if ( empty( $properties['wc_coupon'] ) ||
! $properties['wc_coupon'] instanceof WC_Coupon ) {
throw InvalidValue::not_instance_of( WC_Coupon::class, 'wc_coupon' );
}
$wc_coupon = $properties['wc_coupon'];
$this->wc_coupon_id = $wc_coupon->get_id();
$this->map_woocommerce_coupon( $wc_coupon, $this->get_coupon_destinations( $properties ) );
// Google doesn't expect extra fields, so it's best to remove them
unset( $properties['wc_coupon'] );
parent::mapTypes( $properties );
}
/**
* Map the WooCommerce coupon attributes to the current class.
*
* @param WC_Coupon $wc_coupon
* @param string[] $destinations The destination ID's for the coupon
*
* @return void
*/
protected function map_woocommerce_coupon( WC_Coupon $wc_coupon, array $destinations ) {
$this->setRedemptionChannel( self::CHANNEL_ONLINE );
$this->setPromotionDestinationIds( $destinations );
$content_language = empty( get_locale() ) ? 'en' : strtolower(
substr( get_locale(), 0, 2 )
); // ISO 639-1.
$this->setContentLanguage( $content_language );
$this->map_wc_coupon_id( $wc_coupon )
->map_wc_general_attributes( $wc_coupon )
->map_wc_usage_restriction( $wc_coupon );
}
/**
* Map the WooCommerce coupon ID.
*
* @param WC_Coupon $wc_coupon
*
* @return $this
*/
protected function map_wc_coupon_id( WC_Coupon $wc_coupon ): WCCouponAdapter {
$coupon_id = "{$this->get_slug()}_{$wc_coupon->get_id()}";
$this->setPromotionId( $coupon_id );
return $this;
}
/**
* Map the general WooCommerce coupon attributes.
*
* @param WC_Coupon $wc_coupon
*
* @return $this
*/
protected function map_wc_general_attributes( WC_Coupon $wc_coupon ): WCCouponAdapter {
$this->setOfferType( self::OFFER_TYPE_GENERIC_CODE );
$this->setGenericRedemptionCode( $wc_coupon->get_code() );
$coupon_amount = $wc_coupon->get_amount();
if ( $wc_coupon->is_type( self::WC_DISCOUNT_TYPE_PERCENT ) ) {
$this->setCouponValueType( self::COUPON_VALUE_TYPE_PERCENT_OFF );
$percent_off = round( floatval( $coupon_amount ) );
$this->setPercentOff( $percent_off );
$this->setLongtitle( sprintf( '%d%% off', $percent_off ) );
} elseif ( $wc_coupon->is_type(
[
self::WC_DISCOUNT_TYPE_FIXED_CART,
self::WC_DISCOUNT_TYPE_FIXED_PRODUCT,
]
) ) {
$this->setCouponValueType( self::COUPON_VALUE_TYPE_MONEY_OFF );
$this->setMoneyOffAmount(
$this->map_google_price_amount( $coupon_amount )
);
$this->setLongtitle(
sprintf(
'%d %s off',
$coupon_amount,
get_woocommerce_currency()
)
);
}
$this->setPromotionEffectiveTimePeriod(
$this->get_wc_coupon_effective_dates( $wc_coupon )
);
return $this;
}
/**
* Return the effective time period for the WooCommerce coupon.
*
* @param WC_Coupon $wc_coupon
*
* @return GoogleTimePeriod
*/
protected function get_wc_coupon_effective_dates( WC_Coupon $wc_coupon ): GoogleTimePeriod {
$start_date = $this->get_wc_coupon_start_date( $wc_coupon );
$end_date = $wc_coupon->get_date_expires();
// If there is no expiring date, set to promotion maximumal effective days allowed by Google.\
// Refer to https://support.google.com/merchants/answer/2906014?hl=en
if ( empty( $end_date ) ) {
$end_date = clone $start_date;
$end_date->add( new DateInterval( 'P183D' ) );
}
// If the coupon is already expired. set the coupon expires immediately after start date.
if ( $end_date < $start_date ) {
$end_date = clone $start_date;
$end_date->add( new DateInterval( 'PT1S' ) );
}
return new GoogleTimePeriod(
[
'startTime' => (string) $start_date,
'endTime' => (string) $end_date,
]
);
}
/**
* Return the start date for the WooCommerce coupon.
*
* @param WC_Coupon $wc_coupon
*
* @return WC_DateTime
*/
protected function get_wc_coupon_start_date( $wc_coupon ): WC_DateTime {
new WC_DateTime();
$post_time = get_post_time( self::DATE_TIME_FORMAT, true, $wc_coupon->get_id(), false );
if ( ! empty( $post_time ) ) {
return new WC_DateTime( $post_time );
} else {
return new WC_DateTime();
}
}
/**
* Map the WooCommerce coupon usage restriction.
*
* @param WC_Coupon $wc_coupon
*
* @return $this
*/
protected function map_wc_usage_restriction( WC_Coupon $wc_coupon ): WCCouponAdapter {
$minimal_spend = $wc_coupon->get_minimum_amount();
if ( ! empty( $minimal_spend ) ) {
$this->setMinimumPurchaseAmount(
$this->map_google_price_amount( $minimal_spend )
);
}
$maximal_spend = $wc_coupon->get_maximum_amount();
if ( ! empty( $maximal_spend ) ) {
$this->setLimitValue(
$this->map_google_price_amount( $maximal_spend )
);
}
$has_product_restriction = false;
$get_offer_id = function ( int $product_id ) {
return WCProductAdapter::get_google_product_offer_id( $this->get_slug(), $product_id );
};
$wc_product_ids = $wc_coupon->get_product_ids();
if ( ! empty( $wc_product_ids ) ) {
$google_product_ids = array_map( $get_offer_id, $wc_product_ids );
$has_product_restriction = true;
$this->setItemId( $google_product_ids );
}
// Currently the brand inclusion restriction will override the product inclustion restriction.
// It's align with the current coupon discounts behaviour in WooCommerce.
$wc_product_ids_in_brand = $this->get_product_ids_in_brand( $wc_coupon );
if ( ! empty( $wc_product_ids_in_brand ) ) {
$google_product_ids = array_map( $get_offer_id, $wc_product_ids_in_brand );
$has_product_restriction = true;
$this->setItemId( $google_product_ids );
}
// Get excluded product IDs and excluded product IDs in brand.
$wc_excluded_product_ids = $wc_coupon->get_excluded_product_ids();
$wc_excluded_product_ids_in_brand = $this->get_product_ids_in_brand( $wc_coupon, true );
if ( ! empty( $wc_excluded_product_ids ) || ! empty( $wc_excluded_product_ids_in_brand ) ) {
$google_product_ids = array_merge(
array_map( $get_offer_id, $wc_excluded_product_ids ),
array_map( $get_offer_id, $wc_excluded_product_ids_in_brand )
);
$google_product_ids = array_values( array_unique( $google_product_ids ) );
$has_product_restriction = true;
$this->setItemIdExclusion( $google_product_ids );
}
$wc_product_catetories = $wc_coupon->get_product_categories();
if ( ! empty( $wc_product_catetories ) ) {
$str_product_categories =
WCProductAdapter::convert_product_types( $wc_product_catetories );
$has_product_restriction = true;
$this->setProductType( $str_product_categories );
}
$wc_excluded_product_catetories = $wc_coupon->get_excluded_product_categories();
if ( ! empty( $wc_excluded_product_catetories ) ) {
$str_product_categories =
WCProductAdapter::convert_product_types( $wc_excluded_product_catetories );
$has_product_restriction = true;
$this->setProductTypeExclusion( $str_product_categories );
}
if ( $has_product_restriction ) {
$this->setProductApplicability(
self::PRODUCT_APPLICABILITY_SPECIFIC_PRODUCTS
);
} else {
$this->setProductApplicability(
self::PRODUCT_APPLICABILITY_ALL_PRODUCTS
);
}
return $this;
}
/**
* Map WooCommerce price number to Google price structure.
*
* @param float $wc_amount
*
* @return GooglePriceAmount
*/
protected function map_google_price_amount( $wc_amount ): GooglePriceAmount {
return new GooglePriceAmount(
[
'currency' => get_woocommerce_currency(),
'value' => $wc_amount,
]
);
}
/**
* Disable promotion shared in Google by only updating promotion effective end_date
* to make the promotion expired.
*
* @param WC_Coupon $wc_coupon
*/
public function disable_promotion( WC_Coupon $wc_coupon ) {
$start_date = $this->get_wc_coupon_start_date( $wc_coupon );
// Set promotion to be disabled immediately.
$end_date = new WC_DateTime();
// If this coupon is scheduled in the future, disable it right after start date.
if ( $start_date >= $end_date ) {
$end_date = clone $start_date;
$end_date->add( new DateInterval( 'PT1S' ) );
}
$this->setPromotionEffectiveTimePeriod(
new GoogleTimePeriod(
[
'startTime' => (string) $start_date,
'endTime' => (string) $end_date,
]
)
);
}
/**
*
* @param ClassMetadata $metadata
*/
public static function load_validator_metadata( ClassMetadata $metadata ) {
$metadata->addPropertyConstraint(
'targetCountry',
new Assert\NotBlank()
);
$metadata->addPropertyConstraint(
'promotionId',
new Assert\NotBlank()
);
$metadata->addPropertyConstraint(
'genericRedemptionCode',
new Assert\NotBlank()
);
$metadata->addPropertyConstraint(
'productApplicability',
new Assert\NotBlank()
);
$metadata->addPropertyConstraint(
'offerType',
new Assert\NotBlank()
);
$metadata->addPropertyConstraint(
'redemptionChannel',
new Assert\NotBlank()
);
$metadata->addPropertyConstraint(
'couponValueType',
new Assert\NotBlank()
);
}
/**
*
* @return int $wc_coupon_id
*/
public function get_wc_coupon_id(): int {
return $this->wc_coupon_id;
}
/**
*
* @param string $targetCountry
* phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*/
public function setTargetCountry( $targetCountry ) {
// set the new target country
parent::setTargetCountry( $targetCountry );
}
/**
* Get the destinations allowed per specific country.
*
* @param array $coupon_data The coupon data to get the allowed destinations.
* @return string[] The destinations country based.
*/
private function get_coupon_destinations( array $coupon_data ): array {
$destinations = [ self::PROMOTION_DESTINATION_ADS ];
if ( isset( $coupon_data['targetCountry'] ) && in_array( $coupon_data['targetCountry'], self::COUNTRIES_WITH_FREE_SHIPPING_DESTINATION, true ) ) {
$destinations[] = self::PROMOTION_DESTINATION_FREE_LISTING;
}
return apply_filters( 'woocommerce_gla_coupon_destinations', $destinations, $coupon_data );
}
/**
* Get the product IDs that belongs to a brand.
*
* @param WC_Coupon $wc_coupon The WC coupon object.
* @param bool $is_exclude If the product IDs are for exclusion.
* @return string[] The product IDs that belongs to a brand.
*/
private function get_product_ids_in_brand( WC_Coupon $wc_coupon, bool $is_exclude = false ) {
$coupon_id = $wc_coupon->get_id();
$meta_key = $is_exclude ? 'exclude_product_brands' : 'product_brands';
// Get the brand term IDs if brand restriction is set.
$brand_term_ids = get_post_meta( $coupon_id, $meta_key );
if ( ! is_array( $brand_term_ids ) ) {
return [];
}
$product_ids = [];
foreach ( $brand_term_ids as $brand_term_id ) {
// Get the product IDs that belongs to the brand.
$object_ids = get_objects_in_term( $brand_term_id, 'product_brand' );
if ( is_wp_error( $object_ids ) ) {
continue;
}
$product_ids = array_merge( $product_ids, $object_ids );
}
return $product_ids;
}
}
DB/Installer.php 0000644 00000003246 15153721357 0007515 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migrator;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\FirstInstallInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\InstallableInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class Installer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*/
class Installer implements Service, FirstInstallInterface, InstallableInterface {
/**
* @var TableManager
*/
protected $table_manager;
/**
* @var Migrator
*/
protected $migrator;
/**
* Installer constructor.
*
* @param TableManager $table_manager
* @param Migrator $migrator
*/
public function __construct( TableManager $table_manager, Migrator $migrator ) {
$this->table_manager = $table_manager;
$this->migrator = $migrator;
}
/**
* Run installation logic for this class.
*
* @param string $old_version Previous version before updating.
* @param string $new_version Current version after updating.
*/
public function install( string $old_version, string $new_version ): void {
foreach ( $this->table_manager->get_tables() as $table ) {
$table->install();
}
// Run migrations.
$this->migrator->migrate( $old_version, $new_version );
}
/**
* Logic to run when the plugin is first installed.
*/
public function first_install(): void {
foreach ( $this->table_manager->get_tables() as $table ) {
if ( $table instanceof FirstInstallInterface ) {
$table->first_install();
}
}
}
}
DB/Migration/AbstractMigration.php 0000644 00000001034 15153721357 0013117 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractMigration
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 1.4.1
*/
abstract class AbstractMigration implements MigrationInterface {
/**
* @var wpdb
*/
protected $wpdb;
/**
* AbstractMigration constructor.
*
* @param wpdb $wpdb The wpdb object.
*/
public function __construct( wpdb $wpdb ) {
$this->wpdb = $wpdb;
}
}
DB/Migration/Migration20211228T1640692399.php 0000644 00000005016 15153721357 0013426 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingRateTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class Migration20211228T1640692399
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 1.12.2
*/
class Migration20211228T1640692399 extends AbstractMigration {
/**
* @var ShippingRateTable
*/
protected $shipping_rate_table;
/**
* @var OptionsInterface
*/
protected $options;
/**
* Migration constructor.
*
* @param wpdb $wpdb The wpdb object.
* @param ShippingRateTable $shipping_rate_table
* @param OptionsInterface $options
*/
public function __construct( wpdb $wpdb, ShippingRateTable $shipping_rate_table, OptionsInterface $options ) {
parent::__construct( $wpdb );
$this->shipping_rate_table = $shipping_rate_table;
$this->options = $options;
}
/**
* Returns the version to apply this migration for.
*
* @return string A version number. For example: 1.4.1
*/
public function get_applicable_version(): string {
return '1.12.2';
}
/**
* Apply the migrations.
*
* @return void
*/
public function apply(): void {
if ( $this->shipping_rate_table->exists() ) {
$mc_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER );
if ( ! is_array( $mc_settings ) ) {
return;
}
if ( isset( $mc_settings['offers_free_shipping'] ) && false !== boolval( $mc_settings['offers_free_shipping'] ) && isset( $mc_settings['free_shipping_threshold'] ) ) {
// Move the free shipping threshold from the options to the shipping rate table.
$options_json = wp_json_encode( [ 'free_shipping_threshold' => (float) $mc_settings['free_shipping_threshold'] ] );
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->wpdb->query( $this->wpdb->prepare( "UPDATE `{$this->wpdb->_escape( $this->shipping_rate_table->get_name() )}` SET `options`=%s WHERE 1=1", $options_json ) );
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
// Remove the free shipping threshold from the options.
unset( $mc_settings['free_shipping_threshold'] );
unset( $mc_settings['offers_free_shipping'] );
$this->options->update( OptionsInterface::MERCHANT_CENTER, $mc_settings );
}
}
}
DB/Migration/Migration20220524T1653383133.php 0000644 00000002160 15153721357 0013405 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\BudgetRecommendationTable;
defined( 'ABSPATH' ) || exit;
/**
* Class Migration20220524T1653383133
*
* Migration class to reload the default Ads budgets recommendations
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 1.13.3
*/
class Migration20220524T1653383133 extends AbstractMigration {
/**
* @var BudgetRecommendationTable
*/
protected $budget_rate_table;
/**
* Migration constructor.
*
* @param BudgetRecommendationTable $budget_rate_table
*/
public function __construct( BudgetRecommendationTable $budget_rate_table ) {
$this->budget_rate_table = $budget_rate_table;
}
/**
* Returns the version to apply this migration for.
*
* @return string A version number. For example: 1.4.1
*/
public function get_applicable_version(): string {
return '1.13.3';
}
/**
* Apply the migrations.
*
* @return void
*/
public function apply(): void {
$this->budget_rate_table->reload_data();
}
}
DB/Migration/Migration20231109T1653383133.php 0000644 00000003542 15153721357 0013413 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\BudgetRecommendationTable;
defined( 'ABSPATH' ) || exit;
/**
* Class Migration20231109T1653383133
*
* Migration class to reload the default Ads budgets recommendations provided by Google on 9 Nov 2023
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 2.5.13
*/
class Migration20231109T1653383133 extends AbstractMigration {
/**
* @var BudgetRecommendationTable
*/
protected $budget_rate_table;
/**
* Migration constructor.
*
* @param \wpdb $wpdb
* @param BudgetRecommendationTable $budget_rate_table
*/
public function __construct( \wpdb $wpdb, BudgetRecommendationTable $budget_rate_table ) {
parent::__construct( $wpdb );
$this->budget_rate_table = $budget_rate_table;
}
/**
* Returns the version to apply this migration for.
*
* @return string A version number. For example: 1.4.1
*/
public function get_applicable_version(): string {
return '2.5.13';
}
/**
* Apply the migrations.
*
* @return void
*/
public function apply(): void {
if ( $this->budget_rate_table->exists() && $this->budget_rate_table->has_column( 'daily_budget_low' ) ) {
$this->wpdb->query( "ALTER TABLE `{$this->wpdb->_escape( $this->budget_rate_table->get_name() )}` DROP COLUMN `daily_budget_low`" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
if ( $this->budget_rate_table->exists() && $this->budget_rate_table->has_column( 'daily_budget_high' ) ) {
$this->wpdb->query( "ALTER TABLE `{$this->wpdb->_escape( $this->budget_rate_table->get_name() )}` DROP COLUMN `daily_budget_high`" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
$this->budget_rate_table->reload_data();
}
}
DB/Migration/Migration20240813T1653383133.php 0000644 00000003215 15153721357 0013412 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingTimeTable;
defined( 'ABSPATH' ) || exit;
/**
* Class Migration20240813T1653383133
*
* Migration class to enable min and max time shippings.
*
* @see pcTzPl-2qP
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 2.9.1
*/
class Migration20240813T1653383133 extends AbstractMigration {
/**
* @var ShippingTimeTable
*/
protected $shipping_time_table;
/**
* Migration constructor.
*
* @param \wpdb $wpdb
* @param ShippingTimeTable $shipping_time_table
*/
public function __construct( \wpdb $wpdb, ShippingTimeTable $shipping_time_table ) {
parent::__construct( $wpdb );
$this->shipping_time_table = $shipping_time_table;
}
/**
* Returns the version to apply this migration for.
*
* @return string A version number. For example: 1.4.1
*/
public function get_applicable_version(): string {
return '2.9.1';
}
/**
* Apply the migrations.
*
* @return void
*/
public function apply(): void {
if ( $this->shipping_time_table->exists() && ! $this->shipping_time_table->has_column( 'max_time' ) ) {
$this->wpdb->query( "ALTER TABLE `{$this->wpdb->_escape( $this->shipping_time_table->get_name() )}` Add COLUMN `max_time` bigint(20) NOT NULL default 0" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
// Fill the new column with the current values
$this->wpdb->query( "UPDATE `{$this->wpdb->_escape( $this->shipping_time_table->get_name() )}` SET `max_time`=`time` WHERE 1=1" );
}
}
DB/Migration/MigrationInterface.php 0000644 00000001062 15153721357 0013255 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
defined( 'ABSPATH' ) || exit;
/**
* Interface MigrationInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 1.4.1
*/
interface MigrationInterface {
/**
* Returns the version to apply this migration for.
*
* @return string A version number. For example: 1.4.1
*/
public function get_applicable_version(): string;
/**
* Apply the migrations.
*
* @return void
*/
public function apply(): void;
}
DB/Migration/MigrationVersion141.php 0000644 00000002546 15153721357 0013240 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class MigrationVersion141
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 1.4.1
*/
class MigrationVersion141 extends AbstractMigration {
/**
* @var MerchantIssueTable
*/
protected $mc_issues_table;
/**
* MigrationVersion141 constructor.
*
* @param wpdb $wpdb The wpdb object.
* @param MerchantIssueTable $mc_issues_table
*/
public function __construct( wpdb $wpdb, MerchantIssueTable $mc_issues_table ) {
parent::__construct( $wpdb );
$this->mc_issues_table = $mc_issues_table;
}
/**
* Returns the version to apply this migration for.
*
* @return string A version number. For example: 1.4.1
*/
public function get_applicable_version(): string {
return '1.4.1';
}
/**
* Apply the migrations.
*
* @return void
*/
public function apply(): void {
if ( $this->mc_issues_table->exists() && $this->mc_issues_table->has_index( 'product_issue' ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->wpdb->query( "ALTER TABLE `{$this->wpdb->_escape( $this->mc_issues_table->get_name() )}` DROP INDEX `product_issue`" );
}
}
}
DB/Migration/Migrator.php 0000644 00000003763 15153721357 0011301 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class Migrator
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration
*
* @since 1.4.1
*/
class Migrator implements Service {
/**
* @var MigrationInterface[]
*/
protected $migrations;
/**
* Migrator constructor.
*
* @param MigrationInterface[] $migrations An array of all available migrations.
*/
public function __construct( array $migrations ) {
$this->migrations = $migrations;
// Sort migrations by version.
uasort(
$this->migrations,
function ( MigrationInterface $migration_a, MigrationInterface $migration_b ) {
return version_compare( $migration_a->get_applicable_version(), $migration_b->get_applicable_version() );
}
);
}
/**
* Run migrations.
*
* @param string $old_version Previous version before updating.
* @param string $new_version Current version after updating.
*/
public function migrate( string $old_version, string $new_version ): void {
// bail if both versions are equal.
if ( 0 === version_compare( $old_version, $new_version ) ) {
return;
}
foreach ( $this->migrations as $migration ) {
if ( $this->can_apply( $migration->get_applicable_version(), $old_version, $new_version ) ) {
$migration->apply();
}
}
}
/**
* @param string $migration_version The applicable version of the migration.
* @param string $old_version Previous version before updating.
* @param string $new_version Current version after updating.
*
* @return bool True if migration should be applied.
*/
protected function can_apply( string $migration_version, string $old_version, string $new_version ): bool {
return version_compare( $old_version, $new_version, '<' ) &&
version_compare( $old_version, $migration_version, '<' ) &&
version_compare( $migration_version, $new_version, '<=' );
}
}
DB/ProductFeedQueryHelper.php 0000644 00000016760 15153721357 0012157 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use WP_Query;
use WP_REST_Request;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductFeedQueryHelper
*
* ContainerAware used to access:
* - MerchantCenterService
* - MerchantStatuses
* - ProductHelper
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*/
class ProductFeedQueryHelper implements ContainerAwareInterface, Service {
use ContainerAwareTrait;
use PluginHelper;
/**
* @var wpdb
*/
protected $wpdb;
/**
* @var WP_REST_Request
*/
protected $request;
/**
* @var ProductRepository
*/
protected $product_repository;
/**
* Meta key for total sales.
*/
protected const META_KEY_TOTAL_SALES = 'total_sales';
/**
* ProductFeedQueryHelper constructor.
*
* @param wpdb $wpdb
* @param ProductRepository $product_repository
*/
public function __construct( wpdb $wpdb, ProductRepository $product_repository ) {
$this->wpdb = $wpdb;
$this->product_repository = $product_repository;
}
/**
* Retrieve an array of product information using the request params.
*
* @param WP_REST_Request $request
*
* @return array
*
* @throws InvalidValue If the orderby value isn't valid.
* @throws Exception If the status data can't be retrieved from Google.
*/
public function get( WP_REST_Request $request ): array {
$this->request = $request;
$products = [];
$args = $this->prepare_query_args();
$refresh_status_data_job = null;
list( $limit, $offset ) = $this->prepare_query_pagination();
$mc_service = $this->container->get( MerchantCenterService::class );
if ( $mc_service->is_connected() ) {
$refresh_status_data_job = $this->container->get( MerchantStatuses::class )->maybe_refresh_status_data();
}
/** @var ProductHelper $product_helper */
$product_helper = $this->container->get( ProductHelper::class );
add_filter( 'posts_where', [ $this, 'title_filter' ], 10, 2 );
foreach ( $this->product_repository->find( $args, $limit, $offset ) as $product ) {
$id = $product->get_id();
$errors = $product_helper->get_validation_errors( $product );
$mc_status = $product_helper->get_mc_status( $product ) ?: $product_helper->get_sync_status( $product );
// If the refresh_status_data_job is scheduled, we don't know the status yet as it is being refreshed.
if ( $refresh_status_data_job && $refresh_status_data_job->is_scheduled() ) {
$mc_status = null;
}
$products[ $id ] = [
'id' => $id,
'title' => $product->get_name(),
'visible' => $product_helper->get_channel_visibility( $product ) !== ChannelVisibility::DONT_SYNC_AND_SHOW,
'status' => $mc_status,
'image_url' => wp_get_attachment_image_url( $product->get_image_id(), 'full' ),
'price' => $product->get_price(),
'errors' => array_values( $errors ),
];
}
remove_filter( 'posts_where', [ $this, 'title_filter' ] );
return array_values( $products );
}
/**
* Count the number of products (using title filter if present).
*
* @param WP_REST_Request $request
*
* @return int
*
* @throws InvalidValue If the orderby value isn't valid.
*/
public function count( WP_REST_Request $request ): int {
$this->request = $request;
$args = $this->prepare_query_args();
add_filter( 'posts_where', [ $this, 'title_filter' ], 10, 2 );
$ids = $this->product_repository->find_ids( $args );
remove_filter( 'posts_where', [ $this, 'title_filter' ] );
return count( $ids );
}
/**
* Prepare the args to be used to retrieve the products, namely orderby, meta_query and type.
*
* @return array
*
* @throws InvalidValue If the orderby value isn't valid.
*/
protected function prepare_query_args(): array {
$product_types = ProductSyncer::get_supported_product_types();
$product_types = array_diff( $product_types, [ 'variation' ] );
$args = [
'type' => $product_types,
'status' => 'publish',
'orderby' => [ 'title' => 'ASC' ],
];
if ( ! empty( $this->request['ids'] ) ) {
$args['include'] = explode( ',', $this->request['ids'] );
}
if ( ! empty( $this->request['search'] ) ) {
$args['gla_search'] = $this->request['search'];
}
if ( empty( $this->request['orderby'] ) ) {
return $args;
}
switch ( $this->request['orderby'] ) {
case 'title':
$args['orderby']['title'] = $this->get_order();
break;
case 'id':
$args['orderby'] = [ 'ID' => $this->get_order() ] + $args['orderby'];
break;
case 'visible':
$args['meta_key'] = $this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY );
$args['orderby'] = [ 'meta_value' => $this->get_order() ] + $args['orderby'];
break;
case 'status':
$args['meta_key'] = $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS );
$args['orderby'] = [ 'meta_value' => $this->get_order() ] + $args['orderby'];
break;
case 'total_sales':
$args['meta_key'] = self::META_KEY_TOTAL_SALES;
$args['orderby'] = [ 'meta_value_num' => $this->get_order() ] + $args['orderby'];
break;
default:
throw InvalidValue::not_in_allowed_list( 'orderby', [ 'title', 'id', 'visible', 'status', 'total_sales' ] );
}
return $args;
}
/**
* Convert the per_page and page parameters into limit and offset values.
*
* @return array Containing limit and offset values.
*/
protected function prepare_query_pagination(): array {
$limit = -1;
$offset = 0;
if ( ! empty( $this->request['per_page'] ) ) {
$limit = intval( $this->request['per_page'] );
$page = max( 1, intval( $this->request['page'] ) );
$offset = $limit * ( $page - 1 );
}
return [ $limit, $offset ];
}
/**
* Filter for the posts_where hook, adds WHERE clause to search
* for the 'search' parameter in the product titles (when present).
*
* @param string $where The WHERE clause of the query.
* @param WP_Query $wp_query The WP_Query instance (passed by reference).
*
* @return string The updated WHERE clause.
*/
public function title_filter( string $where, WP_Query $wp_query ): string {
$gla_search = $wp_query->get( 'gla_search' );
if ( $gla_search ) {
$title_search = '%' . $this->wpdb->esc_like( $gla_search ) . '%';
$where .= $this->wpdb->prepare( " AND `{$this->wpdb->posts}`.`post_title` LIKE %s", $title_search ); // phpcs:ignore WordPress.DB.PreparedSQL
}
return $where;
}
/**
* Return the ORDER BY order based on the order request parameter value.
*
* @return string
*/
protected function get_order(): string {
return strtoupper( $this->request['order'] ?? '' ) === 'DESC' ? 'DESC' : 'ASC';
}
}
DB/ProductMetaQueryHelper.php 0000644 00000004764 15153721357 0012203 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidMeta;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductMetaQueryHelper.
*
* @since 1.1.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*/
class ProductMetaQueryHelper implements Service {
use PluginHelper;
protected const BATCH_SIZE = 500;
/**
* @var wpdb
*/
protected $wpdb;
/**
* ProductMetaQueryHelper constructor.
*
* @param wpdb $wpdb
*/
public function __construct( wpdb $wpdb ) {
$this->wpdb = $wpdb;
}
/**
* Get all values for a given meta_key as post_id=>meta_value.
*
* @param string $meta_key The meta value to retrieve for all posts.
* @return array Meta values by post ID.
*
* @throws InvalidMeta If the meta key isn't valid.
*/
public function get_all_values( string $meta_key ): array {
self::validate_meta_key( $meta_key );
$query = "SELECT post_id, meta_value FROM {$this->wpdb->postmeta} WHERE meta_key = %s";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$results = $this->wpdb->get_results( $this->wpdb->prepare( $query, $this->prefix_meta_key( $meta_key ) ) );
$return = [];
foreach ( $results as $r ) {
$return[ $r->post_id ] = maybe_unserialize( $r->meta_value );
}
return $return;
}
/**
* Delete all values for a given meta_key.
*
* @since 2.6.4
*
* @param string $meta_key The meta key to delete.
*
* @throws InvalidMeta If the meta key isn't valid.
*/
public function delete_all_values( string $meta_key ) {
self::validate_meta_key( $meta_key );
$meta_key = $this->prefix_meta_key( $meta_key );
$query = "DELETE FROM {$this->wpdb->postmeta} WHERE meta_key = %s";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$this->wpdb->query( $this->wpdb->prepare( $query, $meta_key ) );
}
/**
* @param string $meta_key The meta key to validate
*
* @throws InvalidMeta If the meta key isn't valid.
*/
protected static function validate_meta_key( string $meta_key ) {
if ( ! ProductMetaHandler::is_meta_key_valid( $meta_key ) ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Product meta key is invalid: %s', $meta_key ),
__METHOD__
);
throw InvalidMeta::invalid_key( $meta_key );
}
}
}
DB/Query/AttributeMappingRulesQuery.php 0000644 00000002727 15153721357 0014210 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\AttributeMappingRulesTable;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class defining the queries for the Attribute Mapping Rules Table
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Query
*/
class AttributeMappingRulesQuery extends Query {
/**
* AttributeMappingRulesQuery constructor.
*
* @param wpdb $wpdb
* @param AttributeMappingRulesTable $table
*/
public function __construct( wpdb $wpdb, AttributeMappingRulesTable $table ) {
parent::__construct( $wpdb, $table );
}
/**
* Sanitize a value for a given column before inserting it into the DB.
*
* @param string $column The column name.
* @param mixed $value The value to sanitize.
*
* @return mixed The sanitized value.
*/
protected function sanitize_value( string $column, $value ) {
if ( $column === 'attribute' || $column === 'source' ) {
return sanitize_text_field( $value );
}
if ( $column === 'categories' && is_null( $value ) ) {
return '';
}
return $value;
}
/**
* Gets a specific rule from Database
*
* @param int $rule_id The rule ID to get from Database
* @return array The rule from database
*/
public function get_rule( int $rule_id ): array {
return $this->where( 'id', $rule_id )->get_row();
}
}
DB/Query/BudgetRecommendationQuery.php 0000644 00000002355 15153721357 0014012 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\BudgetRecommendationTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class BudgetRecommendationQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Query
*/
class BudgetRecommendationQuery extends Query {
/**
* BudgetRecommendationQuery constructor.
*
* @param wpdb $wpdb
* @param BudgetRecommendationTable $table
*/
public function __construct( wpdb $wpdb, BudgetRecommendationTable $table ) {
parent::__construct( $wpdb, $table );
}
/**
* Sanitize a value for a given column before inserting it into the DB.
*
* @param string $column The column name.
* @param mixed $value The value to sanitize.
*
* @return mixed The sanitized value.
* @throws InvalidQuery When the code tries to set the ID column.
*/
protected function sanitize_value( string $column, $value ) {
if ( 'id' === $column ) {
throw InvalidQuery::cant_set_id( BudgetRecommendationTable::class );
}
return $value;
}
}
DB/Query/MerchantIssueQuery.php 0000644 00000001710 15153721357 0012457 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantIssueQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Query
*/
class MerchantIssueQuery extends Query {
/**
* MerchantIssueQuery constructor.
*
* @param wpdb $wpdb
* @param MerchantIssueTable $table
*/
public function __construct( wpdb $wpdb, MerchantIssueTable $table ) {
parent::__construct( $wpdb, $table );
}
/**
* Sanitize a value for a given column before inserting it into the DB.
*
* @param string $column The column name.
* @param mixed $value The value to sanitize.
*
* @return mixed The sanitized value.
*/
protected function sanitize_value( string $column, $value ) {
return $value;
}
}
DB/Query/ShippingRateQuery.php 0000644 00000003243 15153721357 0012305 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingRateTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRateQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Query
*/
class ShippingRateQuery extends Query {
/**
* ShippingRateQuery constructor.
*
* @param wpdb $wpdb
* @param ShippingRateTable $table
*/
public function __construct( wpdb $wpdb, ShippingRateTable $table ) {
parent::__construct( $wpdb, $table );
}
/**
* Sanitize a value for a given column before inserting it into the DB.
*
* @param string $column The column name.
* @param mixed $value The value to sanitize.
*
* @return mixed The sanitized value.
* @throws InvalidQuery When the code tries to set the ID column.
*/
protected function sanitize_value( string $column, $value ) {
if ( 'id' === $column ) {
throw InvalidQuery::cant_set_id( ShippingRateTable::class );
}
if ( 'options' === $column ) {
if ( ! is_array( $value ) ) {
throw InvalidQuery::invalid_value( $column );
}
$value = wp_json_encode( $value );
}
return $value;
}
/**
* Perform the query and save it to the results.
*/
protected function query_results() {
parent::query_results();
$this->results = array_map(
function ( $row ) {
$row['options'] = ! empty( $row['options'] ) ? json_decode( $row['options'], true ) : $row['options'];
return $row;
},
$this->results
);
}
}
DB/Query/ShippingTimeQuery.php 0000644 00000003126 15153721357 0012310 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingTimeTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingTimeQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Query
*/
class ShippingTimeQuery extends Query {
/**
* ShippingTimeQuery constructor.
*
* @param wpdb $wpdb
* @param ShippingTimeTable $table
*/
public function __construct( wpdb $wpdb, ShippingTimeTable $table ) {
parent::__construct( $wpdb, $table );
}
/**
* Sanitize a value for a given column before inserting it into the DB.
*
* @param string $column The column name.
* @param mixed $value The value to sanitize.
*
* @return mixed The sanitized value.
* @throws InvalidQuery When the code tries to set the ID column.
*/
protected function sanitize_value( string $column, $value ) {
if ( 'id' === $column ) {
throw InvalidQuery::cant_set_id( ShippingTimeTable::class );
}
return $value;
}
/**
* Get all shipping times.
*
* @since 2.8.0
*
* @return array
*/
public function get_all_shipping_times() {
$times = $this->get_results();
$items = [];
foreach ( $times as $time ) {
$data = [
'country_code' => $time['country'],
'time' => $time['time'],
'max_time' => $time['max_time'] ?: $time['time'],
];
$items[ $time['country'] ] = $data;
}
return $items;
}
}
DB/Query.php 0000644 00000027112 15153721357 0006663 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PositiveInteger;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class Query
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*/
abstract class Query implements QueryInterface {
/** @var int */
protected $limit;
/** @var int */
protected $offset = 0;
/** @var array */
protected $orderby = [];
/** @var array */
protected $groupby = [];
/**
* The result of the query.
*
* @var mixed
*/
protected $results = null;
/**
* The number of rows returned by the query.
*
* @var int
*/
protected $count = null;
/**
* The last inserted ID (updated after a call to insert).
*
* @var int
*/
protected $last_insert_id = null;
/** @var TableInterface */
protected $table;
/**
* Where clauses for the query.
*
* @var array
*/
protected $where = [];
/**
* Where relation for multiple clauses.
*
* @var string
*/
protected $where_relation;
/** @var wpdb */
protected $wpdb;
/**
* Query constructor.
*
* @param wpdb $wpdb
* @param TableInterface $table
*/
public function __construct( wpdb $wpdb, TableInterface $table ) {
$this->wpdb = $wpdb;
$this->table = $table;
}
/**
* Add a where clause to the query.
*
* @param string $column The column name.
* @param mixed $value The where value.
* @param string $compare The comparison to use. Valid values are =, <, >, IN, NOT IN.
*
* @return $this
*/
public function where( string $column, $value, string $compare = '=' ): QueryInterface {
$this->validate_column( $column );
$this->validate_compare( $compare );
$this->where[] = [
'column' => $column,
'value' => $value,
'compare' => $compare,
];
return $this;
}
/**
* Add a group by clause to the query.
*
* @param string $column The column name.
*
* @return $this
*
* @since 1.12.0
*/
public function group_by( string $column ): QueryInterface {
$this->validate_column( $column );
$this->groupby[] = "`{$column}`";
return $this;
}
/**
* Set the where relation for the query.
*
* @param string $relation
*
* @return QueryInterface
*/
public function set_where_relation( string $relation ): QueryInterface {
$this->validate_where_relation( $relation );
$this->where_relation = $relation;
return $this;
}
/**
* Set ordering information for the query.
*
* @param string $column
* @param string $order
*
* @return QueryInterface
*/
public function set_order( string $column, string $order = 'ASC' ): QueryInterface {
$this->validate_column( $column );
$this->orderby[] = "`{$column}` {$this->normalize_order( $order )}";
return $this;
}
/**
* Limit the number of results for the query.
*
* @param int $limit
*
* @return QueryInterface
*/
public function set_limit( int $limit ): QueryInterface {
$this->limit = ( new PositiveInteger( $limit ) )->get();
return $this;
}
/**
* Set an offset for the results.
*
* @param int $offset
*
* @return QueryInterface
*/
public function set_offset( int $offset ): QueryInterface {
$this->offset = ( new PositiveInteger( $offset ) )->get();
return $this;
}
/**
* Get the results of the query.
*
* @return mixed
*/
public function get_results() {
if ( null === $this->results ) {
$this->query_results();
}
return $this->results;
}
/**
* Get the number of results returned by the query.
*
* @return int
*/
public function get_count(): int {
if ( null === $this->count ) {
$this->count_results();
}
return $this->count;
}
/**
* Gets the first result of the query.
*
* @return array
*/
public function get_row(): array {
if ( null === $this->results ) {
$old_limit = $this->limit ?? 0;
$this->set_limit( 1 );
$this->query_results();
$this->set_limit( $old_limit );
}
return $this->results[0] ?? [];
}
/**
* Perform the query and save it to the results.
*/
protected function query_results() {
$this->results = $this->wpdb->get_results(
$this->build_query(), // phpcs:ignore WordPress.DB.PreparedSQL
ARRAY_A
);
}
/**
* Count the results and save the result.
*/
protected function count_results() {
$this->count = (int) $this->wpdb->get_var( $this->build_query( true ) ); // phpcs:ignore WordPress.DB.PreparedSQL
}
/**
* Validate that a given column is valid for the current table.
*
* @param string $column
*
* @throws InvalidQuery When the given column is not valid for the current table.
*/
protected function validate_column( string $column ) {
if ( ! array_key_exists( $column, $this->table->get_columns() ) ) {
throw InvalidQuery::from_column( $column, get_class( $this->table ) );
}
}
/**
* Validate that a compare operator is valid.
*
* @param string $compare
*
* @throws InvalidQuery When the compare value is not valid.
*/
protected function validate_compare( string $compare ) {
switch ( $compare ) {
case '=':
case '>':
case '<':
case 'IN':
case 'NOT IN':
// These are all valid.
return;
default:
throw InvalidQuery::from_compare( $compare );
}
}
/**
* Validate that a where relation is valid.
*
* @param string $relation
*
* @throws InvalidQuery When the relation value is not valid.
*/
protected function validate_where_relation( string $relation ) {
switch ( $relation ) {
case 'AND':
case 'OR':
// These are all valid.
return;
default:
throw InvalidQuery::where_relation( $relation );
}
}
/**
* Normalize the string for the order.
*
* Converts the string to uppercase, and will return only DESC or ASC.
*
* @param string $order
*
* @return string
*/
protected function normalize_order( string $order ): string {
$order = strtoupper( $order );
return 'DESC' === $order ? $order : 'ASC';
}
/**
* Build the query and return the query string.
*
* @param bool $get_count False to build a normal query, true to build a COUNT(*) query.
*
* @return string
*/
protected function build_query( bool $get_count = false ): string {
$columns = $get_count ? 'COUNT(*)' : '*';
$pieces = [ "SELECT {$columns} FROM `{$this->table->get_name()}`" ];
$pieces = array_merge( $pieces, $this->generate_where_pieces() );
if ( ! empty( $this->groupby ) ) {
$pieces[] = 'GROUP BY ' . implode( ', ', $this->groupby );
}
if ( ! $get_count ) {
if ( $this->orderby ) {
$pieces[] = 'ORDER BY ' . implode( ', ', $this->orderby );
}
if ( $this->limit ) {
$pieces[] = "LIMIT {$this->limit}";
}
if ( $this->offset ) {
$pieces[] = "OFFSET {$this->offset}";
}
}
return join( "\n", $pieces );
}
/**
* Generate the pieces for the WHERE part of the query.
*
* @return string[]
*/
protected function generate_where_pieces(): array {
if ( empty( $this->where ) ) {
return [];
}
$where_pieces = [ 'WHERE' ];
foreach ( $this->where as $where ) {
$column = $where['column'];
$compare = $where['compare'];
if ( $compare === 'IN' || $compare === 'NOT IN' ) {
$value = sprintf(
"('%s')",
join(
"','",
array_map(
function ( $value ) {
return $this->wpdb->_escape( $value );
},
$where['value']
)
)
);
} else {
$value = "'{$this->wpdb->_escape( $where['value'] )}'";
}
if ( count( $where_pieces ) > 1 ) {
$where_pieces[] = $this->where_relation ?? 'AND';
}
$where_pieces[] = "{$column} {$compare} {$value}";
}
return $where_pieces;
}
/**
* Insert a row of data into the table.
*
* @param array $data
*
* @return int
* @throws InvalidQuery When there is an error inserting the data.
*/
public function insert( array $data ): int {
foreach ( $data as $column => &$value ) {
$this->validate_column( $column );
$value = $this->sanitize_value( $column, $value );
}
$result = $this->wpdb->insert( $this->table->get_name(), $data );
if ( false === $result ) {
throw InvalidQuery::from_insert( $this->wpdb->last_error ?: 'Error inserting data.' );
}
// Save a local copy of the last inserted ID.
$this->last_insert_id = $this->wpdb->insert_id;
return $result;
}
/**
* Returns the last inserted ID. Must be called after insert.
*
* @since 1.12.0
*
* @return int|null
*/
public function last_insert_id(): ?int {
return $this->last_insert_id;
}
/**
* Delete rows from the database.
*
* @param string $where_column Column to use when looking for values to delete.
* @param mixed $value Value to use when determining what rows to delete.
*
* @return int The number of rows deleted.
* @throws InvalidQuery When there is an error deleting data.
*/
public function delete( string $where_column, $value ): int {
$this->validate_column( $where_column );
$result = $this->wpdb->delete( $this->table->get_name(), [ $where_column => $value ] );
if ( false === $result ) {
throw InvalidQuery::from_delete( $this->wpdb->last_error ?: 'Error deleting data.' );
}
return $result;
}
/**
* Update data in the database.
*
* @param array $data Array of columns and their values.
* @param array $where Array of where conditions for updating values.
*
* @return int
* @throws InvalidQuery When there is an error updating data, or when an empty where array is provided.
*/
public function update( array $data, array $where ): int {
if ( empty( $where ) ) {
throw InvalidQuery::empty_where();
}
foreach ( $data as $column => &$value ) {
$this->validate_column( $column );
$value = $this->sanitize_value( $column, $value );
}
$result = $this->wpdb->update(
$this->table->get_name(),
$data,
$where
);
if ( false === $result ) {
throw InvalidQuery::from_update( $this->wpdb->last_error ?: 'Error updating data.' );
}
return $result;
}
/**
* Batch update or insert a set of records.
*
* @param array $records Array of records to be updated or inserted.
*
* @throws InvalidQuery If an invalid column name is provided.
*/
public function update_or_insert( array $records ): void {
if ( empty( $records ) ) {
return;
}
$update_values = [];
$columns = array_keys( reset( $records ) );
foreach ( $columns as $c ) {
$this->validate_column( $c );
$update_values[] = "`$c`=VALUES(`$c`)";
}
$single_placeholder = '(' . implode( ',', array_fill( 0, count( $columns ), "'%s'" ) ) . ')';
$chunk_size = 200;
$num_issues = count( $records );
for ( $i = 0; $i < $num_issues; $i += $chunk_size ) {
$all_values = [];
$all_placeholders = [];
foreach ( array_slice( $records, $i, $chunk_size ) as $issue ) {
if ( array_keys( $issue ) !== $columns ) {
throw new InvalidQuery( 'Not all records contain the same columns' );
}
$all_placeholders[] = $single_placeholder;
array_push( $all_values, ...array_values( $issue ) );
}
$column_names = '(`' . implode( '`, `', $columns ) . '`)';
$query = "INSERT INTO `{$this->table->get_name()}` $column_names VALUES ";
$query .= implode( ', ', $all_placeholders );
$query .= ' ON DUPLICATE KEY UPDATE ' . implode( ', ', $update_values );
$this->wpdb->query( $this->wpdb->prepare( $query, $all_values ) ); // phpcs:ignore WordPress.DB.PreparedSQL
}
}
/**
* Sanitize a value for a given column before inserting it into the DB.
*
* @param string $column The column name.
* @param mixed $value The value to sanitize.
*
* @return mixed The sanitized value.
*/
abstract protected function sanitize_value( string $column, $value );
}
DB/QueryInterface.php 0000644 00000005776 15153721357 0010520 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
defined( 'ABSPATH' ) || exit;
/**
* Interface QueryInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*/
interface QueryInterface {
/**
* Set a where clause to query.
*
* @param string $column The column name.
* @param mixed $value The where value.
* @param string $compare The comparison to use. Valid values are =, <, >, IN, NOT IN.
*
* @return $this
*/
public function where( string $column, $value, string $compare = '=' ): QueryInterface;
/**
* Set the where relation for the query.
*
* @param string $relation
*
* @return QueryInterface
*/
public function set_where_relation( string $relation ): QueryInterface;
/**
* @param string $column
* @param string $order
*
* @return QueryInterface
*/
public function set_order( string $column, string $order = 'DESC' ): QueryInterface;
/**
* Limit the number of results for the query.
*
* @param int $limit
*
* @return QueryInterface
*/
public function set_limit( int $limit ): QueryInterface;
/**
* Set an offset for the results.
*
* @param int $offset
*
* @return QueryInterface
*/
public function set_offset( int $offset ): QueryInterface;
/**
* Get the results of the query.
*
* @return mixed
*/
public function get_results();
/**
* Get the number of results returned by the query.
*
* @return int
*/
public function get_count(): int;
/**
* Gets the first result of the query.
*
* @return array
*/
public function get_row(): array;
/**
* Insert a row of data into the table.
*
* @param array $data
*
* @return int
* @throws InvalidQuery When there is an error inserting the data.
*/
public function insert( array $data ): int;
/**
* Returns the last inserted ID. Must be called after insert.
*
* @since 1.12.0
*
* @return int|null
*/
public function last_insert_id(): ?int;
/**
* Delete rows from the database.
*
* @param string $where_column Column to use when looking for values to delete.
* @param mixed $value Value to use when determining what rows to delete.
*
* @return int The number of rows deleted.
* @throws InvalidQuery When there is an error deleting data.
*/
public function delete( string $where_column, $value ): int;
/**
* Update data in the database.
*
* @param array $data Array of columns and their values.
* @param array $where Array of where conditions for updating values.
*
* @return int
* @throws InvalidQuery When there is an error updating data, or when an empty where array is provided.
*/
public function update( array $data, array $where ): int;
/**
* Batch update or insert a set of records.
*
* @param array $records Array of records to be updated or inserted.
*
* @throws InvalidQuery If an invalid column name is provided.
*/
public function update_or_insert( array $records ): void;
}
DB/Table/AttributeMappingRulesTable.php 0000644 00000002516 15153721357 0014050 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
defined( 'ABSPATH' ) || exit;
/**
* Definition class for the Attribute Mapping Rules Table
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Tables
*/
class AttributeMappingRulesTable extends Table {
/**
* Get the schema for the DB.
*
* This should be a SQL string for creating the DB table.
*
* @return string
*/
protected function get_install_query(): string {
return "
CREATE TABLE `{$this->get_sql_safe_name()}` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`attribute` varchar(255) NOT NULL,
`source` varchar(100) NOT NULL,
`category_condition_type` varchar(10) NOT NULL,
`categories` text NOT NULL,
PRIMARY KEY `id` (`id`)
) {$this->get_collation()};
";
}
/**
* Get the un-prefixed (raw) table name.
*
* @return string
*/
public static function get_raw_name(): string {
return 'attribute_mapping_rules';
}
/**
* Get the columns for the table.
*
* @return array
*/
public function get_columns(): array {
return [
'id' => true,
'attribute' => true,
'source' => true,
'category_condition_type' => true,
'categories' => true,
];
}
}
DB/Table/BudgetRecommendationTable.php 0000644 00000007061 15153721357 0013655 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
defined( 'ABSPATH' ) || exit;
/**
* Class BudgetRecommendationTable
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Tables
*/
class BudgetRecommendationTable extends Table {
/**
* Whether the initial data has been loaded
*
* @var bool
*/
public $has_loaded_initial_data = false;
/**
* Get the schema for the DB.
*
* This should be a SQL string for creating the DB table.
*
* @return string
*/
protected function get_install_query(): string {
return "
CREATE TABLE `{$this->get_sql_safe_name()}` (
id bigint(20) NOT NULL AUTO_INCREMENT,
currency varchar(3) NOT NULL,
country varchar(2) NOT NULL,
daily_budget int(11) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY country_currency (country, currency)
) {$this->get_collation()};
";
}
/**
* Install the Database table.
*
* Add data if there is none.
*/
public function install(): void {
parent::install();
// Load the data if the table is empty.
// phpcs:ignore WordPress.DB.PreparedSQL
$result = $this->wpdb->get_row( "SELECT COUNT(*) AS count FROM `{$this->get_sql_safe_name()}`" );
if ( empty( $result->count ) ) {
$this->load_initial_data();
}
}
/**
* Reload initial data.
*
* @return void
*/
public function reload_data(): void {
if ( $this->exists() && ! $this->has_loaded_initial_data ) {
$this->truncate();
$this->load_initial_data();
}
}
/**
* Get the un-prefixed (raw) table name.
*
* @return string
*/
public static function get_raw_name(): string {
return 'budget_recommendations';
}
/**
* Get the columns for the table.
*
* @return array
*/
public function get_columns(): array {
return [
'id' => true,
'currency' => true,
'country' => true,
'daily_budget' => true,
];
}
/**
* Load packaged recommendation data on the first install of GLA.
*
* Inserts 500 records at a time.
*/
private function load_initial_data(): void {
$path = $this->get_root_dir() . '/data/budget-recommendations.csv';
$chunk_size = 500;
if ( file_exists( $path ) ) {
$csv = array_map(
function ( $row ) {
return str_getcsv( $row, ',', '"', '\\' );
},
file( $path )
);
// Remove the headers
array_shift( $csv );
if ( empty( $csv ) ) {
return;
}
$values = [];
$placeholders = [];
// Build placeholders for each row, and add values to data array
foreach ( $csv as $row ) {
if ( empty( $row ) ) {
continue;
}
$row_placeholders = [];
foreach ( $row as $value ) {
$values[] = $value;
$row_placeholders[] = is_numeric( $value ) ? '%d' : '%s';
}
$placeholders[] = '(' . implode( ', ', $row_placeholders ) . ')';
if ( count( $placeholders ) >= $chunk_size ) {
$this->insert_chunk( $placeholders, $values );
$placeholders = [];
$values = [];
}
}
$this->insert_chunk( $placeholders, $values );
}
$this->has_loaded_initial_data = true;
}
/**
* Insert a chunk of budget recommendations
*
* @param string[] $placeholders
* @param array $values
*/
private function insert_chunk( array $placeholders, array $values ): void {
$sql = "INSERT INTO `{$this->get_sql_safe_name()}` (country,daily_budget,currency) VALUES\n";
$sql .= implode( ",\n", $placeholders );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$this->wpdb->query( $this->wpdb->prepare( $sql, $values ) );
}
}
DB/Table/MerchantIssueTable.php 0000644 00000005620 15153721357 0012327 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantIssueTable
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Tables
*/
class MerchantIssueTable extends Table {
/**
* Get the schema for the DB.
*
* This should be a SQL string for creating the DB table.
*
* @return string
*/
protected function get_install_query(): string {
return "
CREATE TABLE `{$this->get_sql_safe_name()}` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`product_id` bigint(20) NOT NULL,
`issue` varchar(200) NOT NULL,
`code` varchar(100) NOT NULL,
`severity` varchar(20) NOT NULL DEFAULT 'warning',
`product` varchar(100) NOT NULL,
`action` text NOT NULL,
`action_url` varchar(1024) NOT NULL,
`applicable_countries` text NOT NULL,
`source` varchar(10) NOT NULL DEFAULT 'mc',
`type` varchar(10) NOT NULL DEFAULT 'product',
`created_at` datetime NOT NULL,
PRIMARY KEY `id` (`id`)
) {$this->get_collation()};
";
}
/**
* Get the un-prefixed (raw) table name.
*
* @return string
*/
public static function get_raw_name(): string {
return 'merchant_issues';
}
/**
* Delete stale issue records.
*
* @param DateTime $created_before Delete all records created before this.
*/
public function delete_stale( DateTime $created_before ): void {
$query = "DELETE FROM `{$this->get_sql_safe_name()}` WHERE `created_at` < '%s'";
$this->wpdb->query( $this->wpdb->prepare( $query, $created_before->format( 'Y-m-d H:i:s' ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL
}
/**
* Delete product issues for specific products and source.
*
* @param array $products_ids Array of product IDs to delete issues for.
* @param string $source The source of the issues. Default is 'mc'.
*/
public function delete_specific_product_issues( array $products_ids, string $source = 'mc' ): void {
if ( empty( $products_ids ) ) {
return;
}
$placeholder = '(' . implode( ',', array_fill( 0, count( $products_ids ), '%d' ) ) . ')';
$this->wpdb->query( $this->wpdb->prepare( "DELETE FROM `{$this->get_sql_safe_name()}` WHERE `product_id` IN {$placeholder} AND `source` = %s", array_merge( $products_ids, [ $source ] ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL
}
/**
* Get the columns for the table.
*
* @return array
*/
public function get_columns(): array {
return [
'id' => true,
'product_id' => true,
'code' => true,
'severity' => true,
'issue' => true,
'product' => true,
'action' => true,
'action_url' => true,
'applicable_countries' => true,
'source' => true,
'type' => true,
'created_at' => true,
];
}
}
DB/Table/ShippingRateTable.php 0000644 00000002347 15153721357 0012155 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRateTable
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Tables
*/
class ShippingRateTable extends Table {
/**
* Get the schema for the DB.
*
* This should be a SQL string for creating the DB table.
*
* @return string
*/
protected function get_install_query(): string {
return "
CREATE TABLE `{$this->get_sql_safe_name()}` (
id bigint(20) NOT NULL AUTO_INCREMENT,
country varchar(2) NOT NULL,
currency varchar(3) NOT NULL,
rate double NOT NULL default 0,
options text DEFAULT NULL,
PRIMARY KEY (id),
KEY country (country),
KEY currency (currency)
) {$this->get_collation()};
";
}
/**
* Get the un-prefixed (raw) table name.
*
* @return string
*/
public static function get_raw_name(): string {
return 'shipping_rates';
}
/**
* Get the columns for the table.
*
* @return array
*/
public function get_columns(): array {
return [
'id' => true,
'country' => true,
'currency' => true,
'rate' => true,
'options' => true,
];
}
}
DB/Table/ShippingTimeTable.php 0000644 00000002236 15153721357 0012155 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingTimeTable
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB\Table
*/
class ShippingTimeTable extends Table {
/**
* Get the schema for the DB.
*
* This should be a SQL string for creating the DB table.
*
* @return string
*/
protected function get_install_query(): string {
return "
CREATE TABLE `{$this->get_sql_safe_name()}` (
id bigint(20) NOT NULL AUTO_INCREMENT,
country varchar(2) NOT NULL,
time bigint(20) NOT NULL default 0,
max_time bigint(20) NOT NULL default 0,
PRIMARY KEY (id),
KEY country (country)
) {$this->get_collation()};
";
}
/**
* Get the un-prefixed (raw) table name.
*
* @return string
*/
public static function get_raw_name(): string {
return 'shipping_times';
}
/**
* Get the columns for the table.
*
* @return array
*/
public function get_columns(): array {
return [
'id' => true,
'country' => true,
'time' => true,
'max_time' => true,
];
}
}
DB/Table.php 0000644 00000007744 15153721357 0006616 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class Table
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*
* @see TableManager::VALID_TABLES contains a list of table classes that will be installed.
* @see \Automattic\WooCommerce\GoogleListingsAndAds\DB\Installer::install for installing tables.
*/
abstract class Table implements TableInterface {
use PluginHelper;
/** @var WP */
protected $wp;
/** @var wpdb */
protected $wpdb;
/**
* Table constructor.
*
* @param WP $wp The WP proxy object.
* @param wpdb $wpdb The wpdb object.
*/
public function __construct( WP $wp, wpdb $wpdb ) {
$this->wp = $wp;
$this->wpdb = $wpdb;
}
/**
* Install the Database table.
*/
public function install(): void {
$this->wp->db_delta( $this->get_install_query() );
}
/**
* Determine whether the table actually exists in the DB.
*
* @return bool
*/
public function exists(): bool {
$result = $this->wpdb->get_var(
"SHOW TABLES LIKE '{$this->wpdb->esc_like( $this->get_name() )}'" // phpcs:ignore WordPress.DB.PreparedSQL
);
return $result === $this->get_name();
}
/**
* Delete the Database table.
*/
public function delete(): void {
$this->wpdb->query( "DROP TABLE IF EXISTS `{$this->get_sql_safe_name()}`" ); // phpcs:ignore WordPress.DB.PreparedSQL
}
/**
* Truncate the Database table.
*/
public function truncate(): void {
$this->wpdb->query( "TRUNCATE TABLE `{$this->get_sql_safe_name()}`" ); // phpcs:ignore WordPress.DB.PreparedSQL
}
/**
* Get the SQL escaped version of the table name.
*
* @return string
*/
protected function get_sql_safe_name(): string {
return $this->wpdb->_escape( $this->get_name() );
}
/**
* Get the name of the Database table.
*
* The name is prefixed with the wpdb prefix, and our plugin prefix.
*
* @return string
*/
public function get_name(): string {
return "{$this->wpdb->prefix}{$this->get_slug()}_{$this->get_raw_name()}";
}
/**
* Get the primary column name for the table.
*
* @return string
*/
public function get_primary_column(): string {
return 'id';
}
/**
* Checks whether an index exists for the table.
*
* @param string $index_name The index name.
*
* @return bool True if the index exists on the table and False if not.
*
* @since 1.4.1
*/
public function has_index( string $index_name ): bool {
$result = $this->wpdb->get_results(
$this->wpdb->prepare( "SHOW INDEX FROM `{$this->get_sql_safe_name()}` WHERE Key_name = %s ", [ $index_name ] ) // phpcs:ignore WordPress.DB.PreparedSQL
);
return ! empty( $result );
}
/**
* Get the DB collation.
*
* @return string
*/
protected function get_collation(): string {
return $this->wpdb->has_cap( 'collation' ) ? $this->wpdb->get_charset_collate() : '';
}
/**
* Checks whether a column exists for the table.
*
* @param string $column_name The column name.
*
* @return bool True if the column exists on the table or False if not.
*
* @since 2.5.13
*/
public function has_column( string $column_name ): bool {
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->wpdb->get_results(
$this->wpdb->prepare( "SHOW COLUMNS FROM `{$this->get_sql_safe_name()}` WHERE Field = %s", [ $column_name ] )
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
return (bool) $this->wpdb->num_rows;
}
/**
* Get the schema for the DB.
*
* This should be a SQL string for creating the DB table.
*
* @return string
*/
abstract protected function get_install_query(): string;
/**
* Get the un-prefixed (raw) table name.
*
* @return string
*/
abstract public static function get_raw_name(): string;
}
DB/TableInterface.php 0000644 00000002717 15153721357 0010432 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
defined( 'ABSPATH' ) || exit;
/**
* Interface TableInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*/
interface TableInterface {
/**
* Install the Database table.
*/
public function install(): void;
/**
* Determine whether the table actually exists in the DB.
*
* @return bool
*/
public function exists(): bool;
/**
* Delete the Database table.
*/
public function delete(): void;
/**
* Truncate the Database table.
*/
public function truncate(): void;
/**
* Get the name of the Database table.
*
* @return string
*/
public function get_name(): string;
/**
* Get the columns for the table.
*
* @return array
*/
public function get_columns(): array;
/**
* Get the primary column name for the table.
*
* @return string
*/
public function get_primary_column(): string;
/**
* Checks whether an index exists for the table.
*
* @param string $index_name The index name.
*
* @return bool True if the index exists on the table and False if not.
*
* @since 1.4.1
*/
public function has_index( string $index_name ): bool;
/**
* Checks whether a column exists for the table.
*
* @param string $column_name The column name.
*
* @return bool True if the column exists on the table or False if not.
*
* @since 2.5.13
*/
public function has_column( string $column_name ): bool;
}
DB/TableManager.php 0000644 00000004342 15153721357 0010100 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\DB;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\AttributeMappingRulesTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\BudgetRecommendationTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingRateTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingTimeTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class TableManager
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\DB
*
* @since 1.3.0
*/
class TableManager {
use ValidateInterface;
protected const VALID_TABLES = [
AttributeMappingRulesTable::class => true,
BudgetRecommendationTable::class => true,
MerchantIssueTable::class => true,
ShippingRateTable::class => true,
ShippingTimeTable::class => true,
];
/**
* @var Table[]
*/
protected $tables;
/**
* TableManager constructor.
*
* @param Table[] $tables
*/
public function __construct( array $tables ) {
$this->setup_tables( $tables );
}
/**
* @return Table[]
*
* @see \Automattic\WooCommerce\GoogleListingsAndAds\DB\Installer::install for installing these tables.
*/
public function get_tables(): array {
return $this->tables;
}
/**
* Returns a list of table names to be installed.
*
* @return string[] Table names
*
* @see TableManager::VALID_TABLES for the list of valid table classes.
*/
public static function get_all_table_names(): array {
$tables = [];
foreach ( array_keys( self::VALID_TABLES ) as $table_class ) {
$table_name = call_user_func( [ $table_class, 'get_raw_name' ] );
$tables[ $table_name ] = $table_name;
}
return $tables;
}
/**
* Set up each of the table controllers.
*
* @param Table[] $tables
*/
protected function setup_tables( array $tables ) {
foreach ( $tables as $table ) {
$this->validate_instanceof( $table, Table::class );
// only include tables from the installable tables list.
if ( isset( self::VALID_TABLES[ get_class( $table ) ] ) ) {
$this->tables[] = $table;
}
}
}
}
Event/ClearProductStatsCache.php 0000644 00000002654 15153721357 0012710 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Event;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ClearProductStatsCache
*
* @since 1.1.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ClearProductStatsCache implements Registerable, Service {
/**
* @var MerchantStatuses
*/
protected $merchant_statuses;
/**
* ClearProductStatsCache constructor.
*
* @param MerchantStatuses $merchant_statuses
*/
public function __construct( MerchantStatuses $merchant_statuses ) {
$this->merchant_statuses = $merchant_statuses;
}
/**
* Register a service.
*/
public function register(): void {
add_action(
'woocommerce_gla_batch_updated_products',
function () {
$this->clear_stats_cache();
}
);
add_action(
'woocommerce_gla_batch_deleted_products',
function () {
$this->clear_stats_cache();
}
);
}
/**
* Clears the product statistics cache
*/
protected function clear_stats_cache() {
try {
$this->merchant_statuses->clear_cache();
} catch ( Exception $exception ) {
// log and fail silently
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
}
}
}
Event/StartProductSync.php 0000644 00000003316 15153721357 0011645 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Event;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\CleanupProductsJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateAllProducts;
defined( 'ABSPATH' ) || exit;
/**
* Class StartProductSync
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class StartProductSync implements Registerable, Service {
/**
* @var JobRepository
*/
protected $job_repository;
/**
* StartProductSync constructor.
*
* @param JobRepository $job_repository
*/
public function __construct( JobRepository $job_repository ) {
$this->job_repository = $job_repository;
}
/**
* Register a service.
*/
public function register(): void {
add_action(
'woocommerce_gla_mc_settings_sync',
function () {
$this->on_settings_sync();
}
);
add_action(
'woocommerce_gla_mapping_rules_change',
function () {
$this->on_rules_change();
}
);
}
/**
* Start the cleanup and update all products.
*/
protected function on_settings_sync() {
$cleanup = $this->job_repository->get( CleanupProductsJob::class );
$cleanup->schedule();
$update = $this->job_repository->get( UpdateAllProducts::class );
$update->schedule();
}
/**
* Creates a Job for updating all products with a 30 minutes delay.
*/
protected function on_rules_change() {
$update = $this->job_repository->get( UpdateAllProducts::class );
$update->schedule_delayed( 1800 ); // 30 minutes
}
}
Exception/AccountReconnect.php 0000644 00000002254 15153721357 0012464 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountReconnect
*
* Error messages generated in this class should be translated, as they are intended to be displayed
* to end users.
*
* @since 1.12.5
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class AccountReconnect extends ExceptionWithResponseData {
/**
* Create a new instance of the exception when the Jetpack account is not connected.
*
* @return static
*/
public static function jetpack_disconnected(): AccountReconnect {
return new static(
__( 'Please reconnect your Jetpack account.', 'google-listings-and-ads' ),
401,
null,
[
'status' => 401,
'code' => 'JETPACK_DISCONNECTED',
]
);
}
/**
* Create a new instance of the exception when the Google account is not connected.
*
* @return static
*/
public static function google_disconnected(): AccountReconnect {
return new static(
__( 'Please reconnect your Google account.', 'google-listings-and-ads' ),
401,
null,
[
'status' => 401,
'code' => 'GOOGLE_DISCONNECTED',
]
);
}
}
Exception/ApiNotReady.php 0000644 00000001542 15153721357 0011405 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ApiNotReady
*
* Error messages generated in this class should be translated, as they are intended to be displayed
* to end users.
*
* @since 1.12.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class ApiNotReady extends ExceptionWithResponseData {
/**
* Create a new instance of the exception when an API is not ready and the request needs to be retried.
*
* @param int $wait Time to wait in seconds.
*
* @return static
*/
public static function retry_after( int $wait ): ApiNotReady {
return new static(
__( 'Please retry the request after the indicated number of seconds.', 'google-listings-and-ads' ),
503,
null,
[
'retry_after' => $wait,
]
);
}
}
Exception/ExceptionWithResponseData.php 0000644 00000003230 15153721357 0014325 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use Exception;
use Throwable;
defined( 'ABSPATH' ) || exit;
/**
* Class ExceptionWithResponseData
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class ExceptionWithResponseData extends Exception implements GoogleListingsAndAdsException {
/** @var array $response_data */
private $response_data = [];
/**
* Construct the exception. Note: The message is NOT binary safe.
*
* @link https://php.net/manual/en/exception.construct.php
*
* @param string $message [optional] The Exception message to throw.
* @param int $code [optional] The Exception code.
* @param Throwable|null $previous [optional] The previous throwable used for the exception chaining.
* @param array $data [optional] Extra data to attach to the exception (ostensibly for use in an HTTP response).
*/
public function __construct( string $message = '', int $code = 0, ?Throwable $previous = null, array $data = [] ) {
parent::__construct( $message, $code, $previous );
if ( ! empty( $data ) && is_array( $data ) ) {
$this->response_data = $data;
}
}
/**
* @param bool $with_message include the message in the returned data array.
*
* @return array
*/
public function get_response_data( bool $with_message = false ): array {
if ( $with_message ) {
return array_merge( [ 'message' => $this->getMessage() ], $this->response_data );
}
return $this->response_data;
}
/**
* @param array $response_data
*/
public function set_response_data( array $response_data ) {
$this->response_data = $response_data;
}
}
Exception/ExtensionRequirementException.php 0000644 00000003706 15153721357 0015306 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ExtensionRequirementException
*
* Error messages generated in this class should be translated, as they are intended to be displayed
* to end users. We pass the translated message as a function so they are only translated when shown.
* This prevents translation functions to be called before init which is not allowed in WP 6.7+.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class ExtensionRequirementException extends RuntimeExceptionWithMessageFunction implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when a required plugin/extension isn't activated.
*
* @param string $plugin_name The name of the missing required plugin.
*
* @return static
*/
public static function missing_required_plugin( string $plugin_name ): ExtensionRequirementException {
return new static(
sprintf(
'Google for WooCommerce requires %1$s to be enabled.', // Fallback exception message.
$plugin_name
),
0,
null,
fn () => sprintf(
/* translators: 1 the missing plugin name */
__( 'Google for WooCommerce requires %1$s to be enabled.', 'google-listings-and-ads' ),
$plugin_name
)
);
}
/**
* Create a new instance of the exception when an incompatible plugin/extension is activated.
*
* @param string $plugin_name The name of the incompatible plugin.
*
* @return static
*/
public static function incompatible_plugin( string $plugin_name ): ExtensionRequirementException {
return new static(
sprintf(
'Google for WooCommerce is incompatible with %1$s.', // Fallback exception message.
$plugin_name
),
0,
null,
fn () => sprintf(
/* translators: 1 the incompatible plugin name */
__( 'Google for WooCommerce is incompatible with %1$s.', 'google-listings-and-ads' ),
$plugin_name
)
);
}
}
Exception/GoogleListingsAndAdsException.php 0000644 00000000442 15153721357 0015107 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use Throwable;
/**
* This interface is used for all of our exceptions so that we can easily catch only our own exceptions.
*/
interface GoogleListingsAndAdsException extends Throwable {}
Exception/InvalidArray.php 0000644 00000001605 15153721357 0011613 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidArray
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidArray extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Return a new instance of the exception when specific keys are missing from an array.
*
* @param string $method The method where the keys were to be provided.
* @param array $missing_keys The array of key names that were missing. This should be value-based.
*
* @return static
*/
public static function missing_keys( string $method, array $missing_keys ) {
return new static(
sprintf(
'The array provided to %s was missing the following keys: %s',
$method,
join( ',', $missing_keys )
)
);
}
}
Exception/InvalidAsset.php 0000644 00000003165 15153721357 0011617 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use LogicException;
/**
* Class InvalidAsset
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidAsset extends LogicException implements GoogleListingsAndAdsException {
/**
* Return an instance of the exception when an asset attempts to be enqueued without first being
* registered.
*
* @param string $handle The asset handle.
*
* @return static
*/
public static function asset_not_registered( string $handle ) {
return new static(
sprintf(
'The asset "%s" was not registered before it was enqueued. The register() method must be called during init.',
$handle
)
);
}
/**
* Return an instance of the exception when an asset handle is invalid.
*
* @param string $handle The invalid handle.
*
* @return static
*/
public static function invalid_handle( string $handle ) {
return new static( sprintf( 'The asset handle "%s" is invalid.', $handle ) );
}
/**
* Return a new instance of the exception when an asset with the given handle already exists.
*
* @param string $handle The asset handle that exists.
*
* @return static
*/
public static function handle_exists( string $handle ) {
return new static( sprintf( 'The asset handle "%s" already exists.', $handle ) );
}
/**
* Create a new exception for an unreadable asset.
*
* @param string $path
*
* @return static
*/
public static function invalid_path( string $path ) {
return new static( sprintf( 'The asset "%s" is unreadable. Do build scripts need to be run?', $path ) );
}
}
Exception/InvalidClass.php 0000644 00000003272 15153721357 0011604 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use LogicException;
/**
* Class InvalidClass
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidClass extends LogicException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when a class should implement an interface but does not.
*
* @param string $class_name The class name.
* @param string $interface_name The interface name.
*
* @return static
*/
public static function should_implement( string $class_name, string $interface_name ) {
return new static(
sprintf(
'The class "%s" must implement the "%s" interface.',
$class_name,
$interface_name
)
);
}
/**
* Create a new instance of the exception when a class should NOT implement an interface but it does.
*
* @param string $class_name The class name.
* @param string $interface_name The interface name.
*
* @return static
*/
public static function should_not_implement( string $class_name, string $interface_name ): InvalidClass {
return new static(
sprintf(
'The class "%s" must NOT implement the "%s" interface.',
$class_name,
$interface_name
)
);
}
/**
* Create a new instance of the exception when a class should override a method but does not.
*
* @param string $class_name The class name.
* @param string $method_name The method name.
*
* @return static
*/
public static function should_override( string $class_name, string $method_name ) {
return new static(
sprintf(
'The class "%s" must override the "%s()" method.',
$class_name,
$method_name
)
);
}
}
Exception/InvalidDomainName.php 0000644 00000001675 15153721357 0012554 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidDomainName
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidDomainName extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when a Merchant Center account can't be created
* because of an invalid top-level domain name.
*
* @param string $domain_name The top level domain name.
*
* @return static
*/
public static function create_account_failed_invalid_top_level_domain_name( string $domain_name ): InvalidDomainName {
return new static(
/* translators: 1 top level domain name. */
sprintf( __( 'Unable to create an account, the domain name "%s" must end with a valid top-level domain name.', 'google-listings-and-ads' ), $domain_name )
);
}
}
Exception/InvalidMeta.php 0000644 00000001157 15153721357 0011425 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use LogicException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidMeta
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidMeta extends LogicException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when an invalid meta key is provided.
*
* @param string $key The meta key.
*
* @return static
*/
public static function invalid_key( string $key ) {
return new static( sprintf( 'The meta key "%s" is not valid.', $key ) );
}
}
Exception/InvalidOption.php 0000644 00000001200 15153721357 0011774 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use LogicException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidOption
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidOption extends LogicException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when an invalid option name is provided.
*
* @param string $name The option name.
*
* @return static
*/
public static function invalid_name( string $name ) {
return new static( sprintf( 'The option name "%s" is not valid.', $name ) );
}
}
Exception/InvalidProperty.php 0000644 00000001347 15153721357 0012364 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use LogicException;
/**
* InvalidProperty class.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidProperty extends LogicException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception for a class property that should not be null.
*
* @param string $class_name The class name.
* @param string $property The class property name.
*
* @return static
*/
public static function not_null( string $class_name, string $property ) {
return new static(
sprintf(
'The class "%s" property "%s" must be set.',
$class_name,
$property
)
);
}
}
Exception/InvalidQuery.php 0000644 00000010474 15153721357 0011646 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidQuery
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidQuery extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when a column is not valid for a given table.
*
* @param string $name The column name.
* @param string $table_class The table class name.
*
* @return static
*/
public static function from_column( string $name, string $table_class ): InvalidQuery {
return new static( sprintf( 'The column "%s" is not valid for table class "%s".', $name, $table_class ) );
}
/**
* Create a new instance of the exception when a column is not valid.
*
* @param string $name The column name.
*
* @return static
*/
public static function invalid_column( string $name ): InvalidQuery {
return new static( sprintf( 'The column "%s" is not valid, it should only contain the characters "a-zA-Z0-9._"', $name ) );
}
/**
* Create a new instance of the exception when a column is not valid for ordering.
*
* @param string $name The column name.
*
* @return static
*/
public static function invalid_order_column( string $name ): InvalidQuery {
return new static( sprintf( 'The column "%s" is not valid for ordering results.', $name ) );
}
/**
* @param string $compare
*
* @return static
*/
public static function from_compare( string $compare ): InvalidQuery {
return new static( sprintf( 'The compare value "%s" is not valid.', $compare ) );
}
/**
* @param string $relation
*
* @return static
*/
public static function where_relation( string $relation ): InvalidQuery {
return new static( sprintf( 'The where relation value "%s" is not valid.', $relation ) );
}
/**
* Create a new instance of the exception when there is an error inserting data into the DB.
*
* @param string $error
*
* @return InvalidQuery
*/
public static function from_insert( string $error ): InvalidQuery {
return new static( sprintf( 'Error inserting data into the database: "%s"', $error ) );
}
/**
* Create a new instance of the exception when trying to set an auto increment ID.
*
* @param string $table_class
* @param string $column_name
*
* @return InvalidQuery
*/
public static function cant_set_id( string $table_class, string $column_name = 'id' ): InvalidQuery {
return new static( sprintf( 'Cannot set column "%s" for table class "%s".', $column_name, $table_class ) );
}
/**
* Create a new instance of the exception when there is an error deleting data from the DB.
*
* @param string $error
*
* @return InvalidQuery
*/
public static function from_delete( string $error ): InvalidQuery {
return new static( sprintf( 'Error deleting data into the database: "%s"', $error ) );
}
/**
* Create a new instance of the exception when there is an error updating data in the DB.
*
* @param string $error
*
* @return InvalidQuery
*/
public static function from_update( string $error ): InvalidQuery {
return new static( sprintf( 'Error updating data in the database: "%s"', $error ) );
}
/**
* Create a new instance of the exception when an empty where clause is provided.
*
* @return InvalidQuery
*/
public static function empty_where(): InvalidQuery {
return new static( 'Where clause cannot be an empty array.' );
}
/**
* Create a new instance of the exception when an empty set of columns is provided.
*
* @return InvalidQuery
*/
public static function empty_columns(): InvalidQuery {
return new static( 'Columns list cannot be an empty array.' );
}
/**
* Create a new instance of the exception when an invalid resource name is used.
*
* @return InvalidQuery
*/
public static function resource_name(): InvalidQuery {
return new static( 'The resource name can only include alphanumeric and underscore characters.' );
}
/**
* Create a new instance of the exception when an invalid value is used for a column.
*
* @param string $name The column name.
*
* @return InvalidQuery
*/
public static function invalid_value( string $name ): InvalidQuery {
return new static( sprintf( 'The value for column "%s" is not valid.', $name ) );
}
}
Exception/InvalidService.php 0000644 00000002325 15153721357 0012135 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
/**
* InvalidService class.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidService extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception for a service class name that is
* not recognized.
*
* @param string|object $service Class name of the service that was not recognized.
*
* @return static
*/
public static function from_service( $service ) {
$message = sprintf(
'The service "%s" cannot be registered because it is not recognized.',
is_object( $service ) ? get_class( $service ) : (string) $service
);
return new static( $message );
}
/**
* Create a new instance of the exception for a service identifier that is
* not recognized.
*
* @param string $service_id Identifier of the service that is not being recognized.
*
* @return static
*/
public static function from_service_id( string $service_id ) {
$message = sprintf( 'The service ID "%s" cannot be retrieved because is not recognized.', $service_id );
return new static( $message );
}
}
Exception/InvalidState.php 0000644 00000001211 15153721357 0011606 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidState
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidState extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when an invalid state is requested.
*
* @param string $state
*
* @return InvalidState
*/
public static function from_state( string $state ): InvalidState {
return new static( sprintf( 'The state %s is not valid.', $state ) );
}
}
Exception/InvalidTerm.php 0000644 00000001231 15153721357 0011437 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidTerm
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidTerm extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when a text contains invalid terms.
*
* @param string $text
*
* @return InvalidTerm
*/
public static function contains_invalid_terms( string $text ): InvalidTerm {
return new static( sprintf( 'The text "%s" contains invalid terms.', $text ) );
}
}
Exception/InvalidType.php 0000644 00000001366 15153721357 0011462 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidType
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidType extends InvalidArgumentException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when an invalid type is provided.
*
* @param string $type
* @param string[] $valid_types
*
* @return InvalidType
*/
public static function from_type( string $type, array $valid_types ): InvalidType {
return new static(
sprintf(
'Invalid type "%s". Valid types are: "%s"',
$type,
join( '", "', $valid_types )
)
);
}
}
Exception/InvalidValue.php 0000644 00000006165 15153721357 0011617 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use LogicException;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidValue
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidValue extends LogicException implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when a value is not a positive integer.
*
* @param string $method The method that requires a positive integer.
*
* @return static
*/
public static function negative_integer( string $method ) {
return new static( sprintf( 'The method "%s" requires a positive integer value.', $method ) );
}
/**
* Create a new instance of the exception when a value is not a string.
*
* @param string $key The name of the value.
*
* @return static
*/
public static function not_string( string $key ) {
return new static( sprintf( 'The value of %s must be of type string.', $key ) );
}
/**
* Create a new instance of the exception when a value is not a string.
*
* @param string $key The name of the value.
*
* @return static
*/
public static function not_integer( string $key ): InvalidValue {
return new static( sprintf( 'The value of %s must be of type integer.', $key ) );
}
/**
* Create a new instance of the exception when a value is not an instance of a given class.
*
* @param string $class_name The name of the class that the value must be an instance of.
* @param string $key The name of the value.
*
* @return static
*/
public static function not_instance_of( string $class_name, string $key ) {
return new static( sprintf( 'The value of %s must be an instance of %s.', $key, $class_name ) );
}
/**
* Create a new instance of the exception when a value is empty.
*
* @param string $key The name of the value.
*
* @return static
*
* @since 1.2.0
*/
public static function is_empty( string $key ): InvalidValue {
return new static( sprintf( 'The value of %s can not be empty.', $key ) );
}
/**
* Create a new instance of the exception when a value is not from a predefined list of allowed values.
*
* @param mixed $key The name of the value.
* @param array $allowed_values The list of allowed values.
*
* @return static
*/
public static function not_in_allowed_list( $key, array $allowed_values ): InvalidValue {
return new static( sprintf( 'The value of %s must be either of [%s].', $key, implode( ', ', $allowed_values ) ) );
}
/**
* Create a new instance of the exception when a value isn't a valid coupon ID.
*
* @param mixed $value The provided coupon ID that isn't valid.
*
* @return static
*/
public static function not_valid_coupon_id( $value ): InvalidValue {
return new static( sprintf( 'Invalid coupon ID: %s', $value ) );
}
/**
* Create a new instance of the exception when a value isn't a valid product ID.
*
* @param mixed $value The provided product ID that isn't valid.
*
* @return static
*/
public static function not_valid_product_id( $value ): InvalidValue {
return new static( sprintf( 'Invalid product ID: %s', $value ) );
}
}
Exception/InvalidVersion.php 0000644 00000005330 15153721357 0012161 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class InvalidVersion
*
* Error messages generated in this class should be translated, as they are intended to be displayed
* to end users. We pass the translated message as a function so they are only translated when shown.
* This prevents translation functions to be called before init which is not allowed in WP 6.7+.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class InvalidVersion extends RuntimeExceptionWithMessageFunction implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception when an invalid version is detected.
*
* @param string $requirement
* @param string $found_version
* @param string $minimum_version
*
* @return static
*/
public static function from_requirement( string $requirement, string $found_version, string $minimum_version ): InvalidVersion {
return new static(
sprintf(
'Google for WooCommerce requires %1$s version %2$s or higher. You are using version %3$s.', // Fallback exception message.
$requirement,
$minimum_version,
$found_version
),
0,
null,
fn () => sprintf(
/* translators: 1 is the required component, 2 is the minimum required version, 3 is the version in use on the site */
__( 'Google for WooCommerce requires %1$s version %2$s or higher. You are using version %3$s.', 'google-listings-and-ads' ),
$requirement,
$minimum_version,
$found_version
)
);
}
/**
* Create a new instance of the exception when a requirement is missing.
*
* @param string $requirement
* @param string $minimum_version
*
* @return InvalidVersion
*/
public static function requirement_missing( string $requirement, string $minimum_version ): InvalidVersion {
return new static(
sprintf(
'Google for WooCommerce requires %1$s version %2$s or higher.', // Fallback exception message.
$requirement,
$minimum_version
),
0,
null,
fn () => sprintf(
/* translators: 1 is the required component, 2 is the minimum required version */
__( 'Google for WooCommerce requires %1$s version %2$s or higher.', 'google-listings-and-ads' ),
$requirement,
$minimum_version
)
);
}
/**
* Create a new instance of the exception when an invalid architecture is detected.
*
* @since 2.3.9
* @return InvalidVersion
*/
public static function invalid_architecture(): InvalidVersion {
return new static(
'Google for WooCommerce requires a 64 bit version of PHP.', // Fallback exception message.
0,
null,
fn () => __( 'Google for WooCommerce requires a 64 bit version of PHP.', 'google-listings-and-ads' )
);
}
}
Exception/RuntimeExceptionWithMessageFunction.php 0000644 00000003133 15153721357 0016375 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use Exception;
use RuntimeException;
use Throwable;
defined( 'ABSPATH' ) || exit;
/**
* Class RuntimeExceptionWithMessageFunction
*
* The purpose of this Exception type is to be able to throw an exception early,
* but translate the string late. This is because WP 6.7+ requires translations
* to happen after the init hook.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class RuntimeExceptionWithMessageFunction extends RuntimeException implements GoogleListingsAndAdsException {
/** @var callable $message_function */
private $message_function;
/**
* Construct the exception
*
* @param string $message [optional] The Exception message to throw.
* @param int $code [optional] The Exception code.
* @param Throwable|null $previous [optional] The previous throwable used for the exception chaining.
* @param callable|null $message_function [optional] Function to format/translate the message string.
*/
public function __construct( string $message = '', int $code = 0, ?Throwable $previous = null, ?callable $message_function = null ) {
parent::__construct( $message, $code, $previous );
$this->message_function = $message_function;
}
/**
* Override getMessage function to return message from function if available.
*
* @return string Exception message.
*/
public function get_formatted_message(): string {
if ( is_callable( $this->message_function ) ) {
return ( $this->message_function )();
}
return parent::getMessage();
}
}
Exception/ValidateInterface.php 0000644 00000003712 15153721357 0012601 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
/**
* Trait ValidateInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
trait ValidateInterface {
/**
* Validate that a class implements a given interface.
*
* @param string $class_name The class name.
* @param string $interface_name The interface name.
*
* @throws InvalidClass When the given class does not implement the interface.
*/
protected function validate_interface( string $class_name, string $interface_name ) {
$implements = class_implements( $class_name );
if ( ! array_key_exists( $interface_name, $implements ) ) {
throw InvalidClass::should_implement( $class_name, $interface_name );
}
}
/**
* Validate that an object is an instance of an interface.
*
* @param object $object_instance The object to validate.
* @param string $interface_name The interface name.
*
* @throws InvalidClass When the given object does not implement the interface.
*/
protected function validate_instanceof( $object_instance, string $interface_name ) {
$class_name = '';
if ( is_object( $object_instance ) ) {
$class_name = get_class( $object_instance );
}
if ( ! $object_instance instanceof $interface_name ) {
throw InvalidClass::should_implement( $class_name, $interface_name );
}
}
/**
* Validate that an object is NOT an instance of an interface.
*
* @param object $object_instance The object to validate.
* @param string $interface_name The interface name.
*
* @throws InvalidClass When the given object implements the interface.
*/
protected function validate_not_instanceof( $object_instance, string $interface_name ) {
$class_name = '';
if ( is_object( $object_instance ) ) {
$class_name = get_class( $object_instance );
}
if ( $object_instance instanceof $interface_name ) {
throw InvalidClass::should_not_implement( $class_name, $interface_name );
}
}
}
Exception/WPError.php 0000644 00000001603 15153721357 0010564 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use RuntimeException;
use WP_Error;
/**
* Class WPError.
*
* Used to convert a WP_Error object to a thrown exception.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class WPError extends RuntimeException implements GoogleListingsAndAdsException {
/**
* Convert a WP_Error object to a throwable exception.
*
* @param WP_Error $error The error object.
*
* @return static
*/
public static function from_error( WP_Error $error ) {
$message = $error->get_error_message();
$code = $error->get_error_code();
$string_code = '';
if ( ! is_numeric( $code ) ) {
$string_code = $code;
$code = 0;
}
return new static(
sprintf( 'A WP Error was generated. Code: "%s" Message: "%s".', $string_code, $message ),
$code
);
}
}
Exception/WPErrorTrait.php 0000644 00000002605 15153721357 0011573 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Exception;
use Exception;
use Throwable;
use WP_Error;
/**
* Trait WPErrorTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
trait WPErrorTrait {
/**
* Check for a WP_Error object and throw an exception if we found one.
*
* @param mixed|WP_Error $maybe_error
*
* @throws WPError When the object is a WP_Error.
*/
protected function check_for_wp_error( $maybe_error ) {
if ( ! $maybe_error instanceof WP_Error ) {
return;
}
if ( $maybe_error->has_errors() ) {
throw WPError::from_error( $maybe_error );
}
}
/**
* Create a WP_Error from an exception.
*
* @param Throwable $e
* @param string $code
* @param array $data
*
* @return WP_Error
*/
protected function error_from_exception( Throwable $e, string $code, array $data = [] ): WP_Error {
return new WP_Error( $code, $data['message'] ?? $e->getMessage(), $data );
}
/**
* Try to decode a JSON string.
*
* @param string $message
*
* @return array
* @throws Exception When the JSON could not be decoded.
*/
protected function json_decode_message( string $message ): array {
$decoded = json_decode( $message, true );
if ( null === $decoded || JSON_ERROR_NONE !== json_last_error() ) {
throw new Exception( 'Could not decode JSON' );
}
return $decoded;
}
}
Google/Ads/GoogleAdsClient.php 0000644 00000002660 15153721357 0012220 0 ustar 00 <?php
declare( strict_types=1 );
/**
* Overrides vendor/googleads/google-ads-php/src/Google/Ads/GoogleAds/Lib/V18/GoogleAdsClient.php
*
* phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
* phpcs:disable WordPress.NamingConventions.ValidVariableName
*/
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Auth\Credentials\InsecureCredentials;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Auth\HttpHandler\HttpHandlerFactory;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client;
/**
* A Google Ads API client for handling common configuration and OAuth2 settings.
*/
class GoogleAdsClient {
use ServiceClientFactoryTrait;
/** @var Client $httpClient */
private $httpClient = null;
/**
* GoogleAdsClient constructor
*
* @param string $endpoint Endpoint URL to send requests to.
*/
public function __construct( string $endpoint ) {
$this->oAuth2Credential = new InsecureCredentials();
$this->endpoint = $endpoint;
}
/**
* Set a guzzle client to use for requests.
*
* @param Client $client Guzzle client.
*/
public function setHttpClient( Client $client ) {
$this->httpClient = $client;
}
/**
* Build a HTTP Handler to handle the requests.
*/
protected function buildHttpHandler() {
return [ HttpHandlerFactory::build( $this->httpClient ), 'async' ];
}
}
Google/Ads/ServiceClientFactoryTrait.php 0000644 00000015464 15153721357 0014316 0 ustar 00 <?php
declare( strict_types=1 );
/**
* Overrides vendor/googleads/google-ads-php/src/Google/Ads/GoogleAds/Lib/V18/ServiceClientFactoryTrait.php
*
* phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
* phpcs:disable WordPress.NamingConventions.ValidVariableName
* phpcs:disable Squiz.Commenting.VariableComment
*/
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads;
use Google\Ads\GoogleAds\Constants;
use Google\Ads\GoogleAds\Lib\ConfigurationTrait;
use Google\Ads\GoogleAds\V18\Services\Client\AccountLinkServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupAdLabelServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupAdServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupCriterionServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AssetGroupListingGroupFilterServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AssetGroupServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\BillingSetupServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignBudgetServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignCriterionServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\ConversionActionServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CustomerServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CustomerUserAccessServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\GeoTargetConstantServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\GoogleAdsServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\ProductLinkInvitationServiceClient;
/**
* Contains service client factory methods.
*/
trait ServiceClientFactoryTrait {
use ConfigurationTrait;
private static $CREDENTIALS_LOADER_KEY = 'credentials';
private static $DEVELOPER_TOKEN_KEY = 'developer-token';
private static $LOGIN_CUSTOMER_ID_KEY = 'login-customer-id';
private static $LINKED_CUSTOMER_ID_KEY = 'linked-customer-id';
private static $SERVICE_ADDRESS_KEY = 'serviceAddress';
private static $DEFAULT_SERVICE_ADDRESS = 'googleads.googleapis.com';
private static $TRANSPORT_KEY = 'transport';
/**
* Gets the Google Ads client options for making API calls.
*
* @return array the client options
*/
public function getGoogleAdsClientOptions(): array {
$clientOptions = [
self::$CREDENTIALS_LOADER_KEY => $this->getOAuth2Credential(),
self::$DEVELOPER_TOKEN_KEY => '',
self::$TRANSPORT_KEY => 'rest',
'libName' => Constants::LIBRARY_NAME,
'libVersion' => Constants::LIBRARY_VERSION,
];
if ( ! empty( $this->getEndpoint() ) ) {
$clientOptions += [ self::$SERVICE_ADDRESS_KEY => $this->getEndpoint() ];
}
if ( isset( $this->httpClient ) ) {
$clientOptions['transportConfig'] = [
'rest' => [
'httpHandler' => $this->buildHttpHandler(),
],
];
}
return $clientOptions;
}
/**
* @return AccountLinkServiceClient
*/
public function getAccountLinkServiceClient(): AccountLinkServiceClient {
return new AccountLinkServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AdGroupAdLabelServiceClient
*/
public function getAdGroupAdLabelServiceClient(): AdGroupAdLabelServiceClient {
return new AdGroupAdLabelServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AdGroupAdServiceClient
*/
public function getAdGroupAdServiceClient(): AdGroupAdServiceClient {
return new AdGroupAdServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AdGroupCriterionServiceClient
*/
public function getAdGroupCriterionServiceClient(): AdGroupCriterionServiceClient {
return new AdGroupCriterionServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AdGroupServiceClient
*/
public function getAdGroupServiceClient(): AdGroupServiceClient {
return new AdGroupServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AdServiceClient
*/
public function getAdServiceClient(): AdServiceClient {
return new AdServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AssetGroupListingGroupFilterServiceClient
*/
public function getAssetGroupListingGroupFilterServiceClient(): AssetGroupListingGroupFilterServiceClient {
return new AssetGroupListingGroupFilterServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return AssetGroupServiceClient
*/
public function getAssetGroupServiceClient(): AssetGroupServiceClient {
return new AssetGroupServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return BillingSetupServiceClient
*/
public function getBillingSetupServiceClient(): BillingSetupServiceClient {
return new BillingSetupServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return CampaignBudgetServiceClient
*/
public function getCampaignBudgetServiceClient(): CampaignBudgetServiceClient {
return new CampaignBudgetServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return CampaignCriterionServiceClient
*/
public function getCampaignCriterionServiceClient(): CampaignCriterionServiceClient {
return new CampaignCriterionServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return CampaignServiceClient
*/
public function getCampaignServiceClient(): CampaignServiceClient {
return new CampaignServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return ConversionActionServiceClient
*/
public function getConversionActionServiceClient(): ConversionActionServiceClient {
return new ConversionActionServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return CustomerServiceClient
*/
public function getCustomerServiceClient(): CustomerServiceClient {
return new CustomerServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return CustomerUserAccessServiceClient
*/
public function getCustomerUserAccessServiceClient(): CustomerUserAccessServiceClient {
return new CustomerUserAccessServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return GeoTargetConstantServiceClient
*/
public function getGeoTargetConstantServiceClient(): GeoTargetConstantServiceClient {
return new GeoTargetConstantServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return GoogleAdsServiceClient
*/
public function getGoogleAdsServiceClient(): GoogleAdsServiceClient {
return new GoogleAdsServiceClient( $this->getGoogleAdsClientOptions() );
}
/**
* @return ProductLinkInvitationServiceClient
*/
public function getProductLinkInvitationServiceClient(): ProductLinkInvitationServiceClient {
return new ProductLinkInvitationServiceClient( $this->getGoogleAdsClientOptions() );
}
}
Google/BatchInvalidProductEntry.php 0000644 00000004432 15153721357 0013420 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use JsonSerializable;
use Symfony\Component\Validator\ConstraintViolationListInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class BatchInvalidProductEntry
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class BatchInvalidProductEntry implements JsonSerializable {
/**
* @var int WooCommerce product ID.
*/
protected $wc_product_id;
/**
* @var string|null Google product ID. Always defined if the method is delete.
*/
protected $google_product_id;
/**
* @var string[]
*/
protected $errors;
/**
* BatchInvalidProductEntry constructor.
*
* @param int $wc_product_id
* @param string|null $google_product_id
* @param string[] $errors
*/
public function __construct( int $wc_product_id, ?string $google_product_id = null, array $errors = [] ) {
$this->wc_product_id = $wc_product_id;
$this->google_product_id = $google_product_id;
$this->errors = $errors;
}
/**
* @return int
*/
public function get_wc_product_id(): int {
return $this->wc_product_id;
}
/**
* @return string|null
*/
public function get_google_product_id(): ?string {
return $this->google_product_id;
}
/**
* @return string[]
*/
public function get_errors(): array {
return $this->errors;
}
/**
* @param string $error_reason
*
* @return bool
*/
public function has_error( string $error_reason ): bool {
return ! empty( $this->errors[ $error_reason ] );
}
/**
* @param ConstraintViolationListInterface $violations
*
* @return BatchInvalidProductEntry
*/
public function map_validation_violations( ConstraintViolationListInterface $violations ): BatchInvalidProductEntry {
$validation_errors = [];
foreach ( $violations as $violation ) {
$validation_errors[] = sprintf( '[%s] %s', $violation->getPropertyPath(), $violation->getMessage() );
}
$this->errors = $validation_errors;
return $this;
}
/**
* @return array
*/
public function jsonSerialize(): array {
$data = [
'woocommerce_id' => $this->get_wc_product_id(),
'errors' => $this->get_errors(),
];
if ( null !== $this->get_google_product_id() ) {
$data['google_id'] = $this->get_google_product_id();
}
return $data;
}
}
Google/BatchProductEntry.php 0000644 00000002651 15153721357 0012112 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use JsonSerializable;
defined( 'ABSPATH' ) || exit;
/**
* Class BatchProductEntry
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class BatchProductEntry implements JsonSerializable {
/**
* @var int WooCommerce product ID.
*/
protected $wc_product_id;
/**
* @var GoogleProduct|null The inserted product. Only defined if the method is insert.
*/
protected $google_product;
/**
* BatchProductEntry constructor.
*
* @param int $wc_product_id
* @param GoogleProduct|null $google_product
*/
public function __construct( int $wc_product_id, ?GoogleProduct $google_product = null ) {
$this->wc_product_id = $wc_product_id;
$this->google_product = $google_product;
}
/**
* @return int
*/
public function get_wc_product_id(): int {
return $this->wc_product_id;
}
/**
* @return GoogleProduct|null
*/
public function get_google_product(): ?GoogleProduct {
return $this->google_product;
}
/**
* @return array
*/
public function jsonSerialize(): array {
$data = [ 'woocommerce_id' => $this->get_wc_product_id() ];
if ( null !== $this->get_google_product() ) {
$data['google_id'] = $this->get_google_product()->getId();
}
return $data;
}
}
Google/BatchProductIDRequestEntry.php 0000644 00000003342 15153721357 0013676 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ProductIDMap;
defined( 'ABSPATH' ) || exit;
/**
* Class BatchProductIDRequestEntry
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class BatchProductIDRequestEntry {
/**
* @var int
*/
protected $wc_product_id;
/**
* @var string The Google product REST ID.
*/
protected $product_id;
/**
* BatchProductIDRequestEntry constructor.
*
* @param int $wc_product_id
* @param string $product_id
*/
public function __construct( int $wc_product_id, string $product_id ) {
$this->wc_product_id = $wc_product_id;
$this->product_id = $product_id;
}
/**
* @return int
*/
public function get_wc_product_id(): int {
return $this->wc_product_id;
}
/**
* @return string
*/
public function get_product_id(): string {
return $this->product_id;
}
/**
* @param ProductIDMap $product_id_map
*
* @return BatchProductIDRequestEntry[]
*/
public static function create_from_id_map( ProductIDMap $product_id_map ): array {
$product_entries = [];
foreach ( $product_id_map as $google_product_id => $wc_product_id ) {
$product_entries[] = new BatchProductIDRequestEntry( $wc_product_id, $google_product_id );
}
return $product_entries;
}
/**
* @param BatchProductIDRequestEntry[] $request_entries
*
* @return ProductIDMap $product_id_map
*/
public static function convert_to_id_map( array $request_entries ): ProductIDMap {
$id_map = [];
foreach ( $request_entries as $request_entry ) {
$id_map[ $request_entry->get_product_id() ] = $request_entry->get_wc_product_id();
}
return new ProductIDMap( $id_map );
}
}
Google/BatchProductRequestEntry.php 0000644 00000001750 15153721357 0013462 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;
defined( 'ABSPATH' ) || exit;
/**
* Class BatchProductRequestEntry
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class BatchProductRequestEntry {
/**
* @var int
*/
protected $wc_product_id;
/**
* @var WCProductAdapter The Google product object
*/
protected $product;
/**
* BatchProductRequestEntry constructor.
*
* @param int $wc_product_id
* @param WCProductAdapter $product
*/
public function __construct( int $wc_product_id, WCProductAdapter $product ) {
$this->wc_product_id = $wc_product_id;
$this->product = $product;
}
/**
* @return int
*/
public function get_wc_product_id(): int {
return $this->wc_product_id;
}
/**
* @return WCProductAdapter
*/
public function get_product(): WCProductAdapter {
return $this->product;
}
}
Google/BatchProductResponse.php 0000644 00000001675 15153721357 0012614 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
defined( 'ABSPATH' ) || exit;
/**
* Class BatchProductResponse
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class BatchProductResponse {
/**
* @var BatchProductEntry[] Products that were successfully updated, deleted or retrieved.
*/
protected $products;
/**
* @var BatchInvalidProductEntry[]
*/
protected $errors;
/**
* BatchProductResponse constructor.
*
* @param BatchProductEntry[] $products
* @param BatchInvalidProductEntry[] $errors
*/
public function __construct( array $products, array $errors ) {
$this->products = $products;
$this->errors = $errors;
}
/**
* @return BatchProductEntry[]
*/
public function get_products(): array {
return $this->products;
}
/**
* @return BatchInvalidProductEntry[]
*/
public function get_errors(): array {
return $this->errors;
}
}
Google/DeleteCouponEntry.php 0000644 00000002614 15153721357 0012115 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
defined( 'ABSPATH' ) || exit();
/**
* Class DeleteCouponEntry
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class DeleteCouponEntry {
/**
*
* @var int
*/
protected $wc_coupon_id;
/**
*
* @var GooglePromotion
*/
protected $google_promotion;
/**
*
* @var array List of country to google promotion id mappings
*/
protected $synced_google_ids;
/**
* DeleteCouponEntry constructor.
*
* @param int $wc_coupon_id
* @param GooglePromotion $google_promotion
* @param array $synced_google_ids
*/
public function __construct(
int $wc_coupon_id,
GooglePromotion $google_promotion,
array $synced_google_ids
) {
$this->wc_coupon_id = $wc_coupon_id;
$this->google_promotion = $google_promotion;
$this->synced_google_ids = $synced_google_ids;
}
/**
*
* @return int
*/
public function get_wc_coupon_id(): int {
return $this->wc_coupon_id;
}
/**
*
* @return GooglePromotion
*/
public function get_google_promotion(): GooglePromotion {
return $this->google_promotion;
}
/**
*
* @return array
*/
public function get_synced_google_ids(): array {
return $this->synced_google_ids;
}
}
Google/GlobalSiteTag.php 0000644 00000041206 15153721357 0011166 0 ustar 00 <?php
declare( strict_types=1 );
/**
* Global Site Tag functionality - add main script and track conversions.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds
*/
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\ScriptWithBuiltDependenciesAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\GoogleGtagJs;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Main class for Global Site Tag.
*/
class GlobalSiteTag implements Service, Registerable, Conditional, OptionsAwareInterface {
use OptionsAwareTrait;
use PluginHelper;
/** @var string Developer ID */
protected const DEVELOPER_ID = 'dOGY3NW';
/** @var string Meta key used to mark orders as converted */
protected const ORDER_CONVERSION_META_KEY = '_gla_tracked';
/**
* @var AssetsHandlerInterface
*/
protected $assets_handler;
/**
* @var GoogleGtagJs
*/
protected $gtag_js;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* @var WC
*/
protected $wc;
/**
* @var WP
*/
protected $wp;
/**
* Additional product data used for tracking add_to_cart events.
*
* @var array
*/
protected $products = [];
/**
* Global Site Tag constructor.
*
* @param AssetsHandlerInterface $assets_handler
* @param GoogleGtagJs $gtag_js
* @param ProductHelper $product_helper
* @param WC $wc
* @param WP $wp
*/
public function __construct(
AssetsHandlerInterface $assets_handler,
GoogleGtagJs $gtag_js,
ProductHelper $product_helper,
WC $wc,
WP $wp
) {
$this->assets_handler = $assets_handler;
$this->gtag_js = $gtag_js;
$this->product_helper = $product_helper;
$this->wc = $wc;
$this->wp = $wp;
}
/**
* Register the service.
*/
public function register(): void {
$conversion_action = $this->options->get( OptionsInterface::ADS_CONVERSION_ACTION );
// No snippets without conversion action info.
if ( ! $conversion_action ) {
return;
}
$ads_conversion_id = $conversion_action['conversion_id'];
$ads_conversion_label = $conversion_action['conversion_label'];
add_action(
'wp_head',
function () use ( $ads_conversion_id ) {
$this->activate_global_site_tag( $ads_conversion_id );
},
999999
);
add_action(
'woocommerce_before_thankyou',
function ( $order_id ) use ( $ads_conversion_id, $ads_conversion_label ) {
$this->maybe_display_conversion_and_purchase_event_snippets( $ads_conversion_id, $ads_conversion_label, $order_id );
},
);
add_action(
'woocommerce_after_single_product',
function () {
$this->display_view_item_event_snippet();
}
);
add_action(
'wp_body_open',
function () {
$this->display_page_view_event_snippet();
}
);
$this->product_data_hooks();
$this->register_assets();
}
/**
* Attach filters to add product data required for tracking events.
*/
protected function product_data_hooks() {
// Add product data for any add_to_cart link.
add_filter(
'woocommerce_loop_add_to_cart_link',
function ( $link, $product ) {
$this->add_product_data( $product );
return $link;
},
10,
2
);
// Add display name for an available variation.
add_filter(
'woocommerce_available_variation',
function ( $data, $instance, $variation ) {
$data['display_name'] = $variation->get_name();
return $data;
},
10,
3
);
}
/**
* Register and enqueue assets for gtag events in blocks.
*/
protected function register_assets() {
$gtag_events = new ScriptWithBuiltDependenciesAsset(
'gla-gtag-events',
'js/build/gtag-events',
"{$this->get_root_dir()}/js/build/gtag-events.asset.php",
new BuiltScriptDependencyArray(
[
'dependencies' => [],
'version' => $this->get_version(),
]
),
function () {
return is_page() || is_woocommerce() || is_cart();
}
);
$this->assets_handler->register( $gtag_events );
$wp_consent_api = new ScriptWithBuiltDependenciesAsset(
'gla-wp-consent-api',
'js/build/wp-consent-api',
"{$this->get_root_dir()}/js/build/wp-consent-api.asset.php",
new BuiltScriptDependencyArray(
[
'dependencies' => [ 'wp-consent-api' ],
'version' => $this->get_version(),
]
)
);
$this->assets_handler->register( $wp_consent_api );
add_action(
'wp_footer',
function () use ( $gtag_events, $wp_consent_api ) {
$gtag_events->add_localization(
'glaGtagData',
[
'currency_minor_unit' => wc_get_price_decimals(),
'products' => $this->products,
]
);
$this->register_js_for_fast_refresh_dev();
$this->assets_handler->enqueue( $gtag_events );
if ( ! class_exists( '\WC_Google_Gtag_JS' ) && function_exists( 'wp_has_consent' ) ) {
$this->assets_handler->enqueue( $wp_consent_api );
}
}
);
}
/**
* Activate the Global Site Tag framework:
* - Insert GST code, or
* - Include the Google Ads conversion ID in WooCommerce Google Analytics for WooCommerce output, if available
*
* @param string $ads_conversion_id Google Ads account conversion ID.
*/
public function activate_global_site_tag( string $ads_conversion_id ) {
if ( $this->gtag_js->is_adding_framework() ) {
if ( $this->gtag_js->ga4w_v2 ) {
$this->wp->wp_add_inline_script(
'woocommerce-google-analytics-integration',
$this->get_gtag_config( $ads_conversion_id )
);
} else {
// Legacy code to support Google Analytics for WooCommerce version < 2.0.0.
add_filter(
'woocommerce_gtag_snippet',
function ( $gtag_snippet ) use ( $ads_conversion_id ) {
return preg_replace(
'~(\s)</script>~',
"\tgtag('config', '" . $ads_conversion_id . "', { 'groups': 'GLA', 'send_page_view': false });\n$1</script>",
$gtag_snippet
);
}
);
}
} else {
$this->display_global_site_tag( $ads_conversion_id );
}
}
/**
* Display the JavaScript code to load the Global Site Tag framework.
*
* @param string $ads_conversion_id Google Ads account conversion ID.
*/
protected function display_global_site_tag( string $ads_conversion_id ) {
// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript
?>
<!-- Global site tag (gtag.js) - Google Ads: <?php echo esc_js( $ads_conversion_id ); ?> - Google for WooCommerce -->
<script async src="https://www.googletagmanager.com/gtag/js?id=<?php echo esc_js( $ads_conversion_id ); ?>"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->get_consent_mode_config();
?>
gtag('js', new Date());
gtag('set', 'developer_id.<?php echo esc_js( self::DEVELOPER_ID ); ?>', true);
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->get_gtag_config( $ads_conversion_id );
?>
</script>
<?php
// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
}
/**
* Get the ads conversion configuration for the Global Site Tag
*
* @param string $ads_conversion_id Google Ads account conversion ID.
*/
protected function get_gtag_config( string $ads_conversion_id ) {
return sprintf(
'gtag("config", "%1$s", { "groups": "GLA", "send_page_view": false });',
esc_js( $ads_conversion_id )
);
}
/**
* Get the default consent mode configuration.
*/
protected function get_consent_mode_config() {
$consent_mode_snippet = "gtag( 'consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
region: ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IS', 'IE', 'IT', 'LV', 'LI', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'CH'],
wait_for_update: 500,
} );";
/**
* Filters the default gtag consent mode configuration.
*
* @param string $consent_mode_snippet Default configuration with all the parameters `denied` for the EEA region.
*/
return apply_filters( 'woocommerce_gla_gtag_consent', $consent_mode_snippet );
}
/**
* Add inline JavaScript to the page either as a standalone script or
* attach it to Google Analytics for WooCommerce if it's installed
*
* @param string $inline_script The JavaScript code to display
*
* @return void
*/
public function add_inline_event_script( string $inline_script ) {
if ( class_exists( '\WC_Google_Gtag_JS' ) ) {
$this->wp->wp_add_inline_script(
'woocommerce-google-analytics-integration',
$inline_script
);
} else {
$this->wp->wp_print_inline_script_tag( $inline_script );
}
}
/**
* Display the JavaScript code to track conversions on the order confirmation page.
*
* @param string $ads_conversion_id Google Ads account conversion ID.
* @param string $ads_conversion_label Google Ads conversion label.
* @param int $order_id The order id.
*/
public function maybe_display_conversion_and_purchase_event_snippets( string $ads_conversion_id, string $ads_conversion_label, int $order_id ): void {
// Only display on the order confirmation page.
if ( ! is_order_received_page() ) {
return;
}
$order = wc_get_order( $order_id );
// Make sure there is a valid order object and it is not already marked as tracked
if ( ! $order || 1 === (int) $order->get_meta( self::ORDER_CONVERSION_META_KEY, true ) ) {
return;
}
// Mark the order as tracked, to avoid double-reporting if the confirmation page is reloaded.
$order->update_meta_data( self::ORDER_CONVERSION_META_KEY, 1 );
$order->save_meta_data();
$conversion_gtag_info =
sprintf(
'gtag("event", "conversion", {
send_to: "%s",
value: %f,
currency: "%s",
transaction_id: "%s"});',
esc_js( "{$ads_conversion_id}/{$ads_conversion_label}" ),
$order->get_total(),
esc_js( $order->get_currency() ),
esc_js( $order->get_id() ),
);
$this->add_inline_event_script( $conversion_gtag_info );
// Get the item info in the order
$item_info = [];
foreach ( $order->get_items() as $item_id => $item ) {
$product_id = $item->get_product_id();
$product_name = $item->get_name();
$quantity = $item->get_quantity();
$price = $order->get_item_total( $item );
$item_info [] = sprintf(
'{
id: "gla_%s",
price: %f,
google_business_vertical: "retail",
name: "%s",
quantity: %d,
}',
esc_js( $product_id ),
$price,
esc_js( $product_name ),
$quantity,
);
}
// Check if this is the first time customer
$is_new_customer = $this->is_first_time_customer( $order->get_billing_email() );
// Track the purchase page
$language = $this->wp->get_locale();
if ( 'en_US' === $language ) {
$language = 'English';
}
$purchase_page_gtag =
sprintf(
'gtag("event", "purchase", {
ecomm_pagetype: "purchase",
send_to: "%s",
transaction_id: "%s",
currency: "%s",
country: "%s",
value: %f,
new_customer: %s,
tax: %f,
shipping: %f,
delivery_postal_code: "%s",
aw_feed_country: "%s",
aw_feed_language: "%s",
items: [%s]});',
esc_js( "{$ads_conversion_id}/{$ads_conversion_label}" ),
esc_js( $order->get_id() ),
esc_js( $order->get_currency() ),
esc_js( $this->wc->get_base_country() ),
$order->get_total(),
$is_new_customer ? 'true' : 'false',
esc_js( $order->get_cart_tax() ),
$order->get_total_shipping(),
esc_js( $order->get_billing_postcode() ),
esc_js( $this->wc->get_base_country() ),
esc_js( $language ),
join( ',', $item_info ),
);
$this->add_inline_event_script( $purchase_page_gtag );
}
/**
* Display the JavaScript code to track the product view page.
*/
private function display_view_item_event_snippet(): void {
$product = wc_get_product( get_the_ID() );
if ( ! $product instanceof WC_Product ) {
return;
}
$this->add_product_data( $product );
$view_item_gtag = sprintf(
'gtag("event", "view_item", {
send_to: "GLA",
ecomm_pagetype: "product",
value: %f,
items:[{
id: "gla_%s",
price: %f,
google_business_vertical: "retail",
name: "%s",
category: "%s",
}]});',
wc_get_price_to_display( $product ),
esc_js( $product->get_id() ),
wc_get_price_to_display( $product ),
esc_js( $product->get_name() ),
esc_js( join( ' & ', $this->product_helper->get_categories( $product ) ) ),
);
$this->add_inline_event_script( $view_item_gtag );
}
/**
* Display the JavaScript code to track all pages.
*/
private function display_page_view_event_snippet(): void {
if ( ! is_cart() ) {
$this->add_inline_event_script(
'gtag("event", "page_view", {send_to: "GLA"});'
);
return;
}
// display the JavaScript code to track the cart page
$item_info = [];
foreach ( WC()->cart->get_cart() as $cart_item ) {
// gets the product id
$id = $cart_item['product_id'];
// gets the product object
$product = $cart_item['data'];
$name = $product->get_name();
$price = WC()->cart->display_prices_including_tax() ? wc_get_price_including_tax( $product ) : wc_get_price_excluding_tax( $product );
// gets the cart item quantity
$quantity = $cart_item['quantity'];
$item_info[] = sprintf(
'{
id: "gla_%s",
price: %f,
google_business_vertical: "retail",
name:"%s",
quantity: %d,
}',
esc_js( $id ),
$price,
esc_js( $name ),
$quantity,
);
}
$value = WC()->cart->total;
$page_view_gtag = sprintf(
'gtag("event", "page_view", {
send_to: "GLA",
ecomm_pagetype: "cart",
value: %f,
items: [%s]});',
$value,
join( ',', $item_info ),
);
$this->add_inline_event_script( $page_view_gtag );
}
/**
* Add product data to include in JS data.
*
* @since 2.0.3
*
* @param WC_Product $product
*/
protected function add_product_data( $product ) {
$this->products[ $product->get_id() ] = [
'name' => $product->get_name(),
'price' => wc_get_price_to_display( $product ),
];
}
/**
* TODO: Should the Global Site Tag framework be used if there are no paid Ads campaigns?
*
* @return bool True if the Global Site Tag framework should be included.
*/
public static function is_needed(): bool {
if ( apply_filters( 'woocommerce_gla_disable_gtag_tracking', false ) ) {
return false;
}
return true;
}
/**
* Check if the customer has previous orders.
* Called after order creation (check for older orders including the order which was just created).
*
* @param string $customer_email Customer email address.
* @return bool True if this customer has previous orders.
*/
private static function is_first_time_customer( $customer_email ): bool {
$query = new \WC_Order_Query(
[
'limit' => 2,
'return' => 'ids',
]
);
$query->set( 'customer', $customer_email );
$orders = $query->get_orders();
return count( $orders ) === 1 ? true : false;
}
/**
* This method ONLY works during development in the Fast Refresh mode.
*
* The runtime.js and react-refresh-runtime.js files are created when the front-end development is
* running `npm run start:hot`, and they need to be loaded to make the gtag-events scrips work.
*/
private function register_js_for_fast_refresh_dev() {
// This file exists only when running `npm run start:hot`
$runtime_path = "{$this->get_root_dir()}/js/build/runtime.js";
if ( ! file_exists( $runtime_path ) ) {
return;
}
$plugin_url = $this->get_plugin_url();
wp_enqueue_script(
'gla-webpack-runtime',
"{$plugin_url}/js/build/runtime.js",
[],
(string) filemtime( $runtime_path ),
false
);
// This script is one of the gtag-events dependencies, and its handle is wp-react-refresh-runtime.
// Ref: js/build/gtag-events.asset.php
wp_register_script(
'wp-react-refresh-runtime',
"{$plugin_url}/js/build-dev/react-refresh-runtime.js",
[ 'gla-webpack-runtime' ],
$this->get_version(),
false
);
}
}
Google/GoogleHelper.php 0000644 00000066245 15153721357 0011073 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
/**
* Class GoogleHelper
*
* @package Automattic\WooCommerce\GoogleListingsAndAds
*
* @since 1.12.0
*/
class GoogleHelper implements Service {
protected const SUPPORTED_COUNTRIES = [
// Algeria
'DZ' => [
'code' => 'DZ',
'currency' => 'DZD',
'id' => 2012,
],
// Angola
'AO' => [
'code' => 'AO',
'currency' => 'AOA',
'id' => 2024,
],
// Argentina
'AR' => [
'code' => 'AR',
'currency' => 'ARS',
'id' => 2032,
],
// Australia
'AU' => [
'code' => 'AU',
'currency' => 'AUD',
'id' => 2036,
],
// Austria
'AT' => [
'code' => 'AT',
'currency' => 'EUR',
'id' => 2040,
],
// Bahrain
'BH' => [
'code' => 'BH',
'currency' => 'BHD',
'id' => 2048,
],
// Bangladesh
'BD' => [
'code' => 'BD',
'currency' => 'BDT',
'id' => 2050,
],
// Belarus
'BY' => [
'code' => 'BY',
'currency' => 'BYN',
'id' => 2112,
],
// Belgium
'BE' => [
'code' => 'BE',
'currency' => 'EUR',
'id' => 2056,
],
// Brazil
'BR' => [
'code' => 'BR',
'currency' => 'BRL',
'id' => 2076,
],
// Cambodia
'KH' => [
'code' => 'KH',
'currency' => 'KHR',
'id' => 2116,
],
// Cameroon
'CM' => [
'code' => 'CM',
'currency' => 'XAF',
'id' => 2120,
],
// Canada
'CA' => [
'code' => 'CA',
'currency' => 'CAD',
'id' => 2124,
],
// Chile
'CL' => [
'code' => 'CL',
'currency' => 'CLP',
'id' => 2152,
],
// Colombia
'CO' => [
'code' => 'CO',
'currency' => 'COP',
'id' => 2170,
],
// Costa Rica
'CR' => [
'code' => 'CR',
'currency' => 'CRC',
'id' => 2188,
],
// Cote d'Ivoire
'CI' => [
'code' => 'CI',
'currency' => 'XOF',
'id' => 2384,
],
// Czechia
'CZ' => [
'code' => 'CZ',
'currency' => 'CZK',
'id' => 2203,
],
// Denmark
'DK' => [
'code' => 'DK',
'currency' => 'DKK',
'id' => 2208,
],
// Dominican Republic
'DO' => [
'code' => 'DO',
'currency' => 'DOP',
'id' => 2214,
],
// Ecuador
'EC' => [
'code' => 'EC',
'currency' => 'USD',
'id' => 2218,
],
// Egypt
'EG' => [
'code' => 'EG',
'currency' => 'EGP',
'id' => 2818,
],
// El Salvador
'SV' => [
'code' => 'SV',
'currency' => 'USD',
'id' => 2222,
],
// Ethiopia
'ET' => [
'code' => 'ET',
'currency' => 'ETB',
'id' => 2231,
],
// Finland
'FI' => [
'code' => 'FI',
'currency' => 'EUR',
'id' => 2246,
],
// France
'FR' => [
'code' => 'FR',
'currency' => 'EUR',
'id' => 2250,
],
// Georgia
'GE' => [
'code' => 'GE',
'currency' => 'GEL',
'id' => 2268,
],
// Germany
'DE' => [
'code' => 'DE',
'currency' => 'EUR',
'id' => 2276,
],
// Ghana
'GH' => [
'code' => 'GH',
'currency' => 'GHS',
'id' => 2288,
],
// Greece
'GR' => [
'code' => 'GR',
'currency' => 'EUR',
'id' => 2300,
],
// Guatemala
'GT' => [
'code' => 'GT',
'currency' => 'GTQ',
'id' => 2320,
],
// Hong Kong
'HK' => [
'code' => 'HK',
'currency' => 'HKD',
'id' => 2344,
],
// Hungary
'HU' => [
'code' => 'HU',
'currency' => 'HUF',
'id' => 2348,
],
// India
'IN' => [
'code' => 'IN',
'currency' => 'INR',
'id' => 2356,
],
// Indonesia
'ID' => [
'code' => 'ID',
'currency' => 'IDR',
'id' => 2360,
],
// Ireland
'IE' => [
'code' => 'IE',
'currency' => 'EUR',
'id' => 2372,
],
// Israel
'IL' => [
'code' => 'IL',
'currency' => 'ILS',
'id' => 2376,
],
// Italy
'IT' => [
'code' => 'IT',
'currency' => 'EUR',
'id' => 2380,
],
// Japan
'JP' => [
'code' => 'JP',
'currency' => 'JPY',
'id' => 2392,
],
// Jordan
'JO' => [
'code' => 'JO',
'currency' => 'JOD',
'id' => 2400,
],
// Kazakhstan
'KZ' => [
'code' => 'KZ',
'currency' => 'KZT',
'id' => 2398,
],
// Kenya
'KE' => [
'code' => 'KE',
'currency' => 'KES',
'id' => 2404,
],
// Kuwait
'KW' => [
'code' => 'KW',
'currency' => 'KWD',
'id' => 2414,
],
// Lebanon
'LB' => [
'code' => 'LB',
'currency' => 'LBP',
'id' => 2422,
],
// Madagascar
'MG' => [
'code' => 'MG',
'currency' => 'MGA',
'id' => 2450,
],
// Malaysia
'MY' => [
'code' => 'MY',
'currency' => 'MYR',
'id' => 2458,
],
// Mauritius
'MU' => [
'code' => 'MU',
'currency' => 'MUR',
'id' => 2480,
],
// Mexico
'MX' => [
'code' => 'MX',
'currency' => 'MXN',
'id' => 2484,
],
// Morocco
'MA' => [
'code' => 'MA',
'currency' => 'MAD',
'id' => 2504,
],
// Mozambique
'MZ' => [
'code' => 'MZ',
'currency' => 'MZN',
'id' => 2508,
],
// Myanmar 'Burma'
'MM' => [
'code' => 'MM',
'currency' => 'MMK',
'id' => 2104,
],
// Nepal
'NP' => [
'code' => 'NP',
'currency' => 'NPR',
'id' => 2524,
],
// Netherlands
'NL' => [
'code' => 'NL',
'currency' => 'EUR',
'id' => 2528,
],
// New Zealand
'NZ' => [
'code' => 'NZ',
'currency' => 'NZD',
'id' => 2554,
],
// Nicaragua
'NI' => [
'code' => 'NI',
'currency' => 'NIO',
'id' => 2558,
],
// Nigeria
'NG' => [
'code' => 'NG',
'currency' => 'NGN',
'id' => 2566,
],
// Norway
'NO' => [
'code' => 'NO',
'currency' => 'NOK',
'id' => 2578,
],
// Oman
'OM' => [
'code' => 'OM',
'currency' => 'OMR',
'id' => 2512,
],
// Pakistan
'PK' => [
'code' => 'PK',
'currency' => 'PKR',
'id' => 2586,
],
// Panama
'PA' => [
'code' => 'PA',
'currency' => 'PAB',
'id' => 2591,
],
// Paraguay
'PY' => [
'code' => 'PY',
'currency' => 'PYG',
'id' => 2600,
],
// Peru
'PE' => [
'code' => 'PE',
'currency' => 'PEN',
'id' => 2604,
],
// Philippines
'PH' => [
'code' => 'PH',
'currency' => 'PHP',
'id' => 2608,
],
// Poland
'PL' => [
'code' => 'PL',
'currency' => 'PLN',
'id' => 2616,
],
// Portugal
'PT' => [
'code' => 'PT',
'currency' => 'EUR',
'id' => 2620,
],
// Puerto Rico
'PR' => [
'code' => 'PR',
'currency' => 'USD',
'id' => 2630,
],
// Romania
'RO' => [
'code' => 'RO',
'currency' => 'RON',
'id' => 2642,
],
// Russia
'RU' => [
'code' => 'RU',
'currency' => 'RUB',
'id' => 2643,
],
// Saudi Arabia
'SA' => [
'code' => 'SA',
'currency' => 'SAR',
'id' => 2682,
],
// Senegal
'SN' => [
'code' => 'SN',
'currency' => 'XOF',
'id' => 2686,
],
// Singapore
'SG' => [
'code' => 'SG',
'currency' => 'SGD',
'id' => 2702,
],
// Slovakia
'SK' => [
'code' => 'SK',
'currency' => 'EUR',
'id' => 2703,
],
// South Africa
'ZA' => [
'code' => 'ZA',
'currency' => 'ZAR',
'id' => 2710,
],
// Spain
'ES' => [
'code' => 'ES',
'currency' => 'EUR',
'id' => 2724,
],
// Sri Lanka
'LK' => [
'code' => 'LK',
'currency' => 'LKR',
'id' => 2144,
],
// Sweden
'SE' => [
'code' => 'SE',
'currency' => 'SEK',
'id' => 2752,
],
// Switzerland
'CH' => [
'code' => 'CH',
'currency' => 'CHF',
'id' => 2756,
],
// Taiwan
'TW' => [
'code' => 'TW',
'currency' => 'TWD',
'id' => 2158,
],
// Tanzania
'TZ' => [
'code' => 'TZ',
'currency' => 'TZS',
'id' => 2834,
],
// Thailand
'TH' => [
'code' => 'TH',
'currency' => 'THB',
'id' => 2764,
],
// Tunisia
'TN' => [
'code' => 'TN',
'currency' => 'TND',
'id' => 2788,
],
// Turkey
'TR' => [
'code' => 'TR',
'currency' => 'TRY',
'id' => 2792,
],
// United Arab Emirates
'AE' => [
'code' => 'AE',
'currency' => 'AED',
'id' => 2784,
],
// Uganda
'UG' => [
'code' => 'UG',
'currency' => 'UGX',
'id' => 2800,
],
// Ukraine
'UA' => [
'code' => 'UA',
'currency' => 'UAH',
'id' => 2804,
],
// United Kingdom
'GB' => [
'code' => 'GB',
'currency' => 'GBP',
'id' => 2826,
],
// United States
'US' => [
'code' => 'US',
'currency' => 'USD',
'id' => 2840,
],
// Uruguay
'UY' => [
'code' => 'UY',
'currency' => 'UYU',
'id' => 2858,
],
// Uzbekistan
'UZ' => [
'code' => 'UZ',
'currency' => 'UZS',
'id' => 2860,
],
// Venezuela
'VE' => [
'code' => 'VE',
'currency' => 'VEF',
'id' => 2862,
],
// Vietnam
'VN' => [
'code' => 'VN',
'currency' => 'VND',
'id' => 2704,
],
// Zambia
'ZM' => [
'code' => 'ZM',
'currency' => 'ZMW',
'id' => 2894,
],
// Zimbabwe
'ZW' => [
'code' => 'ZW',
'currency' => 'USD',
'id' => 2716,
],
];
protected const COUNTRY_SUBDIVISIONS = [
// Australia
'AU' => [
'ACT' => [
'id' => 20034,
'code' => 'ACT',
'name' => 'Australian Capital Territory',
],
'NSW' => [
'id' => 20035,
'code' => 'NSW',
'name' => 'New South Wales',
],
'NT' => [
'id' => 20036,
'code' => 'NT',
'name' => 'Northern Territory',
],
'QLD' => [
'id' => 20037,
'code' => 'QLD',
'name' => 'Queensland',
],
'SA' => [
'id' => 20038,
'code' => 'SA',
'name' => 'South Australia',
],
'TAS' => [
'id' => 20039,
'code' => 'TAS',
'name' => 'Tasmania',
],
'VIC' => [
'id' => 20040,
'code' => 'VIC',
'name' => 'Victoria',
],
'WA' => [
'id' => 20041,
'code' => 'WA',
'name' => 'Western Australia',
],
],
// Japan
'JP' => [
'JP01' => [
'id' => 20624,
'code' => 'JP01',
'name' => 'Hokkaido',
],
'JP02' => [
'id' => 20625,
'code' => 'JP02',
'name' => 'Aomori',
],
'JP03' => [
'id' => 20626,
'code' => 'JP03',
'name' => 'Iwate',
],
'JP04' => [
'id' => 20627,
'code' => 'JP04',
'name' => 'Miyagi',
],
'JP05' => [
'id' => 20628,
'code' => 'JP05',
'name' => 'Akita',
],
'JP06' => [
'id' => 20629,
'code' => 'JP06',
'name' => 'Yamagata',
],
'JP07' => [
'id' => 20630,
'code' => 'JP07',
'name' => 'Fukushima',
],
'JP08' => [
'id' => 20631,
'code' => 'JP08',
'name' => 'Ibaraki',
],
'JP09' => [
'id' => 20632,
'code' => 'JP09',
'name' => 'Tochigi',
],
'JP10' => [
'id' => 20633,
'code' => 'JP10',
'name' => 'Gunma',
],
'JP11' => [
'id' => 20634,
'code' => 'JP11',
'name' => 'Saitama',
],
'JP12' => [
'id' => 20635,
'code' => 'JP12',
'name' => 'Chiba',
],
'JP13' => [
'id' => 20636,
'code' => 'JP13',
'name' => 'Tokyo',
],
'JP14' => [
'id' => 20637,
'code' => 'JP14',
'name' => 'Kanagawa',
],
'JP15' => [
'id' => 20638,
'code' => 'JP15',
'name' => 'Niigata',
],
'JP16' => [
'id' => 20639,
'code' => 'JP16',
'name' => 'Toyama',
],
'JP17' => [
'id' => 20640,
'code' => 'JP17',
'name' => 'Ishikawa',
],
'JP18' => [
'id' => 20641,
'code' => 'JP18',
'name' => 'Fukui',
],
'JP19' => [
'id' => 20642,
'code' => 'JP19',
'name' => 'Yamanashi',
],
'JP20' => [
'id' => 20643,
'code' => 'JP20',
'name' => 'Nagano',
],
'JP21' => [
'id' => 20644,
'code' => 'JP21',
'name' => 'Gifu',
],
'JP22' => [
'id' => 20645,
'code' => 'JP22',
'name' => 'Shizuoka',
],
'JP23' => [
'id' => 20646,
'code' => 'JP23',
'name' => 'Aichi',
],
'JP24' => [
'id' => 20647,
'code' => 'JP24',
'name' => 'Mie',
],
'JP25' => [
'id' => 20648,
'code' => 'JP25',
'name' => 'Shiga',
],
'JP26' => [
'id' => 20649,
'code' => 'JP26',
'name' => 'Kyoto',
],
'JP27' => [
'id' => 20650,
'code' => 'JP27',
'name' => 'Osaka',
],
'JP28' => [
'id' => 20651,
'code' => 'JP28',
'name' => 'Hyogo',
],
'JP29' => [
'id' => 20652,
'code' => 'JP29',
'name' => 'Nara',
],
'JP30' => [
'id' => 20653,
'code' => 'JP30',
'name' => 'Wakayama',
],
'JP31' => [
'id' => 20654,
'code' => 'JP31',
'name' => 'Tottori',
],
'JP32' => [
'id' => 20655,
'code' => 'JP32',
'name' => 'Shimane',
],
'JP33' => [
'id' => 20656,
'code' => 'JP33',
'name' => 'Okayama',
],
'JP34' => [
'id' => 20657,
'code' => 'JP34',
'name' => 'Hiroshima',
],
'JP35' => [
'id' => 20658,
'code' => 'JP35',
'name' => 'Yamaguchi',
],
'JP36' => [
'id' => 20659,
'code' => 'JP36',
'name' => 'Tokushima',
],
'JP37' => [
'id' => 20660,
'code' => 'JP37',
'name' => 'Kagawa',
],
'JP38' => [
'id' => 20661,
'code' => 'JP38',
'name' => 'Ehime',
],
'JP39' => [
'id' => 20662,
'code' => 'JP39',
'name' => 'Kochi',
],
'JP40' => [
'id' => 20663,
'code' => 'JP40',
'name' => 'Fukuoka',
],
'JP41' => [
'id' => 20664,
'code' => 'JP41',
'name' => 'Saga',
],
'JP42' => [
'id' => 20665,
'code' => 'JP42',
'name' => 'Nagasaki',
],
'JP43' => [
'id' => 20666,
'code' => 'JP43',
'name' => 'Kumamoto',
],
'JP44' => [
'id' => 20667,
'code' => 'JP44',
'name' => 'Oita',
],
'JP45' => [
'id' => 20668,
'code' => 'JP45',
'name' => 'Miyazaki',
],
'JP46' => [
'id' => 20669,
'code' => 'JP46',
'name' => 'Kagoshima',
],
'JP47' => [
'id' => 20670,
'code' => 'JP47',
'name' => 'Okinawa',
],
],
// United States
'US' => [
'AK' => [
'id' => 21132,
'code' => 'AK',
'name' => 'Alaska',
],
'AL' => [
'id' => 21133,
'code' => 'AL',
'name' => 'Alabama',
],
'AR' => [
'id' => 21135,
'code' => 'AR',
'name' => 'Arkansas',
],
'AZ' => [
'id' => 21136,
'code' => 'AZ',
'name' => 'Arizona',
],
'CA' => [
'id' => 21137,
'code' => 'CA',
'name' => 'California',
],
'CO' => [
'id' => 21138,
'code' => 'CO',
'name' => 'Colorado',
],
'CT' => [
'id' => 21139,
'code' => 'CT',
'name' => 'Connecticut',
],
'DC' => [
'id' => 21140,
'code' => 'DC',
'name' => 'District of Columbia',
],
'DE' => [
'id' => 21141,
'code' => 'DE',
'name' => 'Delaware',
],
'FL' => [
'id' => 21142,
'code' => 'FL',
'name' => 'Florida',
],
'GA' => [
'id' => 21143,
'code' => 'GA',
'name' => 'Georgia',
],
'HI' => [
'id' => 21144,
'code' => 'HI',
'name' => 'Hawaii',
],
'IA' => [
'id' => 21145,
'code' => 'IA',
'name' => 'Iowa',
],
'ID' => [
'id' => 21146,
'code' => 'ID',
'name' => 'Idaho',
],
'IL' => [
'id' => 21147,
'code' => 'IL',
'name' => 'Illinois',
],
'IN' => [
'id' => 21148,
'code' => 'IN',
'name' => 'Indiana',
],
'KS' => [
'id' => 21149,
'code' => 'KS',
'name' => 'Kansas',
],
'KY' => [
'id' => 21150,
'code' => 'KY',
'name' => 'Kentucky',
],
'LA' => [
'id' => 21151,
'code' => 'LA',
'name' => 'Louisiana',
],
'MA' => [
'id' => 21152,
'code' => 'MA',
'name' => 'Massachusetts',
],
'MD' => [
'id' => 21153,
'code' => 'MD',
'name' => 'Maryland',
],
'ME' => [
'id' => 21154,
'code' => 'ME',
'name' => 'Maine',
],
'MI' => [
'id' => 21155,
'code' => 'MI',
'name' => 'Michigan',
],
'MN' => [
'id' => 21156,
'code' => 'MN',
'name' => 'Minnesota',
],
'MO' => [
'id' => 21157,
'code' => 'MO',
'name' => 'Missouri',
],
'MS' => [
'id' => 21158,
'code' => 'MS',
'name' => 'Mississippi',
],
'MT' => [
'id' => 21159,
'code' => 'MT',
'name' => 'Montana',
],
'NC' => [
'id' => 21160,
'code' => 'NC',
'name' => 'North Carolina',
],
'ND' => [
'id' => 21161,
'code' => 'ND',
'name' => 'North Dakota',
],
'NE' => [
'id' => 21162,
'code' => 'NE',
'name' => 'Nebraska',
],
'NH' => [
'id' => 21163,
'code' => 'NH',
'name' => 'New Hampshire',
],
'NJ' => [
'id' => 21164,
'code' => 'NJ',
'name' => 'New Jersey',
],
'NM' => [
'id' => 21165,
'code' => 'NM',
'name' => 'New Mexico',
],
'NV' => [
'id' => 21166,
'code' => 'NV',
'name' => 'Nevada',
],
'NY' => [
'id' => 21167,
'code' => 'NY',
'name' => 'New York',
],
'OH' => [
'id' => 21168,
'code' => 'OH',
'name' => 'Ohio',
],
'OK' => [
'id' => 21169,
'code' => 'OK',
'name' => 'Oklahoma',
],
'OR' => [
'id' => 21170,
'code' => 'OR',
'name' => 'Oregon',
],
'PA' => [
'id' => 21171,
'code' => 'PA',
'name' => 'Pennsylvania',
],
'RI' => [
'id' => 21172,
'code' => 'RI',
'name' => 'Rhode Island',
],
'SC' => [
'id' => 21173,
'code' => 'SC',
'name' => 'South Carolina',
],
'SD' => [
'id' => 21174,
'code' => 'SD',
'name' => 'South Dakota',
],
'TN' => [
'id' => 21175,
'code' => 'TN',
'name' => 'Tennessee',
],
'TX' => [
'id' => 21176,
'code' => 'TX',
'name' => 'Texas',
],
'UT' => [
'id' => 21177,
'code' => 'UT',
'name' => 'Utah',
],
'VA' => [
'id' => 21178,
'code' => 'VA',
'name' => 'Virginia',
],
'VT' => [
'id' => 21179,
'code' => 'VT',
'name' => 'Vermont',
],
'WA' => [
'id' => 21180,
'code' => 'WA',
'name' => 'Washington',
],
'WI' => [
'id' => 21182,
'code' => 'WI',
'name' => 'Wisconsin',
],
'WV' => [
'id' => 21183,
'code' => 'WV',
'name' => 'West Virginia',
],
'WY' => [
'id' => 21184,
'code' => 'WY',
'name' => 'Wyoming',
],
],
];
/**
* @var WC
*/
protected $wc;
/**
* @var array Map of location ids to country codes.
*/
private $country_id_code_map;
/**
* @var array Map of location ids to subdivision codes.
*/
private $subdivision_id_code_map;
/**
* GoogleHelper constructor.
*
* @param WC $wc
*/
public function __construct( WC $wc ) {
$this->wc = $wc;
}
/**
* Get the data for countries supported by Google.
*
* @return array[]
*/
protected function get_mc_supported_countries_data(): array {
$supported = self::SUPPORTED_COUNTRIES;
// Currency conversion is unavailable in South Korea: https://support.google.com/merchants/answer/7055540
if ( 'KRW' === $this->wc->get_woocommerce_currency() ) {
// South Korea
$supported['KR'] = [
'code' => 'KR',
'currency' => 'KRW',
'id' => 2410,
];
}
return $supported;
}
/**
* Get an array of Google Merchant Center supported countries and currencies.
*
* Note - Other currencies may be supported using currency conversion.
*
* WooCommerce Countries -> https://github.com/woocommerce/woocommerce/blob/master/i18n/countries.php
* Google Supported Countries -> https://support.google.com/merchants/answer/160637?hl=en
*
* @return array
*/
public function get_mc_supported_countries_currencies(): array {
return array_column(
$this->get_mc_supported_countries_data(),
'currency',
'code'
);
}
/**
* Get an array of Google Merchant Center supported countries.
*
* WooCommerce Countries -> https://github.com/woocommerce/woocommerce/blob/master/i18n/countries.php
* Google Supported Countries -> https://support.google.com/merchants/answer/160637?hl=en
*
* @return string[] Array of country codes.
*/
public function get_mc_supported_countries(): array {
return array_keys( $this->get_mc_supported_countries_data() );
}
/**
* Get an array of Google Merchant Center supported countries and currencies for promotions.
*
* Google Promotion Supported Countries -> https://developers.google.com/shopping-content/reference/rest/v2.1/promotions
*
* @return array
*/
protected function get_mc_promotion_supported_countries_currencies(): array {
return [
'AU' => 'AUD', // Australia
'BR' => 'BRL', // Brazil
'CA' => 'CAD', // Canada
'DE' => 'EUR', // Germany
'ES' => 'EUR', // Spain
'FR' => 'EUR', // France
'GB' => 'GBP', // United Kingdom
'IN' => 'INR', // India
'IT' => 'EUR', // Italy
'JP' => 'JPY', // Japan
'NL' => 'EUR', // The Netherlands
'KR' => 'KRW', // South Korea
'US' => 'USD', // United States
];
}
/**
* Get an array of Google Merchant Center supported countries for promotions.
*
* @return string[]
*/
public function get_mc_promotion_supported_countries(): array {
return array_keys( $this->get_mc_promotion_supported_countries_currencies() );
}
/**
* Get an array of Google Merchant Center supported languages (ISO 639-1).
*
* WooCommerce Languages -> https://translate.wordpress.org/projects/wp-plugins/woocommerce/
* Google Supported Languages -> https://support.google.com/merchants/answer/160637?hl=en
* ISO 639-1 -> https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
*
* @return array
*/
public function get_mc_supported_languages(): array {
// Repeated values removed:
// 'pt', // Brazilian Portuguese
// 'zh', // Simplified Chinese*
return [
'ar' => 'ar', // Arabic
'cs' => 'cs', // Czech
'da' => 'da', // Danish
'nl' => 'nl', // Dutch
'en' => 'en', // English
'fi' => 'fi', // Finnish
'fr' => 'fr', // French
'de' => 'de', // German
'he' => 'he', // Hebrew
'hu' => 'hu', // Hungarian
'id' => 'id', // Indonesian
'it' => 'it', // Italian
'ja' => 'ja', // Japanese
'ko' => 'ko', // Korean
'el' => 'el', // Modern Greek
'nb' => 'nb', // Norwegian (Norsk Bokmål)
'nn' => 'nn', // Norwegian (Norsk Nynorsk)
'no' => 'no', // Norwegian
'pl' => 'pl', // Polish
'pt' => 'pt', // Portuguese
'ro' => 'ro', // Romanian
'ru' => 'ru', // Russian
'sk' => 'sk', // Slovak
'es' => 'es', // Spanish
'sv' => 'sv', // Swedish
'th' => 'th', // Thai
'zh' => 'zh', // Traditional Chinese
'tr' => 'tr', // Turkish
'uk' => 'uk', // Ukrainian
'vi' => 'vi', // Vietnamese
];
}
/**
* Get whether the country is supported by the Merchant Center.
*
* @param string $country Country code.
*
* @return bool True if the country is in the list of MC-supported countries.
*/
public function is_country_supported( string $country ): bool {
return array_key_exists(
strtoupper( $country ),
$this->get_mc_supported_countries_data()
);
}
/**
* Find the ISO 3166-1 code of the Merchant Center supported country by its location ID.
*
* @param int $id
*
* @return string|null ISO 3166-1 representation of the country code.
*/
public function find_country_code_by_id( int $id ): ?string {
return $this->get_country_id_code_map()[ $id ] ?? null;
}
/**
* Find the code of the Merchant Center supported subdivision by its location ID.
*
* @param int $id
*
* @return string|null
*/
public function find_subdivision_code_by_id( int $id ): ?string {
return $this->get_subdivision_id_code_map()[ $id ] ?? null;
}
/**
* Find and return the location id for the given country code.
*
* @param string $code
*
* @return int|null
*/
public function find_country_id_by_code( string $code ): ?int {
$countries = $this->get_mc_supported_countries_data();
if ( isset( $countries[ $code ] ) ) {
return $countries[ $code ]['id'];
}
return null;
}
/**
* Find and return the location id for the given subdivision (state, province, etc.) code.
*
* @param string $code
* @param string $country_code
*
* @return int|null
*/
public function find_subdivision_id_by_code( string $code, string $country_code ): ?int {
return self::COUNTRY_SUBDIVISIONS[ $country_code ][ $code ]['id'] ?? null;
}
/**
* Gets the list of supported Merchant Center countries from a continent.
*
* @param string $continent_code
*
* @return string[] Returns an array of country codes with each country code used both as the key and value.
* For example: [ 'US' => 'US', 'DE' => 'DE' ].
*
* @since 1.13.0
*/
public function get_supported_countries_from_continent( string $continent_code ): array {
$countries = [];
$continents = $this->wc->get_continents();
if ( isset( $continents[ $continent_code ] ) ) {
$countries = $continents[ $continent_code ]['countries'];
// Match the list of countries with the list of Merchant Center supported countries.
$countries = array_intersect( $countries, $this->get_mc_supported_countries() );
// Use the country code as array keys.
$countries = array_combine( $countries, $countries );
}
return $countries;
}
/**
* Check whether the given country code supports regional shipping (i.e. setting up rates for states/provinces and postal codes).
*
* @param string $country_code
*
* @return bool
*
* @since 2.1.0
*/
public function does_country_support_regional_shipping( string $country_code ): bool {
return in_array( $country_code, [ 'AU', 'JP', 'US' ], true );
}
/**
* Returns an array mapping the ID of the Merchant Center supported countries to their respective codes.
*
* @return string[] Array of country codes with location IDs as keys. e.g. [ 2840 => 'US' ]
*/
protected function get_country_id_code_map(): array {
if ( isset( $this->country_id_code_map ) ) {
return $this->country_id_code_map;
}
$this->country_id_code_map = [];
$countries = $this->get_mc_supported_countries_data();
foreach ( $countries as $country ) {
$this->country_id_code_map[ $country['id'] ] = $country['code'];
}
return $this->country_id_code_map;
}
/**
* Returns an array mapping the ID of the Merchant Center supported subdivisions to their respective codes.
*
* @return string[] Array of subdivision codes with location IDs as keys. e.g. [ 20035 => 'NSW' ]
*/
protected function get_subdivision_id_code_map(): array {
if ( isset( $this->subdivision_id_code_map ) ) {
return $this->subdivision_id_code_map;
}
$this->subdivision_id_code_map = [];
foreach ( self::COUNTRY_SUBDIVISIONS as $subdivisions ) {
foreach ( $subdivisions as $item ) {
$this->subdivision_id_code_map[ $item['id'] ] = $item['code'];
}
}
return $this->subdivision_id_code_map;
}
}
Google/GoogleHelperAwareInterface.php 0000644 00000000663 15153721357 0013664 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
defined( 'ABSPATH' ) || exit;
/**
* Interface GoogleHelperAwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google;
*/
interface GoogleHelperAwareInterface {
/**
* @param GoogleHelper $google_helper
*
* @return void
*/
public function set_google_helper_object( GoogleHelper $google_helper ): void;
}
Google/GoogleHelperAwareTrait.php 0000644 00000001056 15153721357 0013044 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
defined( 'ABSPATH' ) || exit;
/**
* Trait GoogleHelperAwareTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google;
*/
trait GoogleHelperAwareTrait {
/**
* The GoogleHelper object.
*
* @var google_helper
*/
protected $google_helper;
/**
* @param GoogleHelper $google_helper
*
* @return void
*/
public function set_google_helper_object( GoogleHelper $google_helper ): void {
$this->google_helper = $google_helper;
}
}
Google/GoogleProductService.php 0000644 00000020237 15153721357 0012604 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchRequest as GoogleBatchRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchRequestEntry as GoogleBatchRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchResponse as GoogleBatchResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchResponseEntry as GoogleBatchResponseEntry;
defined( 'ABSPATH' ) || exit;
/**
* Class GoogleProductService
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class GoogleProductService implements OptionsAwareInterface, Service {
use OptionsAwareTrait;
use ValidateInterface;
public const INTERNAL_ERROR_REASON = 'internalError';
public const NOT_FOUND_ERROR_REASON = 'notFound';
/**
* This is the maximum batch size recommended by Google
*
* @link https://developers.google.com/shopping-content/guides/how-tos/batch
*/
public const BATCH_SIZE = 1000;
protected const METHOD_DELETE = 'delete';
protected const METHOD_GET = 'get';
protected const METHOD_INSERT = 'insert';
/**
* @var ShoppingContent
*/
protected $shopping_service;
/**
* GoogleProductService constructor.
*
* @param ShoppingContent $shopping_service
*/
public function __construct( ShoppingContent $shopping_service ) {
$this->shopping_service = $shopping_service;
}
/**
* @param string $product_id Google product ID.
*
* @return GoogleProduct
*
* @throws GoogleException If there are any Google API errors.
*/
public function get( string $product_id ): GoogleProduct {
$merchant_id = $this->options->get_merchant_id();
return $this->shopping_service->products->get( $merchant_id, $product_id );
}
/**
* @param GoogleProduct $product
*
* @return GoogleProduct
*
* @throws GoogleException If there are any Google API errors.
*/
public function insert( GoogleProduct $product ): GoogleProduct {
$merchant_id = $this->options->get_merchant_id();
return $this->shopping_service->products->insert( $merchant_id, $product );
}
/**
* @param string $product_id Google product ID.
*
* @throws GoogleException If there are any Google API errors.
*/
public function delete( string $product_id ) {
$merchant_id = $this->options->get_merchant_id();
$this->shopping_service->products->delete( $merchant_id, $product_id );
}
/**
* @param BatchProductIDRequestEntry[] $products
*
* @return BatchProductResponse
*
* @throws InvalidValue If any of the provided products are invalid.
* @throws GoogleException If there are any Google API errors.
*/
public function get_batch( array $products ): BatchProductResponse {
return $this->custom_batch( $products, self::METHOD_GET );
}
/**
* @param BatchProductRequestEntry[] $products
*
* @return BatchProductResponse
*
* @throws InvalidValue If any of the provided products are invalid.
* @throws GoogleException If there are any Google API errors.
*/
public function insert_batch( array $products ): BatchProductResponse {
return $this->custom_batch( $products, self::METHOD_INSERT );
}
/**
* @param BatchProductIDRequestEntry[] $products
*
* @return BatchProductResponse
*
* @throws InvalidValue If any of the provided products are invalid.
* @throws GoogleException If there are any Google API errors.
*/
public function delete_batch( array $products ): BatchProductResponse {
return $this->custom_batch( $products, self::METHOD_DELETE );
}
/**
* @param BatchProductRequestEntry[]|BatchProductIDRequestEntry[] $products
* @param string $method
*
* @return BatchProductResponse
*
* @throws InvalidValue If any of the products' type is invalid for the batch method.
* @throws GoogleException If there are any Google API errors.
*/
protected function custom_batch( array $products, string $method ): BatchProductResponse {
if ( empty( $products ) ) {
return new BatchProductResponse( [], [] );
}
$merchant_id = $this->options->get_merchant_id();
$request_entries = [];
// An array of product entries mapped to each batch ID. Used to parse Google's batch response.
$batch_id_product_map = [];
$batch_id = 0;
foreach ( $products as $product_entry ) {
$this->validate_batch_request_entry( $product_entry, $method );
$request_entry = new GoogleBatchRequestEntry(
[
'batchId' => $batch_id,
'merchantId' => $merchant_id,
'method' => $method,
]
);
if ( $product_entry instanceof BatchProductRequestEntry ) {
$request_entry['product'] = $product_entry->get_product();
} else {
$request_entry['product_id'] = $product_entry->get_product_id();
}
$request_entries[] = $request_entry;
$batch_id_product_map[ $batch_id ] = $product_entry;
++$batch_id;
}
$responses = $this->shopping_service->products->custombatch( new GoogleBatchRequest( [ 'entries' => $request_entries ] ) );
return $this->parse_batch_responses( $responses, $batch_id_product_map );
}
/**
* @param GoogleBatchResponse $responses
* @param BatchProductRequestEntry[]|BatchProductIDRequestEntry[] $batch_id_product_map An array of product entries mapped to each batch ID. Used to parse Google's batch response.
*
* @return BatchProductResponse
*/
protected function parse_batch_responses( GoogleBatchResponse $responses, array $batch_id_product_map ): BatchProductResponse {
$result_products = [];
$errors = [];
/**
* @var GoogleBatchResponseEntry $response
*/
foreach ( $responses as $response ) {
// Product entry is mapped to batchId when sending the request
$product_entry = $batch_id_product_map[ $response->batchId ]; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$wc_product_id = $product_entry->get_wc_product_id();
if ( $product_entry instanceof BatchProductRequestEntry ) {
$google_product_id = $product_entry->get_product()->getId();
} else {
$google_product_id = $product_entry->get_product_id();
}
if ( empty( $response->getErrors() ) ) {
$result_products[] = new BatchProductEntry( $wc_product_id, $response->getProduct() );
} else {
$errors[] = new BatchInvalidProductEntry( $wc_product_id, $google_product_id, self::get_batch_response_error_messages( $response ) );
}
}
return new BatchProductResponse( $result_products, $errors );
}
/**
* @param BatchProductRequestEntry|BatchProductIDRequestEntry $request_entry
* @param string $method
*
* @throws InvalidValue If the product type is invalid for the batch method.
*/
protected function validate_batch_request_entry( $request_entry, string $method ) {
if ( self::METHOD_INSERT === $method ) {
$this->validate_instanceof( $request_entry, BatchProductRequestEntry::class );
} else {
$this->validate_instanceof( $request_entry, BatchProductIDRequestEntry::class );
}
}
/**
* @param GoogleBatchResponseEntry $batch_response_entry
*
* @return string[]
*/
protected static function get_batch_response_error_messages( GoogleBatchResponseEntry $batch_response_entry ): array {
$errors = [];
foreach ( $batch_response_entry->getErrors()->getErrors() as $error ) {
$errors[ $error->getReason() ] = $error->getMessage();
}
return $errors;
}
}
Google/GooglePromotionService.php 0000644 00000003114 15153721357 0013145 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
defined( 'ABSPATH' ) || exit();
/**
* Class GooglePromotionService
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class GooglePromotionService implements OptionsAwareInterface, Service {
use OptionsAwareTrait;
public const INTERNAL_ERROR_CODE = 500;
public const INTERNAL_ERROR_MSG = 'Internal error';
/**
*
* @var ShoppingContent
*/
protected $shopping_service;
/**
* GooglePromotionService constructor.
*
* @param ShoppingContent $shopping_service
*/
public function __construct( ShoppingContent $shopping_service ) {
$this->shopping_service = $shopping_service;
}
/**
*
* @param GooglePromotion $promotion
*
* @return GooglePromotion
*
* @throws GoogleException If there are any Google API errors.
*/
public function create( GooglePromotion $promotion ): GooglePromotion {
$merchant_id = $this->options->get_merchant_id();
return $this->shopping_service->promotions->create(
$merchant_id,
$promotion
);
}
}
Google/InvalidCouponEntry.php 0000644 00000005365 15153721357 0012307 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use JsonSerializable;
use Symfony\Component\Validator\ConstraintViolationListInterface;
defined( 'ABSPATH' ) || exit();
/**
* Class InvalidCouponEntry
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class InvalidCouponEntry implements JsonSerializable {
/**
*
* @var int WooCommerce coupon ID.
*/
protected $wc_coupon_id;
/**
*
* @var string target country of the promotion.
*/
protected $target_country;
/**
*
* @var string|null Google promotion ID.
*/
protected $google_promotion_id;
/**
*
* @var string[]
*/
protected $errors;
/**
* InvalidCouponEntry constructor.
*
* @param int $wc_coupon_id
* @param string[] $errors
* @param string|null $target_country
* @param string|null $google_promotion_id
*/
public function __construct(
int $wc_coupon_id,
array $errors = [],
?string $target_country = null,
?string $google_promotion_id = null
) {
$this->wc_coupon_id = $wc_coupon_id;
$this->target_country = $target_country;
$this->google_promotion_id = $google_promotion_id;
$this->errors = $errors;
}
/**
*
* @return int
*/
public function get_wc_coupon_id(): int {
return $this->wc_coupon_id;
}
/**
*
* @return string|null
*/
public function get_google_promotion_id(): ?string {
return $this->google_promotion_id;
}
/**
*
* @return string|null
*/
public function get_target_country(): ?string {
return $this->target_country;
}
/**
*
* @return string[]
*/
public function get_errors(): array {
return $this->errors;
}
/**
*
* @param int $error_code
*
* @return bool
*/
public function has_error( int $error_code ): bool {
return ! empty( $this->errors[ $error_code ] );
}
/**
*
* @param ConstraintViolationListInterface $violations
*
* @return InvalidCouponEntry
*/
public function map_validation_violations(
ConstraintViolationListInterface $violations
): InvalidCouponEntry {
$validation_errors = [];
foreach ( $violations as $violation ) {
array_push(
$validation_errors,
sprintf(
'[%s] %s',
$violation->getPropertyPath(),
$violation->getMessage()
)
);
}
$this->errors = $validation_errors;
return $this;
}
/**
*
* @return array
*/
public function jsonSerialize(): array {
$data = [
'woocommerce_id' => $this->get_wc_coupon_id(),
'errors' => $this->get_errors(),
];
if ( null !== $this->get_google_promotion_id() ) {
$data['google_id'] = $this->get_google_promotion_id();
}
if ( null !== $this->get_target_country() ) {
$data['google_target_country'] = $this->get_target_country();
}
return $data;
}
}
Google/RequestReviewStatuses.php 0000644 00000013731 15153721357 0013055 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Helper class for Account request Review feature
*/
class RequestReviewStatuses implements Service {
public const ENABLED = 'ENABLED';
public const DISAPPROVED = 'DISAPPROVED';
public const WARNING = 'WARNING';
public const UNDER_REVIEW = 'UNDER_REVIEW';
public const PENDING_REVIEW = 'PENDING_REVIEW';
public const ONBOARDING = 'ONBOARDING';
public const APPROVED = 'APPROVED';
public const NO_OFFERS = 'NO_OFFERS_UPLOADED';
public const ELIGIBLE = 'ELIGIBLE';
public const MC_ACCOUNT_REVIEW_LIFETIME = MINUTE_IN_SECONDS * 20; // 20 minutes
/**
* Merges the different program statuses, issues and cooldown period date.
*
* @param array $response Associative array containing the response data from Google API
* @return array The computed status, with the issues and cooldown period.
*/
public function get_statuses_from_response( array $response ) {
$issues = [];
$cooldown = 0;
$status = null;
$valid_program_states = [ self::ENABLED, self::NO_OFFERS ];
$review_eligible_regions = [];
foreach ( $response as $program_type_name => $program_type ) {
// In case any Program is with no offers we consider it Onboarding
if ( $program_type['globalState'] === self::NO_OFFERS ) {
$status = self::ONBOARDING;
break;
}
// In case any Program is not enabled or there are no regionStatuses we return null status
if ( ! isset( $program_type['regionStatuses'] ) || ! in_array( $program_type['globalState'], $valid_program_states, true ) ) {
continue;
}
// Otherwise, we compute the new status, issues and cooldown period
foreach ( $program_type['regionStatuses'] as $region_status ) {
$issues = array_merge( $issues, $region_status['reviewIssues'] ?? [] );
$cooldown = $this->maybe_update_cooldown_period( $region_status, $cooldown );
$status = $this->maybe_update_status( $region_status['eligibilityStatus'], $status );
$review_eligible_regions = $this->maybe_load_eligible_region( $region_status, $review_eligible_regions, $program_type_name );
}
}
return [
'issues' => array_map( 'strtolower', array_values( array_unique( $issues ) ) ),
'cooldown' => $this->get_cooldown( $cooldown ), // add lifetime cache to cooldown time
'status' => $status,
'reviewEligibleRegions' => array_unique( $review_eligible_regions ),
];
}
/**
* Updates the cooldown period in case the new cooldown period date is available and later than the current cooldown period.
*
* @param array $region_status Associative array containing (maybe) a cooldown date property.
* @param int $cooldown Referenced current cooldown to compare with
*
* @return int The cooldown
*/
private function maybe_update_cooldown_period( $region_status, $cooldown ) {
if (
isset( $region_status['reviewIneligibilityReasonDetails'] ) &&
isset( $region_status['reviewIneligibilityReasonDetails']['cooldownTime'] )
) {
$region_cooldown = intval( strtotime( $region_status['reviewIneligibilityReasonDetails']['cooldownTime'] ) );
if ( ! $cooldown || $region_cooldown > $cooldown ) {
$cooldown = $region_cooldown;
}
}
return $cooldown;
}
/**
* Updates the status reference in case the new status has more priority.
*
* @param String $new_status New status to check has more priority than the current one
* @param String $status Referenced current status
*
* @return String The status
*/
private function maybe_update_status( $new_status, $status ) {
$status_priority_list = [
self::ONBOARDING, // highest priority
self::DISAPPROVED,
self::WARNING,
self::UNDER_REVIEW,
self::PENDING_REVIEW,
self::APPROVED,
];
$current_status_priority = array_search( $status, $status_priority_list, true );
$new_status_priority = array_search( $new_status, $status_priority_list, true );
if ( $new_status_priority !== false && ( is_null( $status ) || $current_status_priority > $new_status_priority ) ) {
return $new_status;
}
return $status;
}
/**
* Updates the regions where a request review is allowed.
*
* @param array $region_status Associative array containing the region eligibility.
* @param array $review_eligible_regions Indexed array with the current eligible regions.
* @param "freeListingsProgram"|"shoppingAdsProgram" $type The program type.
*
* @return array The (maybe) modified $review_eligible_regions array
*/
private function maybe_load_eligible_region( $region_status, $review_eligible_regions, $type = 'freeListingsProgram' ) {
if (
! empty( $region_status['regionCodes'] ) &&
isset( $region_status['reviewEligibilityStatus'] ) &&
$region_status['reviewEligibilityStatus'] === self::ELIGIBLE
) {
$region_codes = $region_status['regionCodes'];
sort( $region_codes ); // sometimes the regions come unsorted between the different programs
$region_id = $region_codes[0];
if ( ! isset( $review_eligible_regions[ $region_id ] ) ) {
$review_eligible_regions[ $region_id ] = [];
}
$review_eligible_regions[ $region_id ][] = strtolower( $type ); // lowercase as is how we expect it in WCS
}
return $review_eligible_regions;
}
/**
* Allows a hook to modify the lifetime of the Account review data.
*
* @return int
*/
public function get_account_review_lifetime(): int {
return apply_filters( 'woocommerce_gla_mc_account_review_lifetime', self::MC_ACCOUNT_REVIEW_LIFETIME );
}
/**
* @param int $cooldown The cooldown in PHP format (seconds)
*
* @return int The cooldown in milliseconds and adding the lifetime cache
*/
private function get_cooldown( int $cooldown ) {
if ( $cooldown ) {
$cooldown = ( $cooldown + $this->get_account_review_lifetime() ) * 1000;
}
return $cooldown;
}
}
Google/SiteVerificationMeta.php 0000644 00000002520 15153721357 0012557 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class SiteVerificationMeta
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class SiteVerificationMeta implements OptionsAwareInterface, Registerable, Service {
use OptionsAwareTrait;
/**
* Add the meta header hook.
*/
public function register(): void {
add_action(
'wp_head',
function () {
$this->display_meta_token();
}
);
}
/**
* Display the meta tag with the site verification token.
*/
protected function display_meta_token() {
$settings = $this->options->get( OptionsInterface::SITE_VERIFICATION, [] );
if ( empty( $settings['meta_tag'] ) ) {
return;
}
echo '<!-- Google site verification - Google for WooCommerce -->' . PHP_EOL;
echo wp_kses(
$settings['meta_tag'],
[
'meta' => [
'name' => true,
'content' => true,
],
]
) . PHP_EOL;
}
}
HelperTraits/GTINMigrationUtilities.php 0000644 00000012613 15153721357 0014206 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\MigrateGTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Exception;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Trait GTINMigrationUtilities
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits
*/
trait GTINMigrationUtilities {
use OptionsAwareTrait;
/**
* Get the version from when the GTIN should be hidden in the Google for WooCommerce tab.
*
* @return string
*/
protected function get_gtin_hidden_version(): string {
return '2.8.7';
}
/**
* Get the version from when the GTIN field is available in core.
* 9.2 is the version when GTIN field was added in Woo Core.
*
* @return bool
*/
protected function is_gtin_available_in_core(): bool {
return version_compare( WC_VERSION, '9.2', '>=' ) && method_exists( WC_Product::class, 'get_global_unique_id' );
}
/**
* If GTIN field should be hidden, this is when initial installed version is after the GTIN migration logic.
*
* @return bool
*/
protected function should_hide_gtin(): bool {
// Don't hide in case GTIN is not available in core.
if ( ! $this->is_gtin_available_in_core() ) {
return false;
}
$first_install_version = $this->options()->get( OptionsInterface::INSTALL_VERSION, false );
return $first_install_version && version_compare( $first_install_version, $this->get_gtin_hidden_version(), '>' );
}
/**
* Get the status for the migration of GTIN.
*
* GTIN_MIGRATION_COMPLETED: GTIN is not available on that WC version or the initial version installed after GTIN migration.
* GTIN_MIGRATION_READY: GTIN is available in core and on read-only mode in the extension. It's ready for migration.
* GTIN_MIGRATION_STARTED: GTIN migration is started
* GTIN_MIGRATION_COMPLETED: GTIN Migration is completed
*
* @return string
*/
protected function get_gtin_migration_status(): string {
// If the current version doesn't show GTIN field or the GTIN field is not available in core.
if ( ! $this->is_gtin_available_in_core() || $this->should_hide_gtin() ) {
return MigrateGTIN::GTIN_MIGRATION_UNAVAILABLE;
}
return $this->options()->get( OptionsInterface::GTIN_MIGRATION_STATUS, MigrateGTIN::GTIN_MIGRATION_READY );
}
/**
*
* Get the options object.
* Notice classes with OptionsAwareTrait only get the options object auto-loaded if
* they are registered in the Container class.
* If they are instantiated on the fly (like the input fields), then this won't get done.
* That's why we need to fetch it from the container in case options field is null.
*
* @return OptionsInterface
*/
protected function options(): OptionsInterface {
return $this->options ?? woogle_get_container()->get( OptionsInterface::class );
}
/**
* Prepares the GTIN to be saved.
*
* @param string $gtin
* @return string
*/
protected function prepare_gtin( string $gtin ): string {
return str_replace( '-', '', $gtin );
}
/**
* Gets the message when the GTIN is invalid.
*
* @param WC_Product $product
* @param string $gtin
* @return string
*/
protected function error_gtin_invalid( WC_Product $product, string $gtin ): string {
return sprintf( 'GTIN [ %s ] has been skipped for Product ID: %s - %s. Invalid GTIN was found.', $gtin, $product->get_id(), $product->get_name() );
}
/**
* Gets the message when the GTIN is already in the Product Inventory
*
* @param WC_Product $product
* @return string
*/
protected function error_gtin_already_set( WC_Product $product ): string {
return sprintf( 'GTIN has been skipped for Product ID: %s - %s. GTIN was found in Product Inventory tab.', $product->get_id(), $product->get_name() );
}
/**
* Gets the message when the GTIN is not found.
*
* @param WC_Product $product
* @return string
*/
protected function error_gtin_not_found( WC_Product $product ): string {
return sprintf( 'GTIN has been skipped for Product ID: %s - %s. No GTIN was found', $product->get_id(), $product->get_name() );
}
/**
* Gets the message when the GTIN had an error when saving.
*
* @param WC_Product $product
* @param string $gtin
* @param Exception $e
*
* @return string
*/
protected function error_gtin_not_saved( WC_Product $product, string $gtin, Exception $e ): string {
return sprintf( 'GTIN [ %s ] for Product ID: %s - %s has an error - %s', $gtin, $product->get_id(), $product->get_name(), $e->getMessage() );
}
/**
* Gets the message when the GTIN is successfully migrated.
*
* @param WC_Product $product
* @param string $gtin
*
* @return string
*/
protected function successful_migrated_gtin( WC_Product $product, string $gtin ): string {
return sprintf( 'GTIN [ %s ] has been migrated for Product ID: %s - %s', $gtin, $product->get_id(), $product->get_name() );
}
/**
* Gets the GTIN value
*
* @param WC_Product $product The product
* @return string|null
*/
protected function get_gtin( WC_Product $product ): ?string {
/**
* Filters the value of the GTIN before performing the migration.
* This value will be he one that we copy inside the Product Inventory GTIN.
*/
return apply_filters( 'woocommerce_gla_gtin_migration_value', $this->attribute_manager->get_value( $product, 'gtin' ), $product );
}
}
HelperTraits/ISO3166Awareness.php 0000644 00000001274 15153721357 0012523 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\ISO3166DataProvider;
defined( 'ABSPATH' ) || exit;
/**
* Trait ISO3166Awareness
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits
*/
trait ISO3166Awareness {
/**
* The object implementing the ISO3166DataProvider interface.
*
* @var ISO3166DataProvider
*/
protected $iso3166_data_provider;
/**
* @param ISO3166DataProvider $provider
*
* @return void
*/
public function set_iso3166_provider( ISO3166DataProvider $provider ): void {
$this->iso3166_data_provider = $provider;
}
}
HelperTraits/Utilities.php 0000644 00000005505 15153721357 0011654 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
defined( 'ABSPATH' ) || exit;
/**
* Trait Utilities
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits
*/
trait Utilities {
use OptionsAwareTrait;
/**
* Does the store have (x) orders
*
* @param integer $count Number of orders to check for
* @param array $status Order statuses to check for
* @return boolean
*/
protected function has_orders( $count = 5, $status = [ 'wc-completed' ] ): bool {
$args = [
'status' => $status,
'limit' => $count,
'return' => 'ids',
'orderby' => 'date',
'order' => 'ASC',
];
return $count === count( wc_get_orders( $args ) );
}
/**
* Test how long GLA has been active.
*
* @param int $seconds Time in seconds to check.
* @return bool Whether or not GLA has been active for $seconds.
*/
protected function gla_active_for( $seconds ): bool {
$gla_installed = $this->options->get( OptionsInterface::INSTALL_TIMESTAMP, false );
if ( false === $gla_installed ) {
return false;
}
return ( ( time() - $gla_installed ) >= $seconds );
}
/**
* Test how long GLA has been setup for.
*
* @param int $seconds Time in seconds to check.
* @return bool Whether or not GLA has been active for $seconds.
*/
protected function gla_setup_for( $seconds ): bool {
$gla_completed_setup = $this->options->get( OptionsInterface::MC_SETUP_COMPLETED_AT, false );
if ( false === $gla_completed_setup ) {
return false;
}
return ( ( time() - $gla_completed_setup ) >= $seconds );
}
/**
* Is Jetpack connected?
*
* @since 1.12.5
*
* @return boolean
*/
protected function is_jetpack_connected(): bool {
return boolval( $this->options->get( OptionsInterface::JETPACK_CONNECTED, false ) );
}
/**
* Encode data to Base64URL
*
* @since 2.8.0
*
* @param string $data The string that will be base64 URL encoded.
*
* @return string
*/
protected function base64url_encode( $data ): string {
$b64 = base64_encode( $data );
// Convert Base64 to Base64URL by replacing "+" with "-" and "/" with "_"
$url = strtr( $b64, '+/', '-_' );
// Remove padding character from the end of line and return the Base64URL result
return rtrim( $url, '=' );
}
/**
* Decode Base64URL string
*
* @since 2.8.0
*
* @param string $data The data that will be base64 URL encoded.
*
* @return boolean|string
*/
protected function base64url_decode( $data ): string {
// Convert Base64URL to Base64 by replacing "-" with "+" and "_" with "/"
$b64 = strtr( $data, '-_', '+/' );
// Decode Base64 string and return the original data
return base64_decode( $b64 );
}
}
HelperTraits/ViewHelperTrait.php 0000644 00000002756 15153721357 0012764 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Trait ViewHelperTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits
*/
trait ViewHelperTrait {
use PluginHelper;
/**
* Returns the list of allowed HTML tags used for view sanitization.
*
* @return array
*/
protected function get_allowed_html_form_tags(): array {
$allowed_attributes = [
'aria-describedby' => true,
'aria-details' => true,
'aria-label' => true,
'aria-labelledby' => true,
'aria-hidden' => true,
'class' => true,
'id' => true,
'style' => true,
'title' => true,
'role' => true,
'data-*' => true,
'action' => true,
'value' => true,
'name' => true,
'selected' => true,
'type' => true,
'disabled' => true,
];
return array_merge(
wp_kses_allowed_html( 'post' ),
[
'form' => $allowed_attributes,
'input' => $allowed_attributes,
'select' => $allowed_attributes,
'option' => $allowed_attributes,
]
);
}
/**
* Appends a prefix to the given ID and returns it.
*
* @param string $id
*
* @return string
*
* @since 1.1.0
*/
protected function prefix_id( string $id ): string {
return "{$this->get_slug()}_$id";
}
}
Infrastructure/Activateable.php 0000644 00000000733 15153721357 0012675 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Something that can be activated.
*
* By implementing a class with this interface, the plugin will automatically hook
* it up to the WordPress activation hook. This means we don't have to worry about
* manually writing activation code.
*/
interface Activateable {
/**
* Activate the service.
*
* @return void
*/
public function activate(): void;
}
Infrastructure/AdminConditional.php 0000644 00000000645 15153721357 0013527 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Trait AdminConditional
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
trait AdminConditional {
/**
* Check whether this object is currently needed.
*
* @return bool Whether the object is needed.
*/
public static function is_needed(): bool {
return is_admin();
}
}
Infrastructure/Conditional.php 0000644 00000001204 15153721357 0012546 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Conditional interface.
*
* This interface allows objects to be instantiated only as needed. A static method is used to
* determine whether an object needs to be instantiated. This prevents needless instantiation of
* objects that won't be used in the current request.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
interface Conditional {
/**
* Check whether this object is currently needed.
*
* @return bool Whether the object is needed.
*/
public static function is_needed(): bool;
}
Infrastructure/Deactivateable.php 0000644 00000000747 15153721357 0013213 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Something that can be deactivated.
*
* By implementing a class with this interface, the plugin will automatically hook
* it up to the WordPress deactivation hook. This means we don't have to worry about
* manually writing deactivation code.
*/
interface Deactivateable {
/**
* Deactivate the service.
*
* @return void
*/
public function deactivate(): void;
}
Infrastructure/GoogleListingsAndAdsPlugin.php 0000644 00000007021 15153721357 0015471 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements\PluginValidator;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface;
/**
* Class GoogleListingsAndAdsPlugin
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
final class GoogleListingsAndAdsPlugin implements Plugin {
/**
* The hook for registering our plugin's services.
*
* @var string
*/
private const SERVICE_REGISTRATION_HOOK = 'plugins_loaded';
/**
* @var ContainerInterface
*/
private $container;
/**
* @var Service[]
*/
private $registered_services;
/**
* GoogleListingsAndAdsPlugin constructor.
*
* @param ContainerInterface $container
*/
public function __construct( ContainerInterface $container ) {
$this->container = $container;
}
/**
* Activate the plugin.
*
* @return void
*/
public function activate(): void {
// Delay activation if a required plugin is missing or an incompatible plugin is active.
if ( ! PluginValidator::validate() ) {
// Using update_option because we cannot access the option service
// when the services have not been registered.
update_option( 'gla_' . OptionsInterface::DELAYED_ACTIVATE, true );
return;
}
$this->maybe_register_services();
foreach ( $this->registered_services as $service ) {
if ( $service instanceof Activateable ) {
$service->activate();
}
}
}
/**
* Deactivate the plugin.
*
* @return void
*/
public function deactivate(): void {
$this->maybe_register_services();
foreach ( $this->registered_services as $service ) {
if ( $service instanceof Deactivateable ) {
$service->deactivate();
}
}
}
/**
* Register the plugin with the WordPress system.
*
* @return void
*/
public function register(): void {
add_action(
self::SERVICE_REGISTRATION_HOOK,
function () {
$this->maybe_register_services();
},
20
);
add_action(
'init',
function () {
// Register the job initializer only if it is available, see JobInitializer::is_needed.
// Note: ActionScheduler must be loaded after the init hook, so we can't load JobInitializer like a regular Service.
if ( $this->container->has( JobInitializer::class ) ) {
$this->container->get( JobInitializer::class )->register();
}
// Check if activation is still pending.
if ( $this->container->get( OptionsInterface::class )->get( OptionsInterface::DELAYED_ACTIVATE ) ) {
$this->activate();
// Remove the DELAYED_ACTIVATE flag.
$this->container->get( OptionsInterface::class )->delete( OptionsInterface::DELAYED_ACTIVATE );
}
}
);
}
/**
* Register our services if dependency validation passes.
*/
protected function maybe_register_services(): void {
// Don't register anything if a required plugin is missing or an incompatible plugin is active.
if ( ! PluginValidator::validate() ) {
$this->registered_services = [];
return;
}
static $registered = false;
if ( $registered ) {
return;
}
/** @var Service[] $services */
$services = $this->container->get( Service::class );
foreach ( $services as $service ) {
if ( $service instanceof Registerable ) {
$service->register();
}
$this->registered_services[ get_class( $service ) ] = $service;
}
$registered = true;
}
}
Infrastructure/Plugin.php 0000644 00000000426 15153721357 0011546 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Interface Plugin
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
interface Plugin extends Activateable, Deactivateable, Registerable {}
Infrastructure/Registerable.php 0000644 00000000561 15153721357 0012720 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Registerable interface.
*
* Used to designate an object that can be registered.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
interface Registerable {
/**
* Register a service.
*/
public function register(): void;
}
Infrastructure/Renderable.php 0000644 00000001175 15153721357 0012355 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
defined( 'ABSPATH' ) || exit;
/**
* Interface Renderable
*
* Used to designate an object that can be rendered (e.g. views, blocks, shortcodes, etc.).
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
interface Renderable {
/**
* Render the renderable.
*
* @param array $context Optional. Contextual information to use while
* rendering. Defaults to an empty array.
*
* @return string Rendered result.
*/
public function render( array $context = [] ): string;
}
Infrastructure/Service.php 0000644 00000000577 15153721357 0011717 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
/**
* Service interface.
*
* A services is one piece of functionality within the plugin as a whole. It aims to encapsulate
* a particular functionality within a single class.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
interface Service {}
Infrastructure/View.php 0000644 00000002766 15153721357 0011233 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
use Automattic\WooCommerce\GoogleListingsAndAds\View\ViewException;
defined( 'ABSPATH' ) || exit;
interface View extends Renderable {
/**
* Render the current view with a given context.
*
* @param array $context Context in which to render.
*
* @return string Rendered HTML.
*
* @throws ViewException If the view could not be loaded.
*/
public function render( array $context = [] ): string;
/**
* Render a partial view.
*
* This can be used from within a currently rendered view, to include
* nested partials.
*
* The passed-in context is optional, and will fall back to the parent's
* context if omitted.
*
* @param string $path Path of the partial to render.
* @param array|null $context Context in which to render the partial.
*
* @return string Rendered HTML.
*
* @throws ViewException If the view could not be loaded or provided path was not valid.
*/
public function render_partial( string $path, ?array $context = null ): string;
/**
* Return the raw value of a context property.
*
* By default, properties are automatically escaped when accessing them
* within the view. This method allows direct access to the raw value
* instead to bypass this automatic escaping.
*
* @param string $property Property for which to return the raw value.
*
* @return mixed Raw context property value.
*/
public function raw( string $property );
}
Infrastructure/ViewFactory.php 0000644 00000000721 15153721357 0012550 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure;
defined( 'ABSPATH' ) || exit;
/**
* Interface ViewFactory
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
interface ViewFactory {
/**
* Create a new view object.
*
* @param string $path Path to the view file to render.
*
* @return View Instantiated view object.
*/
public function create( string $path ): View;
}
Installer.php 0000644 00000006224 15153721357 0007227 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
/**
* Installer class.
* Handles installation of Blocks plugin dependencies.
*
* @internal
*/
class Installer {
/**
* Installation tasks ran on admin_init callback.
*/
public function install() {
$this->maybe_create_tables();
}
/**
* Initialize class features.
*/
public function init() {
add_action( 'admin_init', array( $this, 'install' ) );
}
/**
* Set up the database tables which the plugin needs to function.
*/
public function maybe_create_tables() {
global $wpdb;
$schema_version = 260;
$db_schema_version = (int) get_option( 'wc_blocks_db_schema_version', 0 );
if ( $db_schema_version >= $schema_version && 0 !== $db_schema_version ) {
return;
}
$show_errors = $wpdb->hide_errors();
$table_name = $wpdb->prefix . 'wc_reserved_stock';
$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
$exists = $this->maybe_create_table(
$wpdb->prefix . 'wc_reserved_stock',
"
CREATE TABLE {$wpdb->prefix}wc_reserved_stock (
`order_id` bigint(20) NOT NULL,
`product_id` bigint(20) NOT NULL,
`stock_quantity` double NOT NULL DEFAULT 0,
`timestamp` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`expires` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`order_id`, `product_id`)
) $collate;
"
);
if ( $show_errors ) {
$wpdb->show_errors();
}
if ( ! $exists ) {
return $this->add_create_table_notice( $table_name );
}
// Update succeeded. This is only updated when successful and validated.
// $schema_version should be incremented when changes to schema are made within this method.
update_option( 'wc_blocks_db_schema_version', $schema_version );
}
/**
* Create database table, if it doesn't already exist.
*
* Based on admin/install-helper.php maybe_create_table function.
*
* @param string $table_name Database table name.
* @param string $create_sql Create database table SQL.
* @return bool False on error, true if already exists or success.
*/
protected function maybe_create_table( $table_name, $create_sql ) {
global $wpdb;
if ( in_array( $table_name, $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ), 0 ), true ) ) {
return true;
}
$wpdb->query( $create_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return in_array( $table_name, $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ), 0 ), true );
}
/**
* Add a notice if table creation fails.
*
* @param string $table_name Name of the missing table.
*/
protected function add_create_table_notice( $table_name ) {
add_action(
'admin_notices',
function() use ( $table_name ) {
echo '<div class="error"><p>';
printf(
/* translators: %1$s table name, %2$s database user, %3$s database name. */
esc_html__( 'WooCommerce %1$s table creation failed. Does the %2$s user have CREATE privileges on the %3$s database?', 'woocommerce' ),
'<code>' . esc_html( $table_name ) . '</code>',
'<code>' . esc_html( DB_USER ) . '</code>',
'<code>' . esc_html( DB_NAME ) . '</code>'
);
echo '</p></div>';
}
);
}
}
Integration/IntegrationInitializer.php 0000644 00000002305 15153721357 0014240 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class IntegrationInitializer
*
* Initializes all active integrations.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
class IntegrationInitializer implements Service, Registerable {
use ValidateInterface;
/**
* @var IntegrationInterface[]
*/
protected $integrations = [];
/**
* IntegrationInitializer constructor.
*
* @param IntegrationInterface[] $integrations
*/
public function __construct( array $integrations ) {
foreach ( $integrations as $integration ) {
$this->validate_instanceof( $integration, IntegrationInterface::class );
$this->integrations[] = $integration;
}
}
/**
* Initialize all active integrations.
*/
public function register(): void {
foreach ( $this->integrations as $integration ) {
if ( $integration->is_active() ) {
$integration->init();
}
}
}
}
Integration/IntegrationInterface.php 0000644 00000001052 15153721357 0013653 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
defined( 'ABSPATH' ) || exit;
/**
* Interface IntegrationInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
interface IntegrationInterface {
/**
* Returns whether the integration is active or not.
*
* @return bool
*/
public function is_active(): bool;
/**
* Initializes the integration (e.g. by registering the required hooks, filters, etc.).
*
* @return void
*/
public function init(): void;
}
Integration/JetpackWPCOM.php 0000644 00000031745 15153721357 0011752 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\Jetpack\Connection\Tokens;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Connection\Nonce_Handler;
use Jetpack_Signature;
use Jetpack_Options;
defined( 'ABSPATH' ) || exit;
/**
* Class JetpackWPCOM
*
* Initializes the Jetpack function required to connect the WPCOM App.
* This class can be deleted when the jetpack-connection package includes these functions.
*
* The majority of these class methods have been copied from the Jetpack class.
*
* @see https://github.com/Automattic/jetpack/blob/trunk/projects/plugins/jetpack/class.jetpack.php
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
class JetpackWPCOM implements Service, Registerable, Conditional {
/**
* Verified data for JSON authorization request
*
* @var array
*/
public $json_api_authorization_request = [];
/**
* Connection manager.
*
* @var Automattic\Jetpack\Connection\Manager
*/
protected $connection_manager;
/**
* Initialize all active integrations.
*/
public function register(): void {
add_action( 'login_form_jetpack_json_api_authorization', [ $this, 'login_form_json_api_authorization' ] );
// This filter only simulates the Jetpack version for the test connection response, and it can be any value greater than 1.2.3.
add_filter(
'jetpack_xmlrpc_test_connection_response',
function () {
return '9.5';
}
);
}
/**
* Check if this class is required based on the presence of the Jetpack class.
*
* @return bool Whether the class is needed.
*/
public static function is_needed(): bool {
return ! class_exists( 'Jetpack' );
}
/**
* Handles the login action for Authorizing the JSON API
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5301
*/
public function login_form_json_api_authorization() {
$this->verify_json_api_authorization_request();
add_action( 'wp_login', [ $this, 'store_json_api_authorization_token' ], 10, 2 );
add_action( 'login_message', [ $this, 'login_message_json_api_authorization' ] );
add_action( 'login_form', [ $this, 'preserve_action_in_login_form_for_json_api_authorization' ] );
add_filter( 'site_url', [ $this, 'post_login_form_to_signed_url' ], 10, 3 );
}
/**
* If someone logs in to approve API access, store the Access Code in usermeta.
*
* @param string $user_login Unused.
* @param WP_User $user User logged in.
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5349
*/
public function store_json_api_authorization_token( $user_login, $user ) {
add_filter( 'login_redirect', [ $this, 'add_token_to_login_redirect_json_api_authorization' ], 10, 3 );
add_filter( 'allowed_redirect_hosts', [ $this, 'allow_wpcom_public_api_domain' ] );
$token = wp_generate_password( 32, false );
update_user_meta( $user->ID, 'jetpack_json_api_' . $this->json_api_authorization_request['client_id'], $token );
}
/**
* Make sure the POSTed request is handled by the same action.
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5336
*/
public function preserve_action_in_login_form_for_json_api_authorization() {
$http_host = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- escaped with esc_url below.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- escaped with esc_url below.
echo "<input type='hidden' name='action' value='jetpack_json_api_authorization' />\n";
echo "<input type='hidden' name='jetpack_json_api_original_query' value='" . esc_url( set_url_scheme( $http_host . $request_uri ) ) . "' />\n";
}
/**
* Make sure the login form is POSTed to the signed URL so we can reverify the request.
*
* @param string $url Redirect URL.
* @param string $path Path.
* @param string $scheme URL Scheme.
*
* @see https://github.com/Automattic/jetpack/blob/trunk/projects/plugins/jetpack/class.jetpack.php#L5318
*/
public function post_login_form_to_signed_url( $url, $path, $scheme ) {
if ( 'wp-login.php' !== $path || ( 'login_post' !== $scheme && 'login' !== $scheme ) ) {
return $url;
}
$query_string = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$parsed_url = wp_parse_url( $url );
$url = strtok( $url, '?' );
$url = "$url?{$query_string}";
if ( ! empty( $parsed_url['query'] ) ) {
$url .= "&{$parsed_url['query']}";
}
return $url;
}
/**
* Add the Access Code details to the public-api.wordpress.com redirect.
*
* @param string $redirect_to URL.
* @param string $original_redirect_to URL.
* @param WP_User $user WP_User for the redirect.
*
* @return string
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5401
*/
public function add_token_to_login_redirect_json_api_authorization( $redirect_to, $original_redirect_to, $user ) {
return add_query_arg(
urlencode_deep(
[
'jetpack-code' => get_user_meta( $user->ID, 'jetpack_json_api_' . $this->json_api_authorization_request['client_id'], true ),
'jetpack-user-id' => (int) $user->ID,
'jetpack-state' => $this->json_api_authorization_request['state'],
]
),
$redirect_to
);
}
/**
* Add public-api.wordpress.com to the safe redirect allowed list - only added when someone allows API access.
* To be used with a filter of allowed domains for a redirect.
*
* @param array $domains Allowed WP.com Environments.
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5363
*/
public function allow_wpcom_public_api_domain( $domains ) {
$domains[] = 'public-api.wordpress.com';
return $domains;
}
/**
* Check if the redirect is encoded.
*
* @param string $redirect_url Redirect URL.
*
* @return bool If redirect has been encoded.
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5375
*/
public static function is_redirect_encoded( $redirect_url ) {
return preg_match( '/https?%3A%2F%2F/i', $redirect_url ) > 0;
}
/**
* HTML for the JSON API authorization notice.
*
* @return string
*
* @see https://github.com/Automattic/jetpack/blob/6066d7181f78bdec7c355d8b2152733f4691e8a9/projects/plugins/jetpack/class.jetpack.php#L5603
*/
public function login_message_json_api_authorization() {
return '<p class="message">' . sprintf(
/* translators: Name/image of the client requesting authorization */
esc_html__( '%s wants to access your site’s data. Log in to authorize that access.', 'google-listings-and-ads' ),
'<strong>' . esc_html( $this->json_api_authorization_request['client_title'] ) . '</strong>'
) . '<img src="' . esc_url( $this->json_api_authorization_request['client_image'] ) . '" /></p>';
}
/**
* Verifies the request by checking the signature
*
* @param null|array $environment Value to override $_REQUEST.
*
* @see https://github.com/Automattic/jetpack/blob/trunk/projects/plugins/jetpack/class.jetpack.php#L5422
*/
public function verify_json_api_authorization_request( $environment = null ) {
$environment = $environment === null
? $_REQUEST // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verification handled later in function.
: $environment;
list( $env_token,, $env_user_id ) = explode( ':', $environment['token'] );
$token = ( new Tokens() )->get_access_token( $env_user_id, $env_token );
if ( ! $token || empty( $token->secret ) ) {
wp_die( esc_html__( 'You must connect your Jetpack plugin to WordPress.com to use this feature.', 'google-listings-and-ads' ) );
}
$die_error = __( 'Someone may be trying to trick you into giving them access to your site. Or it could be you just encountered a bug :). Either way, please close this window.', 'google-listings-and-ads' );
// Host has encoded the request URL, probably as a result of a bad http => https redirect.
if ( self::is_redirect_encoded( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- no site changes, we're erroring out.
/**
* Jetpack authorisation request Error.
*/
do_action( 'jetpack_verify_api_authorization_request_error_double_encode' );
$die_error = sprintf(
/* translators: %s is a URL */
__( 'Your site is incorrectly double-encoding redirects from http to https. This is preventing Jetpack from authenticating your connection. Please visit our <a href="%s">support page</a> for details about how to resolve this.', 'google-listings-and-ads' ),
esc_url( 'https://jetpack.com/support/double-encoding/' )
);
}
$jetpack_signature = new Jetpack_Signature( $token->secret, (int) Jetpack_Options::get_option( 'time_diff' ) );
if ( isset( $environment['jetpack_json_api_original_query'] ) ) {
$signature = $jetpack_signature->sign_request(
$environment['token'],
$environment['timestamp'],
$environment['nonce'],
'',
'GET',
$environment['jetpack_json_api_original_query'],
null,
true
);
} else {
$signature = $jetpack_signature->sign_current_request(
[
'body' => null,
'method' => 'GET',
]
);
}
if ( ! $signature ) {
wp_die(
wp_kses(
$die_error,
[
'a' => [
'href' => [],
],
]
)
);
} elseif ( is_wp_error( $signature ) ) {
wp_die(
wp_kses(
$die_error,
[
'a' => [
'href' => [],
],
]
)
);
} elseif ( ! hash_equals( $signature, $environment['signature'] ) ) {
if ( is_ssl() ) {
// If we signed an HTTP request on the Jetpack Servers, but got redirected to HTTPS by the local blog, check the HTTP signature as well.
$signature = $jetpack_signature->sign_current_request(
[
'scheme' => 'http',
'body' => null,
'method' => 'GET',
]
);
if ( ! $signature || is_wp_error( $signature ) || ! hash_equals( $signature, $environment['signature'] ) ) {
wp_die(
wp_kses(
$die_error,
[
'a' => [
'href' => [],
],
]
)
);
}
} else {
wp_die(
wp_kses(
$die_error,
[
'a' => [
'href' => [],
],
]
)
);
}
}
$timestamp = (int) $environment['timestamp'];
$nonce = stripslashes( (string) $environment['nonce'] );
if ( ! $this->connection_manager ) {
$this->connection_manager = new Connection_Manager();
}
if ( ! ( new Nonce_Handler() )->add( $timestamp, $nonce ) ) {
// De-nonce the nonce, at least for 5 minutes.
// We have to reuse this nonce at least once (used the first time when the initial request is made, used a second time when the login form is POSTed).
$old_nonce_time = get_option( "jetpack_nonce_{$timestamp}_{$nonce}" );
if ( $old_nonce_time < time() - 300 ) {
wp_die( esc_html__( 'The authorization process expired. Please go back and try again.', 'google-listings-and-ads' ) );
}
}
$data = json_decode( base64_decode( stripslashes( $environment['data'] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$data_filters = [
'state' => 'opaque',
'client_id' => 'int',
'client_title' => 'string',
'client_image' => 'url',
];
foreach ( $data_filters as $key => $sanitation ) {
if ( ! isset( $data->$key ) ) {
wp_die(
wp_kses(
$die_error,
[
'a' => [
'href' => [],
],
]
)
);
}
switch ( $sanitation ) {
case 'int':
$this->json_api_authorization_request[ $key ] = (int) $data->$key;
break;
case 'opaque':
$this->json_api_authorization_request[ $key ] = (string) $data->$key;
break;
case 'string':
$this->json_api_authorization_request[ $key ] = wp_kses( (string) $data->$key, [] );
break;
case 'url':
$this->json_api_authorization_request[ $key ] = esc_url_raw( (string) $data->$key );
break;
}
}
if ( empty( $this->json_api_authorization_request['client_id'] ) ) {
wp_die(
wp_kses(
$die_error,
[
'a' => [
'href' => [],
],
]
)
);
}
}
}
Integration/WPCOMProxy.php 0000644 00000024123 15153721357 0011502 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use WC_Product;
use WP_REST_Response;
use WP_REST_Request;
defined( 'ABSPATH' ) || exit;
/**
* Class WPCOMProxy
*
* Initializes the hooks to filter the data sent to the WPCOM proxy depending on the query parameter gla_syncable.
*
* @since 2.8.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
class WPCOMProxy implements Service, Registerable, OptionsAwareInterface {
use OptionsAwareTrait;
/**
* The ShippingTimeQuery object.
*
* @var ShippingTimeQuery
*/
protected $shipping_time_query;
/**
* The AttributeManager object.
*
* @var AttributeManager
*/
protected $attribute_manager;
/**
* The protected resources. Only items with visibility set to sync-and-show will be returned.
*/
protected const PROTECTED_RESOURCES = [
'products',
'coupons',
];
/**
* WPCOMProxy constructor.
*
* @param ShippingTimeQuery $shipping_time_query The ShippingTimeQuery object.
* @param AttributeManager $attribute_manager The AttributeManager object.
*/
public function __construct( ShippingTimeQuery $shipping_time_query, AttributeManager $attribute_manager ) {
$this->shipping_time_query = $shipping_time_query;
$this->attribute_manager = $attribute_manager;
}
/**
* The meta key used to filter the items.
*
* @var string
*/
public const KEY_VISIBILITY = '_wc_gla_visibility';
/**
* The Post types to be filtered.
*
* @var array
*/
public static $post_types_to_filter = [
'product' => [
'meta_query' => [
[
'key' => self::KEY_VISIBILITY,
'value' => ChannelVisibility::SYNC_AND_SHOW,
'compare' => '=',
],
],
],
'shop_coupon' => [
'meta_query' => [
[
'key' => self::KEY_VISIBILITY,
'value' => ChannelVisibility::SYNC_AND_SHOW,
'compare' => '=',
],
[
'key' => 'customer_email',
'compare' => 'NOT EXISTS',
],
],
],
'product_variation' => [
'meta_query' => null,
],
];
/**
* Register all filters.
*/
public function register(): void {
// Allow to filter by gla_syncable.
add_filter(
'woocommerce_rest_query_vars',
function ( $valid_vars ) {
$valid_vars[] = 'gla_syncable';
return $valid_vars;
}
);
$this->register_callbacks();
foreach ( array_keys( self::$post_types_to_filter ) as $object_type ) {
$this->register_object_types_filter( $object_type );
}
}
/**
* Register the filters for a specific object type.
*
* @param string $object_type The object type.
*/
protected function register_object_types_filter( string $object_type ): void {
add_filter(
'woocommerce_rest_prepare_' . $object_type . '_object',
[ $this, 'filter_response_by_syncable_item' ],
PHP_INT_MAX, // Run this filter last to override any other response.
3
);
add_filter(
'woocommerce_rest_prepare_' . $object_type . '_object',
[ $this, 'prepare_response' ],
PHP_INT_MAX - 1,
3
);
add_filter(
'woocommerce_rest_' . $object_type . '_object_query',
[ $this, 'filter_by_metaquery' ],
10,
2
);
}
/**
* Register the callbacks.
*/
protected function register_callbacks() {
add_filter(
'rest_request_after_callbacks',
/**
* Add the Google for WooCommerce and Ads settings to the settings/general response.
*
* @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response The response object.
* @param mixed $handler The handler.
* @param WP_REST_Request $request The request object.
*/
function ( $response, $handler, $request ) {
if ( ! $this->is_gla_request( $request ) || ! $response instanceof WP_REST_Response ) {
return $response;
}
$data = $response->get_data();
if ( $request->get_route() === '/wc/v3/settings/general' ) {
$data[] = [
'id' => 'gla_target_audience',
'label' => 'Google for WooCommerce: Target Audience',
'value' => $this->options->get( OptionsInterface::TARGET_AUDIENCE, [] ),
];
$data[] = [
'id' => 'gla_shipping_times',
'label' => 'Google for WooCommerce: Shipping Times',
'value' => $this->shipping_time_query->get_all_shipping_times(),
];
$data[] = [
'id' => 'gla_language',
'label' => 'Google for WooCommerce: Store language',
'value' => get_locale(),
];
$response->set_data( array_values( $data ) );
}
$response->set_data( $this->prepare_data( $response->get_data(), $request ) );
return $response;
},
10,
3
);
}
/**
* Prepares the data converting the empty arrays in objects for consistency.
*
* @param array $data The response data to parse
* @param WP_REST_Request $request The request object.
* @return mixed
*/
public function prepare_data( $data, $request ) {
if ( ! is_array( $data ) ) {
return $data;
}
foreach ( $data as $key => $value ) {
if ( preg_match( '/^\/wc\/v3\/shipping\/zones\/\d+\/methods/', $request->get_route() ) && isset( $value['settings'] ) && empty( $value['settings'] ) ) {
$data[ $key ]['settings'] = (object) $value['settings'];
}
}
return $data;
}
/**
* Whether the request is coming from the WPCOM proxy.
*
* @param WP_REST_Request $request The request object.
*
* @return bool
*/
protected function is_gla_request( WP_REST_Request $request ): bool {
// WPCOM proxy will set the gla_syncable to 1 if the request is coming from the proxy and it is the Google App.
return $request->get_param( 'gla_syncable' ) === '1';
}
/**
* Get route pieces: resource and id, if present.
*
* @param WP_REST_Request $request The request object.
*
* @return array The route pieces.
*/
protected function get_route_pieces( WP_REST_Request $request ): array {
$route = $request->get_route();
$pattern = '/(?P<resource>[\w]+)(?:\/(?P<id>[\d]+))?$/';
preg_match( $pattern, $route, $matches );
return $matches;
}
/**
* Filter response by syncable item.
*
* @param WP_REST_Response $response The response object.
* @param mixed $item The item.
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object updated.
*/
public function filter_response_by_syncable_item( $response, $item, WP_REST_Request $request ): WP_REST_Response {
if ( ! $this->is_gla_request( $request ) ) {
return $response;
}
$pieces = $this->get_route_pieces( $request );
if ( ! isset( $pieces['id'] ) || ! isset( $pieces['resource'] ) || ! in_array( $pieces['resource'], self::PROTECTED_RESOURCES, true ) ) {
return $response;
}
$meta_data = $response->get_data()['meta_data'] ?? [];
foreach ( $meta_data as $meta ) {
if ( $meta->key === self::KEY_VISIBILITY && $meta->value === ChannelVisibility::SYNC_AND_SHOW ) {
return $response;
}
}
return new WP_REST_Response(
[
'code' => 'gla_rest_item_no_syncable',
'message' => 'Item not syncable',
'data' => [
'status' => '403',
],
],
403
);
}
/**
* Query items with specific args for example where _wc_gla_visibility is set to sync-and-show.
*
* @param array $args The query args.
* @param WP_REST_Request $request The request object.
*
* @return array The query args updated.
* */
public function filter_by_metaquery( array $args, WP_REST_Request $request ): array {
if ( ! $this->is_gla_request( $request ) ) {
return $args;
}
$post_type = $args['post_type'];
$post_type_filters = self::$post_types_to_filter[ $post_type ];
if ( ! isset( $post_type_filters['meta_query'] ) || ! is_array( $post_type_filters['meta_query'] ) ) {
return $args;
}
$args['meta_query'] = [ ...$args['meta_query'] ?? [], ...$post_type_filters['meta_query'] ];
return $args;
}
/**
* Prepares the response when the request is coming from the WPCOM proxy:
*
* Filter all the private metadata and returns only the public metadata and those prefixed with _wc_gla
* For WooCommerce products, it will add the attribute mapping values.
*
* @param WP_REST_Response $response The response object.
* @param mixed $item The item.
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object updated.
*/
public function prepare_response( WP_REST_Response $response, $item, WP_REST_Request $request ): WP_REST_Response {
if ( ! $this->is_gla_request( $request ) ) {
return $response;
}
$data = $response->get_data();
$resource = $this->get_route_pieces( $request )['resource'] ?? null;
if ( $item instanceof WC_Product && ( $resource === 'products' || $resource === 'variations' ) ) {
$attr = $this->attribute_manager->get_all_aggregated_values( $item );
// In case of empty array, convert to object to keep the response consistent.
$data['gla_attributes'] = (object) $attr;
// Force types and prevent user type change for fields as Google has strict type requirements.
$data['price'] = strval( $data['price'] ?? null );
$data['regular_price'] = strval( $data['regular_price'] ?? null );
$data['sale_price'] = strval( $data['sale_price'] ?? null );
}
foreach ( $data['meta_data'] ?? [] as $key => $meta ) {
if ( str_starts_with( $meta->key, '_' ) && ! str_starts_with( $meta->key, '_wc_gla' ) ) {
unset( $data['meta_data'][ $key ] );
}
}
$data['meta_data'] = array_values( $data['meta_data'] );
$response->set_data( $data );
return $response;
}
}
Integration/WooCommerceBrands.php 0000644 00000004740 15153721357 0013127 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use WC_Product;
use WC_Product_Variation;
use WP_Term;
defined( 'ABSPATH' ) || exit;
/**
* Class WooCommerceBrands
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
class WooCommerceBrands implements IntegrationInterface {
protected const VALUE_KEY = 'woocommerce_brands';
/**
* The WP proxy object.
*
* @var WP
*/
protected $wp;
/**
* WooCommerceBrands constructor.
*
* @param WP $wp
*/
public function __construct( WP $wp ) {
$this->wp = $wp;
}
/**
* Returns whether the integration is active or not.
*
* @return bool
*/
public function is_active(): bool {
return defined( 'WC_BRANDS_VERSION' );
}
/**
* Initializes the integration (e.g. by registering the required hooks, filters, etc.).
*
* @return void
*/
public function init(): void {
add_filter(
'woocommerce_gla_product_attribute_value_options_brand',
function ( array $value_options ) {
return $this->add_value_option( $value_options );
}
);
add_filter(
'woocommerce_gla_product_attribute_value_brand',
function ( $value, WC_Product $product ) {
return $this->get_brand( $value, $product );
},
10,
2
);
}
/**
* @param array $value_options
*
* @return array
*/
protected function add_value_option( array $value_options ): array {
$value_options[ self::VALUE_KEY ] = 'From WooCommerce Brands';
return $value_options;
}
/**
* @param mixed $value
* @param WC_Product $product
*
* @return mixed
*/
protected function get_brand( $value, WC_Product $product ) {
if ( self::VALUE_KEY === $value ) {
$product_id = $product instanceof WC_Product_Variation ? $product->get_parent_id() : $product->get_id();
$terms = $this->wp->get_the_terms( $product_id, 'product_brand' );
if ( is_array( $terms ) ) {
return $this->get_brand_from_terms( $terms );
}
}
return self::VALUE_KEY === $value ? null : $value;
}
/**
* Returns the brand from the given taxonomy terms.
*
* If multiple, it returns the first selected brand as primary brand
*
* @param WP_Term[] $terms
*
* @return string
*/
protected function get_brand_from_terms( array $terms ): string {
$brands = [];
foreach ( $terms as $term ) {
$brands[] = $term->name;
if ( empty( $term->parent ) ) {
return $term->name;
}
}
return $brands[0];
}
}
Integration/WooCommercePreOrders.php 0000644 00000007010 15153721357 0013614 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;
use DateTimeZone;
use Exception;
use WC_DateTime;
use WC_Pre_Orders_Product;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class WooCommercePreOrders
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*
* @link https://woocommerce.com/products/woocommerce-pre-orders/
*
* @since 1.5.0
*/
class WooCommercePreOrders implements IntegrationInterface {
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* WooCommercePreOrders constructor.
*
* @param ProductHelper $product_helper
*/
public function __construct( ProductHelper $product_helper ) {
$this->product_helper = $product_helper;
}
/**
* Returns whether the integration is active or not.
*
* @return bool
*/
public function is_active(): bool {
return defined( 'WC_PRE_ORDERS_VERSION' );
}
/**
* Initializes the integration (e.g. by registering the required hooks, filters, etc.).
*
* @return void
*/
public function init(): void {
add_filter(
'woocommerce_gla_product_attribute_values',
function ( array $attributes, WC_Product $product ) {
return $this->maybe_set_preorder_availability( $attributes, $product );
},
2,
10
);
add_action(
'wc_pre_orders_pre_orders_disabled_for_product',
function ( $product_id ) {
$this->trigger_sync( $product_id );
},
);
}
/**
* Sets the product's availability to "preorder" if it's in-stock and can be pre-ordered.
*
* @param array $attributes
* @param WC_Product $product
*
* @return array
*/
protected function maybe_set_preorder_availability( array $attributes, WC_Product $product ): array {
if ( $product->is_in_stock() && WC_Pre_Orders_Product::product_can_be_pre_ordered( $product ) ) {
$attributes['availability'] = WCProductAdapter::AVAILABILITY_PREORDER;
$availability_date = $this->get_availability_datetime( $product );
if ( ! empty( $availability_date ) ) {
$attributes['availabilityDate'] = (string) $availability_date;
}
}
return $attributes;
}
/**
* @param WC_Product $product
*
* @return WC_DateTime|null
*/
protected function get_availability_datetime( WC_Product $product ): ?WC_DateTime {
$product = $this->product_helper->maybe_swap_for_parent( $product );
$timestamp = $product->get_meta( '_wc_pre_orders_availability_datetime', true );
if ( empty( $timestamp ) ) {
return null;
}
try {
return new WC_DateTime( "@{$timestamp}", new DateTimeZone( 'UTC' ) );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
return null;
}
}
/**
* Triggers an update job for the product to be synced with Merchant Center.
*
* This is required because WooCommerce Pre-orders updates the product's metadata via `update_post_meta`, which
* does not automatically trigger a sync.
*
* @hooked wc_pre_orders_pre_orders_disabled_for_product
*
* @param mixed $product_id
*/
protected function trigger_sync( $product_id ): void {
try {
$product = $this->product_helper->get_wc_product( (int) $product_id );
} catch ( InvalidValue $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
return;
}
// Manually trigger an update job by saving the product object.
$product->save();
}
}
Integration/WooCommerceProductBundles.php 0000644 00000014637 15153721357 0014661 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\IsBundle;
use WC_Product;
use WC_Product_Bundle;
defined( 'ABSPATH' ) || exit;
/**
* Class WooCommerceProductBundles
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
class WooCommerceProductBundles implements IntegrationInterface {
/**
* @var AttributeManager
*/
protected $attribute_manager;
/**
* WooCommerceProductBundles constructor.
*
* @param AttributeManager $attribute_manager
*/
public function __construct( AttributeManager $attribute_manager ) {
$this->attribute_manager = $attribute_manager;
}
/**
* Returns whether the integration is active or not.
*
* @return bool
*/
public function is_active(): bool {
return class_exists( 'WC_Bundles' ) && is_callable( 'WC_Bundles::instance' );
}
/**
* Initializes the integration (e.g. by registering the required hooks, filters, etc.).
*
* @return void
*/
public function init(): void {
$this->init_product_types();
// update the isBundle attribute for bundle products
add_action(
'woocommerce_new_product',
function ( int $product_id, WC_Product $product ) {
$this->handle_update_product( $product );
},
10,
2
);
add_action(
'woocommerce_update_product',
function ( int $product_id, WC_Product $product ) {
$this->handle_update_product( $product );
},
10,
2
);
// recalculate the product price for bundles
add_filter(
'woocommerce_gla_product_attribute_value_price',
function ( float $price, WC_Product $product, bool $tax_excluded ) {
return $this->calculate_price( $price, $product, $tax_excluded );
},
10,
3
);
add_filter(
'woocommerce_gla_product_attribute_value_sale_price',
function ( float $sale_price, WC_Product $product, bool $tax_excluded ) {
return $this->calculate_sale_price( $sale_price, $product, $tax_excluded );
},
10,
3
);
// adapt the `is_virtual` property for bundle products
add_filter(
'woocommerce_gla_product_property_value_is_virtual',
function ( bool $is_virtual, WC_Product $product ) {
return $this->is_virtual_bundle( $is_virtual, $product );
},
10,
2
);
// filter unsupported bundle products
add_filter(
'woocommerce_gla_get_sync_ready_products_pre_filter',
function ( array $products ) {
return $this->get_sync_ready_bundle_products( $products );
}
);
}
/**
* Adds the "bundle" product type to the list of applicable types
* for every attribute that can be applied to "simple" products.
*
* @return void
*/
protected function init_product_types(): void {
// every attribute that applies to simple products also applies to bundle products.
foreach ( AttributeManager::get_available_attribute_types() as $attribute_type ) {
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
$applicable_types = call_user_func( [ $attribute_type, 'get_applicable_product_types' ] );
if ( ! in_array( 'simple', $applicable_types, true ) ) {
continue;
}
add_filter(
"woocommerce_gla_attribute_applicable_product_types_{$attribute_id}",
function ( array $applicable_types ) {
return $this->add_bundle_type( $applicable_types );
}
);
}
// hide the isBundle attribute on 'bundle' products (we set it automatically to true)
add_filter(
'woocommerce_gla_attribute_hidden_product_types_isBundle',
function ( array $applicable_types ) {
return $this->add_bundle_type( $applicable_types );
}
);
// add the 'bundle' type to list of supported product types
add_filter(
'woocommerce_gla_supported_product_types',
function ( array $product_types ) {
return $this->add_bundle_type( $product_types );
}
);
}
/**
* @param array $types
*
* @return array
*/
private function add_bundle_type( array $types ): array {
$types[] = 'bundle';
return $types;
}
/**
* Set the isBundle product attribute to 'true' if product is a bundle.
*
* @param WC_Product $product
*/
private function handle_update_product( WC_Product $product ) {
if ( $product->is_type( 'bundle' ) ) {
$this->attribute_manager->update( $product, new IsBundle( true ) );
}
}
/**
* @param float $price Calculated price of the product
* @param WC_Product $product WooCommerce product
* @param bool $tax_excluded Whether tax is excluded from product price
*/
private function calculate_price( float $price, WC_Product $product, bool $tax_excluded ): float {
if ( ! $product instanceof WC_Product_Bundle ) {
return $price;
}
return $tax_excluded ? $product->get_bundle_regular_price_excluding_tax() : $product->get_bundle_regular_price_including_tax();
}
/**
* @param float $sale_price Calculated sale price of the product
* @param WC_Product $product WooCommerce product
* @param bool $tax_excluded Whether tax is excluded from product price
*/
private function calculate_sale_price( float $sale_price, WC_Product $product, bool $tax_excluded ): float {
if ( ! $product instanceof WC_Product_Bundle ) {
return $sale_price;
}
$regular_price = $tax_excluded ? $product->get_bundle_regular_price_excluding_tax() : $product->get_bundle_regular_price_including_tax();
$price = $tax_excluded ? $product->get_bundle_price_excluding_tax() : $product->get_bundle_price_including_tax();
// return current price as the sale price if it's lower than the regular price.
if ( $price < $regular_price ) {
return $price;
}
return $sale_price;
}
/**
* @param bool $is_virtual Whether a product is virtual
* @param WC_Product $product WooCommerce product
*/
private function is_virtual_bundle( bool $is_virtual, WC_Product $product ): bool {
if ( $product instanceof WC_Product_Bundle && is_callable( [ $product, 'is_virtual_bundle' ] ) ) {
return $product->is_virtual_bundle();
}
return $is_virtual;
}
/**
* Skip unsupported bundle products.
*
* @param WC_Product[] $products WooCommerce products
*/
private function get_sync_ready_bundle_products( array $products ): array {
return array_filter(
$products,
function ( WC_Product $product ) {
if ( $product instanceof WC_Product_Bundle && $product->requires_input() ) {
return false;
}
return true;
}
);
}
}
Integration/YoastWooCommerceSeo.php 0000644 00000014255 15153721357 0013466 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Integration;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\GTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\MPN;
use WC_Product;
use WC_Product_Variation;
defined( 'ABSPATH' ) || exit;
/**
* Class YoastWooCommerceSeo
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Integration
*/
class YoastWooCommerceSeo implements IntegrationInterface {
protected const VALUE_KEY = 'yoast_seo';
/**
* @var array Meta values stored by Yoast WooCommerce SEO plugin (per product).
*/
protected $yoast_global_identifiers = [];
/**
* Returns whether the integration is active or not.
*
* @return bool
*/
public function is_active(): bool {
return defined( 'WPSEO_WOO_VERSION' );
}
/**
* Initializes the integration (e.g. by registering the required hooks, filters, etc.).
*
* @return void
*/
public function init(): void {
add_filter(
'woocommerce_gla_product_attribute_value_options_mpn',
function ( array $value_options ) {
return $this->add_value_option( $value_options );
}
);
add_filter(
'woocommerce_gla_product_attribute_value_options_gtin',
function ( array $value_options ) {
return $this->add_value_option( $value_options );
}
);
add_filter(
'woocommerce_gla_product_attribute_value_mpn',
function ( $value, WC_Product $product ) {
return $this->get_mpn( $value, $product );
},
10,
2
);
add_filter(
'woocommerce_gla_product_attribute_value_gtin',
function ( $value, WC_Product $product ) {
return $this->get_gtin( $value, $product );
},
10,
2
);
add_filter(
'woocommerce_gla_attribute_mapping_sources',
function ( $sources, $attribute_id ) {
return $this->load_yoast_seo_attribute_mapping_sources( $sources, $attribute_id );
},
10,
2
);
add_filter(
'woocommerce_gla_gtin_migration_value',
function ( $gtin, $product ) {
if ( ! $gtin || self::VALUE_KEY === $gtin ) {
return $this->get_gtin( self::VALUE_KEY, $product );
}
return $gtin;
},
10,
2
);
}
/**
* @param array $value_options
*
* @return array
*/
protected function add_value_option( array $value_options ): array {
$value_options[ self::VALUE_KEY ] = 'From Yoast WooCommerce SEO';
return $value_options;
}
/**
* @param mixed $value
* @param WC_Product $product
*
* @return mixed
*/
protected function get_mpn( $value, WC_Product $product ) {
if ( strpos( $value, self::VALUE_KEY ) === 0 ) {
$value = $this->get_identifier_value( 'mpn', $product );
}
return ! empty( $value ) ? $value : null;
}
/**
* @param mixed $value
* @param WC_Product $product
*
* @return mixed
*/
protected function get_gtin( $value, WC_Product $product ) {
if ( strpos( $value, self::VALUE_KEY ) === 0 ) {
$gtin_values = [
$this->get_identifier_value( 'isbn', $product ),
$this->get_identifier_value( 'gtin8', $product ),
$this->get_identifier_value( 'gtin12', $product ),
$this->get_identifier_value( 'gtin13', $product ),
$this->get_identifier_value( 'gtin14', $product ),
];
$gtin_values = array_values( array_filter( $gtin_values ) );
$value = $gtin_values[0] ?? null;
}
return $value;
}
/**
* Get the identifier value from cache or product meta.
*
* @param string $key
* @param WC_Product $product
*
* @return mixed|null
*/
protected function get_identifier_value( string $key, WC_Product $product ) {
$product_id = $product->get_id();
if ( ! isset( $this->yoast_global_identifiers[ $product_id ] ) ) {
$this->yoast_global_identifiers[ $product_id ] = $this->get_identifier_meta( $product );
}
return ! empty( $this->yoast_global_identifiers[ $product_id ][ $key ] ) ? $this->yoast_global_identifiers[ $product_id ][ $key ] : null;
}
/**
* Get identifier meta from product.
* For variations fallback to parent product if meta is empty.
*
* @since 2.3.1
*
* @param WC_Product $product
*
* @return mixed|null
*/
protected function get_identifier_meta( WC_Product $product ) {
if ( ! $product ) {
return null;
}
if ( $product instanceof WC_Product_Variation ) {
$identifiers = $product->get_meta( 'wpseo_variation_global_identifiers_values', true );
if ( ! is_array( $identifiers ) || empty( array_filter( $identifiers ) ) ) {
$parent_product = wc_get_product( $product->get_parent_id() );
$identifiers = $this->get_identifier_meta( $parent_product );
}
return $identifiers;
}
return $product->get_meta( 'wpseo_global_identifier_values', true );
}
/**
*
* Merge the YOAST Fields with the Attribute Mapping available sources
*
* @param array $sources The current sources
* @param string $attribute_id The Attribute ID
* @return array The merged sources
*/
protected function load_yoast_seo_attribute_mapping_sources( array $sources, string $attribute_id ): array {
if ( $attribute_id === GTIN::get_id() ) {
return array_merge( self::get_yoast_seo_attribute_mapping_gtin_sources(), $sources );
}
if ( $attribute_id === MPN::get_id() ) {
return array_merge( self::get_yoast_seo_attribute_mapping_mpn_sources(), $sources );
}
return $sources;
}
/**
* Load the group disabled option for Attribute mapping YOAST SEO
*
* @return array The disabled group option
*/
protected function get_yoast_seo_attribute_mapping_group_source(): array {
return [ 'disabled:' . self::VALUE_KEY => __( '- Yoast SEO -', 'google-listings-and-ads' ) ];
}
/**
* Load the GTIN Fields for Attribute mapping YOAST SEO
*
* @return array The GTIN sources
*/
protected function get_yoast_seo_attribute_mapping_gtin_sources(): array {
return array_merge( self::get_yoast_seo_attribute_mapping_group_source(), [ self::VALUE_KEY . ':gtin' => __( 'GTIN Field', 'google-listings-and-ads' ) ] );
}
/**
* Load the MPN Fields for Attribute mapping YOAST SEO
*
* @return array The MPN sources
*/
protected function get_yoast_seo_attribute_mapping_mpn_sources(): array {
return array_merge( self::get_yoast_seo_attribute_mapping_group_source(), [ self::VALUE_KEY . ':mpn' => __( 'MPN Field', 'google-listings-and-ads' ) ] );
}
}
Internal/ContainerAwareTrait.php 0000644 00000001120 15153721357 0012742 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface;
defined( 'ABSPATH' ) || exit;
/**
* Trait ContainerAwareTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal
*/
trait ContainerAwareTrait {
/** @var ContainerInterface */
protected $container;
/**
* @param ContainerInterface $container
*
* @return void
*/
public function set_container( ContainerInterface $container ): void {
$this->container = $container;
}
}
Internal/DependencyManagement/AdminServiceProvider.php 0000644 00000011775 15153721357 0017214 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit\BulkEditInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\BulkEdit\CouponBulkEdit;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox\ChannelVisibilityMetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox\CouponChannelVisibilityMetaBox;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox\MetaBoxInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\MetaBox\MetaBoxInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Redirect;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\ConnectionTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\AttributeMapping;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Dashboard;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\GetStarted;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\ProductFeed;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Reports;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\SetupAds;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\SetupMerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\View\PHPViewFactory;
/**
* Class AdminServiceProvider
* Provides services which are only required for the WP admin dashboard.
*
* Note: These services will not be available in a REST API request.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class AdminServiceProvider extends AbstractServiceProvider implements Conditional {
use AdminConditional;
/**
* @var array
*/
protected $provides = [
Admin::class => true,
AttributeMapping::class => true,
BulkEditInitializer::class => true,
ConnectionTest::class => true,
CouponBulkEdit::class => true,
Dashboard::class => true,
GetStarted::class => true,
MetaBoxInterface::class => true,
MetaBoxInitializer::class => true,
ProductFeed::class => true,
Redirect::class => true,
Reports::class => true,
Settings::class => true,
SetupAds::class => true,
SetupMerchantCenter::class => true,
Shipping::class => true,
Service::class => true,
];
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->share_with_tags(
Admin::class,
AssetsHandlerInterface::class,
PHPViewFactory::class,
MerchantCenterService::class,
AdsService::class
);
$this->share_with_tags( PHPViewFactory::class );
$this->share_with_tags( Redirect::class, WP::class );
// Share bulk edit views
$this->share_with_tags( CouponBulkEdit::class, CouponMetaHandler::class, MerchantCenterService::class, TargetAudience::class );
$this->share_with_tags( BulkEditInitializer::class );
// Share admin meta boxes
$this->share_with_tags( ChannelVisibilityMetaBox::class, Admin::class, ProductMetaHandler::class, ProductHelper::class, MerchantCenterService::class );
$this->share_with_tags( CouponChannelVisibilityMetaBox::class, Admin::class, CouponMetaHandler::class, CouponHelper::class, MerchantCenterService::class, TargetAudience::class );
$this->share_with_tags( MetaBoxInitializer::class, Admin::class, MetaBoxInterface::class );
$this->share_with_tags( ConnectionTest::class );
$this->share_with_tags( AttributeMapping::class );
$this->share_with_tags( Dashboard::class );
$this->share_with_tags( GetStarted::class );
$this->share_with_tags( ProductFeed::class );
$this->share_with_tags( Reports::class );
$this->share_with_tags( Settings::class );
$this->share_with_tags( SetupAds::class );
$this->share_with_tags( SetupMerchantCenter::class );
$this->share_with_tags( Shipping::class );
}
}
Internal/DependencyManagement/CoreServiceProvider.php 0000644 00000047015 15153721357 0017050 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannels;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionScheduler;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\AttributesTab;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\VariationsAttributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\ChannelVisibilityBlock;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\ProductBlocksService;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AccountService as AdsAccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AssetSuggestionsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection as GoogleConnection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings as GoogleSettings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroupAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\RESTControllers;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\OAuthService;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Installer as DBInstaller;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migrator;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\TableManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Event\ClearProductStatsCache;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GlobalSiteTag;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelperAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GooglePromotionService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\RequestReviewStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\SiteVerificationMeta;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\ViewFactory;
use Automattic\WooCommerce\GoogleListingsAndAds\Installer;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DeprecatedFilters;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\InstallTimestamp;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\Logging\DebugLogger;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService as MerchantAccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\ContactInformation;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PhoneVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\MultichannelMarketing\GLAChannel;
use Automattic\WooCommerce\GoogleListingsAndAds\MultichannelMarketing\MarketingChannelRegistrar;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PolicyComplianceCheck;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\CompleteSetup as CompleteSetupNote;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\ContactInformation as ContactInformationNote;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\NoteInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\ReconnectWordPress as ReconnectWordPressNote;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\ReviewAfterClicks as ReviewAfterClicksNote;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\ReviewAfterConversions as ReviewAfterConversionsNote;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\SetupCampaign as SetupCampaignNote;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\SetupCampaignTwoWeeks as SetupCampaign2Note;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\SetupCouponSharing as SetupCouponSharingNote;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsSetupCompleted;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantSetupCompleted;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\Transients;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\AttributeMappingHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\BatchProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductFactory;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductFilter;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\GoogleGtagJs;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\Tracks as TracksProxy;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\LocationRatesProcessor;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingSuggestionService;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ZoneMethodsParser;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ZoneLocationsParser;
use Automattic\WooCommerce\GoogleListingsAndAds\TaskList\CompleteSetupTask;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\ActivatedEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\GenericEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\SiteClaimEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\SiteVerificationEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\EventTracking;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\TrackerSnapshot;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Tracks;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\TracksAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\TracksInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\AddressUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\DateTimeUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ImageUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ISOUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\WPCLIMigrationGTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\ISO3166DataProvider;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use wpdb;
/**
* Class CoreServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class CoreServiceProvider extends AbstractServiceProvider {
/**
* @var array
*/
protected $provides = [
Installer::class => true,
AddressUtility::class => true,
AssetsHandlerInterface::class => true,
ContactInformationNote::class => true,
CompleteSetupTask::class => true,
CompleteSetupNote::class => true,
CouponHelper::class => true,
CouponMetaHandler::class => true,
CouponSyncer::class => true,
DateTimeUtility::class => true,
EventTracking::class => true,
GlobalSiteTag::class => true,
ISOUtility::class => true,
SiteVerificationEvents::class => true,
OptionsInterface::class => true,
TransientsInterface::class => true,
ReconnectWordPressNote::class => true,
ReviewAfterClicksNote::class => true,
RESTControllers::class => true,
Service::class => true,
SetupCampaignNote::class => true,
SetupCampaign2Note::class => true,
SetupCouponSharingNote::class => true,
TableManager::class => true,
TrackerSnapshot::class => true,
Tracks::class => true,
TracksInterface::class => true,
ProductSyncer::class => true,
ProductHelper::class => true,
ProductMetaHandler::class => true,
SiteVerificationMeta::class => true,
BatchProductHelper::class => true,
ProductFilter::class => true,
ProductRepository::class => true,
ViewFactory::class => true,
DebugLogger::class => true,
MerchantStatuses::class => true,
PhoneVerification::class => true,
PolicyComplianceCheck::class => true,
ContactInformation::class => true,
MerchantCenterService::class => true,
NotificationsService::class => true,
TargetAudience::class => true,
MerchantAccountState::class => true,
AdsAccountState::class => true,
DBInstaller::class => true,
AttributeManager::class => true,
ProductFactory::class => true,
AttributesTab::class => true,
VariationsAttributes::class => true,
DeprecatedFilters::class => true,
ZoneLocationsParser::class => true,
ZoneMethodsParser::class => true,
LocationRatesProcessor::class => true,
ShippingZone::class => true,
AdsAccountService::class => true,
MerchantAccountService::class => true,
MarketingChannelRegistrar::class => true,
OAuthService::class => true,
WPCLIMigrationGTIN::class => true,
];
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->conditionally_share_with_tags( DebugLogger::class );
// Share our interfaces, possibly with concrete objects.
$this->share_concrete( AssetsHandlerInterface::class, AssetsHandler::class );
$this->share_concrete( TransientsInterface::class, Transients::class );
$this->share_concrete(
TracksInterface::class,
$this->share_with_tags( Tracks::class, TracksProxy::class )
);
// Set up Options, and inflect classes that need options.
$this->share_concrete( OptionsInterface::class, Options::class );
$this->getContainer()
->inflector( OptionsAwareInterface::class )
->invokeMethod( 'set_options_object', [ OptionsInterface::class ] );
// Share helper classes, and inflect classes that need it.
$this->share_with_tags( GoogleHelper::class, WC::class );
$this->getContainer()
->inflector( GoogleHelperAwareInterface::class )
->invokeMethod( 'set_google_helper_object', [ GoogleHelper::class ] );
// Set up the TargetAudience service.
$this->share_with_tags( TargetAudience::class, WC::class, OptionsInterface::class, GoogleHelper::class );
// Set up MerchantCenter service, and inflect classes that need it.
$this->share_with_tags( MerchantCenterService::class );
// Set up Notifications service.
$this->share_with_tags( NotificationsService::class, MerchantCenterService::class, AccountService::class );
// Set up OAuthService service.
$this->share_with_tags( OAuthService::class );
$this->getContainer()
->inflector( MerchantCenterAwareInterface::class )
->invokeMethod( 'set_merchant_center_object', [ MerchantCenterService::class ] );
// Set up Ads service, and inflect classes that need it.
$this->share_with_tags( AdsAccountState::class );
$this->share_with_tags( AdsService::class, AdsAccountState::class );
$this->getContainer()
->inflector( AdsAwareInterface::class )
->invokeMethod( 'set_ads_object', [ AdsService::class ] );
$this->share_with_tags( AssetSuggestionsService::class, WP::class, WC::class, ImageUtility::class, wpdb::class, AdsAssetGroupAsset::class );
// Set up the installer.
$this->share_with_tags( Installer::class, WP::class );
// Share utility classes
$this->share_with_tags( AddressUtility::class );
$this->share_with_tags( DateTimeUtility::class );
$this->share_with_tags( ImageUtility::class, WP::class );
$this->share_with_tags( ISOUtility::class, ISO3166DataProvider::class );
// Share our regular service classes.
$this->share_with_tags( TrackerSnapshot::class );
$this->share_with_tags( EventTracking::class );
$this->share_with_tags( RESTControllers::class );
$this->share_with_tags( CompleteSetupTask::class );
$this->conditionally_share_with_tags( GlobalSiteTag::class, AssetsHandlerInterface::class, GoogleGtagJs::class, ProductHelper::class, WC::class, WP::class );
$this->share_with_tags( SiteVerificationMeta::class );
$this->conditionally_share_with_tags( MerchantSetupCompleted::class );
$this->conditionally_share_with_tags( AdsSetupCompleted::class );
$this->share_with_tags( AdsAccountService::class, AdsAccountState::class );
$this->share_with_tags( MerchantAccountService::class, MerchantAccountState::class );
// Inbox Notes
$this->share_with_tags( ContactInformationNote::class );
$this->share_with_tags( CompleteSetupNote::class );
$this->share_with_tags( ReconnectWordPressNote::class, GoogleConnection::class );
$this->share_with_tags( ReviewAfterClicksNote::class, MerchantMetrics::class, WP::class );
$this->share_with_tags( ReviewAfterConversionsNote::class, MerchantMetrics::class, WP::class );
$this->share_with_tags( SetupCampaignNote::class, MerchantCenterService::class );
$this->share_with_tags( SetupCampaign2Note::class, MerchantCenterService::class );
$this->share_with_tags( SetupCouponSharingNote::class, MerchantStatuses::class );
$this->share_with_tags( NoteInitializer::class, ActionScheduler::class );
// Product attributes
$this->conditionally_share_with_tags( AttributeManager::class, AttributeMappingRulesQuery::class, WC::class );
$this->conditionally_share_with_tags( AttributesTab::class, Admin::class, AttributeManager::class, MerchantCenterService::class );
$this->conditionally_share_with_tags( VariationsAttributes::class, Admin::class, AttributeManager::class, MerchantCenterService::class );
// Product Block Editor
$this->share_with_tags( ChannelVisibilityBlock::class, ProductHelper::class, MerchantCenterService::class );
$this->conditionally_share_with_tags( ProductBlocksService::class, AssetsHandlerInterface::class, ChannelVisibilityBlock::class, AttributeManager::class, MerchantCenterService::class );
$this->share_with_tags( MerchantAccountState::class );
$this->share_with_tags( MerchantStatuses::class );
$this->share_with_tags( PhoneVerification::class, Merchant::class, WP::class, ISOUtility::class );
$this->share_with_tags( PolicyComplianceCheck::class, WC::class, GoogleHelper::class, TargetAudience::class );
$this->share_with_tags( ContactInformation::class, Merchant::class, GoogleSettings::class );
$this->share_with_tags( ProductMetaHandler::class );
$this->share( ProductHelper::class, ProductMetaHandler::class, WC::class, TargetAudience::class );
$this->share_with_tags( ProductFilter::class, ProductHelper::class );
$this->share_with_tags( ProductRepository::class, ProductMetaHandler::class, ProductFilter::class );
$this->share_with_tags( ProductFactory::class, AttributeManager::class, WC::class );
$this->share_with_tags(
BatchProductHelper::class,
ProductMetaHandler::class,
ProductHelper::class,
ValidatorInterface::class,
ProductFactory::class,
TargetAudience::class,
AttributeMappingRulesQuery::class
);
$this->share_with_tags(
ProductSyncer::class,
GoogleProductService::class,
BatchProductHelper::class,
ProductHelper::class,
MerchantCenterService::class,
WC::class,
ProductRepository::class
);
// Coupon management classes
$this->share_with_tags( CouponMetaHandler::class );
$this->share_with_tags(
CouponHelper::class,
CouponMetaHandler::class,
WC::class,
MerchantCenterService::class
);
$this->share_with_tags(
CouponSyncer::class,
GooglePromotionService::class,
CouponHelper::class,
ValidatorInterface::class,
MerchantCenterService::class,
TargetAudience::class,
WC::class
);
// Set up inflector for tracks classes.
$this->getContainer()
->inflector( TracksAwareInterface::class )
->invokeMethod( 'set_tracks', [ TracksInterface::class ] );
// Share other classes.
$this->share_with_tags( ActivatedEvents::class, $_SERVER );
$this->share_with_tags( GenericEvents::class );
$this->share_with_tags( SiteClaimEvents::class );
$this->share_with_tags( SiteVerificationEvents::class );
$this->conditionally_share_with_tags( InstallTimestamp::class );
$this->conditionally_share_with_tags( ClearProductStatsCache::class, MerchantStatuses::class );
$this->share_with_tags( TableManager::class, 'db_table' );
$this->share_with_tags( DBInstaller::class, TableManager::class, Migrator::class );
$this->share_with_tags( DeprecatedFilters::class );
$this->share_with_tags( LocationRatesProcessor::class );
$this->share_with_tags( ZoneLocationsParser::class, GoogleHelper::class );
$this->share_with_tags( ZoneMethodsParser::class, WC::class );
$this->share_with_tags( ShippingZone::class, WC::class, ZoneLocationsParser::class, ZoneMethodsParser::class, LocationRatesProcessor::class );
$this->share_with_tags( ShippingSuggestionService::class, ShippingZone::class, WC::class );
$this->share_with_tags( RequestReviewStatuses::class );
// Share Attribute Mapping related classes
$this->share_with_tags( AttributeMappingHelper::class );
if ( class_exists( MarketingChannels::class ) ) {
$this->share_with_tags( GLAChannel::class, MerchantCenterService::class, AdsCampaign::class, Ads::class, MerchantStatuses::class, ProductSyncStats::class );
$this->share_with_tags( MarketingChannelRegistrar::class, GLAChannel::class, WC::class );
}
// ClI Classes
$this->conditionally_share_with_tags( WPCLIMigrationGTIN::class, ProductRepository::class, AttributeManager::class );
}
}
Internal/DependencyManagement/DBServiceProvider.php 0000644 00000015006 15153721357 0016440 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migration20231109T1653383133;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\MigrationInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migration20211228T1640692399;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migration20220524T1653383133;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migration20240813T1653383133;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\MigrationVersion141;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Migration\Migrator;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductFeedQueryHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductMetaQueryHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\BudgetRecommendationQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantIssueQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\AttributeMappingRulesTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\BudgetRecommendationTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingRateTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingTimeTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidClass;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\DefinitionInterface;
use wpdb;
defined( 'ABSPATH' ) || exit;
/**
* Class DBServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class DBServiceProvider extends AbstractServiceProvider {
use ValidateInterface;
/**
* Array of classes provided by this container.
*
* Keys should be the class name, and the value can be anything (like `true`).
*
* @var array
*/
protected $provides = [
AttributeMappingRulesTable::class => true,
AttributeMappingRulesQuery::class => true,
ShippingRateTable::class => true,
ShippingRateQuery::class => true,
ShippingTimeTable::class => true,
ShippingTimeQuery::class => true,
BudgetRecommendationTable::class => true,
BudgetRecommendationQuery::class => true,
MerchantIssueTable::class => true,
MerchantIssueQuery::class => true,
ProductFeedQueryHelper::class => true,
ProductMetaQueryHelper::class => true,
MigrationInterface::class => true,
Migrator::class => true,
];
/**
* Returns a boolean if checking whether this provider provides a specific
* service or returns an array of provided services if no argument passed.
*
* @param string $service
*
* @return boolean
*/
public function provides( string $service ): bool {
return 'db_table' === $service || 'db_query' === $service || parent::provides( $service );
}
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->share_table_class( AttributeMappingRulesTable::class );
$this->add_query_class( AttributeMappingRulesQuery::class, AttributeMappingRulesTable::class );
$this->share_table_class( BudgetRecommendationTable::class );
$this->add_query_class( BudgetRecommendationQuery::class, BudgetRecommendationTable::class );
$this->share_table_class( ShippingRateTable::class );
$this->add_query_class( ShippingRateQuery::class, ShippingRateTable::class );
$this->share_table_class( ShippingTimeTable::class );
$this->add_query_class( ShippingTimeQuery::class, ShippingTimeTable::class );
$this->share_table_class( MerchantIssueTable::class );
$this->add_query_class( MerchantIssueQuery::class, MerchantIssueTable::class );
$this->share_with_tags( ProductFeedQueryHelper::class, wpdb::class, ProductRepository::class );
$this->share_with_tags( ProductMetaQueryHelper::class, wpdb::class );
// Share DB migrations
$this->share_migration( MigrationVersion141::class, MerchantIssueTable::class );
$this->share_migration( Migration20211228T1640692399::class, ShippingRateTable::class, OptionsInterface::class );
$this->share_with_tags( Migration20220524T1653383133::class, BudgetRecommendationTable::class );
$this->share_migration( Migration20231109T1653383133::class, BudgetRecommendationTable::class );
$this->share_migration( Migration20240813T1653383133::class, ShippingTimeTable::class );
$this->share_with_tags( Migrator::class, MigrationInterface::class );
}
/**
* Add a query class.
*
* @param string $class_name
* @param mixed ...$arguments
*
* @return DefinitionInterface
*/
protected function add_query_class( string $class_name, ...$arguments ): DefinitionInterface {
return $this->add( $class_name, wpdb::class, ...$arguments )->addTag( 'db_query' );
}
/**
* Share a table class.
*
* Shared classes will always return the same instance of the class when the class is requested
* from the container.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @return DefinitionInterface
*/
protected function share_table_class( string $class_name, ...$arguments ): DefinitionInterface {
return parent::share( $class_name, WP::class, wpdb::class, ...$arguments )->addTag( 'db_table' );
}
/**
* Share a migration class.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @throws InvalidClass When the given class does not implement the MigrationInterface.
*
* @since 1.4.1
*/
protected function share_migration( string $class_name, ...$arguments ) {
$this->validate_interface( $class_name, MigrationInterface::class );
$this->share_with_tags(
$class_name,
wpdb::class,
...$arguments
);
}
}
Internal/DependencyManagement/GoogleServiceProvider.php 0000644 00000035755 15153721357 0017404 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignBudget;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignCriterion;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignLabel;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsConversionAction;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsReport;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroupAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantReport;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\SiteVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\AccountReconnect;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\WPError;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\WPErrorTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GooglePromotionService;
use Automattic\WooCommerce\GoogleListingsAndAds\Notes\ReconnectWordPress;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification as SiteVerificationService;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client as GuzzleClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\ClientInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Exception\RequestException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\HandlerStack;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\Definition;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\RequestInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\ResponseInterface;
use Google\Ads\GoogleAds\Util\V18\GoogleAdsFailures;
use Jetpack_Options;
defined( 'ABSPATH' ) || exit;
/**
* Class GoogleServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class GoogleServiceProvider extends AbstractServiceProvider {
use PluginHelper;
use WPErrorTrait;
/**
* Array of classes provided by this container.
*
* Keys should be the class name, and the value can be anything (like `true`).
*
* @var array
*/
protected $provides = [
Client::class => true,
ShoppingContent::class => true,
GoogleAdsClient::class => true,
GuzzleClient::class => true,
Middleware::class => true,
Merchant::class => true,
MerchantMetrics::class => true,
Ads::class => true,
AdsAssetGroup::class => true,
AdsCampaign::class => true,
AdsCampaignBudget::class => true,
AdsCampaignLabel::class => true,
AdsConversionAction::class => true,
AdsReport::class => true,
AdsAssetGroupAsset::class => true,
AdsAsset::class => true,
'connect_server_root' => true,
Connection::class => true,
GoogleProductService::class => true,
GooglePromotionService::class => true,
SiteVerification::class => true,
Settings::class => true,
];
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->register_guzzle();
$this->register_ads_client();
$this->register_google_classes();
$this->share( Middleware::class );
$this->add( Connection::class );
$this->add( Settings::class );
$this->share( Ads::class, GoogleAdsClient::class );
$this->share( AdsAssetGroup::class, GoogleAdsClient::class, AdsAssetGroupAsset::class );
$this->share( AdsCampaign::class, GoogleAdsClient::class, AdsCampaignBudget::class, AdsCampaignCriterion::class, GoogleHelper::class, AdsCampaignLabel::class );
$this->share( AdsCampaignBudget::class, GoogleAdsClient::class );
$this->share( AdsAssetGroupAsset::class, GoogleAdsClient::class, AdsAsset::class );
$this->share( AdsAsset::class, GoogleAdsClient::class, WP::class );
$this->share( AdsCampaignCriterion::class );
$this->share( AdsCampaignLabel::class, GoogleAdsClient::class );
$this->share( AdsConversionAction::class, GoogleAdsClient::class );
$this->share( AdsReport::class, GoogleAdsClient::class );
$this->share( Merchant::class, ShoppingContent::class );
$this->share( MerchantMetrics::class, ShoppingContent::class, GoogleAdsClient::class, WP::class, TransientsInterface::class );
$this->share( MerchantReport::class, ShoppingContent::class, ProductHelper::class );
$this->share( SiteVerification::class );
$this->getContainer()->add( 'connect_server_root', $this->get_connect_server_url_root() );
}
/**
* Register guzzle with authorization middleware added.
*/
protected function register_guzzle() {
$callback = function () {
$handler_stack = HandlerStack::create();
$handler_stack->remove( 'http_errors' );
$handler_stack->push( $this->error_handler(), 'http_errors' );
$handler_stack->push( $this->add_auth_header(), 'auth_header' );
$handler_stack->push( $this->add_plugin_version_header(), 'plugin_version_header' );
// Override endpoint URL if we are using http locally.
if ( 0 === strpos( $this->get_connect_server_url_root(), 'http://' ) ) {
$handler_stack->push( $this->override_http_url(), 'override_http_url' );
}
return new GuzzleClient( [ 'handler' => $handler_stack ] );
};
$this->share_concrete( GuzzleClient::class, new Definition( GuzzleClient::class, $callback ) );
$this->share_concrete( ClientInterface::class, new Definition( GuzzleClient::class, $callback ) );
}
/**
* Register ads client.
*/
protected function register_ads_client() {
$callback = function () {
return new GoogleAdsClient( $this->get_connect_server_endpoint() );
};
$this->share_concrete(
GoogleAdsClient::class,
new Definition( GoogleAdsClient::class, $callback )
)->addMethodCall( 'setHttpClient', [ ClientInterface::class ] );
}
/**
* Register the various Google classes we use.
*/
protected function register_google_classes() {
$this->add( Client::class )->addMethodCall( 'setHttpClient', [ ClientInterface::class ] );
$this->add(
ShoppingContent::class,
Client::class,
$this->get_connect_server_url_root( 'google/google-mc' )
);
$this->add(
SiteVerificationService::class,
Client::class,
$this->get_connect_server_url_root( 'google/google-sv' )
);
$this->share( GoogleProductService::class, ShoppingContent::class );
$this->share( GooglePromotionService::class, ShoppingContent::class );
}
/**
* Custom error handler to detect and handle a disconnected status.
*
* @return callable
*/
protected function error_handler(): callable {
return function ( callable $handler ) {
return function ( RequestInterface $request, array $options ) use ( $handler ) {
return $handler( $request, $options )->then(
function ( ResponseInterface $response ) use ( $request ) {
$code = $response->getStatusCode();
$path = $request->getUri()->getPath();
// Partial Failures come back with a status code of 200, so it's necessary to call GoogleAdsFailures:init every time.
if ( strpos( $path, 'google-ads' ) !== false ) {
GoogleAdsFailures::init();
}
if ( $code < 400 ) {
return $response;
}
if ( 401 === $code ) {
$this->handle_unauthorized_error( $request, $response );
}
throw RequestException::create( $request, $response );
}
);
};
};
}
/**
* Handle a 401 unauthorized error.
* Marks either the Jetpack or the Google account as disconnected.
*
* @since 1.12.5
*
* @param RequestInterface $request
* @param ResponseInterface $response
*
* @throws AccountReconnect When an account must be reconnected.
*/
protected function handle_unauthorized_error( RequestInterface $request, ResponseInterface $response ) {
$auth_header = $response->getHeader( 'www-authenticate' )[0] ?? '';
if ( 0 === strpos( $auth_header, 'X_JP_Auth' ) ) {
// Log original exception before throwing reconnect exception.
do_action( 'woocommerce_gla_exception', RequestException::create( $request, $response ), __METHOD__ );
$this->set_jetpack_connected( false );
throw AccountReconnect::jetpack_disconnected();
}
// Exclude listing customers as it will handle it's own unauthorized errors.
$path = $request->getUri()->getPath();
if ( false === strpos( $path, 'customers:listAccessibleCustomers' ) ) {
// Log original exception before throwing reconnect exception.
do_action( 'woocommerce_gla_exception', RequestException::create( $request, $response ), __METHOD__ );
$this->set_google_disconnected();
throw AccountReconnect::google_disconnected();
}
}
/**
* @return callable
*/
protected function add_auth_header(): callable {
return function ( callable $handler ) {
return function ( RequestInterface $request, array $options ) use ( $handler ) {
try {
$request = $request->withHeader( 'Authorization', $this->generate_auth_header() );
// Getting a valid authorization token, indicates Jetpack is connected.
$this->set_jetpack_connected( true );
} catch ( WPError $error ) {
do_action( 'woocommerce_gla_guzzle_client_exception', $error, __METHOD__ . ' in add_auth_header()' );
$this->set_jetpack_connected( false );
throw AccountReconnect::jetpack_disconnected();
}
return $handler( $request, $options );
};
};
}
/**
* Add client name and version headers to request
*
* @since 2.4.11
*
* @return callable
*/
protected function add_plugin_version_header(): callable {
return function ( callable $handler ) {
return function ( RequestInterface $request, array $options ) use ( $handler ) {
$request = $request->withHeader( 'x-client-name', $this->get_client_name() )
->withHeader( 'x-client-version', $this->get_version() );
return $handler( $request, $options );
};
};
}
/**
* @return callable
*/
protected function override_http_url(): callable {
return function ( callable $handler ) {
return function ( RequestInterface $request, array $options ) use ( $handler ) {
$request = $request->withUri( $request->getUri()->withScheme( 'http' ) );
return $handler( $request, $options );
};
};
}
/**
* Generate the authorization header for the GuzzleClient and GoogleAdsClient.
*
* @return string Empty if no access token is available.
*
* @throws WPError If the authorization token isn't found.
*/
protected function generate_auth_header(): string {
/** @var Manager $manager */
$manager = $this->getContainer()->get( Manager::class );
$token = $manager->get_tokens()->get_access_token( false, false, false );
$this->check_for_wp_error( $token );
[ $key, $secret ] = explode( '.', $token->secret );
$key = sprintf(
'%s:%d:%d',
$key,
defined( 'JETPACK__API_VERSION' ) ? JETPACK__API_VERSION : 1,
$token->external_user_id
);
$timestamp = time() + (int) Jetpack_Options::get_option( 'time_diff' );
$nonce = wp_generate_password( 10, false );
$request = join( "\n", [ $key, $timestamp, $nonce, '' ] );
$signature = base64_encode( hash_hmac( 'sha1', $request, $secret, true ) );
$auth = [
'token' => $key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'signature' => $signature,
];
$pieces = [ 'X_JP_Auth' ];
foreach ( $auth as $key => $value ) {
$pieces[] = sprintf( '%s="%s"', $key, $value );
}
return join( ' ', $pieces );
}
/**
* Get the root Url for the connect server.
*
* @param string $path (Optional) A path relative to the root to include.
*
* @return string
*/
protected function get_connect_server_url_root( string $path = '' ): string {
$url = trailingslashit( $this->get_connect_server_url() );
$path = trim( $path, '/' );
return "{$url}{$path}";
}
/**
* Get the connect server endpoint in the format `domain:port/path`
*
* @return string
*/
protected function get_connect_server_endpoint(): string {
$parts = wp_parse_url( $this->get_connect_server_url_root( 'google/google-ads' ) );
$port = empty( $parts['port'] ) ? 443 : $parts['port'];
return sprintf( '%s:%d%s', $parts['host'], $port, $parts['path'] );
}
/**
* Set the Google account connection as disconnected.
*/
protected function set_google_disconnected() {
/** @var Options $options */
$options = $this->getContainer()->get( OptionsInterface::class );
$options->update( OptionsInterface::GOOGLE_CONNECTED, false );
}
/**
* Set the Jetpack account connection.
*
* @since 1.12.5
*
* @param bool $connected Is connected or disconnected?
*/
protected function set_jetpack_connected( bool $connected ) {
/** @var Options $options */
$options = $this->getContainer()->get( OptionsInterface::class );
// Save previous connected status before updating.
$previous_connected = boolval( $options->get( OptionsInterface::JETPACK_CONNECTED ) );
$options->update( OptionsInterface::JETPACK_CONNECTED, $connected );
if ( $previous_connected !== $connected ) {
$this->jetpack_connected_change( $connected );
}
}
/**
* Handle the reconnect notification when the Jetpack connection status changes.
*
* @since 1.12.5
*
* @param boolean $connected
*/
protected function jetpack_connected_change( bool $connected ) {
/** @var ReconnectWordPress $note */
$note = $this->getContainer()->get( ReconnectWordPress::class );
if ( $connected ) {
$note->delete();
} else {
$note->get_entry()->save();
}
}
}
Internal/DependencyManagement/IntegrationServiceProvider.php 0000644 00000004437 15153721357 0020444 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\IntegrationInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\IntegrationInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\WooCommerceBrands;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\WooCommercePreOrders;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\WooCommerceProductBundles;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\WPCOMProxy;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\YoastWooCommerceSeo;
use Automattic\WooCommerce\GoogleListingsAndAds\Integration\JetpackWPCOM;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
defined( 'ABSPATH' ) || exit;
/**
* Class IntegrationServiceProvider
*
* Provides the integration classes and their related services to the container.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class IntegrationServiceProvider extends AbstractServiceProvider {
use ValidateInterface;
/**
* @var array
*/
protected $provides = [
Service::class => true,
IntegrationInterface::class => true,
IntegrationInitializer::class => true,
];
/**
* @return void
*/
public function register(): void {
$this->share_with_tags( YoastWooCommerceSeo::class );
$this->share_with_tags( WooCommerceBrands::class, WP::class );
$this->share_with_tags( WooCommerceProductBundles::class, AttributeManager::class );
$this->share_with_tags( WooCommercePreOrders::class, ProductHelper::class );
$this->conditionally_share_with_tags( JetpackWPCOM::class );
$this->share_with_tags( WPCOMProxy::class, ShippingTimeQuery::class, AttributeManager::class );
$this->share_with_tags(
IntegrationInitializer::class,
IntegrationInterface::class
);
}
}
Internal/DependencyManagement/JobServiceProvider.php 0000644 00000024055 15153721357 0016671 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use ActionScheduler as ActionSchedulerCore;
use ActionScheduler_AsyncRequest_QueueRunner as QueueRunnerAsyncRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionScheduler;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\AsyncActionRunner;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings as GoogleSettings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantReport;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidClass;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\AbstractProductSyncerBatchedJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobMonitor;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\CleanupProductsJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\CleanupSyncedProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\MigrateGTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\CouponNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\ProductNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\SettingsNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\ShippingNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ResubmitExpiringProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteCoupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateCoupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update\CleanupProductTargetCountriesJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update\PluginUpdate;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateShippingSettings;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateSyncableProductsCount;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateMerchantProductStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\BatchProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Event\StartProductSync;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Settings;
defined( 'ABSPATH' ) || exit;
/**
* Class JobServiceProvider
*
* Provides the job classes and their related services to the container.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class JobServiceProvider extends AbstractServiceProvider {
use ValidateInterface;
/**
* @var array
*/
protected $provides = [
JobInterface::class => true,
ActionSchedulerInterface::class => true,
AsyncActionRunner::class => true,
ActionSchedulerJobMonitor::class => true,
Coupon\SyncerHooks::class => true,
PluginUpdate::class => true,
Product\SyncerHooks::class => true,
ProductSyncStats::class => true,
Service::class => true,
JobRepository::class => true,
];
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->share_with_tags(
AsyncActionRunner::class,
new QueueRunnerAsyncRequest( ActionSchedulerCore::store() ),
ActionSchedulerCore::lock()
);
$this->share_with_tags( ActionScheduler::class, AsyncActionRunner::class );
$this->share_with_tags( ActionSchedulerJobMonitor::class, ActionScheduler::class );
$this->share_with_tags( ProductSyncStats::class, ActionScheduler::class );
// share product syncer jobs
$this->share_product_syncer_job( UpdateAllProducts::class );
$this->share_product_syncer_job( DeleteAllProducts::class );
$this->share_product_syncer_job( UpdateProducts::class );
$this->share_product_syncer_job( DeleteProducts::class );
$this->share_product_syncer_job( ResubmitExpiringProducts::class );
$this->share_product_syncer_job( CleanupProductsJob::class );
$this->share_product_syncer_job( CleanupSyncedProducts::class );
// share coupon syncer jobs.
$this->share_coupon_syncer_job( UpdateCoupon::class );
$this->share_coupon_syncer_job( DeleteCoupon::class );
// share product notifications job
$this->share_action_scheduler_job(
ProductNotificationJob::class,
NotificationsService::class,
ProductHelper::class
);
// share coupon notifications job
$this->share_action_scheduler_job(
CouponNotificationJob::class,
NotificationsService::class,
CouponHelper::class
);
// share GTIN migration job
$this->share_action_scheduler_job(
MigrateGTIN::class,
ProductRepository::class,
Product\Attributes\AttributeManager::class
);
$this->share_with_tags( JobRepository::class );
$this->conditionally_share_with_tags(
JobInitializer::class,
JobRepository::class,
ActionScheduler::class
);
$this->share_with_tags(
Product\SyncerHooks::class,
BatchProductHelper::class,
ProductHelper::class,
JobRepository::class,
MerchantCenterService::class,
NotificationsService::class,
WC::class
);
$this->share_with_tags(
Coupon\SyncerHooks::class,
CouponHelper::class,
JobRepository::class,
MerchantCenterService::class,
NotificationsService::class,
WC::class,
WP::class
);
$this->share_with_tags( StartProductSync::class, JobRepository::class );
$this->share_with_tags( PluginUpdate::class, JobRepository::class );
// Share shipping notifications job
$this->share_action_scheduler_job(
ShippingNotificationJob::class,
NotificationsService::class
);
// Share settings notifications job
$this->share_action_scheduler_job(
SettingsNotificationJob::class,
NotificationsService::class
);
// Share settings syncer hooks
$this->share_with_tags( Settings\SyncerHooks::class, JobRepository::class, NotificationsService::class );
// Share shipping settings syncer job and hooks.
$this->share_action_scheduler_job( UpdateShippingSettings::class, MerchantCenterService::class, GoogleSettings::class );
$this->share_with_tags( Shipping\SyncerHooks::class, MerchantCenterService::class, GoogleSettings::class, JobRepository::class, NotificationsService::class );
// Share plugin update jobs
$this->share_product_syncer_job( CleanupProductTargetCountriesJob::class );
// Share update syncable products count job
$this->share_action_scheduler_job( UpdateSyncableProductsCount::class, ProductRepository::class, ProductHelper::class );
$this->share_action_scheduler_job( UpdateMerchantProductStatuses::class, MerchantCenterService::class, MerchantReport::class, MerchantStatuses::class );
}
/**
* Share an ActionScheduler job class
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @throws InvalidClass When the given class does not implement the ActionSchedulerJobInterface.
*/
protected function share_action_scheduler_job( string $class_name, ...$arguments ) {
$this->validate_interface( $class_name, ActionSchedulerJobInterface::class );
$this->share_with_tags(
$class_name,
ActionScheduler::class,
ActionSchedulerJobMonitor::class,
...$arguments
);
}
/**
* Share a product syncer job class
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*/
protected function share_product_syncer_job( string $class_name, ...$arguments ) {
if ( is_subclass_of( $class_name, AbstractProductSyncerBatchedJob::class ) ) {
$this->share_action_scheduler_job(
$class_name,
ProductSyncer::class,
ProductRepository::class,
BatchProductHelper::class,
MerchantCenterService::class,
MerchantStatuses::class,
...$arguments
);
} else {
$this->share_action_scheduler_job(
$class_name,
ProductSyncer::class,
ProductRepository::class,
MerchantCenterService::class,
...$arguments
);
}
}
/**
* Share a coupon syncer job class
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*/
protected function share_coupon_syncer_job( string $class_name, ...$arguments ) {
$this->share_action_scheduler_job(
$class_name,
CouponHelper::class,
CouponSyncer::class,
WC::class,
MerchantCenterService::class,
...$arguments
);
}
}
Internal/DependencyManagement/ProxyServiceProvider.php 0000644 00000003406 15153721357 0017275 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\GoogleGtagJs;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\Tracks;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC as WCProxy;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\Jetpack;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\Definition;
use wpdb;
use function WC;
/**
* Class ProxyServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class ProxyServiceProvider extends AbstractServiceProvider {
/**
* Array of classes provided by this container.
*
* @var array
*/
protected $provides = [
RESTServer::class => true,
Tracks::class => true,
GoogleGtagJs::class => true,
WP::class => true,
Jetpack::class => true,
WCProxy::class => true,
];
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->share( RESTServer::class );
$this->share( Tracks::class );
$this->share( GoogleGtagJs::class );
$this->share( WP::class );
$this->share( Jetpack::class );
$this->share( WCProxy::class, WC()->countries );
// Use a wrapper function to get the wpdb object.
$this->share_concrete(
wpdb::class,
new Definition(
wpdb::class,
function () {
global $wpdb;
return $wpdb;
}
)
);
}
}
Internal/DependencyManagement/RESTServiceProvider.php 0000644 00000027015 15153721357 0016733 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AccountService as AdsAccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AssetSuggestionsService as AdsAssetSuggestionsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AccountController as AdsAccountController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\BudgetRecommendationController as AdsBudgetRecommendationController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\CampaignController as AdsCampaignController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\ReportsController as AdsReportsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\SetupCompleteController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetGroupController as AdsAssetGroupController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetSuggestionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\GTINMigrationController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\TourController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\DisconnectController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Google\AccountController as GoogleAccountController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Jetpack\AccountController as JetpackAccountController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\AccountController as MerchantCenterAccountController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\AttributeMappingCategoriesController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping\AttributeMappingDataController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping\AttributeMappingRulesController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\AttributeMapping\AttributeMappingSyncerController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\RequestReviewController as MerchantCenterRequestReviewController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ConnectionController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ContactInformationController as MerchantCenterContactInformationController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\IssuesController as MerchantCenterIssuesController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\PolicyComplianceCheckController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\PhoneVerificationController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ProductFeedController as MerchantCenterProductFeedController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ProductStatisticsController as MerchantCenterProductStatsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ProductVisibilityController as MerchantCenterProductVisibilityController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ReportsController as MerchantCenterReportsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\SettingsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\SettingsSyncController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ShippingRateBatchController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ShippingRateController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ShippingRateSuggestionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ShippingTimeBatchController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\ShippingTimeController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\SupportedCountriesController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\SyncableProductsCountController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter\TargetAudienceController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\RestAPI\AuthController as RestAPIAuthController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\OAuthService;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductFeedQueryHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\BudgetRecommendationQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantIssueQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\RequestReviewStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService as MerchantAccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\ContactInformation;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PhoneVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PolicyComplianceCheck;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\AttributeMappingHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingSuggestionService;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\AddressUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\DefinitionInterface;
/**
* Class RESTServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class RESTServiceProvider extends AbstractServiceProvider {
/**
* Returns a boolean if checking whether this provider provides a specific
* service or returns an array of provided services if no argument passed.
*
* @param string $service
*
* @return boolean
*/
public function provides( string $service ): bool {
return 'rest_controller' === $service;
}
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$this->share( SettingsController::class, ShippingZone::class );
$this->share( ConnectionController::class );
$this->share( AdsAccountController::class, AdsAccountService::class );
$this->share( AdsCampaignController::class, AdsCampaign::class );
$this->share( AdsAssetGroupController::class, AdsAssetGroup::class );
$this->share( AdsReportsController::class );
$this->share( GoogleAccountController::class, Connection::class );
$this->share( JetpackAccountController::class, Manager::class, Middleware::class );
$this->share( MerchantCenterProductStatsController::class, MerchantStatuses::class, ProductSyncStats::class );
$this->share( MerchantCenterIssuesController::class, MerchantStatuses::class, ProductHelper::class );
$this->share( MerchantCenterProductFeedController::class, ProductFeedQueryHelper::class );
$this->share( MerchantCenterProductVisibilityController::class, ProductHelper::class, MerchantIssueQuery::class );
$this->share( MerchantCenterContactInformationController::class, ContactInformation::class, Settings::class, AddressUtility::class );
$this->share( AdsBudgetRecommendationController::class, BudgetRecommendationQuery::class, Ads::class );
$this->share( PhoneVerificationController::class, PhoneVerification::class );
$this->share( MerchantCenterAccountController::class, MerchantAccountService::class );
$this->share( MerchantCenterRequestReviewController::class, Middleware::class, Merchant::class, RequestReviewStatuses::class, TransientsInterface::class );
$this->share( MerchantCenterReportsController::class );
$this->share( ShippingRateBatchController::class, ShippingRateQuery::class );
$this->share( ShippingRateController::class, ShippingRateQuery::class );
$this->share( ShippingRateSuggestionsController::class, ShippingSuggestionService::class );
$this->share( ShippingTimeBatchController::class );
$this->share( ShippingTimeController::class );
$this->share( TargetAudienceController::class, WP::class, WC::class, ShippingZone::class, GoogleHelper::class );
$this->share( SupportedCountriesController::class, WC::class, GoogleHelper::class );
$this->share( SettingsSyncController::class, Settings::class );
$this->share( DisconnectController::class );
$this->share( SetupCompleteController::class, MerchantMetrics::class );
$this->share( AssetSuggestionsController::class, AdsAssetSuggestionsService::class );
$this->share( SyncableProductsCountController::class, JobRepository::class );
$this->share( PolicyComplianceCheckController::class, PolicyComplianceCheck::class );
$this->share( AttributeMappingDataController::class, AttributeMappingHelper::class );
$this->share( AttributeMappingRulesController::class, AttributeMappingHelper::class, AttributeMappingRulesQuery::class );
$this->share( AttributeMappingCategoriesController::class );
$this->share( AttributeMappingSyncerController::class, ProductSyncStats::class );
$this->share( TourController::class );
$this->share( RestAPIAuthController::class, OAuthService::class, MerchantAccountService::class );
$this->share( GTINMigrationController::class, JobRepository::class );
}
/**
* Share a class.
*
* Overridden to include the RESTServer proxy with all classes.
*
* @param string $class_name The class name to add.
* @param mixed ...$arguments Constructor arguments for the class.
*
* @return DefinitionInterface
*/
protected function share( string $class_name, ...$arguments ): DefinitionInterface {
return parent::share( $class_name, RESTServer::class, ...$arguments )->addTag( 'rest_controller' );
}
}
Internal/DependencyManagement/ThirdPartyServiceProvider.php 0000644 00000004727 15153721357 0020255 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\Jetpack\Config;
use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\ISO3166;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\ISO3166DataProvider;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class ThirdPartyServiceProvider
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement
*/
class ThirdPartyServiceProvider extends AbstractServiceProvider {
use PluginHelper;
/**
* Array of classes provided by this container.
*
* Keys should be the class name, and the value can be anything (like `true`).
*
* @var array
*/
protected $provides = [
Config::class => true,
Manager::class => true,
ISO3166DataProvider::class => true,
ValidatorInterface::class => true,
];
/**
* Use the register method to register items with the container via the
* protected $this->container property or the `getContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register(): void {
$jetpack_id = 'google-listings-and-ads';
$this->share( Manager::class )->addArgument( $jetpack_id );
$this->share( Config::class )->addMethodCall(
'ensure',
[
'connection',
[
'slug' => $jetpack_id,
'name' => 'Google for WooCommerce', // Use hardcoded name for initial registration.
],
]
);
$this->share_concrete( ISO3166DataProvider::class, ISO3166::class );
$this->getContainer()
->inflector( ISO3166AwareInterface::class )
->invokeMethod( 'set_iso3166_provider', [ ISO3166DataProvider::class ] );
$this->share_concrete(
ValidatorInterface::class,
function () {
return Validation::createValidatorBuilder()
->addMethodMapping( 'load_validator_metadata' )
->getValidator();
}
);
// Update Jetpack connection with a translatable name, after init is called.
add_action(
'init',
function () {
$manager = $this->getContainer()->get( Manager::class );
$manager->get_plugin()->add(
__( 'Google for WooCommerce', 'google-listings-and-ads' )
);
}
);
}
}
Internal/DeprecatedFilters.php 0000644 00000004214 15153721357 0012434 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use WC_Deprecated_Hooks;
defined( 'ABSPATH' ) || exit;
/**
* Deprecated Filters class.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal
*/
class DeprecatedFilters extends WC_Deprecated_Hooks implements Service {
/**
* Array of deprecated hooks we need to handle.
* Format of 'new' => 'old'.
*
* @var array
*/
protected $deprecated_hooks = [
'woocommerce_gla_enable_connection_test' => 'gla_enable_connection_test',
'woocommerce_gla_enable_debug_logging' => 'gla_enable_debug_logging',
'woocommerce_gla_enable_reports' => 'gla_enable_reports',
];
/**
* Array of versions when each hook has been deprecated.
*
* @var array
*/
protected $deprecated_version = [
'gla_enable_connection_test' => '1.0.1',
'gla_enable_debug_logging' => '1.0.1',
'gla_enable_reports' => '1.0.1',
];
/**
* Hook into the new hook so we can handle deprecated hooks once fired.
*
* @param string $hook_name Hook name.
*/
public function hook_in( $hook_name ) {
add_filter( $hook_name, [ $this, 'maybe_handle_deprecated_hook' ], -1000, 8 );
}
/**
* If the old hook is in-use, trigger it.
*
* @param string $new_hook New hook name.
* @param string $old_hook Old hook name.
* @param array $new_callback_args New callback args.
* @param mixed $return_value Returned value.
* @return mixed
*/
public function handle_deprecated_hook( $new_hook, $old_hook, $new_callback_args, $return_value ) {
if ( has_filter( $old_hook ) ) {
$this->display_notice( $old_hook, $new_hook );
$return_value = $this->trigger_hook( $old_hook, $new_callback_args );
}
return $return_value;
}
/**
* Fire off a legacy hook with it's args.
*
* @param string $old_hook Old hook name.
* @param array $new_callback_args New callback args.
* @return mixed
*/
protected function trigger_hook( $old_hook, $new_callback_args ) {
return apply_filters_ref_array( $old_hook, $new_callback_args );
}
}
Internal/InstallTimestamp.php 0000644 00000002252 15153721357 0012335 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\FirstInstallInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class InstallTimestamp
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal
*/
class InstallTimestamp implements Conditional, FirstInstallInterface, OptionsAwareInterface {
use AdminConditional;
use OptionsAwareTrait;
use PluginHelper;
/**
* Logic to run when the plugin is first installed.
*/
public function first_install(): void {
$this->options->add( OptionsInterface::INSTALL_TIMESTAMP, time() );
$this->options->add( OptionsInterface::INSTALL_VERSION, $this->get_version() );
}
}
Internal/Interfaces/ContainerAwareInterface.php 0000644 00000001031 15153721357 0015643 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface;
defined( 'ABSPATH' ) || exit;
/**
* Interface ContainerAwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces
*/
interface ContainerAwareInterface {
/**
* @param ContainerInterface $container
*
* @return void
*/
public function set_container( ContainerInterface $container ): void;
}
Internal/Interfaces/FirstInstallInterface.php 0000644 00000000564 15153721357 0015371 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces;
/**
* Interface FirstInstallInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces
*/
interface FirstInstallInterface {
/**
* Logic to run when the plugin is first installed.
*/
public function first_install(): void;
}
Internal/Interfaces/ISO3166AwareInterface.php 0000644 00000001027 15153721357 0014700 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\ISO3166DataProvider;
defined( 'ABSPATH' ) || exit;
/**
* Interface ISO3166AwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits
*/
interface ISO3166AwareInterface {
/**
* @param ISO3166DataProvider $provider
*
* @return void
*/
public function set_iso3166_provider( ISO3166DataProvider $provider ): void;
}
Internal/Interfaces/InstallableInterface.php 0000644 00000001046 15153721357 0015201 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces;
defined( 'ABSPATH' ) || exit;
/**
* Interface Installable
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces
*/
interface InstallableInterface {
/**
* Run installation logic for this class.
*
* @param string $old_version Previous version before updating.
* @param string $new_version Current version after updating.
*/
public function install( string $old_version, string $new_version ): void;
}
Internal/Requirements/GoogleProductFeedValidator.php 0000644 00000005633 15153721357 0016743 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExtensionRequirementException;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Class GoogleProductFeedValidator
*
* @since 1.2.0
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
class GoogleProductFeedValidator extends RequirementValidator {
use PluginHelper;
/**
* Validate all requirements for the plugin to function properly.
*
* @return bool
*/
public function validate(): bool {
try {
$this->validate_google_product_feed_inactive();
} catch ( ExtensionRequirementException $e ) {
add_filter(
'woocommerce_gla_custom_merchant_issues',
function ( $issues, $current_time ) {
return $this->add_conflict_issue( $issues, $current_time );
},
10,
2
);
add_action(
'deactivated_plugin',
function ( $plugin ) {
if ( 'woocommerce-product-feeds/woocommerce-gpf.php' === $plugin ) {
/** @var MerchantStatuses $merchant_statuses */
$merchant_statuses = woogle_get_container()->get( MerchantStatuses::class );
if ( $merchant_statuses instanceof MerchantStatuses ) {
$merchant_statuses->clear_cache();
}
}
}
);
}
return true;
}
/**
* Validate that Google Product Feed isn't enabled.
*
* @throws ExtensionRequirementException When Google Product Feed is active.
*/
protected function validate_google_product_feed_inactive() {
if ( defined( 'WOOCOMMERCE_GPF_VERSION' ) ) {
throw ExtensionRequirementException::incompatible_plugin( 'Google Product Feed' );
}
}
/**
* Add an account-level issue regarding the plugin conflict
* to the array of issues to be saved in the database.
*
* @param array $issues The current array of account-level issues
* @param DateTime $cache_created_time The time of the cache/issues generation.
*
* @return array The issues with the new conflict issue included
*/
protected function add_conflict_issue( array $issues, DateTime $cache_created_time ): array {
$issues[] = [
'product_id' => 0,
'product' => 'All products',
'code' => 'incompatible_google_product_feed',
'issue' => __( 'The Google Product Feed plugin may cause conflicts or unexpected results.', 'google-listings-and-ads' ),
'action' => __( 'Deactivate the Google Product Feed plugin from your store', 'google-listings-and-ads' ),
'action_url' => 'https://developers.google.com/shopping-content/guides/best-practices#do-not-use-api-and-feeds',
'created_at' => $cache_created_time->format( 'Y-m-d H:i:s' ),
'type' => MerchantStatuses::TYPE_ACCOUNT,
'severity' => 'error',
'source' => 'filter',
];
return $issues;
}
}
Internal/Requirements/PluginValidator.php 0000644 00000002261 15153721357 0014632 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements\WCAdminValidator;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements\WCValidator;
defined( 'ABSPATH' ) || exit;
/**
* Class PluginValidator
*
* Display admin notices for required and incompatible plugins.
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
class PluginValidator {
private const PLUGINS = [
WCAdminValidator::class,
WCValidator::class,
GoogleProductFeedValidator::class,
];
/**
* @var bool $is_validated
* Holds the validation status of the plugin.
*/
protected static $is_validated = null;
/**
* Validate all required and incompatible plugins.
*
* @return bool
*/
public static function validate(): bool {
if ( null !== self::$is_validated ) {
return self::$is_validated;
}
self::$is_validated = true;
/** @var RequirementValidator $plugin */
foreach ( self::PLUGINS as $plugin ) {
if ( ! $plugin::instance()->validate() ) {
self::$is_validated = false;
}
}
return self::$is_validated;
}
}
Internal/Requirements/RequirementValidator.php 0000644 00000002451 15153721357 0015675 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\RuntimeExceptionWithMessageFunction;
defined( 'ABSPATH' ) || exit;
/**
* Class RequirementValidator
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
abstract class RequirementValidator implements RequirementValidatorInterface {
/**
* @var RequirementValidator[]
*/
private static $instances = [];
/**
* Get the instance of the RequirementValidator object.
*
* @return RequirementValidator
*/
public static function instance(): RequirementValidator {
$class = get_called_class();
if ( ! isset( self::$instances[ $class ] ) ) {
self::$instances[ $class ] = new $class();
}
return self::$instances[ $class ];
}
/**
* Add a standard requirement validation error notice.
*
* @param RuntimeExceptionWithMessageFunction $e
*/
protected function add_admin_notice( RuntimeExceptionWithMessageFunction $e ) {
// Display notice error message.
add_action(
'admin_notices',
function () use ( $e ) {
echo '<div class="notice notice-error">' . PHP_EOL;
echo ' <p>' . esc_html( $e->get_formatted_message() ) . '</p>' . PHP_EOL;
echo '</div>' . PHP_EOL;
}
);
}
}
Internal/Requirements/RequirementValidatorInterface.php 0000644 00000000732 15153721357 0017516 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
defined( 'ABSPATH' ) || exit;
/**
* Interface RequirementValidatorInterface
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
interface RequirementValidatorInterface {
/**
* Validate requirements for plugin to function properly.
*
* @return bool True if the requirements are met.
*/
public function validate(): bool;
}
Internal/Requirements/VersionValidator.php 0000644 00000003062 15153721357 0015021 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidVersion;
defined( 'ABSPATH' ) || exit;
/**
* Class VersionValidator. Validates PHP Requirements like the version and the architecture.
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
class VersionValidator extends RequirementValidator {
/**
* Validate all requirements for the plugin to function properly.
*
* @return bool
*/
public function validate(): bool {
try {
$this->validate_php_version();
$this->validate_php_architecture();
return true;
} catch ( InvalidVersion $e ) {
$this->add_admin_notice( $e );
return false;
}
}
/**
* Validate the PHP version being used.
*
* @throws InvalidVersion When the PHP version does not meet the minimum version.
*/
protected function validate_php_version() {
if ( ! version_compare( PHP_VERSION, WC_GLA_MIN_PHP_VER, '>=' ) ) {
throw InvalidVersion::from_requirement( 'PHP', PHP_VERSION, WC_GLA_MIN_PHP_VER );
}
}
/**
* Validate the PHP Architecture being 64 Bits.
* This is done by checking PHP_INT_SIZE. In 32 bits this will be 4 Bytes. In 64 Bits this will be 8 Bytes
*
* @see https://www.php.net/manual/en/language.types.integer.php
* @since 2.3.9
*
* @throws InvalidVersion When the PHP Architecture is not 64 Bits.
*/
protected function validate_php_architecture() {
if ( PHP_INT_SIZE !== 8 ) {
throw InvalidVersion::invalid_architecture();
}
}
}
Internal/Requirements/WCAdminValidator.php 0000644 00000002051 15153721357 0014653 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExtensionRequirementException;
defined( 'ABSPATH' ) || exit;
/**
* Class WCAdminValidator
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
class WCAdminValidator extends RequirementValidator {
/**
* Validate all requirements for the plugin to function properly.
*
* @return bool
*/
public function validate(): bool {
try {
$this->validate_wc_admin_active();
return true;
} catch ( ExtensionRequirementException $e ) {
$this->add_admin_notice( $e );
return false;
}
}
/**
* Validate that WooCommerce Admin is enabled.
*
* @throws ExtensionRequirementException When the WooCommerce Admin is disabled by hook.
*/
protected function validate_wc_admin_active() {
if ( apply_filters( 'woocommerce_admin_disabled', false ) ) {
throw ExtensionRequirementException::missing_required_plugin( 'WooCommerce Admin' );
}
}
}
Internal/Requirements/WCValidator.php 0000644 00000002243 15153721357 0013705 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\Requirements;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidVersion;
defined( 'ABSPATH' ) || exit;
/**
* Class WCValidator
*
* @package AutomatticWooCommerceGoogleListingsAndAdsInternalRequirements
*/
class WCValidator extends RequirementValidator {
/**
* Validate all requirements for the plugin to function properly.
*
* @return bool
*/
public function validate(): bool {
try {
$this->validate_wc_version();
return true;
} catch ( InvalidVersion $e ) {
$this->add_admin_notice( $e );
return false;
}
}
/**
* Validate the minimum required WooCommerce version (after plugins are fully loaded).
*
* @throws InvalidVersion When the WooCommerce version does not meet the minimum version.
*/
protected function validate_wc_version() {
if ( ! defined( 'WC_VERSION' ) ) {
throw InvalidVersion::requirement_missing( 'WooCommerce', WC_GLA_MIN_WC_VER );
}
if ( ! version_compare( WC_VERSION, WC_GLA_MIN_WC_VER, '>=' ) ) {
throw InvalidVersion::from_requirement( 'WooCommerce', WC_VERSION, WC_GLA_MIN_WC_VER );
}
}
}
Internal/StatusMapping.php 0000644 00000001705 15153721357 0011644 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal;
defined( 'ABSPATH' ) || exit;
/**
* Class for mapping between a status number and a status label.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Internal
*/
abstract class StatusMapping {
/**
* Return the status as a label.
*
* @param int $number Status number.
*
* @return string
*/
public static function label( int $number ): string {
return isset( static::MAPPING[ $number ] ) ? static::MAPPING[ $number ] : '';
}
/**
* Return the status as a number.
*
* @param string $label Status label.
*
* @return int
*/
public static function number( string $label ): int {
$key = array_search( $label, static::MAPPING, true );
return $key === false ? 0 : $key;
}
/**
* Return all the status labels.
*
* @return array
*/
public static function labels(): array {
return array_values( static::MAPPING );
}
}
Jobs/AbstractActionSchedulerJob.php 0000644 00000007104 15153721357 0013360 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractActionSchedulerJob
*
* Abstract class for jobs that use ActionScheduler.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
abstract class AbstractActionSchedulerJob implements ActionSchedulerJobInterface {
use PluginHelper;
/**
* @var ActionSchedulerInterface
*/
protected $action_scheduler;
/**
* @var ActionSchedulerJobMonitor
*/
protected $monitor;
/**
* Whether the job should be rescheduled on timeout.
*
* @var bool
*/
protected $retry_on_timeout = true;
/**
* AbstractActionSchedulerJob constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
*/
public function __construct( ActionSchedulerInterface $action_scheduler, ActionSchedulerJobMonitor $monitor ) {
$this->action_scheduler = $action_scheduler;
$this->monitor = $monitor;
}
/**
* Init the batch schedule for the job.
*
* The job name is used to generate the schedule event name.
*/
public function init(): void {
add_action( $this->get_process_item_hook(), [ $this, 'handle_process_items_action' ] );
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return ! $this->is_running( $args );
}
/**
* Handles processing single item action hook.
*
* @hooked gla/jobs/{$job_name}/process_item
*
* @param array $items The job items from the current batch.
*
* @throws Exception If an error occurs.
*/
public function handle_process_items_action( array $items = [] ) {
$process_hook = $this->get_process_item_hook();
$process_args = [ $items ];
$this->monitor->validate_failure_rate( $this, $process_hook, $process_args );
if ( $this->retry_on_timeout ) {
$this->monitor->attach_timeout_monitor( $process_hook, $process_args );
}
try {
$this->process_items( $items );
} catch ( Exception $exception ) {
// reschedule on failure
$this->action_scheduler->schedule_immediate( $process_hook, $process_args );
// throw the exception again so that it can be logged
throw $exception;
}
$this->monitor->detach_timeout_monitor( $process_hook, $process_args );
}
/**
* Check if this job is running.
*
* The job is considered to be running if the "process_item" action is currently pending or in-progress.
*
* @param array|null $args
*
* @return bool
*/
protected function is_running( ?array $args = [] ): bool {
return $this->action_scheduler->has_scheduled_action( $this->get_process_item_hook(), $args );
}
/**
* Get the base name for the job's scheduled actions.
*
* @return string
*/
protected function get_hook_base_name(): string {
return "{$this->get_slug()}/jobs/{$this->get_name()}/";
}
/**
* Get the hook name for the "process item" action.
*
* This method is required by the job monitor.
*
* @return string
*/
public function get_process_item_hook(): string {
return "{$this->get_hook_base_name()}process_item";
}
/**
* Process batch items.
*
* @param array $items A single batch from the get_batch() method.
*
* @throws Exception If an error occurs. The exception will be logged by ActionScheduler.
*/
abstract protected function process_items( array $items );
}
Jobs/AbstractBatchedActionSchedulerJob.php 0000644 00000013025 15153721357 0014632 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* AbstractBatchedActionSchedulerJob class.
*
* Enables a job to be processed in recurring scheduled batches with queued events.
*
* Notes:
* - Uses ActionScheduler's very scalable async actions feature which will run async batches in loop back requests until all batches are done
* - Items may be processed concurrently by AS, but batches will be created one after the other, not concurrently
* - The job will not start if it is already running
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
abstract class AbstractBatchedActionSchedulerJob extends AbstractActionSchedulerJob implements BatchedActionSchedulerJobInterface {
/**
* Init the batch schedule for the job.
*
* The job name is used to generate the schedule event name.
*/
public function init(): void {
add_action( $this->get_create_batch_hook(), [ $this, 'handle_create_batch_action' ] );
parent::init();
}
/**
* Get the hook name for the "create batch" action.
*
* @return string
*/
protected function get_create_batch_hook(): string {
return "{$this->get_hook_base_name()}create_batch";
}
/**
* Enqueue the "create_batch" action provided it doesn't already exist.
*
* To minimize the resource use of starting the job the batch creation is handled async.
*
* @param array $args
*/
public function schedule( array $args = [] ) {
$this->schedule_create_batch_action( 1 );
}
/**
* Handles batch creation action hook.
*
* @hooked gla/jobs/{$job_name}/create_batch
*
* Schedules an action to run immediately for the items in the batch.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @throws Exception If an error occurs.
* @throws JobException If the job failure rate is too high.
*/
public function handle_create_batch_action( int $batch_number ) {
$create_batch_hook = $this->get_create_batch_hook();
$create_batch_args = [ $batch_number ];
$this->monitor->validate_failure_rate( $this, $create_batch_hook, $create_batch_args );
if ( $this->retry_on_timeout ) {
$this->monitor->attach_timeout_monitor( $create_batch_hook, $create_batch_args );
}
$items = $this->get_batch( $batch_number );
if ( empty( $items ) ) {
// if no more items the job is complete
$this->handle_complete( $batch_number );
} else {
// if items, schedule the process action
$this->schedule_process_action( $items );
// Add another "create_batch" action to handle unfiltered items.
// The last batch created here will be an empty batch, it
// will call "handle_complete" to finish the job.
$this->schedule_create_batch_action( $batch_number + 1 );
}
$this->monitor->detach_timeout_monitor( $create_batch_hook, $create_batch_args );
}
/**
* Get job batch size.
*
* @return int
*/
protected function get_batch_size(): int {
/**
* Filters the batch size for the job.
*
* @param string Job's name
*/
return apply_filters( 'woocommerce_gla_batched_job_size', 100, $this->get_name() );
}
/**
* Get the query offset based on a given batch number and the specified batch size.
*
* @param int $batch_number
*
* @return int
*/
protected function get_query_offset( int $batch_number ): int {
return $this->get_batch_size() * ( $batch_number - 1 );
}
/**
* Schedule a new "create batch" action to run immediately.
*
* @param int $batch_number The batch number for the new batch.
*/
protected function schedule_create_batch_action( int $batch_number ) {
if ( $this->can_schedule( [ $batch_number ] ) ) {
$this->action_scheduler->schedule_immediate( $this->get_create_batch_hook(), [ $batch_number ] );
}
}
/**
* Schedule a new "process" action to run immediately.
*
* @param int[] $items Array of item ids.
*/
protected function schedule_process_action( array $items ) {
if ( ! $this->is_processing( $items ) ) {
$this->action_scheduler->schedule_immediate( $this->get_process_item_hook(), [ $items ] );
}
}
/**
* Check if this job is running.
*
* The job is considered to be running if a "create_batch" action is currently pending or in-progress.
*
* @param array|null $args
*
* @return bool
*/
protected function is_running( ?array $args = [] ): bool {
return $this->action_scheduler->has_scheduled_action( $this->get_create_batch_hook(), $args );
}
/**
* Check if this job is processing the given items.
*
* The job is considered to be processing if a "process_item" action is currently pending or in-progress.
*
* @param array $items
*
* @return bool
*/
protected function is_processing( array $items = [] ): bool {
return $this->action_scheduler->has_scheduled_action( $this->get_process_item_hook(), [ $items ] );
}
/**
* Called when the job is completed.
*
* @param int $final_batch_number The final batch number when the job was completed.
* If equal to 1 then no items were processed by the job.
*/
protected function handle_complete( int $final_batch_number ) {
// Optionally over-ride this method in child class.
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return array
*
* @throws Exception If an error occurs. The exception will be logged by ActionScheduler.
*/
abstract protected function get_batch( int $batch_number ): array;
}
Jobs/AbstractCouponSyncerJob.php 0000644 00000003712 15153721357 0012734 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractCouponSyncerJob
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
abstract class AbstractCouponSyncerJob extends AbstractActionSchedulerJob {
/**
* @var CouponHelper
*/
protected $coupon_helper;
/**
* @var CouponSyncer
*/
protected $coupon_syncer;
/**
* @var WC
*/
protected $wc;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* AbstractCouponSyncerJob constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param CouponHelper $coupon_helper
* @param CouponSyncer $coupon_syncer
* @param WC $wc
* @param MerchantCenterService $merchant_center
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
CouponHelper $coupon_helper,
CouponSyncer $coupon_syncer,
WC $wc,
MerchantCenterService $merchant_center
) {
$this->coupon_helper = $coupon_helper;
$this->coupon_syncer = $coupon_syncer;
$this->wc = $wc;
$this->merchant_center = $merchant_center;
parent::__construct( $action_scheduler, $monitor );
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return ! $this->is_running( $args ) && $this->merchant_center->should_push();
}
}
Jobs/AbstractProductSyncerBatchedJob.php 0000644 00000004701 15153721357 0014363 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\BatchProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractGoogleBatchedJob
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
abstract class AbstractProductSyncerBatchedJob extends AbstractBatchedActionSchedulerJob {
/**
* @var ProductSyncer
*/
protected $product_syncer;
/**
* @var ProductRepository
*/
protected $product_repository;
/**
* @var BatchProductHelper
*/
protected $batch_product_helper;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var MerchantStatuses
*/
protected $merchant_statuses;
/**
* AbstractProductSyncerBatchedJob constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param ProductSyncer $product_syncer
* @param ProductRepository $product_repository
* @param BatchProductHelper $batch_product_helper
* @param MerchantCenterService $merchant_center
* @param MerchantStatuses $merchant_statuses
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
ProductSyncer $product_syncer,
ProductRepository $product_repository,
BatchProductHelper $batch_product_helper,
MerchantCenterService $merchant_center,
MerchantStatuses $merchant_statuses
) {
$this->batch_product_helper = $batch_product_helper;
$this->product_syncer = $product_syncer;
$this->product_repository = $product_repository;
$this->merchant_center = $merchant_center;
$this->merchant_statuses = $merchant_statuses;
parent::__construct( $action_scheduler, $monitor );
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return ! $this->is_running( $args ) && $this->merchant_center->should_push();
}
}
Jobs/AbstractProductSyncerJob.php 0000644 00000003513 15153721357 0013110 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractProductSyncerJob
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
abstract class AbstractProductSyncerJob extends AbstractActionSchedulerJob {
/**
* @var ProductSyncer
*/
protected $product_syncer;
/**
* @var ProductRepository
*/
protected $product_repository;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* AbstractProductSyncerJob constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param ProductSyncer $product_syncer
* @param ProductRepository $product_repository
* @param MerchantCenterService $merchant_center
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
ProductSyncer $product_syncer,
ProductRepository $product_repository,
MerchantCenterService $merchant_center
) {
$this->product_syncer = $product_syncer;
$this->product_repository = $product_repository;
$this->merchant_center = $merchant_center;
parent::__construct( $action_scheduler, $monitor );
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return ! $this->is_running( $args ) && $this->merchant_center->should_push();
}
}
Jobs/ActionSchedulerJobInterface.php 0000644 00000001421 15153721357 0013511 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
defined( 'ABSPATH' ) || exit;
/**
* Interface ActionSchedulerJobInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
interface ActionSchedulerJobInterface extends JobInterface {
/**
* Get the hook name for the "process item" action.
*
* This method is required by the job monitor.
*
* @return string
*/
public function get_process_item_hook(): string;
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool;
/**
* Schedule the job.
*
* @param array $args
*/
public function schedule( array $args = [] );
}
Jobs/ActionSchedulerJobMonitor.php 0000644 00000015610 15153721357 0013245 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class ActionSchedulerJobMonitor
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class ActionSchedulerJobMonitor implements Service {
use PluginHelper;
/**
* @var ActionSchedulerInterface
*/
protected $action_scheduler;
/**
* @var bool[] Array of `true` values for each job that is monitored. A hash string generated by `self::get_job_hash`
* is used as keys.
*/
protected $monitored_hooks = [];
/**
* ActionSchedulerJobMonitor constructor.
*
* @param ActionSchedulerInterface $action_scheduler
*/
public function __construct( ActionSchedulerInterface $action_scheduler ) {
$this->action_scheduler = $action_scheduler;
}
/**
* Check whether the failure rate is above the specified threshold within the timeframe.
*
* To protect against failing jobs running forever the job's failure rate is checked before creating a new batch.
* By default, a job is stopped if it has 5 failures in the last hour.
*
* @param ActionSchedulerJobInterface $job
* @param string $hook The job action hook.
* @param array|null $args The job arguments.
*
* @throws JobException If the job's error rate is above the threshold.
*/
public function validate_failure_rate( ActionSchedulerJobInterface $job, string $hook, ?array $args = null ) {
if ( $this->is_failure_rate_above_threshold( $hook, $args ) ) {
throw JobException::stopped_due_to_high_failure_rate( $job->get_name() );
}
}
/**
* Reschedules the job if it has failed due to timeout.
*
* @param string $hook The job action hook.
* @param array|null $args The job arguments.
*
* @since 1.7.0
*/
public function attach_timeout_monitor( string $hook, ?array $args = null ) {
$this->monitored_hooks[ self::get_job_hash( $hook, $args ) ] = true;
add_action(
'action_scheduler_unexpected_shutdown',
[ $this, 'reschedule_if_timeout' ],
10,
2
);
}
/**
* Detaches the timeout monitor that handles rescheduling jobs on timeout.
*
* @param string $hook The job action hook.
* @param array|null $args The job arguments.
*
* @since 1.7.0
*/
public function detach_timeout_monitor( string $hook, ?array $args = null ) {
unset( $this->monitored_hooks[ self::get_job_hash( $hook, $args ) ] );
remove_action( 'action_scheduler_unexpected_shutdown', [ $this, 'reschedule_if_timeout' ] );
}
/**
* Reschedules an action if it has failed due to a timeout error.
*
* The number of previous failures will be checked before rescheduling the action, and it must be below the
* specified threshold in `self::get_failure_rate_threshold` within the timeframe specified in
* `self::get_failure_timeframe` for the action to be rescheduled.
*
* @param int $action_id
* @param array $error
*
* @since 1.7.0
*/
public function reschedule_if_timeout( $action_id, $error ) {
if ( ! empty( $error ) && $this->is_timeout_error( $error ) ) {
$action = $this->action_scheduler->fetch_action( $action_id );
$hook = $action->get_hook();
$args = $action->get_args();
// Confirm that the job is initiated by GLA and monitored by this instance.
// The `self::attach_timeout_monitor` method will register the job's hook and arguments hash into the $monitored_hooks variable.
if ( $this->get_slug() !== $action->get_group() || ! $this->is_monitored_for_timeout( $hook, $args ) ) {
return;
}
// Check if the job has not failed more than the allowed threshold.
if ( $this->is_failure_rate_above_threshold( $hook, $args ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'The %s job failed too many times, not rescheduling.', $hook ),
__METHOD__
);
return;
}
do_action(
'woocommerce_gla_debug_message',
sprintf( 'The %s job has failed due to a timeout error, rescheduling...', $hook ),
__METHOD__
);
$this->action_scheduler->schedule_immediate( $hook, $args );
}
}
/**
* Determines whether the given error is an execution "timeout" error.
*
* @param array $error An associative array describing the error with keys "type", "message", "file" and "line".
*
* @return bool
*
* @link https://www.php.net/manual/en/function.error-get-last.php
*
* @since 1.7.0
*/
protected function is_timeout_error( array $error ): bool {
return isset( $error['type'] ) && $error['type'] === E_ERROR &&
isset( $error['message'] ) && strpos( $error ['message'], 'Maximum execution time' ) !== false;
}
/**
* Check whether the job's failure rate is above the specified threshold within the timeframe.
*
* @param string $hook The job action hook.
* @param array|null $args The job arguments.
*
* @return bool True if the job's error rate is above the threshold, and false otherwise.
*
* @see ActionSchedulerJobMonitor::get_failure_rate_threshold()
* @see ActionSchedulerJobMonitor::get_failure_timeframe()
*
* @since 1.7.0
*/
protected function is_failure_rate_above_threshold( string $hook, ?array $args = null ): bool {
$failed_actions = $this->action_scheduler->search(
[
'hook' => $hook,
'args' => $args,
'status' => $this->action_scheduler::STATUS_FAILED,
'per_page' => $this->get_failure_rate_threshold(),
'date' => gmdate( 'U' ) - $this->get_failure_timeframe(),
'date_compare' => '>',
],
'ids'
);
return count( $failed_actions ) >= $this->get_failure_rate_threshold();
}
/**
* Get the job failure rate threshold (per timeframe).
*
* @return int
*/
protected function get_failure_rate_threshold(): int {
return absint( apply_filters( 'woocommerce_gla_job_failure_rate_threshold', 3 ) );
}
/**
* Get the job failure timeframe (in seconds).
*
* @return int
*/
protected function get_failure_timeframe(): int {
return absint( apply_filters( 'woocommerce_gla_job_failure_timeframe', 2 * HOUR_IN_SECONDS ) );
}
/**
* Generates a unique hash (checksum) for each job using its hook name and arguments.
*
* @param string $hook
* @param array|null $args
*
* @return string
*
* @since 1.7.0
*/
protected static function get_job_hash( string $hook, ?array $args = null ): string {
return hash( 'crc32b', $hook . wp_json_encode( $args ) );
}
/**
* Determines whether the given set of job hook and arguments is monitored for timeout.
*
* @param string $hook
* @param array|null $args
*
* @return bool
*
* @since 1.7.0
*/
protected function is_monitored_for_timeout( string $hook, ?array $args = null ): bool {
return isset( $this->monitored_hooks[ self::get_job_hash( $hook, $args ) ] );
}
}
Jobs/BatchedActionSchedulerJobInterface.php 0000644 00000001660 15153721357 0014771 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Interface BatchedActionSchedulerJobInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
interface BatchedActionSchedulerJobInterface extends ActionSchedulerJobInterface {
/**
* Handles batch creation action hook.
*
* @hooked gla/jobs/{$job_name}/create_batch
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @throws Exception If an error occurs.
*/
public function handle_create_batch_action( int $batch_number );
/**
* Handles processing a single batch action hook.
*
* @hooked gla/jobs/{$job_name}/process_item
*
* @param array $items The job items from the current batch.
*
* @throws Exception If an error occurs.
*/
public function handle_process_items_action( array $items );
}
Jobs/CleanupProductsJob.php 0000644 00000003133 15153721357 0011731 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
defined( 'ABSPATH' ) || exit;
/**
* Class CleanupProductsJob
*
* Deletes all stale Google products from Google Merchant Center.
* Stale products are the ones that are no longer relevant due to a change in merchant settings.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class CleanupProductsJob extends AbstractProductSyncerBatchedJob {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'cleanup_products_job';
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return array
*/
public function get_batch( int $batch_number ): array {
return $this->product_repository->find_synced_product_ids( [], $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
protected function process_items( array $items ) {
$products = $this->product_repository->find_by_ids( $items );
$stale_entries = $this->batch_product_helper->generate_stale_products_request_entries( $products );
$this->product_syncer->delete_by_batch_requests( $stale_entries );
}
}
Jobs/CleanupSyncedProducts.php 0000644 00000004254 15153721357 0012451 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
defined( 'ABSPATH' ) || exit;
/**
* Class CleanupSyncedProducts
*
* Marks products as unsynced when we disconnect the Merchant Account.
* The Merchant Center must remain disconnected during the job. If it is
* reconnected during the job it will stop processing, since the
* ProductSyncer will take over and update all the products.
*
* @since 1.12.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class CleanupSyncedProducts extends AbstractProductSyncerBatchedJob {
/**
* Get whether Merchant Center is connected.
*
* @return bool
*/
public function is_mc_connected(): bool {
return $this->merchant_center->is_connected();
}
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'cleanup_synced_products';
}
/**
* Can the job be scheduled.
* Only cleanup when the Merchant Center is disconnected.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return ! $this->is_running( $args ) && ! $this->is_mc_connected();
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return int[]
*/
public function get_batch( int $batch_number ): array {
return $this->product_repository->find_synced_product_ids( [], $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Process batch items.
* Skips processing if the Merchant Center has been connected again.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*/
protected function process_items( array $items ) {
if ( $this->is_mc_connected() ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Skipping cleanup of unsynced products because Merchant Center is connected: %s',
implode( ',', $items )
),
__METHOD__
);
return;
}
$this->batch_product_helper->mark_batch_as_unsynced( $items );
}
}
Jobs/DeleteAllProducts.php 0000644 00000003564 15153721357 0011552 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
defined( 'ABSPATH' ) || exit;
/**
* Class DeleteAllProducts
*
* Deletes all WooCommerce products from Google Merchant Center.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class DeleteAllProducts extends AbstractProductSyncerBatchedJob {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'delete_all_products';
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return int[]
*/
protected function get_batch( int $batch_number ): array {
return $this->product_repository->find_synced_product_ids( [], $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
protected function process_items( array $items ) {
$products = $this->product_repository->find_by_ids( $items );
$product_entries = $this->batch_product_helper->generate_delete_request_entries( $products );
$this->product_syncer->delete_by_batch_requests( $product_entries );
}
/**
* Called when the job is completed.
*
* @since 2.6.4
*
* @param int $final_batch_number The final batch number when the job was completed.
* If equal to 1 then no items were processed by the job.
*/
protected function handle_complete( int $final_batch_number ) {
$this->merchant_statuses->maybe_refresh_status_data( true );
}
}
Jobs/DeleteCoupon.php 0000644 00000005125 15153721357 0010554 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncerException;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\DeleteCouponEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
defined( 'ABSPATH' ) || exit();
/**
* Class DeleteCoupon
*
* Delete existing WooCommerce coupon from Google Merchant Center.
*
* Note: The job will not start if it is already running.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class DeleteCoupon extends AbstractCouponSyncerJob implements
StartOnHookInterface {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'delete_coupon';
}
/**
* Process an item.
*
* @param array[] $coupon_entries
*
* @throws JobException If no valid coupon data is provided as argument. The exception will be logged by ActionScheduler.
* @throws CouponSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
public function process_items( array $coupon_entries ) {
$wc_coupon_id = $coupon_entries[0] ?? null;
$google_promotion = $coupon_entries[1] ?? null;
$google_ids = $coupon_entries[2] ?? null;
if ( ( ! is_int( $wc_coupon_id ) ) || empty( $google_promotion ) || empty( $google_ids ) ) {
throw JobException::item_not_provided(
'Required data for the coupon to delete'
);
}
$this->coupon_syncer->delete(
new DeleteCouponEntry(
$wc_coupon_id,
new GooglePromotion( $google_promotion ),
$google_ids
)
);
}
/**
* Schedule the job.
*
* @param array[] $args
*
* @throws JobException If no coupon is provided as argument. The exception will be logged by ActionScheduler.
*/
public function schedule( array $args = [] ) {
$coupon_entry = $args[0] ?? null;
if ( ! $coupon_entry instanceof DeleteCouponEntry ) {
throw JobException::item_not_provided(
'DeleteCouponEntry for the coupon to delete'
);
}
if ( $this->can_schedule( [ $coupon_entry ] ) ) {
$this->action_scheduler->schedule_immediate(
$this->get_process_item_hook(),
[
[
$coupon_entry->get_wc_coupon_id(),
$coupon_entry->get_google_promotion(),
$coupon_entry->get_synced_google_ids(),
],
]
);
}
}
/**
* Get the name of an action hook to attach the job's start method to.
*
* @return StartHook
*/
public function get_start_hook(): StartHook {
return new StartHook( "{$this->get_hook_base_name()}start" );
}
}
Jobs/DeleteProducts.php 0000644 00000004403 15153721357 0011112 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ProductIDMap;
defined( 'ABSPATH' ) || exit;
/**
* Class DeleteProducts
*
* Deletes WooCommerce products from Google Merchant Center.
*
* Note: The job will not start if it is already running.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class DeleteProducts extends AbstractProductSyncerJob implements StartOnHookInterface {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'delete_products';
}
/**
* Process an item.
*
* @param string[] $product_id_map An array of Google product IDs mapped to WooCommerce product IDs as their key.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
public function process_items( array $product_id_map ) {
$product_entries = BatchProductIDRequestEntry::create_from_id_map( new ProductIDMap( $product_id_map ) );
$this->product_syncer->delete_by_batch_requests( $product_entries );
}
/**
* Schedule the job.
*
* @param array $args
*
* @throws JobException If no product is provided as argument. The exception will be logged by ActionScheduler.
*/
public function schedule( array $args = [] ) {
$args = $args[0] ?? [];
$id_map = ( new ProductIDMap( $args ) )->get();
if ( empty( $id_map ) ) {
throw JobException::item_not_provided( 'Array of WooCommerce product IDs' );
}
if ( did_action( 'woocommerce_gla_batch_retry_delete_products' ) ) {
// Retry after one minute.
$this->action_scheduler->schedule_single( gmdate( 'U' ) + 60, $this->get_process_item_hook(), [ $id_map ] );
} elseif ( $this->can_schedule( [ $id_map ] ) ) {
$this->action_scheduler->schedule_immediate( $this->get_process_item_hook(), [ $id_map ] );
}
}
/**
* Get an action hook to attach the job's start method to.
*
* @return StartHook
*/
public function get_start_hook(): StartHook {
return new StartHook( 'woocommerce_gla_batch_retry_delete_products', 1 );
}
}
Jobs/JobException.php 0000644 00000003636 15153721357 0010564 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use RuntimeException;
defined( 'ABSPATH' ) || exit;
/**
* Class JobException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class JobException extends RuntimeException implements GoogleListingsAndAdsException {
/**
* Create a new exception instance for when a job item is not found.
*
* @return static
*/
public static function item_not_found(): JobException {
return new static( __( 'Job item not found.', 'google-listings-and-ads' ) );
}
/**
* Create a new exception instance for when a required job item is not provided.
*
* @param string $item The item name.
*
* @return static
*/
public static function item_not_provided( string $item ): JobException {
return new static(
sprintf(
/* translators: %s: the job item name */
__( 'Required job item "%s" not provided.', 'google-listings-and-ads' ),
$item
)
);
}
/**
* Create a new exception instance for when a job is stopped due to a high failure rate.
*
* @param string $job_name
*
* @return static
*/
public static function stopped_due_to_high_failure_rate( string $job_name ): JobException {
return new static(
sprintf(
/* translators: %s: the job name */
__( 'The "%s" job was stopped because its failure rate is above the allowed threshold.', 'google-listings-and-ads' ),
$job_name
)
);
}
/**
* Create a new exception instance for when a job class is not found.
*
* @param string $job_classname
*
* @return static
*/
public static function job_does_not_exist( string $job_classname ): JobException {
return new static(
sprintf(
/* translators: %s: the job classname */
__( 'The job "%s" does not exist.', 'google-listings-and-ads' ),
$job_classname
)
);
}
}
Jobs/JobInitializer.php 0000644 00000004360 15153721357 0011104 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Class JobInitializer
*
* Initializes all jobs when certain conditions are met (e.g. the request is async or initiated by CRON, CLI, etc.).
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class JobInitializer implements Registerable, Conditional {
/**
* @var JobRepository
*/
protected $job_repository;
/**
* @var ActionSchedulerInterface
*/
protected $action_scheduler;
/**
* JobInitializer constructor.
*
* @param JobRepository $job_repository
* @param ActionSchedulerInterface $action_scheduler
*/
public function __construct( JobRepository $job_repository, ActionSchedulerInterface $action_scheduler ) {
$this->job_repository = $job_repository;
$this->action_scheduler = $action_scheduler;
}
/**
* Initialize all jobs.
*/
public function register(): void {
foreach ( $this->job_repository->list() as $job ) {
$job->init();
if ( $job instanceof StartOnHookInterface ) {
add_action(
$job->get_start_hook()->get_hook(),
function ( ...$args ) use ( $job ) {
$job->schedule( $args );
},
10,
$job->get_start_hook()->get_argument_count()
);
}
if (
$job instanceof RecurringJobInterface &&
! $this->action_scheduler->has_scheduled_action( $job->get_start_hook()->get_hook() ) &&
$job->can_schedule()
) {
$recurring_date_time = new DateTime( 'tomorrow 3am', wp_timezone() );
$schedule = '0 3 * * *'; // 3 am every day
$this->action_scheduler->schedule_cron( $recurring_date_time->getTimestamp(), $schedule, $job->get_start_hook()->get_hook() );
}
}
}
/**
* Check whether this object is currently needed.
*
* @return bool Whether the object is needed.
*/
public static function is_needed(): bool {
return ( defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) || ( defined( 'WP_CLI' ) && WP_CLI ) || is_admin() );
}
}
Jobs/JobInterface.php 0000644 00000001251 15153721357 0010515 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement\JobServiceProvider;
defined( 'ABSPATH' ) || exit;
/**
* Interface JobInterface
*
* Note: In order for the jobs to be initialized/registered, they need to be added to the container.
*
* @see JobServiceProvider to add job classes to the container.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
interface JobInterface {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string;
/**
* Init the job.
*/
public function init(): void;
}
Jobs/JobRepository.php 0000644 00000003020 15153721357 0010770 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class JobRepository
*
* ContainerAware used for:
* - JobInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class JobRepository implements ContainerAwareInterface, Service {
use ContainerAwareTrait;
/**
* @var JobInterface[] indexed by class name.
*/
protected $jobs = [];
/**
* Fetch all jobs from Container.
*
* @return JobInterface[]
*/
public function list(): array {
foreach ( $this->container->get( JobInterface::class ) as $job ) {
$this->jobs[ get_class( $job ) ] = $job;
}
return $this->jobs;
}
/**
* Fetch job from Container (or cache if previously fetched).
*
* @param string $classname Job class name.
*
* @return JobInterface
*
* @throws JobException If the job is not found.
*/
public function get( string $classname ): JobInterface {
if ( ! isset( $this->jobs[ $classname ] ) ) {
try {
$job = $this->container->get( $classname );
} catch ( Exception $e ) {
throw JobException::job_does_not_exist( $classname );
}
$classname = get_class( $job );
$this->jobs[ $classname ] = $job;
}
return $this->jobs[ $classname ];
}
}
Jobs/MigrateGTIN.php 0000644 00000011402 15153721357 0010233 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\GTINMigrationUtilities;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class MigrateGTIN
*
* Schedules GTIN migration for all the products in the store.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
* @since 2.9.0
*/
class MigrateGTIN extends AbstractBatchedActionSchedulerJob implements OptionsAwareInterface {
use OptionsAwareTrait;
use GTINMigrationUtilities;
public const GTIN_MIGRATION_COMPLETED = 'completed';
public const GTIN_MIGRATION_STARTED = 'started';
public const GTIN_MIGRATION_READY = 'ready';
public const GTIN_MIGRATION_UNAVAILABLE = 'unavailable';
/**
* @var ProductRepository
*/
protected $product_repository;
/**
* @var AttributeManager
*/
protected $attribute_manager;
/**
* MigrateGTIN constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param ProductRepository $product_repository
* @param AttributeManager $attribute_manager
*/
public function __construct( ActionSchedulerInterface $action_scheduler, ActionSchedulerJobMonitor $monitor, ProductRepository $product_repository, AttributeManager $attribute_manager ) {
parent::__construct( $action_scheduler, $monitor );
$this->product_repository = $product_repository;
$this->attribute_manager = $attribute_manager;
}
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'migrate_gtin';
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return ! parent::is_running( $args ) && $this->is_gtin_available_in_core();
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*/
protected function process_items( array $items ) {
// update the product core GTIN using G4W GTIN
$products = $this->product_repository->find_by_ids( $items );
foreach ( $products as $product ) {
// process variations
if ( $product instanceof \WC_Product_Variable ) {
$variations = $product->get_children();
$this->process_items( $variations );
continue;
}
if ( $product->get_global_unique_id() ) {
$this->debug( $this->error_gtin_already_set( $product ) );
continue;
}
$gtin = $this->get_gtin( $product );
if ( ! $gtin ) {
$this->debug( $this->error_gtin_not_found( $product ) );
continue;
}
$gtin = $this->prepare_gtin( $gtin );
if ( ! is_numeric( $gtin ) ) {
$this->debug( $this->error_gtin_invalid( $product, $gtin ) );
continue;
}
try {
$product->set_global_unique_id( $gtin );
$product->save();
$this->debug( $this->successful_migrated_gtin( $product, $gtin ) );
} catch ( Exception $e ) {
$this->debug( $this->error_gtin_not_saved( $product, $gtin, $e ) );
}
}
}
/**
* Tweak schedule function for adding a start flag.
*
* @param array $args
*/
public function schedule( array $args = [] ) {
$this->options->update( OptionsInterface::GTIN_MIGRATION_STATUS, self::GTIN_MIGRATION_STARTED );
parent::schedule( $args );
}
/**
*
* To run when the job is completed.
*
* @param int $final_batch_number
*/
public function handle_complete( int $final_batch_number ) {
$this->options->update( OptionsInterface::GTIN_MIGRATION_STATUS, self::GTIN_MIGRATION_COMPLETED );
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return array
*
* @throws Exception If an error occurs. The exception will be logged by ActionScheduler.
*/
protected function get_batch( int $batch_number ): array {
return $this->product_repository->find_all_product_ids( $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Debug info in the logs.
*
* @param string $message
*
* @return void
*/
protected function debug( string $message ): void {
do_action(
'woocommerce_gla_debug_message',
$message,
__METHOD__
);
}
}
Jobs/Notifications/AbstractItemNotificationJob.php 0000644 00000010505 15153721357 0016361 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractItemNotificationJob
* Generic class for the Notification Jobs containing items
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
abstract class AbstractItemNotificationJob extends AbstractNotificationJob {
/**
* Logic when processing the items
*
* @param array $args Arguments with the item id and the topic
*/
protected function process_items( array $args ): void {
if ( ! isset( $args['item_id'] ) || ! isset( $args['topic'] ) ) {
do_action(
'woocommerce_gla_error',
'Error sending the Notification. Topic and Item ID are mandatory',
__METHOD__
);
return;
}
$item = $args['item_id'];
$topic = $args['topic'];
$data = $args['data'] ?? [];
try {
if ( $this->can_process( $item, $topic ) && $this->notifications_service->notify( $topic, $item, $data ) ) {
$this->set_status( $item, $this->get_after_notification_status( $topic ) );
$this->handle_notified( $topic, $item );
}
} catch ( InvalidValue $e ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Error sending Notification for - Item ID: %s - Topic: %s - Data %s. Product was deleted from the database before the notification was sent.', $item, $topic, wp_json_encode( $data ) ),
__METHOD__
);
}
}
/**
* Set the notification status for the item.
*
* @param int $item_id
* @param string $status
* @throws InvalidValue If the given ID doesn't reference a valid product.
*/
protected function set_status( int $item_id, string $status ): void {
$item = $this->get_item( $item_id );
$this->get_helper()->set_notification_status( $item, $status );
}
/**
* Get the Notification Status after the notification happens
*
* @param string $topic
* @return string
*/
protected function get_after_notification_status( string $topic ): string {
if ( $this->is_create_topic( $topic ) ) {
return NotificationStatus::NOTIFICATION_CREATED;
} elseif ( $this->is_delete_topic( $topic ) ) {
return NotificationStatus::NOTIFICATION_DELETED;
} else {
return NotificationStatus::NOTIFICATION_UPDATED;
}
}
/**
* Checks if the item can be processed based on the topic.
* This is needed because the item can change the Notification Status before
* the Job process the item.
*
* @param int $item_id
* @param string $topic
* @throws InvalidValue If the given ID doesn't reference a valid product.
* @return bool
*/
protected function can_process( int $item_id, string $topic ): bool {
$item = $this->get_item( $item_id );
if ( $this->is_create_topic( $topic ) ) {
return $this->get_helper()->should_trigger_create_notification( $item );
} elseif ( $this->is_delete_topic( $topic ) ) {
return $this->get_helper()->should_trigger_delete_notification( $item );
} else {
return $this->get_helper()->should_trigger_update_notification( $item );
}
}
/**
* Handle the item after the notification.
*
* @param string $topic
* @param int $item
* @throws InvalidValue If the given ID doesn't reference a valid product.
*/
protected function handle_notified( string $topic, int $item ): void {
if ( $this->is_delete_topic( $topic ) ) {
$this->get_helper()->mark_as_unsynced( $this->get_item( $item ) );
}
if ( $this->is_create_topic( $topic ) ) {
$this->get_helper()->mark_as_notified( $this->get_item( $item ) );
}
}
/**
* If a topic is a delete topic
*
* @param string $topic The topic to check
*
* @return bool
*/
protected function is_delete_topic( $topic ): bool {
return str_contains( $topic, '.delete' );
}
/**
* If a topic is a create topic
*
* @param string $topic The topic to check
*
* @return bool
*/
protected function is_create_topic( $topic ): bool {
return str_contains( $topic, '.create' );
}
/**
* Get the item
*
* @param int $item_id
* @return \WC_Product|\WC_Coupon
*/
abstract protected function get_item( int $item_id );
/**
* Get the helper
*
* @return HelperNotificationInterface
*/
abstract public function get_helper(): HelperNotificationInterface;
}
Jobs/Notifications/AbstractNotificationJob.php 0000644 00000005372 15153721357 0015550 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\AbstractActionSchedulerJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobMonitor;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractNotificationJob
* Generic class for the Notifications Jobs
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
abstract class AbstractNotificationJob extends AbstractActionSchedulerJob implements JobInterface {
/**
* @var NotificationsService $notifications_service
*/
protected $notifications_service;
/**
* Notifications Jobs constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param NotificationsService $notifications_service
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
NotificationsService $notifications_service
) {
$this->notifications_service = $notifications_service;
parent::__construct( $action_scheduler, $monitor );
}
/**
* Get the parent job name
*
* @return string
*/
public function get_name(): string {
return 'notifications/' . $this->get_job_name();
}
/**
* Schedule the Job
*
* @param array $args
*/
public function schedule( array $args = [] ): void {
if ( $this->can_schedule( [ $args ] ) ) {
$this->action_scheduler->schedule_immediate(
$this->get_process_item_hook(),
[ $args ]
);
}
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
/**
* Allow users to disable the notification job schedule.
*
* @since 2.8.0
*
* @param bool $value The current filter value. By default, it is the result of `$this->can_schedule` function.
* @param string $job_name The current Job name.
* @param array $args The arguments for the schedule function with the item id and the topic.
*/
return apply_filters( 'woocommerce_gla_notification_job_can_schedule', $this->notifications_service->is_ready() && parent::can_schedule( $args ), $this->get_job_name(), $args );
}
/**
* Get the child job name
*
* @return string
*/
abstract public function get_job_name(): string;
/**
* Logic when processing the items
*
* @param array $args
*/
abstract protected function process_items( array $args ): void;
}
Jobs/Notifications/CouponNotificationJob.php 0000644 00000003606 15153721357 0015246 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobMonitor;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\HelperNotificationInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class CouponNotificationJob
* Class for the Coupons Notifications Jobs
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
class CouponNotificationJob extends AbstractItemNotificationJob {
/**
* @var CouponHelper $helper
*/
protected $helper;
/**
* Notifications Jobs constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param NotificationsService $notifications_service
* @param HelperNotificationInterface $coupon_helper
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
NotificationsService $notifications_service,
HelperNotificationInterface $coupon_helper
) {
$this->helper = $coupon_helper;
parent::__construct( $action_scheduler, $monitor, $notifications_service );
}
/**
* Get the coupon
*
* @param int $item_id
* @return \WC_Coupon
*/
protected function get_item( int $item_id ) {
return $this->helper->get_wc_coupon( $item_id );
}
/**
* Get the Coupon Helper
*
* @return HelperNotificationInterface
*/
public function get_helper(): HelperNotificationInterface {
return $this->helper;
}
/**
* Get the job name
*
* @return string
*/
public function get_job_name(): string {
return 'coupons';
}
}
Jobs/Notifications/HelperNotificationInterface.php 0000644 00000002714 15153721357 0016407 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
defined( 'ABSPATH' ) || exit;
/**
* Interface HelperNotificationInterface
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
interface HelperNotificationInterface {
/**
* Checks if the item can be processed based on the topic.
*
* @param WC_Product|WC_Coupon $item
*
* @return bool
*/
public function should_trigger_create_notification( $item ): bool;
/**
* Indicates if the item ready for sending a delete Notification.
*
* @param WC_Product|WC_Coupon $item
*
* @return bool
*/
public function should_trigger_delete_notification( $item ): bool;
/**
* Indicates if the item ready for sending an update Notification.
*
* @param WC_Product|WC_Coupon $item
*
* @return bool
*/
public function should_trigger_update_notification( $item ): bool;
/**
* Marks the item as unsynced.
*
* @param WC_Product|WC_Coupon $item
*
* @return void
*/
public function mark_as_unsynced( $item ): void;
/**
* Set the notification status for an item.
*
* @param WC_Product|WC_Coupon $item
* @param string $status
*
* @return void
*/
public function set_notification_status( $item, $status ): void;
/**
* Marks the item as notified.
*
* @param WC_Product|WC_Coupon $item
*
* @return void
*/
public function mark_as_notified( $item ): void;
}
Jobs/Notifications/ProductNotificationJob.php 0000644 00000005143 15153721357 0015421 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobMonitor;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductNotificationJob
* Class for the Product Notifications Jobs
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
class ProductNotificationJob extends AbstractItemNotificationJob {
use PluginHelper;
/**
* @var ProductHelper $helper
*/
protected $helper;
/**
* Notifications Jobs constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param NotificationsService $notifications_service
* @param HelperNotificationInterface $helper
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
NotificationsService $notifications_service,
HelperNotificationInterface $helper
) {
$this->helper = $helper;
parent::__construct( $action_scheduler, $monitor, $notifications_service );
}
/**
* Override Product Notification adding Offer ID for deletions.
* The offer_id might match the real offer ID or not, depending on whether the product has been synced by us or not.
* Google should check on their side if the product actually exists.
*
* @param array $args Arguments with the item id and the topic.
*/
protected function process_items( $args ): void {
if ( isset( $args['topic'] ) && isset( $args['item_id'] ) && $this->is_delete_topic( $args['topic'] ) ) {
$args['data'] = [ 'offer_id' => $this->helper->get_offer_id( $args['item_id'] ) ];
}
parent::process_items( $args );
}
/**
* Get the product
*
* @param int $item_id
* @throws InvalidValue If the given ID doesn't reference a valid product.
*
* @return \WC_Product
*/
protected function get_item( int $item_id ) {
return $this->helper->get_wc_product( $item_id );
}
/**
* Get the Product Helper
*
* @return ProductHelper
*/
public function get_helper(): HelperNotificationInterface {
return $this->helper;
}
/**
* Get the job name
*
* @return string
*/
public function get_job_name(): string {
return 'products';
}
}
Jobs/Notifications/SettingsNotificationJob.php 0000644 00000001372 15153721357 0015601 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
defined( 'ABSPATH' ) || exit;
/**
* Class SettingsNotificationJob
* Class for the Settings Notifications
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
class SettingsNotificationJob extends AbstractNotificationJob {
/**
* Logic when processing the items
*
* @param array $args Arguments for the notification
*/
protected function process_items( array $args ): void {
$this->notifications_service->notify( $this->notifications_service::TOPIC_SETTINGS_UPDATED );
}
/**
* Get the job name
*
* @return string
*/
public function get_job_name(): string {
return 'settings';
}
}
Jobs/Notifications/ShippingNotificationJob.php 0000644 00000001407 15153721357 0015561 0 ustar 00 <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingNotificationJob
* Class for the Shipping Notifications
*
* @since 2.8.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
class ShippingNotificationJob extends AbstractNotificationJob {
/**
* Get the job name
*
* @return string
*/
public function get_job_name(): string {
return 'shipping';
}
/**
* Logic when processing the items
*
* @param array $args Arguments for the notification
*/
protected function process_items( array $args ): void {
$this->notifications_service->notify( $this->notifications_service::TOPIC_SHIPPING_UPDATED, null, $args );
}
}
Jobs/ProductSyncStats.php 0000644 00000003117 15153721357 0011461 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionScheduler;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductSyncStats
*
* Counts how many scheduled jobs we have for syncing products.
* A scheduled job can either be a batch or an individual product.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class ProductSyncStats {
/**
* The ActionScheduler object.
*
* @var ActionScheduler
*/
protected $scheduler;
/**
* Job names for syncing products.
*/
protected const MATCHES = [
'refresh_synced_products',
'update_all_products',
'update_products',
'delete_products',
];
/**
* ProductSyncStats constructor.
*
* @param ActionScheduler $scheduler
*/
public function __construct( ActionScheduler $scheduler ) {
$this->scheduler = $scheduler;
}
/**
* Check if a job name is used for product syncing.
*
* @param string $hook
*
* @return bool
*/
protected function job_matches( string $hook ): bool {
foreach ( self::MATCHES as $match ) {
if ( false !== stripos( $hook, $match ) ) {
return true;
}
}
return false;
}
/**
* Return the amount of product sync jobs which are pending.
*
* @return int
*/
public function get_count(): int {
$count = 0;
$scheduled = $this->scheduler->search(
[
'status' => 'pending',
'per_page' => -1,
]
);
foreach ( $scheduled as $action ) {
if ( $this->job_matches( $action->get_hook() ) ) {
++$count;
}
}
return $count;
}
}
Jobs/RecurringJobInterface.php 0000644 00000000643 15153721357 0012402 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
defined( 'ABSPATH' ) || exit;
/**
* Interface RecurringJobInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
interface RecurringJobInterface extends StartOnHookInterface {
/**
* Return the recurring job's interval in seconds.
*
* @return int
*/
public function get_interval(): int;
}
Jobs/ResubmitExpiringProducts.php 0000644 00000003500 15153721357 0013205 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
defined( 'ABSPATH' ) || exit;
/**
* Class ResubmitExpiringProducts
*
* Resubmits all WooCommerce products that are nearly expired to Google Merchant Center.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class ResubmitExpiringProducts extends AbstractProductSyncerBatchedJob implements RecurringJobInterface {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'resubmit_expiring_products';
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return array
*/
public function get_batch( int $batch_number ): array {
return $this->product_repository->find_expiring_product_ids( $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
protected function process_items( array $items ) {
$products = $this->product_repository->find_by_ids( $items );
$this->product_syncer->update( $products );
}
/**
* Return the recurring job's interval in seconds.
*
* @return int
*/
public function get_interval(): int {
return 24 * 60 * 60; // 24 hours
}
/**
* Get the name of an action hook to attach the job's start method to.
*
* @return StartHook
*/
public function get_start_hook(): StartHook {
return new StartHook( "{$this->get_hook_base_name()}start" );
}
}
Jobs/StartHook.php 0000644 00000001630 15153721357 0010101 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
defined( 'ABSPATH' ) || exit;
/**
* Class StartHook
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class StartHook {
/**
* @var string
*/
protected $hook;
/**
* @var int
*/
protected $argument_count;
/**
* StartHook constructor.
*
* @param string $hook The name of an action hook to attach the job's start method to
* @param int $argument_count The number of arguments returned by the specified action hook
*/
public function __construct( string $hook, int $argument_count = 0 ) {
$this->hook = $hook;
$this->argument_count = $argument_count;
}
/**
* @return string
*/
public function get_hook(): string {
return $this->hook;
}
/**
* @return int
*/
public function get_argument_count(): int {
return $this->argument_count;
}
}
Jobs/StartOnHookInterface.php 0000644 00000001036 15153721357 0012217 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
defined( 'ABSPATH' ) || exit;
/**
* Interface StartOnHookInterface
*
* Action Scheduler jobs that implement this interface will start on a specific action hook.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
interface StartOnHookInterface extends ActionSchedulerJobInterface {
/**
* Get an action hook to attach the job's start method to.
*
* @return StartHook
*/
public function get_start_hook(): StartHook;
}
Jobs/SyncableProductsBatchedActionSchedulerJobTrait.php 0000644 00000005323 15153721357 0017361 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\FilteredProductList;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\JobException;
use Exception;
use WC_Product;
/*
* Contains AbstractBatchedActionSchedulerJob methods.
*
* @since 2.2.0
*/
trait SyncableProductsBatchedActionSchedulerJobTrait {
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return WC_Product[]
*/
public function get_batch( int $batch_number ): array {
return $this->get_filtered_batch( $batch_number )->get();
}
/**
* Get a single filtered batch of items.
*
* If no items are returned the job will stop.
*
* @since 1.4.0
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return FilteredProductList
*/
protected function get_filtered_batch( int $batch_number ): FilteredProductList {
return $this->product_repository->find_sync_ready_products( [], $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Handles batch creation action hook.
*
* @hooked gla/jobs/{$job_name}/create_batch
*
* Schedules an action to run immediately for the items in the batch.
* Uses the unfiltered count to check if there are additional batches.
*
* @since 1.4.0
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @throws Exception If an error occurs.
* @throws JobException If the job failure rate is too high.
*/
public function handle_create_batch_action( int $batch_number ) {
$create_batch_hook = $this->get_create_batch_hook();
$create_batch_args = [ $batch_number ];
$this->monitor->validate_failure_rate( $this, $create_batch_hook, $create_batch_args );
if ( $this->retry_on_timeout ) {
$this->monitor->attach_timeout_monitor( $create_batch_hook, $create_batch_args );
}
$items = $this->get_filtered_batch( $batch_number );
if ( 0 === $items->get_unfiltered_count() ) {
// if no more items the job is complete
$this->handle_complete( $batch_number );
} else {
// if items, schedule the process action
if ( count( $items ) ) {
$this->schedule_process_action( $items->get_product_ids() );
}
// Add another "create_batch" action to handle unfiltered items.
// The last batch created here will be an empty batch, it
// will call "handle_complete" to finish the job.
$this->schedule_create_batch_action( $batch_number + 1 );
}
$this->monitor->detach_timeout_monitor( $create_batch_hook, $create_batch_args );
}
}
Jobs/Update/CleanupProductTargetCountriesJob.php 0000644 00000003314 15153721357 0016034 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\AbstractProductSyncerBatchedJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
defined( 'ABSPATH' ) || exit;
/**
* Class CleanupProductTargetCountriesJob
*
* Deletes the previous list of target countries which was in use before the
* Global Offers option became available.
*
* @since 1.1.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update
*/
class CleanupProductTargetCountriesJob extends AbstractProductSyncerBatchedJob {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'cleanup_product_target_countries';
}
/**
* Get a single batch of items.
*
* If no items are returned the job will stop.
*
* @param int $batch_number The batch number increments for each new batch in the job cycle.
*
* @return array
*/
public function get_batch( int $batch_number ): array {
return $this->product_repository->find_synced_product_ids( [], $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
protected function process_items( array $items ) {
$products = $this->product_repository->find_by_ids( $items );
$stale_entries = $this->batch_product_helper->generate_stale_countries_request_entries( $products );
$this->product_syncer->delete_by_batch_requests( $stale_entries );
}
}
Jobs/Update/PluginUpdate.php 0000644 00000003666 15153721357 0012021 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\InstallableInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobException;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateAllProducts;
defined( 'ABSPATH' ) || exit;
/**
* Runs update jobs when the plugin is updated.
*
* @since 1.1.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update
*/
class PluginUpdate implements Service, InstallableInterface {
/**
* @var JobRepository
*/
protected $job_repository;
/**
* PluginUpdate constructor.
*
* @param JobRepository $job_repository
*/
public function __construct( JobRepository $job_repository ) {
$this->job_repository = $job_repository;
}
/**
* Update Jobs that need to be run per version.
*
* @var array
*/
private $updates = [
'1.0.1' => [
CleanupProductTargetCountriesJob::class,
UpdateAllProducts::class,
],
'1.12.6' => [
UpdateAllProducts::class,
],
];
/**
* Run installation logic for this class.
*
* @param string $old_version Previous version before updating.
* @param string $new_version Current version after updating.
*/
public function install( string $old_version, string $new_version ): void {
foreach ( $this->updates as $version => $jobs ) {
if ( version_compare( $old_version, $version, '<' ) ) {
$this->schedule_jobs( $jobs );
}
}
}
/**
* Schedules a list of jobs.
*
* @param array $jobs List of jobs
*/
protected function schedule_jobs( array $jobs ): void {
foreach ( $jobs as $job ) {
try {
$this->job_repository->get( $job )->schedule();
} catch ( JobException $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
}
}
}
}
Jobs/UpdateAllProducts.php 0000644 00000004106 15153721357 0011563 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
defined( 'ABSPATH' ) || exit;
/**
* Class UpdateAllProducts
*
* Submits all WooCommerce products to Google Merchant Center and/or updates the existing ones.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class UpdateAllProducts extends AbstractProductSyncerBatchedJob implements OptionsAwareInterface {
use OptionsAwareTrait;
use SyncableProductsBatchedActionSchedulerJobTrait;
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'update_all_products';
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
protected function process_items( array $items ) {
$products = $this->product_repository->find_by_ids( $items );
$this->product_syncer->update( $products );
}
/**
* Schedules a delayed batched job
*
* @param int $delay The delay time in seconds
*/
public function schedule_delayed( int $delay ) {
if ( $this->can_schedule( [ 1 ] ) ) {
$this->action_scheduler->schedule_single( gmdate( 'U' ) + $delay, $this->get_create_batch_hook(), [ 1 ] );
}
}
/**
* Called when the job is completed.
*
* @param int $final_batch_number The final batch number when the job was completed.
* If equal to 1 then no items were processed by the job.
*/
protected function handle_complete( int $final_batch_number ) {
$this->options->update( OptionsInterface::UPDATE_ALL_PRODUCTS_LAST_SYNC, strtotime( 'now' ) );
$this->merchant_statuses->maybe_refresh_status_data( true );
}
}
Jobs/UpdateCoupon.php 0000644 00000003657 15153721357 0010604 0 ustar 00 <?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncerException;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
/**
* Class UpdateCoupon
*
* Submits WooCommerce coupon to Google Merchant Center and/or updates the existing one.
*
* Note: The job will not start if it is already running.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class UpdateCoupon extends AbstractCouponSyncerJob implements
StartOnHookInterface {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'update_coupon';
}
/**
* Process an item.
*
* @param int[] $coupon_ids
*
* @throws CouponSyncerException If an error occurs. The exception will be logged by ActionScheduler.
*/
public function process_items( $coupon_ids ) {
foreach ( $coupon_ids as $coupon_id ) {
$coupon = $this->wc->maybe_get_coupon( $coupon_id );
if ( $coupon instanceof WC_Coupon &&
$this->coupon_helper->is_sync_ready( $coupon ) ) {
$this->coupon_syncer->update( $coupon );
}
}
}
/**
* Schedule the job.
*
* @param array[] $args
*
* @throws JobException If no coupon is provided as argument. The exception will be logged by ActionScheduler.
*/
public function schedule( array $args = [] ) {
$args = $args[0] ?? null;
$coupon_ids = array_filter( $args, 'is_integer' );
if ( empty( $coupon_ids ) ) {
throw JobException::item_not_provided( 'WooCommerce Coupon IDs' );
}
if ( $this->can_schedule( [ $coupon_ids ] ) ) {
$this->action_scheduler->schedule_immediate(
$this->get_process_item_hook(),
[ $coupon_ids ]
);
}
}
/**
* Get the name of an action hook to attach the job's start method to.
*
* @return StartHook
*/
public function get_start_hook(): StartHook {
return new StartHook( "{$this->get_hook_base_name()}start" );
}
}
Jobs/UpdateMerchantProductStatuses.php 0000644 00000010422 15153721357 0014163 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantReport;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Throwable;
defined( 'ABSPATH' ) || exit;
/**
* Class UpdateMerchantProductStatuses
*
* Update Product Stats
*
* Note: The job will not start if it is already running or if the Google Merchant Center account is not connected.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*
* @since 2.6.4
*/
class UpdateMerchantProductStatuses extends AbstractActionSchedulerJob {
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var MerchantReport
*/
protected $merchant_report;
/**
* @var MerchantStatuses
*/
protected $merchant_statuses;
/**
* UpdateMerchantProductStatuses constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param MerchantCenterService $merchant_center
* @param MerchantReport $merchant_report
* @param MerchantStatuses $merchant_statuses
*/
public function __construct( ActionSchedulerInterface $action_scheduler, ActionSchedulerJobMonitor $monitor, MerchantCenterService $merchant_center, MerchantReport $merchant_report, MerchantStatuses $merchant_statuses ) {
parent::__construct( $action_scheduler, $monitor );
$this->merchant_center = $merchant_center;
$this->merchant_report = $merchant_report;
$this->merchant_statuses = $merchant_statuses;
}
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'update_merchant_product_statuses';
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return parent::can_schedule( $args ) && $this->merchant_center->is_connected();
}
/**
* Process the job.
*
* @param int[] $items An array of job arguments.
*
* @throws JobException If the merchant product statuses cannot be retrieved..
*/
public function process_items( array $items ) {
try {
$next_page_token = $items['next_page_token'] ?? null;
// Clear the cache if we're starting from the beginning.
if ( ! $next_page_token ) {
$this->merchant_statuses->clear_product_statuses_cache_and_issues();
$this->merchant_statuses->refresh_account_and_presync_issues();
}
$results = $this->merchant_report->get_product_view_report( $next_page_token );
$next_page_token = $results['next_page_token'];
$this->merchant_statuses->process_product_statuses( $results['statuses'] );
if ( $next_page_token ) {
$this->schedule( [ [ 'next_page_token' => $next_page_token ] ] );
} else {
$this->merchant_statuses->handle_complete_mc_statuses_fetching();
}
} catch ( Throwable $e ) {
$this->merchant_statuses->handle_failed_mc_statuses_fetching( $e->getMessage() );
throw new JobException( 'Error updating merchant product statuses: ' . $e->getMessage() );
}
}
/**
* Schedule the job.
*
* @param array $args - arguments.
*/
public function schedule( array $args = [] ) {
if ( $this->can_schedule( $args ) ) {
$this->action_scheduler->schedule_immediate( $this->get_process_item_hook(), $args );
}
}
/**
* The job is considered to be scheduled if the "process_item" action is currently pending or in-progress regardless of the arguments.
*
* @return bool
*/
public function is_scheduled(): bool {
// We set 'args' to null so it matches any arguments. This is because it's possible to have multiple instances of the job running with different page tokens
return $this->is_running( null );
}
/**
* Validate the failure rate of the job.
*
* @return string|void Returns an error message if the failure rate is too high, otherwise null.
*/
public function get_failure_rate_message() {
try {
$this->monitor->validate_failure_rate( $this, $this->get_process_item_hook() );
} catch ( JobException $e ) {
return $e->getMessage();
}
}
}
Jobs/UpdateProducts.php 0000644 00000004372 15153721357 0011137 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncerException;
defined( 'ABSPATH' ) || exit;
/**
* Class UpdateProducts
*
* Submits WooCommerce products to Google Merchant Center and/or updates the existing ones.
*
* Note: The job will not start if it is already running.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*/
class UpdateProducts extends AbstractProductSyncerJob implements StartOnHookInterface {
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'update_products';
}
/**
* Process an item.
*
* @param int[] $product_ids An array of WooCommerce product ids.
*
* @throws ProductSyncerException If an error occurs. The exception will be logged by ActionScheduler.
* @throws JobException If invalid or non-existing products are provided. The exception will be logged by ActionScheduler.
*/
public function process_items( array $product_ids ) {
$args = [ 'include' => $product_ids ];
$products = $this->product_repository->find_sync_ready_products( $args )->get();
if ( empty( $products ) ) {
throw JobException::item_not_found();
}
$this->product_syncer->update( $products );
}
/**
* Schedule the job.
*
* @param array $args - arguments.
*
* @throws JobException If no product is provided as argument. The exception will be logged by ActionScheduler.
*/
public function schedule( array $args = [] ) {
$args = $args[0] ?? [];
$ids = array_filter( $args, 'is_integer' );
if ( empty( $ids ) ) {
throw JobException::item_not_provided( 'Array of WooCommerce Product IDs' );
}
if ( did_action( 'woocommerce_gla_batch_retry_update_products' ) ) {
$this->action_scheduler->schedule_single( gmdate( 'U' ) + 60, $this->get_process_item_hook(), [ $ids ] );
} elseif ( $this->can_schedule( [ $ids ] ) ) {
$this->action_scheduler->schedule_immediate( $this->get_process_item_hook(), [ $ids ] );
}
}
/**
* Get an action hook to attach the job's start method to.
*
* @return StartHook
*/
public function get_start_hook(): StartHook {
return new StartHook( 'woocommerce_gla_batch_retry_update_products', 1 );
}
}
Jobs/UpdateShippingSettings.php 0000644 00000006041 15153721357 0012631 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings as GoogleSettings;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
defined( 'ABSPATH' ) || exit;
/**
* Class UpdateShippingSettings
*
* Submits WooCommerce shipping settings to Google Merchant Center replacing the existing shipping settings.
*
* Note: The job will not start if it is already running or if the Google Merchant Center account is not connected.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
*
* @since 2.1.0
*/
class UpdateShippingSettings extends AbstractActionSchedulerJob {
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var GoogleSettings
*/
protected $google_settings;
/**
* UpdateShippingSettings constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param MerchantCenterService $merchant_center
* @param GoogleSettings $google_settings
*/
public function __construct( ActionSchedulerInterface $action_scheduler, ActionSchedulerJobMonitor $monitor, MerchantCenterService $merchant_center, GoogleSettings $google_settings ) {
parent::__construct( $action_scheduler, $monitor );
$this->merchant_center = $merchant_center;
$this->google_settings = $google_settings;
}
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'update_shipping_settings';
}
/**
* Can the job be scheduled.
*
* @param array|null $args
*
* @return bool Returns true if the job can be scheduled.
*/
public function can_schedule( $args = [] ): bool {
return parent::can_schedule( $args ) && $this->can_sync_shipping();
}
/**
* Process the job.
*
* @param int[] $items An array of job arguments.
*
* @throws JobException If the shipping settings cannot be synced.
*/
public function process_items( array $items ) {
if ( ! $this->can_sync_shipping() ) {
throw new JobException( 'Cannot sync shipping settings. Confirm that the merchant center account is connected and the option to automatically sync the shipping settings is selected.' );
}
$this->google_settings->sync_shipping();
}
/**
* Schedule the job.
*
* @param array $args - arguments.
*/
public function schedule( array $args = [] ) {
if ( $this->can_schedule() ) {
$this->action_scheduler->schedule_immediate( $this->get_process_item_hook() );
}
}
/**
* Can the WooCommerce shipping settings be synced to Google Merchant Center.
*
* @return bool
*/
protected function can_sync_shipping(): bool {
// Confirm that the Merchant Center account is connected and the user has chosen for the shipping rates to be synced from WooCommerce settings.
return $this->merchant_center->is_connected() && $this->google_settings->should_get_shipping_rates_from_woocommerce();
}
}
Jobs/UpdateSyncableProductsCount.php 0000644 00000007145 15153721357 0013632 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\AbstractBatchedActionSchedulerJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobMonitor;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\SyncableProductsBatchedActionSchedulerJobTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
defined( 'ABSPATH' ) || exit;
/**
* Class UpdateSyncableProductsCount
*
* Get the number of syncable products (i.e. product ready to be synced to Google Merchant Center) and update it in the DB.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs
* @since 2.2.0
*/
class UpdateSyncableProductsCount extends AbstractBatchedActionSchedulerJob implements OptionsAwareInterface {
use OptionsAwareTrait;
use SyncableProductsBatchedActionSchedulerJobTrait;
/**
* @var ProductRepository
*/
protected $product_repository;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* UpdateSyncableProductsCount constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param ProductRepository $product_repository
* @param ProductHelper $product_helper
*/
public function __construct( ActionSchedulerInterface $action_scheduler, ActionSchedulerJobMonitor $monitor, ProductRepository $product_repository, ProductHelper $product_helper ) {
parent::__construct( $action_scheduler, $monitor );
$this->product_repository = $product_repository;
$this->product_helper = $product_helper;
}
/**
* Get the name of the job.
*
* @return string
*/
public function get_name(): string {
return 'update_syncable_products_count';
}
/**
* Get job batch size.
*
* @return int
*/
protected function get_batch_size(): int {
/**
* Filters the batch size for the job.
*
* @param string Job's name
*/
return apply_filters( 'woocommerce_gla_batched_job_size', 500, $this->get_name() );
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
*/
protected function process_items( array $items ) {
$product_ids = $this->options->get( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA );
if ( ! is_array( $product_ids ) ) {
$product_ids = [];
}
$grouped_items = $this->product_helper->maybe_swap_for_parent_ids( $items );
$this->options->update( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA, array_unique( [ ...$product_ids, ...$grouped_items ] ) );
}
/**
* Called when the job is completed.
*
* @param int $final_batch_number The final batch number when the job was completed.
* If equal to 1 then no items were processed by the job.
*/
protected function handle_complete( int $final_batch_number ) {
$product_ids = $this->options->get( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA );
$count = is_array( $product_ids ) ? count( $product_ids ) : 0;
$this->options->update( OptionsInterface::SYNCABLE_PRODUCTS_COUNT, $count );
$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA );
}
}
Logging/DebugLogger.php 0000644 00000005711 15153721357 0011046 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Logging;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Exception;
use WC_Log_Levels;
use WC_Logger;
/**
* Class DebugLogger
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Logging
*/
class DebugLogger implements Service, Registerable, Conditional {
/**
* WooCommerce logger class instance.
*
* @var WC_Logger
*/
private $logger = null;
/**
* Check if debug logging should be enabled.
*
* @return bool Whether the service is needed.
*/
public static function is_needed(): bool {
return apply_filters( 'woocommerce_gla_enable_debug_logging', true );
}
/**
* Register a service.
*/
public function register(): void {
if ( function_exists( 'wc_get_logger' ) ) {
$this->logger = wc_get_logger();
add_action( 'woocommerce_gla_debug_message', [ $this, 'log_message' ], 10, 2 );
add_action( 'woocommerce_gla_exception', [ $this, 'log_exception' ], 10, 2 );
add_action( 'woocommerce_gla_error', [ $this, 'log_error' ], 10, 2 );
add_action( 'woocommerce_gla_mc_client_exception', [ $this, 'log_exception' ], 10, 2 );
add_action( 'woocommerce_gla_ads_client_exception', [ $this, 'log_exception' ], 10, 2 );
add_action( 'woocommerce_gla_sv_client_exception', [ $this, 'log_exception' ], 10, 2 );
add_action( 'woocommerce_gla_guzzle_client_exception', [ $this, 'log_exception' ], 10, 2 );
add_action( 'woocommerce_gla_guzzle_invalid_response', [ $this, 'log_response' ], 10, 2 );
}
}
/**
* Log an exception.
*
* @param Exception $exception
* @param string $method
*/
public function log_exception( $exception, string $method ): void {
$this->log( $exception->getMessage(), $method, WC_Log_Levels::ERROR );
}
/**
* Log an exception.
*
* @param string $message
* @param string $method
*/
public function log_error( string $message, string $method ): void {
$this->log( $message, $method, WC_Log_Levels::ERROR );
}
/**
* Log a JSON response.
*
* @param mixed $response
* @param string $method
*/
public function log_response( $response, string $method ): void {
$message = wp_json_encode( $response, JSON_PRETTY_PRINT );
$this->log( $message, $method );
}
/**
* Log a generic note.
*
* @param string $message
* @param string $method
*/
public function log_message( string $message, string $method ): void {
$this->log( $message, $method );
}
/**
* Log a message as a debug log entry.
*
* @param string $message
* @param string $method
* @param string $level
*/
protected function log( string $message, string $method, string $level = WC_Log_Levels::DEBUG ) {
$this->logger->log(
$level,
sprintf( '%s %s', $method, $message ),
[
'source' => 'google-listings-and-ads',
]
);
}
}
Menu/AttributeMapping.php 0000644 00000001443 15153721357 0011453 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class AttributeMapping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class AttributeMapping implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'title' => __( 'Attribute Mapping', 'google-listings-and-ads' ),
'parent' => 'google-listings-and-ads-category',
'path' => '/google/attribute-mapping',
'id' => 'google-attribute-mapping',
]
);
}
);
}
}
Menu/Dashboard.php 0000644 00000002210 15153721357 0010054 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
/**
* Class Dashboard
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class Dashboard implements Service, Registerable, MerchantCenterAwareInterface {
use MenuFixesTrait;
use MerchantCenterAwareTrait;
public const PATH = '/google/dashboard';
/**
* Register a service.
*/
public function register(): void {
if ( ! $this->merchant_center->is_setup_complete() ) {
return;
}
add_action(
'admin_menu',
function () {
$this->register_classic_submenu_page(
[
'id' => 'google-listings-and-ads',
'title' => __( 'Google for WooCommerce', 'google-listings-and-ads' ),
'parent' => 'woocommerce-marketing',
'path' => self::PATH,
]
);
}
);
}
}
Menu/GetStarted.php 0000644 00000002204 15153721357 0010236 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
/**
* Class GetStarted
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class GetStarted implements Service, Registerable, MerchantCenterAwareInterface {
use MenuFixesTrait;
use MerchantCenterAwareTrait;
public const PATH = '/google/start';
/**
* Register a service.
*/
public function register(): void {
if ( $this->merchant_center->is_setup_complete() ) {
return;
}
add_action(
'admin_menu',
function () {
$this->register_classic_submenu_page(
[
'id' => 'google-listings-and-ads',
'title' => __( 'Google for WooCommerce', 'google-listings-and-ads' ),
'parent' => 'woocommerce-marketing',
'path' => self::PATH,
]
);
}
);
}
}
Menu/MenuFixesTrait.php 0000644 00000015015 15153721357 0011103 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\Admin\PageController;
/**
* Trait MenuFixesTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
trait MenuFixesTrait {
/**
* Register a React-powered page to the classic submenu of wc-admin.
*
* To ensure the order of this plugin will be below the Coupons in the Marketing menu of
* WC admin page, here needs a workaround due to several different causes.
*
* TL;DR
* - `wc_admin_register_page()` doesn't pass the `position` option to `add_submenu_page`.
* - `PageController->register_page()` called by `wc_admin_register_page` replaces the
* menu/submenu slug internally.
* - Coupons submenu is added by `register_post_type` that calls `add_submenu_page`
* directly in WP core and is moved to Marketing dynamically.
*
* Details:
*
* There is a guide with a few examples showing how to add a page to WooCommerce Admin.
*
* @link https://developer.woocommerce.com/extension-developer-guide/working-with-woocommerce-admin-pages/
*
* Originally, a React-powered page is expected to be registered by WC core function
* `wc_admin_register_page`, and the function also handles the registration of wp-admin
* menu and submenu via PageController. In addition, the function will concatenate
* 'wc-admin&path=' with the page path as the menu/submenu slug when calling `add_menu_page`
* or `add_submenu_page`.
*
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Admin/PageController.php#L449-L451
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Admin/PageController.php#L458
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Admin/PageController.php#L467
*
* However, the main menu of Marketing is not registered in that way but calls WP core
* function `add_menu_page` directly with 'woocommerce-marketing' as its menu slug,
* so the menu slug of Marketing won't have the above replacement processing.
* This causes other pages that need to be submenus under Marketing won't appear
* if they were added by `wc_admin_register_page` due to mismatched slugs.
* Instead, they have to be added via "woocommerce_marketing_menu_items" filter.
*
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Internal/Admin/Marketing.php#L71-L92
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Internal/Admin/Marketing.php#L106-L119
*
* Unfortunately, `wc_admin_register_page` doesn't pass the `position` option to
* `add_submenu_page`. So the order of submenus is determined with the calling order
* of `wc_admin_register_page`, which usually is decided by the `priority` parameter
* of the corresponding "admin_menu" action. Even though raising the priority of
* "admin_menu" action to 6 or a smaller number could make submenu appear but it will
* still be above the Coupons or even the Overview submenu. As mentioned at the beginning,
* specifying the `position` won't work either because it won't be passed to `add_submenu_page`.
*
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Admin/PageController.php#L466-L473
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Internal/Admin/Marketing.php#L60
* @link https://github.com/WordPress/wordpress-develop/blob/6.0.3/src/wp-admin/includes/plugin.php#L1445-L1449
*
* About the Coupons submenu, it's added by `register_post_type` in an "init" action,
* and its appearing menu might be modified to Marketing dynamically, then WP core calls
* `add_submenu_page` directly via "admin_menu" action with the default priority 10.
*
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/includes/class-wc-post-types.php#L451-L491
* @link https://github.com/woocommerce/woocommerce/blob/7.0.0/plugins/woocommerce/src/Internal/Admin/Coupons.php#L95-L98
* @link https://github.com/WordPress/wordpress-develop/blob/6.0.3/src/wp-includes/post.php#L2067-L2076
* @link https://github.com/WordPress/wordpress-develop/blob/6.0.3/src/wp-includes/default-filters.php#L537
*
* Taken together, when using the suggested `wc_admin_register_page` to add a submenu
* under Marketing menu, if the priority of "admin_menu" action is > 6, it won't appear.
* If the priority is <= 6, the order of added submenu will be above the Coupons.
* When using the dedicated "woocommerce_marketing_menu_items" filter, the order of added
* submenu will still be above the Coupons.
*
* In summary, the order in which submenus call `add_submenu_page` determines the order
* in which they appear in the Marketing menu, and the way in which submenus call
* `add_submenu_page` and whether they are called before the Marketing menu calls
* `add_menu_page` determines whether the submenus can match the parent slug to appear
* under Marketing menu.
*
* The method and order of calling is as follows:
* 1. Overview submenu: PageController->register_page() with priority 5.
* 2. Marketing menu: add_menu_page() with priority 6.
* 3. Coupons submenu: add_submenu_page() with the default priority 10.
* 4. This workaround will be this order if we add a submenu by "admin_menu"
* action with a priority >= 10. Moreover, the `position` will be effective
* to change the final ordering.
*
* @param array $options {
* Array describing the submenu page.
*
* @type string id ID to reference the page.
* @type string title Page title. Used in menus and breadcrumbs.
* @type string parent Parent ID.
* @type string path Path for this page.
* @type string capability Capability needed to access the page.
* @type int position|null Menu item position.
* }
*/
protected function register_classic_submenu_page( $options ) {
$defaults = [
'capability' => 'manage_woocommerce',
'position' => null,
];
$options = wp_parse_args( $options, $defaults );
$options['js_page'] = true;
if ( 0 !== strpos( $options['path'], PageController::PAGE_ROOT ) ) {
$options['path'] = PageController::PAGE_ROOT . '&path=' . $options['path'];
}
add_submenu_page(
$options['parent'],
$options['title'],
$options['title'],
$options['capability'],
$options['path'],
[ PageController::class, 'page_wrapper' ],
$options['position'],
);
PageController::get_instance()->connect_page( $options );
}
}
Menu/ProductFeed.php 0000644 00000001412 15153721357 0010374 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class ProductFeed
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class ProductFeed implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'title' => __( 'Product Feed', 'google-listings-and-ads' ),
'parent' => 'google-listings-and-ads-category',
'path' => '/google/product-feed',
'id' => 'google-product-feed',
]
);
}
);
}
}
Menu/Reports.php 0000644 00000001363 15153721357 0007633 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class Reports
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class Reports implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'title' => __( 'Reports', 'google-listings-and-ads' ),
'parent' => 'google-listings-and-ads-category',
'path' => '/google/reports',
'id' => 'google-reports',
]
);
}
);
}
}
Menu/Settings.php 0000644 00000001370 15153721357 0007773 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class Settings
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class Settings implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'title' => __( 'Settings', 'google-listings-and-ads' ),
'parent' => 'google-listings-and-ads-category',
'path' => '/google/settings',
'id' => 'google-settings',
]
);
}
);
}
}
Menu/SetupAds.php 0000644 00000001342 15153721357 0007722 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class SetupAds
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class SetupAds implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'title' => __( 'Ads Setup Wizard', 'google-listings-and-ads' ),
'parent' => '',
'path' => '/google/setup-ads',
'id' => 'google-setup-ads',
]
);
}
);
}
}
Menu/SetupMerchantCenter.php 0000644 00000001365 15153721357 0012122 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class SetupMerchantCenter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class SetupMerchantCenter implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'title' => __( 'MC Setup Wizard', 'google-listings-and-ads' ),
'parent' => '',
'path' => '/google/setup-mc',
'id' => 'google-setup-mc',
]
);
}
);
}
}
Menu/Shipping.php 0000644 00000001370 15153721357 0007754 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class Shipping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*/
class Shipping implements Service, Registerable {
/**
* Register a service.
*/
public function register(): void {
add_action(
'admin_menu',
function () {
wc_admin_register_page(
[
'id' => 'google-shipping',
'parent' => 'google-listings-and-ads-category',
'title' => __( 'Shipping', 'google-listings-and-ads' ),
'path' => '/google/shipping',
]
);
}
);
}
}
MerchantCenter/AccountService.php 0000644 00000055711 15153721357 0013116 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\Jetpack\Connection\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\SiteVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\OAuthService;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingRateTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingTimeTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ApiNotReady;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\CleanupSyncedProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface;
use Exception;
use Jetpack_Options;
defined( 'ABSPATH' ) || exit;
/**
* Class AccountService
*
* Container used to access:
* - Ads
* - AdsAccountState
* - JobRepository
* - Merchant
* - MerchantCenterService
* - MerchantIssueTable
* - MerchantStatuses
* - Middleware
* - SiteVerification
* - ShippingRateTable
* - ShippingTimeTable
*
* @since 1.12.0
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
class AccountService implements ContainerAwareInterface, OptionsAwareInterface, Service {
use ContainerAwareTrait;
use OptionsAwareTrait;
use PluginHelper;
/**
* @var MerchantAccountState
*/
protected $state;
/**
* Perform a website claim with overwrite.
*
* @var bool
*/
protected $overwrite_claim = false;
/**
* Allow switching the existing website URL.
*
* @var bool
*/
protected $allow_switch_url = false;
/**
* AccountService constructor.
*
* @param MerchantAccountState $state
*/
public function __construct( MerchantAccountState $state ) {
$this->state = $state;
}
/**
* Get all Merchant Accounts associated with the connected account.
*
* @return array
* @throws Exception When an API error occurs.
*/
public function get_accounts(): array {
return $this->container->get( Middleware::class )->get_merchant_accounts();
}
/**
* Use an existing MC account. Mark the 'set_id' step as done, update the MC account's website URL,
* and sets the Merchant ID.
*
* @param int $account_id The merchant ID to use.
*
* @throws ExceptionWithResponseData If there's a website URL conflict, or account data can't be retrieved.
*/
public function use_existing_account_id( int $account_id ): void {
// Reset the process if the provided ID isn't the same as the one stored in options.
$merchant_id = $this->options->get_merchant_id();
if ( $merchant_id && $merchant_id !== $account_id ) {
$this->reset_account_setup();
}
$state = $this->state->get();
// Don't do anything if this step was already finished.
if ( MerchantAccountState::STEP_DONE === $state['set_id']['status'] ) {
return;
}
try {
// Make sure the existing account has the correct website URL (or fail).
$this->maybe_add_merchant_center_url( $account_id );
// Re-fetch state as it might have changed.
$state = $this->state->get();
$middleware = $this->container->get( Middleware::class );
// Maybe the existing account is a sub-account!
$state['set_id']['data']['from_mca'] = false;
foreach ( $middleware->get_merchant_accounts() as $existing_account ) {
if ( $existing_account['id'] === $account_id ) {
$state['set_id']['data']['from_mca'] = $existing_account['subaccount'];
break;
}
}
$middleware->link_merchant_account( $account_id );
$state['set_id']['status'] = MerchantAccountState::STEP_DONE;
$this->state->update( $state );
} catch ( ExceptionWithResponseData $e ) {
throw $e;
} catch ( Exception $e ) {
throw $this->prepare_exception( $e->getMessage(), [], $e->getCode() );
}
}
/**
* Run the process for setting up a Merchant Center account (sub-account or standalone).
*
* @param int $account_id
*
* @return array The account ID if setup has completed.
* @throws ExceptionWithResponseData When the account is already connected or a setup error occurs.
*/
public function setup_account( int $account_id ) {
// Reset the process if the provided ID isn't the same as the one stored in options.
$merchant_id = $this->options->get_merchant_id();
if ( $merchant_id && $merchant_id !== $account_id ) {
$this->reset_account_setup();
}
try {
return $this->setup_account_steps();
} catch ( ExceptionWithResponseData | ApiNotReady $e ) {
throw $e;
} catch ( Exception $e ) {
throw $this->prepare_exception( $e->getMessage(), [], $e->getCode() );
}
}
/**
* Create or link an account, switching the URL during the set_id step.
*
* @param int $account_id
*
* @return array
* @throws ExceptionWithResponseData When a setup error occurs.
*/
public function switch_url( int $account_id ): array {
$state = $this->state->get();
$switch_necessary = ! empty( $state['set_id']['data']['old_url'] );
$set_id_status = $state['set_id']['status'] ?? MerchantAccountState::STEP_PENDING;
if ( ! $account_id || MerchantAccountState::STEP_DONE === $set_id_status || ! $switch_necessary ) {
throw $this->prepare_exception(
__( 'Attempting invalid URL switch.', 'google-listings-and-ads' )
);
}
$this->allow_switch_url = true;
$this->use_existing_account_id( $account_id );
return $this->setup_account( $account_id );
}
/**
* Create or link an account, overwriting the website claim during the claim step.
*
* @param int $account_id
*
* @return array
* @throws ExceptionWithResponseData When a setup error occurs.
*/
public function overwrite_claim( int $account_id ): array {
$state = $this->state->get( false );
$overwrite_necessary = ! empty( $state['claim']['data']['overwrite_required'] );
$claim_status = $state['claim']['status'] ?? MerchantAccountState::STEP_PENDING;
if ( MerchantAccountState::STEP_DONE === $claim_status || ! $overwrite_necessary ) {
throw $this->prepare_exception(
__( 'Attempting invalid claim overwrite.', 'google-listings-and-ads' )
);
}
$this->overwrite_claim = true;
return $this->setup_account( $account_id );
}
/**
* Get the connected merchant account.
*
* @return array
*/
public function get_connected_status(): array {
/** @var NotificationsService $notifications_service */
$notifications_service = $this->container->get( NotificationsService::class );
$id = $this->options->get_merchant_id();
$wpcom_rest_api_status = $this->options->get( OptionsInterface::WPCOM_REST_API_STATUS );
// If token is revoked outside the extension. Set the status as error to force the merchant to grant access again.
if ( $wpcom_rest_api_status === 'approved' && ! $this->is_wpcom_api_status_healthy() ) {
$wpcom_rest_api_status = OAuthService::STATUS_ERROR;
$this->options->update( OptionsInterface::WPCOM_REST_API_STATUS, $wpcom_rest_api_status );
}
$status = [
'id' => $id,
'status' => $id ? 'connected' : 'disconnected',
'notification_service_enabled' => $notifications_service->is_enabled(),
'wpcom_rest_api_status' => $wpcom_rest_api_status,
];
$incomplete = $this->state->last_incomplete_step();
if ( ! empty( $incomplete ) ) {
$status['status'] = 'incomplete';
$status['step'] = $incomplete;
}
return $status;
}
/**
* Return the setup status to determine what step to continue at.
*
* @return array
*/
public function get_setup_status(): array {
return $this->container->get( MerchantCenterService::class )->get_setup_status();
}
/**
* Disconnect Merchant Center account
*/
public function disconnect() {
$this->options->delete( OptionsInterface::CONTACT_INFO_SETUP );
$this->options->delete( OptionsInterface::MC_SETUP_COMPLETED_AT );
$this->options->delete( OptionsInterface::MERCHANT_ACCOUNT_STATE );
$this->options->delete( OptionsInterface::MERCHANT_CENTER );
$this->options->delete( OptionsInterface::SITE_VERIFICATION );
$this->options->delete( OptionsInterface::TARGET_AUDIENCE );
$this->options->delete( OptionsInterface::MERCHANT_ID );
$this->options->delete( OptionsInterface::CLAIMED_URL_HASH );
$this->container->get( MerchantStatuses::class )->delete();
$this->container->get( MerchantIssueTable::class )->truncate();
$this->container->get( ShippingRateTable::class )->truncate();
$this->container->get( ShippingTimeTable::class )->truncate();
$this->container->get( JobRepository::class )->get( CleanupSyncedProducts::class )->schedule();
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::MC_ACCOUNT_REVIEW );
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::URL_MATCHES );
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::MC_IS_SUBACCOUNT );
}
/**
* Performs the steps necessary to initialize a Merchant Center account.
* Should always resume up at the last pending or unfinished step. If the Merchant Center account
* has already been created, the ID is simply returned.
*
* @return array The newly created (or pre-existing) Merchant account data.
* @throws ExceptionWithResponseData If an error occurs during any step.
* @throws Exception If the step is unknown.
* @throws ApiNotReady If we should wait to complete the next step.
*/
private function setup_account_steps() {
$state = $this->state->get();
$merchant_id = $this->options->get_merchant_id();
$merchant = $this->container->get( Merchant::class );
$middleware = $this->container->get( Middleware::class );
foreach ( $state as $name => &$step ) {
if ( MerchantAccountState::STEP_DONE === $step['status'] ) {
continue;
}
if ( 'link' === $name ) {
$time_to_wait = $this->state->get_seconds_to_wait_after_created();
if ( $time_to_wait ) {
sleep( $time_to_wait );
}
}
try {
switch ( $name ) {
case 'set_id':
// Just in case, don't create another merchant ID.
if ( ! empty( $merchant_id ) ) {
break;
}
$merchant_id = $middleware->create_merchant_account();
$step['data']['from_mca'] = true;
$step['data']['created_timestamp'] = time();
break;
case 'verify':
// Skip if previously verified.
if ( $this->state->is_site_verified() ) {
break;
}
$site_url = esc_url_raw( $this->get_site_url() );
$this->container->get( SiteVerification::class )->verify_site( $site_url );
break;
case 'link':
$middleware->link_merchant_to_mca();
break;
case 'claim':
// At this step, the website URL is assumed to be correct.
// If the URL is already claimed, no claim should be attempted.
if ( $merchant->get_accountstatus( $merchant_id )->getWebsiteClaimed() ) {
break;
}
if ( $this->overwrite_claim ) {
$middleware->claim_merchant_website( true );
} else {
$merchant->claimwebsite();
}
break;
case 'link_ads':
// Continue to next step if Ads account is not connected yet.
if ( ! $this->options->get_ads_id() ) {
// Save step as pending and continue the foreach loop with `continue 2`.
$state[ $name ]['status'] = MerchantAccountState::STEP_PENDING;
$this->state->update( $state );
continue 2;
}
$this->link_ads_account();
break;
default:
throw new Exception(
sprintf(
/* translators: 1: is a string representing an unknown step name */
__( 'Unknown merchant account creation step %1$s', 'google-listings-and-ads' ),
$name
)
);
}
$step['status'] = MerchantAccountState::STEP_DONE;
$step['message'] = '';
$this->state->update( $state );
} catch ( Exception $e ) {
$step['status'] = MerchantAccountState::STEP_ERROR;
$step['message'] = $e->getMessage();
// URL already claimed.
if ( 'claim' === $name && 403 === $e->getCode() ) {
$data = [
'id' => $merchant_id,
'website_url' => $this->strip_url_protocol(
esc_url_raw( $this->get_site_url() )
),
];
// Sub-account: request overwrite confirmation.
if ( $state['set_id']['data']['from_mca'] ?? true ) {
do_action( 'woocommerce_gla_site_claim_overwrite_required', [] );
$step['data']['overwrite_required'] = true;
$e = $this->prepare_exception( $e->getMessage(), $data, $e->getCode() );
} else {
do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'independent_account' ] );
// Independent account: overwrite not possible.
$e = $this->prepare_exception(
__( 'Unable to claim website URL with this Merchant Center Account.', 'google-listings-and-ads' ),
$data,
406
);
}
} elseif ( 'link' === $name && 401 === $e->getCode() ) {
// New sub-account not yet manipulable.
$state['set_id']['data']['created_timestamp'] = time();
$e = ApiNotReady::retry_after( MerchantAccountState::MC_DELAY_AFTER_CREATE );
}
$this->state->update( $state );
throw $e;
}
}
return [ 'id' => $merchant_id ];
}
/**
* Restart the account setup when we are connecting with a different account ID.
* Do not allow reset when the full setup process has completed.
*
* @throws ExceptionWithResponseData When the full setup process is completed.
*/
private function reset_account_setup() {
// Can't reset if the MC connection process has been completed previously.
if ( $this->container->get( MerchantCenterService::class )->is_setup_complete() ) {
throw $this->prepare_exception(
sprintf(
/* translators: 1: is a numeric account ID */
__( 'Merchant Center account already connected: %d', 'google-listings-and-ads' ),
$this->options->get_merchant_id()
)
);
}
$this->disconnect();
}
/**
* Ensure the Merchant Center account's Website URL matches the site URL. Update an empty value or
* a different, unclaimed URL value. Throw a 409 exception if a different, claimed URL is found.
*
* @param int $merchant_id The Merchant Center account to update.
*
* @throws ExceptionWithResponseData If the account URL doesn't match the site URL or the URL is invalid.
*/
private function maybe_add_merchant_center_url( int $merchant_id ) {
$site_url = esc_url_raw( $this->get_site_url() );
if ( ! wc_is_valid_url( $site_url ) ) {
throw $this->prepare_exception( __( 'Invalid site URL.', 'google-listings-and-ads' ) );
}
/** @var Merchant $merchant */
$merchant = $this->container->get( Merchant::class );
/** @var Account $account */
$account = $merchant->get_account( $merchant_id );
$account_url = $account->getWebsiteUrl() ?: '';
if ( untrailingslashit( $site_url ) !== untrailingslashit( $account_url ) ) {
$is_website_claimed = $merchant->get_accountstatus( $merchant_id )->getWebsiteClaimed();
if ( ! empty( $account_url ) && $is_website_claimed && ! $this->allow_switch_url ) {
$state = $this->state->get();
$state['set_id']['data']['old_url'] = $account_url;
$state['set_id']['status'] = MerchantAccountState::STEP_ERROR;
$this->state->update( $state );
$clean_account_url = $this->strip_url_protocol( $account_url );
$clean_site_url = $this->strip_url_protocol( $site_url );
do_action( 'woocommerce_gla_url_switch_required', [] );
throw $this->prepare_exception(
sprintf(
/* translators: 1: is a website URL (without the protocol) */
__( 'This Merchant Center account already has a verified and claimed URL, %1$s', 'google-listings-and-ads' ),
$clean_account_url
),
[
'id' => $merchant_id,
'claimed_url' => $clean_account_url,
'new_url' => $clean_site_url,
],
409
);
}
$account->setWebsiteUrl( $site_url );
$merchant->update_account( $account );
// Clear previous hashed URL.
$this->options->delete( OptionsInterface::CLAIMED_URL_HASH );
do_action( 'woocommerce_gla_url_switch_success', [] );
}
}
/**
* Get the callback function for linking an Ads account.
*
* @throws Exception When the merchant account hasn't been set yet.
*/
private function link_ads_account() {
if ( ! $this->options->get_merchant_id() ) {
throw new Exception( 'A Merchant Center account must be connected' );
}
$ads_state = $this->container->get( AdsAccountState::class );
// Create link for Merchant and accept it in Ads.
$waiting_acceptance = $this->container->get( Merchant::class )->link_ads_id( $this->options->get_ads_id() );
if ( $waiting_acceptance ) {
$this->container->get( Ads::class )->accept_merchant_link( $this->options->get_merchant_id() );
}
$ads_state->complete_step( 'link_merchant' );
}
/**
* Prepares an Exception to be thrown with Merchant data:
* - Ensure it has the merchant_id value
* - Default to a 400 error code
*
* @param string $message
* @param array $data
* @param int|null $code
*
* @return ExceptionWithResponseData
*/
private function prepare_exception( string $message, array $data = [], ?int $code = null ): ExceptionWithResponseData {
$merchant_id = $this->options->get_merchant_id();
if ( $merchant_id && ! isset( $data['id'] ) ) {
$data['id'] = $merchant_id;
}
return new ExceptionWithResponseData( $message, $code ?: 400, null, $data );
}
/**
* Delete the option regarding WPCOM authorization
*
* @return bool
*/
public function reset_wpcom_api_authorization_data(): bool {
$this->delete_wpcom_api_auth_nonce();
$this->delete_wpcom_api_status_transient();
return $this->options->delete( OptionsInterface::WPCOM_REST_API_STATUS );
}
/**
* Update the status of the merchant granting access to Google's WPCOM app in the database.
* Before updating the status in the DB it will compare the nonce stored in the DB with the nonce passed to the API.
*
* @param string $status The status of the merchant granting access to Google's WPCOM app.
* @param string $nonce The nonce provided by Google in the URL query parameter when Google redirects back to merchant's site.
*
* @return bool
* @throws ExceptionWithResponseData If the stored nonce / nonce from query param is not provided, or the nonces mismatch.
*/
public function update_wpcom_api_authorization( string $status, string $nonce ): bool {
try {
$stored_nonce = $this->options->get( OptionsInterface::GOOGLE_WPCOM_AUTH_NONCE );
if ( empty( $stored_nonce ) ) {
throw $this->prepare_exception(
__( 'No stored nonce found in the database, skip updating auth status.', 'google-listings-and-ads' )
);
}
if ( empty( $nonce ) ) {
throw $this->prepare_exception(
__( 'Nonce is not provided, skip updating auth status.', 'google-listings-and-ads' )
);
}
if ( $stored_nonce !== $nonce ) {
$this->delete_wpcom_api_auth_nonce();
throw $this->prepare_exception(
__( 'Nonces mismatch, skip updating auth status.', 'google-listings-and-ads' )
);
}
$this->delete_wpcom_api_auth_nonce();
/**
* When the WPCOM Authorization status has been updated.
*
* @event update_wpcom_api_authorization
* @property string status The status of the request.
* @property int|null blog_id The blog ID.
*/
do_action(
'woocommerce_gla_track_event',
'update_wpcom_api_authorization',
[
'status' => $status,
'blog_id' => Jetpack_Options::get_option( 'id' ),
]
);
$this->delete_wpcom_api_status_transient();
return $this->options->update( OptionsInterface::WPCOM_REST_API_STATUS, $status );
} catch ( ExceptionWithResponseData $e ) {
/**
* When the WPCOM Authorization status has been updated with errors.
*
* @event update_wpcom_api_authorization
* @property string status The status of the request.
* @property int|null blog_id The blog ID.
*/
do_action(
'woocommerce_gla_track_event',
'update_wpcom_api_authorization',
[
'status' => $e->getMessage(),
'blog_id' => Jetpack_Options::get_option( 'id' ),
]
);
throw $e;
}
}
/**
* Delete the nonce of "verifying Google is the one redirect back to merchant site and set the auth status" in the database.
*
* @return bool
*/
public function delete_wpcom_api_auth_nonce(): bool {
return $this->options->delete( OptionsInterface::GOOGLE_WPCOM_AUTH_NONCE );
}
/**
* Deletes the transient storing the WPCOM Status data.
*/
public function delete_wpcom_api_status_transient(): void {
$transients = $this->container->get( TransientsInterface::class );
$transients->delete( TransientsInterface::WPCOM_API_STATUS );
}
/**
* Check if the WPCOM API Status is healthy by doing a request to /wc/partners/google/remote-site-status endpoint in WPCOM.
*
* @return bool True when the status is healthy, false otherwise.
*/
public function is_wpcom_api_status_healthy() {
/** @var TransientsInterface $transients */
$transients = $this->container->get( TransientsInterface::class );
$status = $transients->get( TransientsInterface::WPCOM_API_STATUS );
if ( ! $status ) {
$integration_status_args = [
'method' => 'GET',
'timeout' => 30,
'url' => 'https://public-api.wordpress.com/wpcom/v2/sites/' . Jetpack_Options::get_option( 'id' ) . '/wc/partners/google/remote-site-status',
'user_id' => get_current_user_id(),
];
$integration_remote_request_response = Client::remote_request( $integration_status_args, null );
if ( is_wp_error( $integration_remote_request_response ) ) {
$status = [ 'is_healthy' => false ];
} else {
$status = json_decode( wp_remote_retrieve_body( $integration_remote_request_response ), true ) ?? [ 'is_healthy' => false ];
}
$transients->set( TransientsInterface::WPCOM_API_STATUS, $status, MINUTE_IN_SECONDS * 30 );
}
return isset( $status['is_healthy'] ) && $status['is_healthy'] && $status['is_wc_rest_api_healthy'] && $status['is_partner_token_healthy'];
}
}
MerchantCenter/ContactInformation.php 0000644 00000005416 15153721357 0013777 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountBusinessInformation;
/**
* Class ContactInformation.
*
* @since 1.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
class ContactInformation implements Service {
/**
* @var Merchant
*/
protected $merchant;
/**
* @var Settings
*/
protected $settings;
/**
* ContactInformation constructor.
*
* @param Merchant $merchant
* @param Settings $settings
*/
public function __construct( Merchant $merchant, Settings $settings ) {
$this->merchant = $merchant;
$this->settings = $settings;
}
/**
* Get the contact information for the connected Merchant Center account.
*
* @return AccountBusinessInformation|null The contact information associated with the Merchant Center account or
* null.
*
* @throws ExceptionWithResponseData If the Merchant Center account can't be retrieved.
*/
public function get_contact_information(): ?AccountBusinessInformation {
$business_information = $this->merchant->get_account()->getBusinessInformation();
return $business_information ?: null;
}
/**
* Update the address for the connected Merchant Center account to the store address set in WooCommerce
* settings.
*
* @return AccountBusinessInformation The contact information associated with the Merchant Center account.
*
* @throws ExceptionWithResponseData If the Merchant Center account can't be retrieved or updated.
*/
public function update_address_based_on_store_settings(): AccountBusinessInformation {
$business_information = $this->get_contact_information() ?: new AccountBusinessInformation();
$store_address = $this->settings->get_store_address();
$business_information->setAddress( $store_address );
$this->update_contact_information( $business_information );
return $business_information;
}
/**
* Update the contact information for the connected Merchant Center account.
*
* @param AccountBusinessInformation $business_information
*
* @throws ExceptionWithResponseData If the Merchant Center account can't be retrieved or updated.
*/
protected function update_contact_information( AccountBusinessInformation $business_information ): void {
$account = $this->merchant->get_account();
$account->setBusinessInformation( $business_information );
$this->merchant->update_account( $account );
}
}
MerchantCenter/MerchantCenterAwareInterface.php 0000644 00000000711 15153721357 0015672 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
defined( 'ABSPATH' ) || exit;
/**
* Interface MerchantCenterAwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
interface MerchantCenterAwareInterface {
/**
* @param MerchantCenterService $merchant_center
*/
public function set_merchant_center_object( MerchantCenterService $merchant_center ): void;
}
MerchantCenter/MerchantCenterAwareTrait.php 0000644 00000001134 15153721357 0015055 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
defined( 'ABSPATH' ) || exit;
/**
* Trait MerchantCenterAwareTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
trait MerchantCenterAwareTrait {
/**
* The MerchantCenterService object.
*
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @param MerchantCenterService $merchant_center
*/
public function set_merchant_center_object( MerchantCenterService $merchant_center ): void {
$this->merchant_center = $merchant_center;
}
}
MerchantCenter/MerchantCenterService.php 0000644 00000032364 15153721357 0014423 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\AddressUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountBusinessInformation;
use DateTime;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantCenterService
*
* ContainerAware used to access:
* - AddressUtility
* - AdsService
* - ContactInformation
* - Merchant
* - MerchantAccountState
* - MerchantStatuses
* - Settings
* - ShippingRateQuery
* - ShippingTimeQuery
* - TransientsInterface
* - WC
* - WP
* - TargetAudience
* - GoogleHelper
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
class MerchantCenterService implements ContainerAwareInterface, OptionsAwareInterface, Service {
use ContainerAwareTrait;
use OptionsAwareTrait;
use PluginHelper;
/**
* MerchantCenterService constructor.
*/
public function __construct() {
add_filter(
'woocommerce_gla_custom_merchant_issues',
function ( array $issues, DateTime $cache_created_time ) {
return $this->maybe_add_contact_info_issue( $issues, $cache_created_time );
},
10,
2
);
}
/**
* Get whether Merchant Center setup is completed.
*
* @return bool
*/
public function is_setup_complete(): bool {
return boolval( $this->options->get( OptionsInterface::MC_SETUP_COMPLETED_AT, false ) );
}
/**
* Get whether Merchant Center is connected.
*
* @return bool
*/
public function is_connected(): bool {
return $this->is_google_connected() && $this->is_setup_complete();
}
/**
* Get whether the dependent Google account is connected.
*
* @return bool
*/
public function is_google_connected(): bool {
return boolval( $this->options->get( OptionsInterface::GOOGLE_CONNECTED, false ) );
}
/**
* Whether we are able to sync data to the Merchant Center account.
* Account must be connected and the URL we claimed with must match the site URL.
* URL matches is stored in a transient to prevent it from being refetched in cases
* where the site is unable to access account data.
*
* @since 1.13.0
* @return boolean
*/
public function is_ready_for_syncing(): bool {
if ( ! $this->is_connected() ) {
return false;
}
/** @var TransientsInterface $transients */
$transients = $this->container->get( TransientsInterface::class );
$url_matches = $transients->get( TransientsInterface::URL_MATCHES );
if ( null === $url_matches ) {
$claimed_url_hash = $this->container->get( Merchant::class )->get_claimed_url_hash();
$site_url_hash = md5( untrailingslashit( $this->get_site_url() ) );
$url_matches = apply_filters( 'woocommerce_gla_ready_for_syncing', $claimed_url_hash === $site_url_hash ) ? 'yes' : 'no';
$transients->set( TransientsInterface::URL_MATCHES, $url_matches, HOUR_IN_SECONDS * 12 );
}
return 'yes' === $url_matches;
}
/**
* Whether we should push data into MC. Only if:
* - MC is ready for syncing {@see is_ready_for_syncing}
* - Notifications Service is not enabled
*
* @return bool
* @since 2.8.0
*/
public function should_push(): bool {
return $this->is_ready_for_syncing();
}
/**
* Get whether the country is supported by the Merchant Center.
*
* @return bool True if the country is in the list of MC-supported countries.
*
* @since 1.9.0
*/
public function is_store_country_supported(): bool {
$country = $this->container->get( WC::class )->get_base_country();
/** @var GoogleHelper $google_helper */
$google_helper = $this->container->get( GoogleHelper::class );
return $google_helper->is_country_supported( $country );
}
/**
* Get whether the language is supported by the Merchant Center.
*
* @param string $language Optional - to check a language other than the site language.
* @return bool True if the language is in the list of MC-supported languages.
*/
public function is_language_supported( string $language = '' ): bool {
// Default to base site language
if ( empty( $language ) ) {
$language = substr( $this->container->get( WP::class )->get_locale(), 0, 2 );
}
/** @var GoogleHelper $google_helper */
$google_helper = $this->container->get( GoogleHelper::class );
return array_key_exists(
strtolower( $language ),
$google_helper->get_mc_supported_languages()
);
}
/**
* Get whether the contact information has been setup.
*
* @since 1.4.0
*
* @return bool
*/
public function is_contact_information_setup(): bool {
if ( true === boolval( $this->options->get( OptionsInterface::CONTACT_INFO_SETUP, false ) ) ) {
return true;
}
// Additional check for users that have already gone through on-boarding.
if ( $this->is_setup_complete() ) {
$is_mc_setup = $this->is_mc_contact_information_setup();
$this->options->update( OptionsInterface::CONTACT_INFO_SETUP, $is_mc_setup );
return $is_mc_setup;
}
return false;
}
/**
* Return if the given country is supported to have promotions on Google.
*
* @param string $country
*
* @return bool
*/
public function is_promotion_supported_country( string $country = '' ): bool {
// Default to WooCommerce store country
if ( empty( $country ) ) {
$country = $this->container->get( WC::class )->get_base_country();
}
/** @var GoogleHelper $google_helper */
$google_helper = $this->container->get( GoogleHelper::class );
return in_array( $country, $google_helper->get_mc_promotion_supported_countries(), true );
}
/**
* Return the setup status to determine what step to continue at.
*
* @return array
*/
public function get_setup_status(): array {
if ( $this->is_setup_complete() ) {
return [ 'status' => 'complete' ];
}
$step = 'accounts';
if (
$this->connected_account() &&
$this->container->get( AdsService::class )->connected_account() &&
$this->is_mc_contact_information_setup()
) {
$step = 'product_listings';
if ( $this->saved_target_audience() && $this->saved_shipping_and_tax_options() ) {
$step = 'paid_ads';
}
}
return [
'status' => 'incomplete',
'step' => $step,
];
}
/**
* Check if account has been connected.
*
* @return bool
*/
protected function connected_account(): bool {
$id = $this->options->get_merchant_id();
return $id && ! $this->container->get( MerchantAccountState::class )->last_incomplete_step();
}
/**
* Check if target audience has been saved (with a valid selection of countries).
*
* @return bool
*/
protected function saved_target_audience(): bool {
$audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE );
if ( empty( $audience ) || ! isset( $audience['location'] ) ) {
return false;
}
$empty_selection = 'selected' === $audience['location'] && empty( $audience['countries'] );
return ! $empty_selection;
}
/**
* Checks if we should add an issue when the contact information is not setup.
*
* @since 1.4.0
*
* @param array $issues The current array of custom issues
* @param DateTime $cache_created_time The time of the cache/issues generation.
*
* @return array
*/
protected function maybe_add_contact_info_issue( array $issues, DateTime $cache_created_time ): array {
if ( $this->is_setup_complete() && ! $this->is_contact_information_setup() ) {
$issues[] = [
'product_id' => 0,
'product' => 'All products',
'code' => 'missing_contact_information',
'issue' => __( 'No contact information.', 'google-listings-and-ads' ),
'action' => __( 'Add store contact information', 'google-listings-and-ads' ),
'action_url' => $this->get_settings_url(),
'created_at' => $cache_created_time->format( 'Y-m-d H:i:s' ),
'type' => MerchantStatuses::TYPE_ACCOUNT,
'severity' => 'error',
'source' => 'filter',
];
}
return $issues;
}
/**
* Check if the Merchant Center contact information has been setup already.
*
* @since 1.4.0
*
* @return boolean
*/
protected function is_mc_contact_information_setup(): bool {
$is_setup = [
'address' => false,
];
try {
$contact_info = $this->container->get( ContactInformation::class )->get_contact_information();
} catch ( ExceptionWithResponseData $exception ) {
do_action(
'woocommerce_gla_debug_message',
'Error retrieving Merchant Center account\'s business information.',
__METHOD__
);
return false;
}
if ( $contact_info instanceof AccountBusinessInformation ) {
/** @var Settings $settings */
$settings = $this->container->get( Settings::class );
if ( $contact_info->getAddress() instanceof AccountAddress && $settings->get_store_address() instanceof AccountAddress ) {
$is_setup['address'] = $this->container->get( AddressUtility::class )->compare_addresses(
$contact_info->getAddress(),
$settings->get_store_address()
);
}
}
return $is_setup['address'];
}
/**
* Check if the taxes + shipping rate and time + free shipping settings have been saved.
*
* @return bool If all required settings have been provided.
*
* @since 1.4.0
*/
protected function saved_shipping_and_tax_options(): bool {
$merchant_center_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
$target_countries = $this->container->get( TargetAudience::class )->get_target_countries();
// Tax options saved if: not US (no taxes) or tax_rate has been set
if ( in_array( 'US', $target_countries, true ) && empty( $merchant_center_settings['tax_rate'] ) ) {
return false;
}
// Shipping time saved if: 'manual' OR records for all countries
if ( isset( $merchant_center_settings['shipping_time'] ) && 'manual' === $merchant_center_settings['shipping_time'] ) {
$saved_shipping_time = true;
} else {
$shipping_time_rows = $this->container->get( ShippingTimeQuery::class )->get_results();
// Get the name of countries that have saved shipping times.
$saved_time_countries = array_column( $shipping_time_rows, 'country' );
// Check if all target countries have a shipping time.
$saved_shipping_time = count( $shipping_time_rows ) === count( $target_countries ) &&
empty( array_diff( $target_countries, $saved_time_countries ) );
}
// Shipping rates saved if: 'manual', 'automatic', OR there are records for all countries
if (
isset( $merchant_center_settings['shipping_rate'] ) &&
in_array( $merchant_center_settings['shipping_rate'], [ 'manual', 'automatic' ], true )
) {
$saved_shipping_rate = true;
} else {
// Get the list of saved shipping rates grouped by country.
/**
* @var ShippingRateQuery $shipping_rate_query
*/
$shipping_rate_query = $this->container->get( ShippingRateQuery::class );
$shipping_rate_query->group_by( 'country' );
$shipping_rate_rows = $shipping_rate_query->get_results();
// Get the name of countries that have saved shipping rates.
$saved_rates_countries = array_column( $shipping_rate_rows, 'country' );
// Check if all target countries have a shipping rate.
$saved_shipping_rate = count( $shipping_rate_rows ) === count( $target_countries ) &&
empty( array_diff( $target_countries, $saved_rates_countries ) );
}
return $saved_shipping_rate && $saved_shipping_time;
}
/**
* Determine whether there are any account-level issues.
*
* @since 1.11.0
* @return bool
*/
public function has_account_issues(): bool {
$issues = $this->container->get( MerchantStatuses::class )->get_issues( MerchantStatuses::TYPE_ACCOUNT );
return isset( $issues['issues'] ) && count( $issues['issues'] ) >= 1;
}
/**
* Determine whether there is at least one synced product.
*
* @since 1.11.0
* @return bool
*/
public function has_at_least_one_synced_product(): bool {
$statuses = $this->container->get( MerchantStatuses::class )->get_product_statistics();
return isset( $statuses['statistics']['active'] ) && $statuses['statistics']['active'] >= 1;
}
}
MerchantCenter/MerchantStatuses.php 0000644 00000111362 15153721357 0013471 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductMetaQueryHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantIssueQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\Transients;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductStatus as GoogleProductStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateMerchantProductStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use DateTime;
use Exception;
use WC_Product;
/**
* Class MerchantStatuses.
* Note: this class uses vanilla WP methods get_post, get_post_meta, update_post_meta
*
* ContainerAware used to retrieve
* - JobRepository
* - Merchant
* - MerchantCenterService
* - MerchantIssueQuery
* - MerchantIssueTable
* - ProductHelper
* - ProductRepository
* - TransientsInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
class MerchantStatuses implements Service, ContainerAwareInterface, OptionsAwareInterface {
use OptionsAwareTrait;
use ContainerAwareTrait;
use PluginHelper;
/**
* The lifetime of the status-related data.
*/
public const STATUS_LIFETIME = 12 * HOUR_IN_SECONDS;
/**
* The types of issues.
*/
public const TYPE_ACCOUNT = 'account';
public const TYPE_PRODUCT = 'product';
/**
* Issue severity levels.
*/
public const SEVERITY_WARNING = 'warning';
public const SEVERITY_ERROR = 'error';
/**
* @var DateTime $cache_created_time For cache age operations.
*/
protected $cache_created_time;
/**
* @var array Reference array of countries associated to each product+issue combo.
*/
protected $product_issue_countries = [];
/**
* @var array Transient with timestamp and product statuses as reported by Merchant Center.
*/
protected $mc_statuses;
/**
* @var array Statuses for each product id and parent id.
*/
protected $product_statuses = [
'products' => [],
'parents' => [],
];
/**
* @var array Default product stats.
*/
public const DEFAULT_PRODUCT_STATS = [
MCStatus::APPROVED => 0,
MCStatus::PARTIALLY_APPROVED => 0,
MCStatus::EXPIRING => 0,
MCStatus::PENDING => 0,
MCStatus::DISAPPROVED => 0,
MCStatus::NOT_SYNCED => 0,
'parents' => [],
];
/**
* @var array Initial intermediate data for product status counts.
*/
protected $initial_intermediate_data = self::DEFAULT_PRODUCT_STATS;
/**
* @var WC_Product[] Lookup of WooCommerce Product Objects.
*/
protected $product_data_lookup = [];
/**
* MerchantStatuses constructor.
*/
public function __construct() {
$this->cache_created_time = new DateTime();
}
/**
* Get the Product Statistics (updating caches if necessary). This is the
* number of product IDs with each status (approved and partially approved are combined as active).
*
* @param bool $force_refresh Force refresh of all product status data.
*
* @return array The product status statistics.
* @throws Exception If no Merchant Center account is connected, or account status is not retrievable.
*/
public function get_product_statistics( bool $force_refresh = false ): array {
$job = $this->maybe_refresh_status_data( $force_refresh );
$failure_rate_msg = $job->get_failure_rate_message();
$this->mc_statuses = $this->container->get( TransientsInterface::class )->get( Transients::MC_STATUSES );
// If the failure rate is too high, return an error message so the UI can stop polling.
if ( $failure_rate_msg && null === $this->mc_statuses ) {
return [
'timestamp' => $this->cache_created_time->getTimestamp(),
'statistics' => null,
'loading' => false,
'error' => __( 'The scheduled job has been paused due to a high failure rate.', 'google-listings-and-ads' ),
];
}
if ( $job->is_scheduled() || null === $this->mc_statuses ) {
return [
'timestamp' => $this->cache_created_time->getTimestamp(),
'statistics' => null,
'loading' => true,
'error' => null,
];
}
if ( ! empty( $this->mc_statuses['error'] ) ) {
return $this->mc_statuses;
}
$counting_stats = $this->mc_statuses['statistics'];
$counting_stats = array_merge(
[ 'active' => $counting_stats[ MCStatus::PARTIALLY_APPROVED ] + $counting_stats[ MCStatus::APPROVED ] ],
$counting_stats
);
unset( $counting_stats[ MCStatus::PARTIALLY_APPROVED ], $counting_stats[ MCStatus::APPROVED ] );
return array_merge(
$this->mc_statuses,
[ 'statistics' => $counting_stats ]
);
}
/**
* Retrieve the Merchant Center issues and total count. Refresh if the cache issues have gone stale.
* Issue details are reduced, and for products, grouped by type.
* Issues can be filtered by type, severity and searched by name or ID (if product type) and paginated.
* Count takes into account the type filter, but not the pagination.
*
* In case there are issues with severity Error we hide the other issues with lower severity.
*
* @param string|null $type To filter by issue type if desired.
* @param int $per_page The number of issues to return (0 for no limit).
* @param int $page The page to start on (1-indexed).
* @param bool $force_refresh Force refresh of all product status data.
*
* @return array With two indices, results (may be paged), count (considers type) and loading (indicating whether the data is loading).
* @throws Exception If the account state can't be retrieved from Google.
*/
public function get_issues( ?string $type = null, int $per_page = 0, int $page = 1, bool $force_refresh = false ): array {
$job = $this->maybe_refresh_status_data( $force_refresh );
// Get only error issues
$severity_error_issues = $this->fetch_issues( $type, $per_page, $page, true );
// In case there are error issues we show only those, otherwise we show all the issues.
$issues = $severity_error_issues['total'] > 0 ? $severity_error_issues : $this->fetch_issues( $type, $per_page, $page );
$issues['loading'] = $job->is_scheduled();
return $issues;
}
/**
* Clears the status cache data.
*
* @since 1.1.0
*/
public function clear_cache(): void {
$job_repository = $this->container->get( JobRepository::class );
$update_all_products_job = $job_repository->get( UpdateAllProducts::class );
$delete_all_products_job = $job_repository->get( DeleteAllProducts::class );
// Clear the cache if we are not in the middle of updating/deleting all products. Otherwise, we might update the product stats for each individual batch.
// See: ClearProductStatsCache::register
if ( $update_all_products_job->can_schedule( null ) && $delete_all_products_job->can_schedule( null ) ) {
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::MC_STATUSES );
}
}
/**
* Delete the intermediate product status count data.
*
* @since 2.6.4
*/
protected function delete_product_statuses_count_intermediate_data(): void {
$this->options->delete( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA );
}
/**
* Delete the stale issues from the database.
*
* @since 2.6.4
*/
protected function delete_stale_issues(): void {
$this->container->get( MerchantIssueTable::class )->delete_stale( $this->cache_created_time );
}
/**
* Delete the stale mc statuses from the database.
*
* @since 2.6.4
*/
protected function delete_stale_mc_statuses(): void {
$product_meta_query_helper = $this->container->get( ProductMetaQueryHelper::class );
$product_meta_query_helper->delete_all_values( ProductMetaHandler::KEY_MC_STATUS );
}
/**
* Clear the product statuses cache and delete stale issues.
*
* @since 2.6.4
*/
public function clear_product_statuses_cache_and_issues(): void {
$this->delete_stale_issues();
$this->delete_stale_mc_statuses();
$this->delete_product_statuses_count_intermediate_data();
}
/**
* Check if the Merchant Center account is connected and throw an exception if it's not.
*
* @since 2.6.4
*
* @throws Exception If the Merchant Center account is not connected.
*/
protected function check_mc_is_connected() {
$mc_service = $this->container->get( MerchantCenterService::class );
if ( ! $mc_service->is_connected() ) {
// Return a 401 to redirect to reconnect flow if the Google account is not connected.
if ( ! $mc_service->is_google_connected() ) {
throw new Exception( __( 'Google account is not connected.', 'google-listings-and-ads' ), 401 );
}
throw new Exception( __( 'Merchant Center account is not set up.', 'google-listings-and-ads' ) );
}
}
/**
* Maybe start the job to refresh the status and issues data.
*
* @param bool $force_refresh Force refresh of all status-related data.
*
* @return UpdateMerchantProductStatuses The job to update the statuses.
*
* @throws Exception If no Merchant Center account is connected, or account status is not retrievable.
* @throws NotFoundExceptionInterface If the class is not found in the container.
* @throws ContainerExceptionInterface If the container throws an exception.
*/
public function maybe_refresh_status_data( bool $force_refresh = false ): UpdateMerchantProductStatuses {
$this->check_mc_is_connected();
// Only refresh if the current data has expired.
$this->mc_statuses = $this->container->get( TransientsInterface::class )->get( Transients::MC_STATUSES );
$job = $this->container->get( JobRepository::class )->get( UpdateMerchantProductStatuses::class );
// If force_refresh is true or if not transient, return empty array and scheduled the job to update the statuses.
if ( ! $job->is_scheduled() && ( $force_refresh || ( ! $force_refresh && null === $this->mc_statuses ) ) ) {
// Delete the transient before scheduling the job because some errors, like the failure rate message, can occur before the job is executed.
$this->clear_cache();
// Schedule job to update the statuses. If the failure rate is too high, the job will not be scheduled.
$job->schedule();
}
return $job;
}
/**
* Delete the cached statistics and issues.
*/
public function delete(): void {
$this->container->get( TransientsInterface::class )->delete( Transients::MC_STATUSES );
$this->container->get( MerchantIssueTable::class )->truncate();
}
/**
* Fetch the cached issues from the database.
*
* @param string|null $type To filter by issue type if desired.
* @param int $per_page The number of issues to return (0 for no limit).
* @param int $page The page to start on (1-indexed).
* @param bool $only_errors Filters only the issues with error and critical severity.
*
* @return array The requested issues and the total count of issues.
* @throws InvalidValue If the type filter is invalid.
*/
protected function fetch_issues( ?string $type = null, int $per_page = 0, int $page = 1, bool $only_errors = false ): array {
/** @var MerchantIssueQuery $issue_query */
$issue_query = $this->container->get( MerchantIssueQuery::class );
// Ensure account issues are shown first.
$issue_query->set_order( 'type' );
$issue_query->set_order( 'product' );
$issue_query->set_order( 'issue' );
// Filter by type if valid.
if ( in_array( $type, $this->get_valid_issue_types(), true ) ) {
$issue_query->where( 'type', $type );
} elseif ( null !== $type ) {
throw InvalidValue::not_in_allowed_list( 'type filter', $this->get_valid_issue_types() );
}
// Result pagination.
if ( $per_page > 0 ) {
$issue_query->set_limit( $per_page );
$issue_query->set_offset( $per_page * ( $page - 1 ) );
}
if ( $only_errors ) {
$issue_query->where( 'severity', [ 'error', 'critical' ], 'IN' );
}
$issues = [];
foreach ( $issue_query->get_results() as $row ) {
$issue = [
'type' => $row['type'],
'product_id' => intval( $row['product_id'] ),
'product' => $row['product'],
'issue' => $row['issue'],
'code' => $row['code'],
'action' => $row['action'],
'action_url' => $row['action_url'],
'severity' => $this->get_issue_severity( $row ),
];
if ( $issue['product_id'] ) {
$issue['applicable_countries'] = json_decode( $row['applicable_countries'], true );
} else {
unset( $issue['product_id'] );
}
$issues[] = $issue;
}
return [
'issues' => $issues,
'total' => $issue_query->get_count(),
];
}
/**
* Get MC product issues from a list of Product View statuses.
*
* @param array $statuses The list of Product View statuses.
* @throws NotFoundExceptionInterface If the class is not found in the container.
* @throws ContainerExceptionInterface If the container throws an exception.
*
* @return array The list of product issues.
*/
protected function get_product_issues( array $statuses ): array {
/** @var Merchant $merchant */
$merchant = $this->container->get( Merchant::class );
/** @var ProductHelper $product_helper */
$product_helper = $this->container->get( ProductHelper::class );
$visibility_meta_key = $this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY );
$google_ids = array_column( $statuses, 'mc_id' );
$product_issues = [];
$created_at = $this->cache_created_time->format( 'Y-m-d H:i:s' );
$entries = $merchant->get_productstatuses_batch( $google_ids )->getEntries() ?? [];
foreach ( $entries as $response_entry ) {
/** @var GoogleProductStatus $mc_product_status */
$mc_product_status = $response_entry->getProductStatus();
$mc_product_id = $mc_product_status->getProductId();
$wc_product_id = $product_helper->get_wc_product_id( $mc_product_id );
$wc_product = $this->product_data_lookup[ $wc_product_id ] ?? null;
// Skip products not synced by this extension.
if ( ! $wc_product ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Merchant Center product %s not found in this WooCommerce store.', $mc_product_id ),
__METHOD__ . ' in remove_invalid_statuses()',
);
continue;
}
// Unsynced issues shouldn't be shown.
if ( ChannelVisibility::DONT_SYNC_AND_SHOW === $wc_product->get_meta( $visibility_meta_key ) ) {
continue;
}
// Confirm there are issues for this product.
if ( empty( $mc_product_status->getItemLevelIssues() ) ) {
continue;
}
$product_issue_template = [
'product' => html_entity_decode( $wc_product->get_name(), ENT_QUOTES ),
'product_id' => $wc_product_id,
'created_at' => $created_at,
'applicable_countries' => [],
'source' => 'mc',
];
foreach ( $mc_product_status->getItemLevelIssues() as $item_level_issue ) {
if ( 'merchant_action' !== $item_level_issue->getResolution() ) {
continue;
}
$hash_key = $wc_product_id . '__' . md5( $item_level_issue->getDescription() );
$this->product_issue_countries[ $hash_key ] = array_merge(
$this->product_issue_countries[ $hash_key ] ?? [],
$item_level_issue->getApplicableCountries()
);
$product_issues[ $hash_key ] = $product_issue_template + [
'code' => $item_level_issue->getCode(),
'issue' => $item_level_issue->getDescription(),
'action' => $item_level_issue->getDetail(),
'action_url' => $item_level_issue->getDocumentation(),
'severity' => $item_level_issue->getServability(),
];
}
}
return $product_issues;
}
/**
* Refresh the account , pre-sync product validation and custom merchant issues.
*
* @since 2.6.4
*
* @throws Exception If the account state can't be retrieved from Google.
*/
public function refresh_account_and_presync_issues(): void {
// Update account-level issues.
$this->refresh_account_issues();
// Update pre-sync product validation issues.
$this->refresh_presync_product_issues();
// Include any custom merchant issues.
$this->refresh_custom_merchant_issues();
}
/**
* Retrieve all account-level issues and store them in the database.
*
* @throws Exception If the account state can't be retrieved from Google.
*/
protected function refresh_account_issues(): void {
/** @var Merchant $merchant */
$merchant = $this->container->get( Merchant::class );
$account_issues = [];
$created_at = $this->cache_created_time->format( 'Y-m-d H:i:s' );
$issues = $merchant->get_accountstatus()->getAccountLevelIssues() ?? [];
foreach ( $issues as $issue ) {
$key = md5( $issue->getTitle() );
if ( isset( $account_issues[ $key ] ) ) {
$account_issues[ $key ]['applicable_countries'][] = $issue->getCountry();
} else {
$account_issues[ $key ] = [
'product_id' => 0,
'product' => __( 'All products', 'google-listings-and-ads' ),
'code' => $issue->getId(),
'issue' => $issue->getTitle(),
'action' => $issue->getDetail(),
'action_url' => $issue->getDocumentation(),
'created_at' => $created_at,
'type' => self::TYPE_ACCOUNT,
'severity' => $issue->getSeverity(),
'source' => 'mc',
'applicable_countries' => [ $issue->getCountry() ],
];
$account_issues[ $key ] = $this->maybe_override_issue_values( $account_issues[ $key ] );
}
}
// Sort and encode countries
$account_issues = array_map(
function ( $issue ) {
sort( $issue['applicable_countries'] );
$issue['applicable_countries'] = wp_json_encode(
array_unique(
$issue['applicable_countries']
)
);
return $issue;
},
$account_issues
);
/** @var MerchantIssueQuery $issue_query */
$issue_query = $this->container->get( MerchantIssueQuery::class );
$issue_query->update_or_insert( $account_issues );
}
/**
* Custom issues can be added to the merchant issues table.
*
* @since 1.2.0
*/
protected function refresh_custom_merchant_issues() {
$custom_issues = apply_filters( 'woocommerce_gla_custom_merchant_issues', [], $this->cache_created_time );
if ( empty( $custom_issues ) ) {
return;
}
/** @var MerchantIssueQuery $issue_query */
$issue_query = $this->container->get( MerchantIssueQuery::class );
$issue_query->update_or_insert( $custom_issues );
}
/**
* Refresh product issues in the merchant issues table.
*
* @param array $product_issues Array of product issues.
* @throws InvalidQuery If an invalid column name is provided.
* @throws NotFoundExceptionInterface If the class is not found in the container.
* @throws ContainerExceptionInterface If the container throws an exception.
*/
protected function refresh_product_issues( array $product_issues ): void {
// Alphabetize all product/issue country lists.
array_walk(
$this->product_issue_countries,
function ( &$countries ) {
sort( $countries );
}
);
// Product issue cleanup: sorting (by product ID) and encode applicable countries.
ksort( $product_issues );
$product_issues = array_map(
function ( $unique_key, $issue ) {
$issue['applicable_countries'] = wp_json_encode( $this->product_issue_countries[ $unique_key ] );
return $issue;
},
array_keys( $product_issues ),
$product_issues
);
/** @var MerchantIssueQuery $issue_query */
$issue_query = $this->container->get( MerchantIssueQuery::class );
$issue_query->update_or_insert( array_values( $product_issues ) );
}
/**
* Include local presync product validation issues in the merchant issues table.
*/
protected function refresh_presync_product_issues(): void {
/** @var MerchantIssueQuery $issue_query */
$issue_query = $this->container->get( MerchantIssueQuery::class );
$created_at = $this->cache_created_time->format( 'Y-m-d H:i:s' );
$issue_action = __( 'Update this attribute in your product data', 'google-listings-and-ads' );
/** @var ProductMetaQueryHelper $product_meta_query_helper */
$product_meta_query_helper = $this->container->get( ProductMetaQueryHelper::class );
// Get all MC statuses.
$all_errors = $product_meta_query_helper->get_all_values( ProductMetaHandler::KEY_ERRORS );
$chunk_size = apply_filters( 'woocommerce_gla_merchant_status_presync_issues_chunk', 500 );
$product_issues = [];
foreach ( $all_errors as $product_id => $presync_errors ) {
// Don't create issues with empty descriptions
// or for variable parents (they contain issues of all children).
$error = $presync_errors[ array_key_first( $presync_errors ) ];
if ( empty( $error ) || ! is_string( $error ) ) {
continue;
}
$product = get_post( $product_id );
// Don't store pre-sync errors for unpublished (draft, trashed) products.
if ( 'publish' !== get_post_status( $product ) ) {
continue;
}
foreach ( $presync_errors as $text ) {
$issue_parts = $this->parse_presync_issue_text( $text );
$product_issues[] = [
'product' => $product->post_title,
'product_id' => $product_id,
'code' => $issue_parts['code'],
'severity' => self::SEVERITY_ERROR,
'issue' => $issue_parts['issue'],
'action' => $issue_action,
'action_url' => 'https://support.google.com/merchants/answer/10538362?hl=en&ref_topic=6098333',
'applicable_countries' => '["all"]',
'source' => 'pre-sync',
'created_at' => $created_at,
];
}
// Do update-or-insert in chunks.
if ( count( $product_issues ) >= $chunk_size ) {
$issue_query->update_or_insert( $product_issues );
$product_issues = [];
}
}
// Handle any leftover issues.
$issue_query->update_or_insert( $product_issues );
}
/**
* Process product status statistics.
*
* @param array $product_view_statuses Product View statuses.
* @see MerchantReport::get_product_view_report
*
* @throws NotFoundExceptionInterface If the class is not found in the container.
* @throws ContainerExceptionInterface If the container throws an exception.
*/
public function process_product_statuses( array $product_view_statuses ): void {
$this->mc_statuses = [];
$product_repository = $this->container->get( ProductRepository::class );
$this->product_data_lookup = $product_repository->find_by_ids_as_associative_array( array_column( $product_view_statuses, 'product_id' ) );
$this->product_statuses = [
'products' => [],
'parents' => [],
];
foreach ( $product_view_statuses as $product_status ) {
$wc_product_id = $product_status['product_id'];
$mc_product_status = $product_status['status'];
$wc_product = $this->product_data_lookup[ $wc_product_id ] ?? null;
if ( ! $wc_product || ! $wc_product_id ) {
// Skip if the product does not exist in WooCommerce.
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Merchant Center product %s not found in this WooCommerce store.', $wc_product_id ),
__METHOD__,
);
continue;
}
if ( $this->product_is_expiring( $product_status['expiration_date'] ) ) {
$mc_product_status = MCStatus::EXPIRING;
}
// Products is used later for global product status statistics.
$this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] ?? 0 );
// Aggregate parent statuses for mc_status postmeta.
$wc_parent_id = $wc_product->get_parent_id();
if ( ! $wc_parent_id ) {
continue;
}
$this->product_statuses['parents'][ $wc_parent_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['parents'][ $wc_parent_id ][ $mc_product_status ] ?? 0 );
}
$parent_keys = array_values( array_keys( $this->product_statuses['parents'] ) );
$parent_products = $product_repository->find_by_ids_as_associative_array( $parent_keys );
$this->product_data_lookup = $this->product_data_lookup + $parent_products;
// Update each product's mc_status and then update the global statistics.
$this->update_products_meta_with_mc_status();
$this->update_intermediate_product_statistics();
$product_issues = $this->get_product_issues( $product_view_statuses );
$this->refresh_product_issues( $product_issues );
}
/**
* Whether a product is expiring.
*
* @param DateTime $expiration_date
*
* @return bool Whether the product is expiring.
*/
protected function product_is_expiring( DateTime $expiration_date ): bool {
if ( ! $expiration_date ) {
return false;
}
// Products are considered expiring if they will expire within 3 days.
return time() + 3 * DAY_IN_SECONDS > $expiration_date->getTimestamp();
}
/**
* Sum and update the intermediate product status statistics. It will group
* the variations for the same parent.
*
* For the case that one variation is approved and the other disapproved:
* 1. Give each status a priority.
* 2. Store the last highest priority status in `$parent_statuses`.
* 3. Compare if a higher priority status is found for that variable product.
* 4. Loop through the `$parent_statuses` array at the end to add the final status counts.
*
* @return array Product status statistics.
*/
protected function update_intermediate_product_statistics(): array {
$product_statistics = self::DEFAULT_PRODUCT_STATS;
// If the option is set, use it to sum the total quantity.
$product_statistics_intermediate_data = $this->options->get( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA );
if ( $product_statistics_intermediate_data ) {
$product_statistics = $product_statistics_intermediate_data;
$this->initial_intermediate_data = $product_statistics;
}
$product_statistics_priority = [
MCStatus::APPROVED => 6,
MCStatus::PARTIALLY_APPROVED => 5,
MCStatus::EXPIRING => 4,
MCStatus::PENDING => 3,
MCStatus::DISAPPROVED => 2,
MCStatus::NOT_SYNCED => 1,
];
$parent_statuses = [];
foreach ( $this->product_statuses['products'] as $product_id => $statuses ) {
foreach ( $statuses as $status => $num_products ) {
$product = $this->product_data_lookup[ $product_id ] ?? null;
if ( ! $product ) {
continue;
}
$parent_id = $product->get_parent_id();
if ( ! $parent_id ) {
$product_statistics[ $status ] += $num_products;
} elseif ( ! isset( $parent_statuses[ $parent_id ] ) ) {
$parent_statuses[ $parent_id ] = $status;
} else {
$current_parent_status = $parent_statuses[ $parent_id ];
if ( $product_statistics_priority[ $status ] < $product_statistics_priority[ $current_parent_status ] ) {
$parent_statuses[ $parent_id ] = $status;
}
}
}
}
foreach ( $parent_statuses as $parent_id => $new_parent_status ) {
$current_parent_intermediate_data_status = $product_statistics_intermediate_data['parents'][ $parent_id ] ?? null;
if ( $current_parent_intermediate_data_status === $new_parent_status ) {
continue;
}
if ( ! $current_parent_intermediate_data_status ) {
$product_statistics[ $new_parent_status ] += 1;
$product_statistics['parents'][ $parent_id ] = $new_parent_status;
continue;
}
// Check if the new parent status has higher priority than the previous one.
if ( $product_statistics_priority[ $new_parent_status ] < $product_statistics_priority[ $current_parent_intermediate_data_status ] ) {
$product_statistics[ $current_parent_intermediate_data_status ] -= 1;
$product_statistics[ $new_parent_status ] += 1;
$product_statistics['parents'][ $parent_id ] = $new_parent_status;
} else {
$product_statistics['parents'][ $parent_id ] = $current_parent_intermediate_data_status;
}
}
$this->options->update( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, $product_statistics );
return $product_statistics;
}
/**
* Calculate the total count of products in the MC using the statistics.
*
* @since 2.6.4
*
* @param array $statistics
*
* @return int
*/
protected function calculate_total_synced_product_statistics( array $statistics ): int {
if ( ! count( $statistics ) ) {
return 0;
}
$synced_status_values = array_values( array_diff( $statistics, [ $statistics[ MCStatus::NOT_SYNCED ] ] ) );
return array_sum( $synced_status_values );
}
/**
* Handle the failure of the Merchant Center statuses fetching.
*
* @since 2.6.4
*
* @param string $error_message The error message.
*
* @throws NotFoundExceptionInterface If the class is not found in the container.
* @throws ContainerExceptionInterface If the container throws an exception.
*/
public function handle_failed_mc_statuses_fetching( string $error_message = '' ): void {
// Reset the intermediate data to the initial state when starting the job.
$this->options->update( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, $this->initial_intermediate_data );
// Let's remove any issue created during the failed fetch.
$this->container->get( MerchantIssueTable::class )->delete_specific_product_issues( array_keys( $this->product_data_lookup ) );
$mc_statuses = [
'timestamp' => $this->cache_created_time->getTimestamp(),
'statistics' => null,
'loading' => false,
'error' => $error_message,
];
$this->container->get( TransientsInterface::class )->set(
Transients::MC_STATUSES,
$mc_statuses,
$this->get_status_lifetime()
);
}
/**
* Handle the completion of the Merchant Center statuses fetching.
*
* @since 2.6.4
*/
public function handle_complete_mc_statuses_fetching() {
$intermediate_data = $this->options->get( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, self::DEFAULT_PRODUCT_STATS );
unset( $intermediate_data['parents'] );
$total_synced_products = $this->calculate_total_synced_product_statistics( $intermediate_data );
/** @var ProductRepository $product_repository */
$product_repository = $this->container->get( ProductRepository::class );
$intermediate_data[ MCStatus::NOT_SYNCED ] = count(
$product_repository->find_all_product_ids()
) - $total_synced_products;
$mc_statuses = [
'timestamp' => $this->cache_created_time->getTimestamp(),
'statistics' => $intermediate_data,
'loading' => false,
'error' => null,
];
$this->container->get( TransientsInterface::class )->set(
Transients::MC_STATUSES,
$mc_statuses,
$this->get_status_lifetime()
);
$this->delete_product_statuses_count_intermediate_data();
}
/**
* Update the Merchant Center status for each product.
*/
protected function update_products_meta_with_mc_status() {
// Generate a product_id=>mc_status array.
$new_product_statuses = [];
foreach ( $this->product_statuses as $types ) {
foreach ( $types as $product_id => $statuses ) {
if ( isset( $statuses[ MCStatus::PENDING ] ) ) {
$new_product_statuses[ $product_id ] = MCStatus::PENDING;
} elseif ( isset( $statuses[ MCStatus::EXPIRING ] ) ) {
$new_product_statuses[ $product_id ] = MCStatus::EXPIRING;
} elseif ( isset( $statuses[ MCStatus::APPROVED ] ) ) {
if ( count( $statuses ) > 1 ) {
$new_product_statuses[ $product_id ] = MCStatus::PARTIALLY_APPROVED;
} else {
$new_product_statuses[ $product_id ] = MCStatus::APPROVED;
}
} else {
$new_product_statuses[ $product_id ] = array_key_first( $statuses );
}
}
}
foreach ( $new_product_statuses as $product_id => $new_status ) {
$product = $this->product_data_lookup[ $product_id ] ?? null;
// At this point, the product should exist in WooCommerce but in the case that product is not found, log it.
if ( ! $product ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Merchant Center product with WooCommerce ID %d is not found in this store.', $product_id ),
__METHOD__,
);
continue;
}
$product->add_meta_data( $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS ), $new_status, true );
// We use save_meta_data so we don't trigger the woocommerce_update_product hook and the Syncer Hooks.
$product->save_meta_data();
}
}
/**
* Allows a hook to modify the lifetime of the statuses data.
*
* @return int
*/
protected function get_status_lifetime(): int {
return apply_filters( 'woocommerce_gla_mc_status_lifetime', self::STATUS_LIFETIME );
}
/**
* Valid issues types for issue type filter.
*
* @return string[]
*/
protected function get_valid_issue_types(): array {
return [
self::TYPE_ACCOUNT,
self::TYPE_PRODUCT,
];
}
/**
* Parse the code and formatted issue text out of the presync validation error text.
*
* Converts the error strings:
* "[attribute] Error message." > "Error message [attribute]"
*
* Note:
* If attribute is an array the name can be "[attribute[0]]".
* So we need to match the additional set of square brackets.
*
* @param string $text
*
* @return string[] With indexes `code` and `issue`
*/
protected function parse_presync_issue_text( string $text ): array {
$matches = [];
preg_match( '/^\[([^\]]+\]?)\]\s*(.+)$/', $text, $matches );
if ( count( $matches ) !== 3 ) {
return [
'code' => 'presync_error_attrib_' . md5( $text ),
'issue' => $text,
];
}
// Convert attribute name "imageLink" to "image".
if ( 'imageLink' === $matches[1] ) {
$matches[1] = 'image';
}
// Convert attribute name "additionalImageLinks[]" to "galleryImage".
if ( str_starts_with( $matches[1], 'additionalImageLinks' ) ) {
$matches[1] = 'galleryImage';
}
$matches[2] = trim( $matches[2], ' .' );
return [
'code' => 'presync_error_' . $matches[1],
'issue' => "{$matches[2]} [{$matches[1]}]",
];
}
/**
* Return a standardized Merchant Issue severity value.
*
* @param array $row
*
* @return string
*/
protected function get_issue_severity( array $row ): string {
$is_warning = in_array(
$row['severity'],
[
'warning',
'suggestion',
'demoted',
'unaffected',
],
true
);
return $is_warning ? self::SEVERITY_WARNING : self::SEVERITY_ERROR;
}
/**
* In very rare instances, issue values need to be overridden manually.
*
* @param array $issue
*
* @return array The original issue with any possibly overridden values.
*/
private function maybe_override_issue_values( array $issue ): array {
/**
* Code 'merchant_quality_low' for matching the original issue.
* Ref: https://developers.google.com/shopping-content/guides/account-issues#merchant_quality_low
*
* Issue string "Account isn't eligible for free listings" for matching
* the updated copy after Free and Enhanced Listings merge.
*
* TODO: Remove the condition of matching the $issue['issue']
* if its issue code is the same as 'merchant_quality_low'
* after Google replaces the issue title on their side.
*/
if ( 'merchant_quality_low' === $issue['code'] || "Account isn't eligible for free listings" === $issue['issue'] ) {
$issue['issue'] = 'Show products on additional surfaces across Google through free listings';
$issue['severity'] = self::SEVERITY_WARNING;
$issue['action_url'] = 'https://support.google.com/merchants/answer/9199328?hl=en';
}
/**
* Reference: https://github.com/woocommerce/google-listings-and-ads/issues/1688
*/
if ( 'home_page_issue' === $issue['code'] ) {
$issue['issue'] = 'Website claim is lost, need to re verify and claim your website. Please reference the support link';
$issue['action_url'] = 'https://woocommerce.com/document/google-for-woocommerce/faq/#reverify-website';
}
return $issue;
}
/**
* Getter for get_cache_created_time
*
* @return DateTime The DateTime stored in cache_created_time
*/
public function get_cache_created_time(): DateTime {
return $this->cache_created_time;
}
}
MerchantCenter/PhoneVerification.php 0000644 00000012127 15153721357 0013607 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ISOUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PhoneNumber;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
defined( 'ABSPATH' ) || exit;
/**
* Class PhoneVerification
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*
* @since 1.5.0
*/
class PhoneVerification implements Service {
public const VERIFICATION_METHOD_SMS = 'SMS';
public const VERIFICATION_METHOD_PHONE_CALL = 'PHONE_CALL';
/**
* @var Merchant
*/
protected $merchant;
/**
* @var WP
*/
protected $wp;
/**
* @var ISOUtility
*/
protected $iso_utility;
/**
* PhoneVerification constructor.
*
* @param Merchant $merchant
* @param WP $wp
* @param ISOUtility $iso_utility
*/
public function __construct( Merchant $merchant, WP $wp, ISOUtility $iso_utility ) {
$this->merchant = $merchant;
$this->wp = $wp;
$this->iso_utility = $iso_utility;
}
/**
* Request verification code to start phone verification.
*
* @param string $region_code Two-letter country code (ISO 3166-1 alpha-2) for the phone number, for
* example CA for Canadian numbers.
* @param PhoneNumber $phone_number Phone number to be verified.
* @param string $verification_method Verification method to receive verification code.
*
* @return string The verification ID to use in subsequent calls to
* `PhoneVerification::verify_phone_number`.
*
* @throws PhoneVerificationException If there are any errors requesting verification.
* @throws InvalidValue If an invalid input provided.
*/
public function request_phone_verification( string $region_code, PhoneNumber $phone_number, string $verification_method ): string {
$this->validate_verification_method( $verification_method );
$this->validate_phone_region( $region_code );
try {
return $this->merchant->request_phone_verification( $region_code, $phone_number->get(), $verification_method, $this->get_language_code() );
} catch ( GoogleServiceException $e ) {
throw $this->map_google_exception( $e );
}
}
/**
* Validates verification code to verify phone number for the account.
*
* @param string $verification_id The verification ID returned by
* `PhoneVerification::request_phone_verification`.
* @param string $verification_code The verification code that was sent to the phone number for validation.
* @param string $verification_method Verification method used to receive verification code.
*
* @return void
*
* @throws PhoneVerificationException If there are any errors verifying the phone number.
* @throws InvalidValue If an invalid input provided.
*/
public function verify_phone_number( string $verification_id, string $verification_code, string $verification_method ): void {
$this->validate_verification_method( $verification_method );
try {
$this->merchant->verify_phone_number( $verification_id, $verification_code, $verification_method );
} catch ( GoogleServiceException $e ) {
throw $this->map_google_exception( $e );
}
}
/**
* @param string $method
*
* @throws InvalidValue If the verification method is invalid.
*/
protected function validate_verification_method( string $method ) {
$allowed = [ self::VERIFICATION_METHOD_SMS, self::VERIFICATION_METHOD_PHONE_CALL ];
if ( ! in_array( $method, $allowed, true ) ) {
throw InvalidValue::not_in_allowed_list( $method, $allowed );
}
}
/**
* @param string $region_code
*
* @throws InvalidValue If the phone region code is not a valid ISO 3166-1 alpha-2 country code.
*/
protected function validate_phone_region( string $region_code ) {
if ( ! $this->iso_utility->is_iso3166_alpha2_country_code( $region_code ) ) {
throw new InvalidValue( 'Invalid phone region! Phone region must be a two letter ISO 3166-1 alpha-2 country code.' );
}
}
/**
* @return string
*/
protected function get_language_code(): string {
return $this->iso_utility->wp_locale_to_bcp47( $this->wp->get_user_locale() );
}
/**
* @param GoogleServiceException $exception
*
* @return PhoneVerificationException
*/
protected function map_google_exception( GoogleServiceException $exception ): PhoneVerificationException {
$code = $exception->getCode();
$message = $exception->getMessage();
$reason = '';
$errors = $exception->getErrors();
if ( ! empty( $errors ) ) {
$error = $errors[ array_key_first( $errors ) ];
$message = $error['message'] ?? '';
$reason = $error['reason'] ?? '';
}
return new PhoneVerificationException( $message, $code, $exception, [ 'reason' => $reason ] );
}
}
MerchantCenter/PhoneVerificationException.php 0000644 00000000646 15153721357 0015471 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
defined( 'ABSPATH' ) || exit;
/**
* Class PhoneVerificationException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*
* @since 1.5.0
*/
class PhoneVerificationException extends ExceptionWithResponseData {}
MerchantCenter/PolicyComplianceCheck.php 0000644 00000012444 15153721357 0014365 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
defined( 'ABSPATH' ) || exit;
/**
* Class PolicyComplianceCheck
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*
* @since 2.1.4
*/
class PolicyComplianceCheck implements Service {
use PluginHelper;
/**
* The WC proxy object.
*
* @var wc
*/
protected $wc;
/**
* @var GoogleHelper
*/
protected $google_helper;
/**
* @var TargetAudience
*/
protected $target_audience;
/**
* PolicyComplianceCheck constructor.
*
* @param WC $wc
* @param GoogleHelper $google_helper
* @param TargetAudience $target_audience
*/
public function __construct( WC $wc, GoogleHelper $google_helper, TargetAudience $target_audience ) {
$this->wc = $wc;
$this->google_helper = $google_helper;
$this->target_audience = $target_audience;
}
/**
* Check if the store website is accessed by all users for the controller.
*
* @return bool
*/
public function is_accessible(): bool {
$all_allowed_countries = $this->wc->get_allowed_countries();
$target_countries = $this->target_audience->get_target_countries();
foreach ( $target_countries as $country ) {
if ( ! array_key_exists( $country, $all_allowed_countries ) ) {
return false;
}
}
return true;
}
/**
* Check if the store sample product landing pages lead to a 404 error.
*
* @return bool
*/
public function has_page_not_found_error(): bool {
$url = $this->get_landing_page_url();
$response = wp_remote_get( $url );
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return true;
}
return false;
}
/**
* Check if the store sample product landing pages has redirects through 3P domains.
*
* @return bool
*/
public function has_redirects(): bool {
$url = $this->get_landing_page_url();
$response = wp_remote_get( $url, [ 'redirection' => 0 ] );
$code = wp_remote_retrieve_response_code( $response );
if ( $code >= 300 && $code <= 399 ) {
return true;
}
return false;
}
/**
* Returns a product page URL, uses homepage as a fallback.
*
* @return string Landing page URL.
*/
private function get_landing_page_url(): string {
$products = wc_get_products(
[
'limit' => 1,
'status' => 'publish',
]
);
if ( ! empty( $products ) ) {
return $products[0]->get_permalink();
}
return $this->get_site_url();
}
/**
* Check if the merchant set the restrictions in robots.txt or not in the store.
*
* @return bool
*/
public function has_restriction(): bool {
return ! $this->robots_allowed( $this->get_site_url() );
}
/**
* Check if the robots.txt has restrictions or not in the store.
*
* @param string $url
* @return bool
*/
private function robots_allowed( $url ) {
$agents = [ preg_quote( '*', '/' ) ];
$agents = implode( '|', $agents );
// location of robots.txt file
$response = wp_remote_get( trailingslashit( $url ) . 'robots.txt' );
if ( is_wp_error( $response ) ) {
return true;
}
$body = wp_remote_retrieve_body( $response );
$robotstxt = preg_split( "/\r\n|\n|\r/", $body );
if ( empty( $robotstxt ) ) {
return true;
}
$rule_applies = false;
foreach ( $robotstxt as $line ) {
$line = trim( $line );
if ( ! $line ) {
continue;
}
// following rules only apply if User-agent matches '*'
if ( preg_match( '/^\s*User-agent:\s*(.*)/i', $line, $match ) ) {
$rule_applies = '*' === $match[1];
}
if ( $rule_applies && preg_match( '/^\s*Disallow:\s*(.*)/i', $line, $regs ) ) {
if ( ! $regs[1] ) {
return true;
}
if ( '/' === trim( $regs[1] ) ) {
return false;
}
}
}
return true;
}
/**
* Check if the payment gateways is empty or not for the controller.
*
* @return bool
*/
public function has_payment_gateways(): bool {
$gateways = $this->wc->get_available_payment_gateways();
if ( empty( $gateways ) ) {
return false;
}
return true;
}
/**
* Check if the store is using SSL for the controller.
*
* @return bool
*/
public function get_is_store_ssl(): bool {
return 'https' === wp_parse_url( $this->get_site_url(), PHP_URL_SCHEME );
}
/**
* Check if the store has refund return policy page for the controller.
*
* @return bool
*/
public function has_refund_return_policy_page(): bool {
// Check the slug as it's translated by the "woocommerce" text domain name.
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch
if ( $this->the_slug_exists( _x( 'refund_returns', 'Page slug', 'woocommerce' ) ) ) {
return true;
}
return false;
}
/**
* Check if the slug exists or not.
*
* @param string $post_name
* @return bool
*/
protected function the_slug_exists( string $post_name ): bool {
$args = [
'name' => $post_name,
'post_type' => 'page',
'post_status' => 'publish',
'numberposts' => 1,
];
if ( get_posts( $args ) ) {
return true;
}
return false;
}
}
MerchantCenter/TargetAudience.php 0000644 00000004317 15153721357 0013061 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
/**
* Class TargetAudience.
*
* @since 1.12.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
class TargetAudience implements Service {
/**
* @var WC
*/
protected $wc;
/**
* @var OptionsInterface
*/
protected $options;
/**
* @var GoogleHelper
*/
protected $google_helper;
/**
* TargetAudience constructor.
*
* @param WC $wc
* @param OptionsInterface $options
* @param GoogleHelper $google_helper
*/
public function __construct( WC $wc, OptionsInterface $options, GoogleHelper $google_helper ) {
$this->wc = $wc;
$this->options = $options;
$this->google_helper = $google_helper;
}
/**
* @return string[] List of target countries specified in options. Defaults to WooCommerce store base country.
*/
public function get_target_countries(): array {
$target_countries = [ $this->wc->get_base_country() ];
$target_audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE );
if ( empty( $target_audience['location'] ) && empty( $target_audience['countries'] ) ) {
return $target_countries;
}
$location = strtolower( $target_audience['location'] );
if ( 'all' === $location ) {
$target_countries = $this->google_helper->get_mc_supported_countries();
} elseif ( 'selected' === $location && ! empty( $target_audience['countries'] ) ) {
$target_countries = $target_audience['countries'];
}
return $target_countries;
}
/**
* Return the main target country (default Store country).
* If the store country is not included then use the first target country.
*
* @return string
*/
public function get_main_target_country(): string {
$target_countries = $this->get_target_countries();
$shop_country = $this->wc->get_base_country();
return in_array( $shop_country, $target_countries, true ) ? $shop_country : $target_countries[0];
}
}
MultichannelMarketing/GLAChannel.php 0000644 00000014510 15153721357 0013450 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MultichannelMarketing;
use Automattic\WooCommerce\Admin\Marketing\MarketingCampaign;
use Automattic\WooCommerce\Admin\Marketing\MarketingCampaignType;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannelInterface;
use Automattic\WooCommerce\Admin\Marketing\Price;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class GLAChannel
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MultichannelMarketing
*
* @since 2.3.10
*/
class GLAChannel implements MarketingChannelInterface {
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var AdsCampaign
*/
protected $ads_campaign;
/**
* @var Ads
*/
protected $ads;
/**
* @var MerchantStatuses
*/
protected $merchant_statuses;
/**
* @var ProductSyncStats
*/
protected $product_sync_stats;
/**
* @var MarketingCampaignType[]
*/
protected $campaign_types;
/**
* GLAChannel constructor.
*
* @param MerchantCenterService $merchant_center
* @param AdsCampaign $ads_campaign
* @param Ads $ads
* @param MerchantStatuses $merchant_statuses
* @param ProductSyncStats $product_sync_stats
*/
public function __construct( MerchantCenterService $merchant_center, AdsCampaign $ads_campaign, Ads $ads, MerchantStatuses $merchant_statuses, ProductSyncStats $product_sync_stats ) {
$this->merchant_center = $merchant_center;
$this->ads_campaign = $ads_campaign;
$this->ads = $ads;
$this->merchant_statuses = $merchant_statuses;
$this->product_sync_stats = $product_sync_stats;
$this->campaign_types = [];
if ( $this->is_mcm_enabled() ) {
$this->campaign_types = $this->generate_campaign_types();
}
}
/**
* Determines if the multichannel marketing is enabled.
*
* @return bool
*/
protected function is_mcm_enabled(): bool {
return apply_filters( 'woocommerce_gla_enable_mcm', false ) === true;
}
/**
* Returns the unique identifier string for the marketing channel extension, also known as the plugin slug.
*
* @return string
*/
public function get_slug(): string {
return 'google-listings-and-ads';
}
/**
* Returns the name of the marketing channel.
*
* @return string
*/
public function get_name(): string {
return __( 'Google for WooCommerce', 'google-listings-and-ads' );
}
/**
* Returns the description of the marketing channel.
*
* @return string
*/
public function get_description(): string {
return __( 'Native integration with Google that allows merchants to easily display their products across Google’s network.', 'google-listings-and-ads' );
}
/**
* Returns the path to the channel icon.
*
* @return string
*/
public function get_icon_url(): string {
return 'https://woocommerce.com/wp-content/uploads/2021/06/woo-GoogleListingsAds-jworee.png';
}
/**
* Returns the setup status of the marketing channel.
*
* @return bool
*/
public function is_setup_completed(): bool {
return $this->merchant_center->is_setup_complete();
}
/**
* Returns the URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet.
*
* @return string
*/
public function get_setup_url(): string {
if ( ! $this->is_setup_completed() ) {
return admin_url( 'admin.php?page=wc-admin&path=/google/start' );
}
return admin_url( 'admin.php?page=wc-admin&path=/google/settings' );
}
/**
* Returns the status of the marketing channel's product listings.
*
* @return string
*/
public function get_product_listings_status(): string {
if ( ! $this->merchant_center->is_ready_for_syncing() ) {
return self::PRODUCT_LISTINGS_NOT_APPLICABLE;
}
return $this->product_sync_stats->get_count() > 0 ? self::PRODUCT_LISTINGS_SYNC_IN_PROGRESS : self::PRODUCT_LISTINGS_SYNCED;
}
/**
* Returns the number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.).
*
* @return int The number of issues to resolve, or 0 if there are no issues with the channel.
*/
public function get_errors_count(): int {
try {
return $this->merchant_statuses->get_issues()['total'];
} catch ( Exception $e ) {
return 0;
}
}
/**
* Returns an array of marketing campaign types that the channel supports.
*
* @return MarketingCampaignType[] Array of marketing campaign type objects.
*/
public function get_supported_campaign_types(): array {
return $this->campaign_types;
}
/**
* Returns an array of the channel's marketing campaigns.
*
* @return MarketingCampaign[]
*/
public function get_campaigns(): array {
if ( ! $this->ads->ads_id_exists() || ! $this->is_mcm_enabled() ) {
return [];
}
try {
$currency = $this->ads->get_ads_currency();
return array_map(
function ( array $campaign_data ) use ( $currency ) {
$cost = null;
if ( isset( $campaign_data['amount'] ) ) {
$cost = new Price( (string) $campaign_data['amount'], $currency );
}
return new MarketingCampaign(
(string) $campaign_data['id'],
$this->campaign_types['google-ads'],
$campaign_data['name'],
admin_url( 'admin.php?page=wc-admin&path=/google/dashboard&subpath=/campaigns/edit&programId=' . $campaign_data['id'] ),
$cost,
);
},
$this->ads_campaign->get_campaigns()
);
} catch ( ExceptionWithResponseData $e ) {
return [];
}
}
/**
* Generate an array of supported marketing campaign types.
*
* @return MarketingCampaignType[]
*/
protected function generate_campaign_types(): array {
return [
'google-ads' => new MarketingCampaignType(
'google-ads',
$this,
'Google Ads',
'Boost your product listings with a campaign that is automatically optimized to meet your goals.',
admin_url( 'admin.php?page=wc-admin&path=/google/dashboard&subpath=/campaigns/create' ),
$this->get_icon_url()
),
];
}
}
MultichannelMarketing/MarketingChannelRegistrar.php 0000644 00000002277 15153721357 0016660 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MultichannelMarketing;
use Automattic\WooCommerce\Admin\Marketing\MarketingChannels;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
defined( 'ABSPATH' ) || exit;
/**
* Class MarketingChannelRegistrar
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MultichannelMarketing
*
* @since 2.3.10
*/
class MarketingChannelRegistrar implements Service, Registerable {
/**
* @var MarketingChannels
*/
protected $marketing_channels;
/**
* @var GLAChannel
*/
protected $channel;
/**
* MarketingChannelRegistrar constructor.
*
* @param GLAChannel $channel
* @param WC $wc
*/
public function __construct( GLAChannel $channel, WC $wc ) {
$this->marketing_channels = $wc->wc_get_container()->get( MarketingChannels::class );
$this->channel = $channel;
}
/**
* Register as a WooCommerce marketing channel.
*/
public function register(): void {
$this->marketing_channels->register( $this->channel );
}
}
Notes/AbstractNote.php 0000644 00000002201 15153721357 0010742 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use WC_Data_Store;
defined( 'ABSPATH' ) || exit;
/**
* AbstractNote class.
*
* @since 1.7.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
abstract class AbstractNote implements Note, OptionsAwareInterface {
/**
* Remove the note from the datastore.
*
* @since 1.12.5
*/
public function delete() {
if ( class_exists( Notes::class ) ) {
Notes::delete_notes_with_name( $this->get_name() );
}
}
/**
* Get note data store.
*
* @see \Automattic\WooCommerce\Admin\Notes\DataStore for relavent data store.
*
* @return WC_Data_Store
*/
protected function get_data_store(): WC_Data_Store {
return WC_Data_Store::load( 'admin-note' );
}
/**
* Check if the note has already been added.
*
* @return bool
*/
protected function has_been_added(): bool {
$note_ids = $this->get_data_store()->get_notes_with_name( $this->get_name() );
return ! empty( $note_ids );
}
}
Notes/AbstractSetupCampaign.php 0000644 00000005714 15153721357 0012611 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Exception;
use stdClass;
defined( 'ABSPATH' ) || exit;
/**
* Abstract Class AbstractSetupCampaign
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
* @since 1.11.0
*/
abstract class AbstractSetupCampaign extends AbstractNote implements AdsAwareInterface {
use AdsAwareTrait;
use PluginHelper;
use Utilities;
/**
* @var MerchantCenterService
*/
protected $merchant_center_service;
/**
* AbstractSetupCampaign constructor.
*
* @param MerchantCenterService $merchant_center_service
*/
public function __construct( MerchantCenterService $merchant_center_service ) {
$this->merchant_center_service = $merchant_center_service;
}
/**
* Get the note entry.
*/
public function get_entry(): NoteEntry {
$note = new NoteEntry();
$this->set_title_and_content( $note );
$this->add_common_note_settings( $note );
return $note;
}
/**
* @param NoteEntry $note
*
* @return void
*/
protected function add_common_note_settings( NoteEntry $note ): void {
$note->set_content_data( new stdClass() );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_layout( 'plain' );
$note->set_image( '' );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
}
/**
* Checks if a note can and should be added.
*
* Check if ads setup IS NOT complete
* Check if it is > $this->get_gla_setup_days() days ago from DATE OF SETUP COMPLETION
* Send notification
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() ) {
return false;
}
if ( $this->ads_service->is_setup_complete() ) {
return false;
}
if ( ! $this->gla_setup_for( $this->get_gla_setup_days() * DAY_IN_SECONDS ) ) {
return false;
}
// We don't need to process exceptions here, as we're just determining whether to add a note.
try {
if ( $this->merchant_center_service->has_account_issues() ) {
return false;
}
if ( ! $this->merchant_center_service->has_at_least_one_synced_product() ) {
return false;
}
} catch ( Exception $e ) {
return false;
}
return true;
}
/**
* Get the number of days after which to add the note.
*
* @since 1.11.0
*
* @return int
*/
abstract protected function get_gla_setup_days(): int;
/**
* Set the title and content of the Note.
*
* @since 1.11.0
*
* @param NoteEntry $note
*
* @return void
*/
abstract protected function set_title_and_content( NoteEntry $note ): void;
}
Notes/CompleteSetup.php 0000644 00000004066 15153721357 0011155 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use stdClass;
defined( 'ABSPATH' ) || exit;
/**
* Class CompleteSetup
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class CompleteSetup extends AbstractNote implements MerchantCenterAwareInterface {
use MerchantCenterAwareTrait;
use PluginHelper;
use Utilities;
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-complete-setup';
}
/**
* Get the note entry.
*/
public function get_entry(): NoteEntry {
$note = new NoteEntry();
$note->set_title( __( 'Reach more shoppers with free listings on Google', 'google-listings-and-ads' ) );
$note->set_content( __( 'Finish setting up Google for WooCommerce to list your products on Google for free and promote them with ads.', 'google-listings-and-ads' ) );
$note->set_content_data( new stdClass() );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_layout( 'plain' );
$note->set_image( '' );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
$note->add_action(
'complete-setup',
__( 'Finish setup', 'google-listings-and-ads' ),
$this->get_start_url()
);
return $note;
}
/**
* Checks if a note can and should be added.
*
* Check if setup IS NOT complete
* Check if a stores done 5 sales
* Send notification
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() ) {
return false;
}
if ( $this->merchant_center->is_setup_complete() ) {
return false;
}
if ( ! $this->has_orders( 5 ) ) {
return false;
}
return true;
}
}
Notes/ContactInformation.php 0000644 00000004406 15153721357 0012163 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class ContactInformation
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*
* @since 1.4.0
*/
class ContactInformation extends AbstractNote implements MerchantCenterAwareInterface {
use MerchantCenterAwareTrait;
use PluginHelper;
use Utilities;
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-contact-information';
}
/**
* Get the note entry.
*/
public function get_entry(): NoteEntry {
$note = new NoteEntry();
$note->set_title( __( 'Please add your contact information', 'google-listings-and-ads' ) );
$note->set_content( __( 'Google requires the phone number and store address for all stores using Google Merchant Center. This is required to verify your store, and it will not be shown to customers. If you do not add your contact information, your listings may not appear on Google.', 'google-listings-and-ads' ) );
$note->set_content_data( (object) [] );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_layout( 'plain' );
$note->set_image( '' );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
$note->add_action(
'contact-information',
__( 'Add contact information', 'google-listings-and-ads' ),
$this->get_settings_url()
);
return $note;
}
/**
* Checks if a note can and should be added.
*
* Checks if merchant center has been setup and contact information is valid.
* Send notification
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() ) {
return false;
}
if ( ! $this->merchant_center->is_connected() ) {
return false;
}
if ( $this->merchant_center->is_contact_information_setup() ) {
return false;
}
return true;
}
}
Notes/LeaveReviewActionTrait.php 0000644 00000001534 15153721357 0012741 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
/**
* Trait LeaveReviewActionTrait
*
* @since 1.7.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
trait LeaveReviewActionTrait {
/**
* Add the 'leave a review' action to a note.
*
* Randomly chooses whether to show the WP.org vs WC.com link.
*
* @param NoteEntry $note
*/
protected function add_leave_review_note_action( NoteEntry $note ) {
$wp_link = 'https://wordpress.org/support/plugin/google-listings-and-ads/reviews/#new-post';
$wc_link = 'https://woocommerce.com/products/google-listings-and-ads/#reviews';
$note->add_action(
'leave-review',
__( 'Leave a review', 'google-listings-and-ads' ),
wp_rand( 0, 1 ) ? $wp_link : $wc_link
);
}
}
Notes/Note.php 0000644 00000001120 15153721357 0007255 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
defined( 'ABSPATH' ) || exit;
/**
* Note interface.
*
* @since 1.7.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
interface Note {
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string;
/**
* Check whether the note should be added.
*/
public function should_be_added(): bool;
/**
* Get the note entry.
*/
public function get_entry(): NoteEntry;
}
Notes/NoteInitializer.php 0000644 00000006777 15153721357 0011510 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerException;
use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Activateable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Deactivateable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\InstallableInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* NoteInitializer class.
*
* ContainerAware used to access:
* - Note
*
* @since 1.7.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class NoteInitializer implements Activateable, Deactivateable, InstallableInterface, Service, Registerable, ContainerAwareInterface {
use ValidateInterface;
use ContainerAwareTrait;
/**
* Hook name for daily cron.
*/
protected const CRON_HOOK = 'wc_gla_cron_daily_notes';
/**
* @var ActionSchedulerInterface
*/
protected $action_scheduler;
/**
* NoteInitializer constructor.
*
* @param ActionSchedulerInterface $action_scheduler
*/
public function __construct( ActionSchedulerInterface $action_scheduler ) {
$this->action_scheduler = $action_scheduler;
}
/**
* Register the service.
*/
public function register(): void {
add_action( self::CRON_HOOK, [ $this, 'add_notes' ] );
}
/**
* Loop through all notes to add any that should be added.
*/
public function add_notes(): void {
$notes = $this->container->get( Note::class );
foreach ( $notes as $note ) {
try {
if ( $note->should_be_added() ) {
$note->get_entry()->save();
}
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
}
}
}
/**
* Activate the service.
*
* @return void
*/
public function activate(): void {
$this->maybe_add_cron_job();
}
/**
* Run's when plugin is installed or updated.
*
* @param string $old_version Previous version before updating.
* @param string $new_version Current version after updating.
*/
public function install( string $old_version, string $new_version ): void {
$this->maybe_add_cron_job();
}
/**
* Add notes cron job if it doesn't already exist.
*/
protected function maybe_add_cron_job(): void {
if ( ! $this->action_scheduler->has_scheduled_action( self::CRON_HOOK ) ) {
$this->action_scheduler->schedule_recurring( time(), DAY_IN_SECONDS, self::CRON_HOOK );
}
}
/**
* Deactivate the service.
*
* Delete the notes cron job and all notes.
*/
public function deactivate(): void {
try {
$this->action_scheduler->cancel( self::CRON_HOOK );
} catch ( ActionSchedulerException $e ) {
do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
}
// Ensure all note names are deleted
if ( class_exists( Notes::class ) ) {
$note_names = [];
$notes = $this->container->get( Note::class );
foreach ( $notes as $note ) {
$note_names[] = $note->get_name();
}
Notes::delete_notes_with_name( $note_names );
}
}
}
Notes/ReconnectWordPress.php 0000644 00000005716 15153721357 0012160 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class ReconnectWordPress
*
* @since 1.12.5
*
* Note for prompting to reconnect the WordPress.com account.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class ReconnectWordPress extends AbstractNote implements MerchantCenterAwareInterface {
use PluginHelper;
use Utilities;
use MerchantCenterAwareTrait;
/**
* @var Connection
*/
protected $connection;
/**
* ReconnectWordPress constructor.
*
* @param Connection $connection
*/
public function __construct( Connection $connection ) {
$this->connection = $connection;
}
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-reconnect-wordpress';
}
/**
* Get the note entry.
*/
public function get_entry(): NoteEntry {
$note = new NoteEntry();
$note->set_title(
__( 'Re-connect your store to Google for WooCommerce', 'google-listings-and-ads' )
);
$note->set_content(
__( 'Your WordPress.com account has been disconnected from Google for WooCommerce. Connect your WordPress.com account again to ensure your products stay listed on Google through the Google for WooCommerce extension.<br/><br/>If you do not re-connect, any existing listings may be removed from Google.', 'google-listings-and-ads' )
);
$note->set_content_data( (object) [] );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
$note->add_action(
'reconnect-wordpress',
__( 'Go to Google for WooCommerce', 'google-listings-and-ads' ),
add_query_arg( 'subpath', '/reconnect-wpcom-account', $this->get_settings_url() )
);
return $note;
}
/**
* Checks if a note can and should be added.
*
* - Triggers a status check if not already disconnected.
* - Checks if Jetpack is disconnected.
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() || ! $this->merchant_center->is_setup_complete() ) {
return false;
}
$this->maybe_check_status();
return ! $this->is_jetpack_connected();
}
/**
* Trigger a status check if we are not already disconnected.
* A request to the server must be sent to detect a disconnect.
*/
protected function maybe_check_status() {
if ( ! $this->is_jetpack_connected() ) {
return;
}
try {
$this->connection->get_status();
} catch ( Exception $e ) {
return;
}
}
}
Notes/ReviewAfterClicks.php 0000644 00000006420 15153721357 0011734 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ReviewAfterClicks
*
* Note for requesting a review after 10 clicks.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class ReviewAfterClicks extends AbstractNote implements MerchantCenterAwareInterface {
use LeaveReviewActionTrait;
use MerchantCenterAwareTrait;
use PluginHelper;
use Utilities;
/**
* @var MerchantMetrics
*/
protected $merchant_metrics;
/**
* @var WP
*/
protected $wp;
/**
* ReviewAfterClicks constructor.
*
* @param MerchantMetrics $merchant_metrics
* @param WP $wp
*/
public function __construct( MerchantMetrics $merchant_metrics, WP $wp ) {
$this->merchant_metrics = $merchant_metrics;
$this->wp = $wp;
}
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-review-after-clicks';
}
/**
* Get the note entry.
*
* @throws Exception When unable to get clicks data.
*/
public function get_entry(): NoteEntry {
$clicks_count = $this->get_free_listing_clicks_count();
// Round to nearest 10
$clicks_count_rounded = floor( $clicks_count / 10 ) * 10;
$note = new NoteEntry();
$note->set_title(
sprintf(
/* translators: %s number of clicks */
__( 'You’ve gotten %s+ clicks on your free listings! 🎉', 'google-listings-and-ads' ),
$this->wp->number_format_i18n( $clicks_count_rounded )
)
);
$note->set_content(
__( 'Congratulations! Tell us what you think about Google for WooCommerce by leaving a review. Your feedback will help us make WooCommerce even better for you.', 'google-listings-and-ads' )
);
$note->set_content_data( (object) [] );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
$this->add_leave_review_note_action( $note );
return $note;
}
/**
* Checks if a note can and should be added.
*
* - checks there are more than 10 clicks
*
* @throws Exception When unable to get clicks data.
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() ) {
return false;
}
$clicks_count = $this->get_free_listing_clicks_count();
return $clicks_count > 10;
}
/**
* Get free listing clicks count.
*
* Will return 0 if account is not connected.
*
* @return int
*
* @throws Exception When unable to get data.
*/
protected function get_free_listing_clicks_count(): int {
if ( ! $this->merchant_center->is_connected() ) {
return 0;
}
$metrics = $this->merchant_metrics->get_cached_free_listing_metrics();
return empty( $metrics ) ? 0 : $metrics['clicks'];
}
}
Notes/ReviewAfterConversions.php 0000644 00000005626 15153721357 0013043 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ReviewAfterConversions
*
* Note for requesting a review after one ad conversion.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class ReviewAfterConversions extends AbstractNote implements AdsAwareInterface {
use AdsAwareTrait;
use LeaveReviewActionTrait;
use PluginHelper;
use Utilities;
/**
* @var MerchantMetrics
*/
protected $merchant_metrics;
/**
* @var WP
*/
protected $wp;
/**
* ReviewAfterConversions constructor.
*
* @param MerchantMetrics $merchant_metrics
* @param WP $wp
*/
public function __construct( MerchantMetrics $merchant_metrics, WP $wp ) {
$this->merchant_metrics = $merchant_metrics;
$this->wp = $wp;
}
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-review-after-conversions';
}
/**
* Get ads conversions count.
*
* @return int
*
* @throws Exception When unable to get data.
*/
protected function get_ads_conversions_count(): int {
$metrics = $this->merchant_metrics->get_cached_ads_metrics();
return empty( $metrics ) ? 0 : (int) $metrics['conversions'];
}
/**
* Get the note entry.
*
* @throws Exception When unable to get data.
*/
public function get_entry(): NoteEntry {
$note = new NoteEntry();
$note->set_title( __( 'You got your first conversion on Google Ads! 🎉', 'google-listings-and-ads' ), );
$note->set_content(
__( 'Congratulations! Tell us what you think about Google for WooCommerce by leaving a review. Your feedback will help us make WooCommerce even better for you.', 'google-listings-and-ads' )
);
$note->set_content_data( (object) [] );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
$this->add_leave_review_note_action( $note );
return $note;
}
/**
* Checks if a note can and should be added.
*
* - checks there is at least one ad conversion
*
* @throws Exception When unable to get data.
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() ) {
return false;
}
if ( ! $this->ads_service->is_connected() ) {
return false;
}
if ( $this->get_ads_conversions_count() < 1 ) {
return false;
}
return true;
}
}
Notes/SetupCampaign.php 0000644 00000004506 15153721357 0011123 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
defined( 'ABSPATH' ) || exit;
/**
* Class SetupCampaign
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class SetupCampaign extends AbstractSetupCampaign {
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-setup-campaign';
}
/**
* Get the number of days after which to add the note.
*
* @since 1.11.0
*
* @return int
*/
protected function get_gla_setup_days(): int {
return 3;
}
/**
* Set the title and content of the Note.
*
* @since 1.11.0
*
* @param NoteEntry $note
*
* @return void
*/
protected function set_title_and_content( NoteEntry $note ): void {
if ( ! $this->ads_service->is_setup_started() ) {
$note->set_title( __( 'Launch ads to drive traffic and grow sales', 'google-listings-and-ads' ) );
$note->set_content(
__(
'Your products are ready for Google Ads! Get your products shown on Google exactly when shoppers are searching for the products you offer. For new Google Ads accounts, get $500 in ad credit when you spend $500 within your first 60 days. T&Cs apply.',
'google-listings-and-ads'
)
);
$note->add_action(
'setup-campaign',
__( 'Set up Google Ads', 'google-listings-and-ads' ),
$this->get_setup_ads_url(),
NoteEntry::E_WC_ADMIN_NOTE_ACTIONED,
true
);
} else {
$note->set_title( __( 'Finish connecting your Google Ads account', 'google-listings-and-ads' ) );
$note->set_content(
__(
'Your products are ready for Google Ads! Finish connecting your account, create your campaign, pick your budget, and easily measure the impact of your ads. Plus, Google will give you $500 USD in ad credit when you spend $500 for new accounts. T&Cs apply.',
'google-listings-and-ads'
)
);
$note->add_action(
'setup-campaign',
__( 'Complete Setup', 'google-listings-and-ads' ),
$this->get_setup_ads_url(),
NoteEntry::E_WC_ADMIN_NOTE_ACTIONED,
true
);
}
$note->add_action(
'setup-campaign-learn-more',
__( 'Learn more', 'google-listings-and-ads' ),
'https://woocommerce.com/document/google-for-woocommerce/get-started/google-performance-max-campaigns'
);
}
}
Notes/SetupCampaignTwoWeeks.php 0000644 00000004052 15153721357 0012610 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
defined( 'ABSPATH' ) || exit;
/**
* Class SetupCampaignTwoWeeks
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class SetupCampaignTwoWeeks extends AbstractSetupCampaign {
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-setup-campaign-two-weeks';
}
/**
* Get the number of days after which to add the note.
*
* @since 1.11.0
*
* @return int
*/
protected function get_gla_setup_days(): int {
return 14;
}
/**
* Set the title and content of the Note.
*
* @since 1.11.0
*
* @param NoteEntry $note
*
* @return void
*/
protected function set_title_and_content( NoteEntry $note ): void {
if ( ! $this->ads_service->is_setup_started() ) {
$note->set_title( __( 'Reach more shoppers with Google Ads', 'google-listings-and-ads' ) );
$note->set_content(
__(
'Your products are ready for Google Ads! Connect with the right shoppers at the right moment when they’re searching for products like yours. Connect your Google Ads account to create your first campaign.',
'google-listings-and-ads'
)
);
$note->add_action(
'setup-campaign',
__( 'Set up Google Ads', 'google-listings-and-ads' ),
$this->get_setup_ads_url(),
NoteEntry::E_WC_ADMIN_NOTE_ACTIONED,
true
);
} else {
$note->set_title(
__( 'Finish setting up your ads campaign and boost your sales', 'google-listings-and-ads' )
);
$note->set_content(
__(
"You're just a few steps away from reaching new shoppers across Google. Finish connecting your account, create your campaign, pick your budget, and easily measure the impact of your ads.",
'google-listings-and-ads'
)
);
$note->add_action(
'setup-campaign',
__( 'Complete Setup', 'google-listings-and-ads' ),
$this->get_setup_ads_url(),
NoteEntry::E_WC_ADMIN_NOTE_ACTIONED,
true
);
}
}
}
Notes/SetupCouponSharing.php 0000644 00000007767 15153721357 0012177 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Notes;
use Automattic\WooCommerce\Admin\Notes\Note as NoteEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\Utilities;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponMetaHandler;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class SetupCouponSharing
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Notes
*/
class SetupCouponSharing extends AbstractNote implements MerchantCenterAwareInterface, AdsAwareInterface {
use AdsAwareTrait;
use MerchantCenterAwareTrait;
use PluginHelper;
use Utilities;
/** @var MerchantStatuses */
protected $merchant_statuses;
/**
* SetupCouponSharing constructor.
*
* @param MerchantStatuses $merchant_statuses
*/
public function __construct( MerchantStatuses $merchant_statuses ) {
$this->merchant_statuses = $merchant_statuses;
}
/**
* Get the note's unique name.
*
* @return string
*/
public function get_name(): string {
return 'gla-coupon-optin';
}
/**
* Get the note entry.
*/
public function get_entry(): NoteEntry {
$note = new NoteEntry();
$note->set_title( __( 'Show your store coupons on your Google listings', 'google-listings-and-ads' ) );
$note->set_content(
__(
'Sync your store promotions and coupons directly with Google to showcase on your product listings across the Google Shopping tab. <br/><br/>When creating a coupon, you’ll see a Channel Visibility settings box on the right; select "Show coupon on Google" to enable.',
'google-listings-and-ads'
)
);
$note->set_content_data( (object) [] );
$note->set_type( NoteEntry::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_layout( 'plain' );
$note->set_image( '' );
$note->set_name( $this->get_name() );
$note->set_source( $this->get_slug() );
$note->add_action(
'coupon-views',
__( 'Go to coupons', 'google-listings-and-ads' ),
$this->get_coupons_url()
);
return $note;
}
/**
* Checks if a note can and should be added. We insert the notes only when all the conditions are satisfied:
* 1. Store is in promotion supported country
* 2. Store has at least one active product in Merchant Center
* 3. Store has coupons created, but no coupons synced with Merchant Center
* 4. Store has Ads account connected and has been setup for >3 days OR no Ads account and >17 days
*
* @return bool
*/
public function should_be_added(): bool {
if ( $this->has_been_added() ) {
return false;
}
if ( ! $this->merchant_center->is_promotion_supported_country() ) {
return false;
}
// Check if there are synced products.
try {
$statuses = $this->merchant_statuses->get_product_statistics();
if ( isset( $statuses['statistics']['active'] ) && $statuses['statistics']['active'] <= 0 ) {
return false;
}
} catch ( Exception $e ) {
return false;
}
// Check if merchants have created coupons.
$coupons = get_posts( [ 'post_type' => 'shop_coupon' ] );
$shared_coupons = get_posts(
[
'post_type' => 'shop_coupon',
'meta_key' => CouponMetaHandler::KEY_VISIBILITY,
'meta_value' => ChannelVisibility::SYNC_AND_SHOW,
]
);
if ( empty( $coupons ) || ! empty( $shared_coupons ) ) {
return false;
}
if ( $this->ads_service->is_setup_complete() ) {
if ( ! $this->gla_setup_for( 3 * DAY_IN_SECONDS ) ) {
return false;
}
} elseif ( ! $this->gla_setup_for( 17 * DAY_IN_SECONDS ) ) {
return false;
}
return true;
}
}
Options/AccountState.php 0000644 00000005134 15153721357 0011321 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
/**
* Class AccountState
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
abstract class AccountState implements Service, OptionsAwareInterface {
use OptionsAwareTrait;
/** @var int Status value for a pending merchant account creation step */
public const STEP_PENDING = 0;
/** @var int Status value for a completed merchant account creation step */
public const STEP_DONE = 1;
/** @var int Status value for an unsuccessful merchant account creation step */
public const STEP_ERROR = - 1;
/**
* Return the option name.
*
* @return string
*/
abstract protected function option_name(): string;
/**
* Return a list of account creation steps.
*
* @return string[]
*/
abstract protected function account_creation_steps(): array;
/**
* Retrieve or initialize the account state option.
*
* @param bool $initialize_if_not_found True to initialize the array of steps.
*
* @return array The account creation steps and statuses.
*/
public function get( bool $initialize_if_not_found = true ): array {
$state = $this->options->get( $this->option_name(), [] );
if ( empty( $state ) && $initialize_if_not_found ) {
$state = [];
foreach ( $this->account_creation_steps() as $step ) {
$state[ $step ] = [
'status' => self::STEP_PENDING,
'message' => '',
'data' => [],
];
}
$this->update( $state );
}
return $state;
}
/**
* Update the account state option.
*
* @param array $state
*/
public function update( array $state ) {
$this->options->update( $this->option_name(), $state );
}
/**
* Mark a step as completed.
*
* @param string $step Name of the completed step.
*/
public function complete_step( string $step ) {
$state = $this->get( false );
if ( isset( $state[ $step ] ) ) {
$state[ $step ]['status'] = self::STEP_DONE;
$this->update( $state );
}
}
/**
* Returns the name of the last incompleted step.
*
* @return string
*/
public function last_incomplete_step(): string {
$incomplete = '';
foreach ( $this->get( false ) as $name => $step ) {
if ( ! isset( $step['status'] ) || self::STEP_DONE !== $step['status'] ) {
$incomplete = $name;
break;
}
}
return $incomplete;
}
/**
* Returns any data from a specific step.
*
* @param string $step Step name.
* @return array
*/
public function get_step_data( string $step ): array {
return $this->get( false )[ $step ]['data'] ?? [];
}
}
Options/AdsAccountState.php 0000644 00000001164 15153721357 0011750 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
/**
* Class AdsAccountState
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
class AdsAccountState extends AccountState {
/**
* Return the option name.
*
* @return string
*/
protected function option_name(): string {
return OptionsInterface::ADS_ACCOUNT_STATE;
}
/**
* Return a list of account creation steps.
*
* @return string[]
*/
protected function account_creation_steps(): array {
return [ 'set_id', 'account_access', 'conversion_action', 'link_merchant', 'billing' ];
}
}
Options/AdsSetupCompleted.php 0000644 00000002067 15153721357 0012313 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class AdsSetupCompleted
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
class AdsSetupCompleted implements OptionsAwareInterface, Registerable, Service {
use OptionsAwareTrait;
protected const OPTION = OptionsInterface::ADS_SETUP_COMPLETED_AT;
/**
* Register a service.
*
* TODO: call `do_action( 'woocommerce_gla_ads_settings_sync' );` when the initial Google Ads account,
* paid campaign, and billing setup is completed.
*/
public function register(): void {
add_action(
'woocommerce_gla_ads_setup_completed',
function () {
$this->set_completed_timestamp();
}
);
}
/**
* Set the timestamp when setup was completed.
*/
protected function set_completed_timestamp() {
$this->options->update( self::OPTION, time() );
}
}
Options/MerchantAccountState.php 0000644 00000003245 15153721357 0013004 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\SiteVerification;
/**
* Class MerchantAccountState
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
class MerchantAccountState extends AccountState {
/** @var int The number of seconds of delay to enforce between site verification and site claim. */
public const MC_DELAY_AFTER_CREATE = 5;
/**
* Return the option name.
*
* @return string
*/
protected function option_name(): string {
return OptionsInterface::MERCHANT_ACCOUNT_STATE;
}
/**
* Return a list of account creation steps.
*
* @return string[]
*/
protected function account_creation_steps(): array {
return [ 'set_id', 'verify', 'link', 'claim', 'link_ads' ];
}
/**
* Determine whether the site has already been verified.
*
* @return bool True if the site is marked as verified.
*/
public function is_site_verified(): bool {
$current_options = $this->options->get( OptionsInterface::SITE_VERIFICATION );
return ! empty( $current_options['verified'] ) && SiteVerification::VERIFICATION_STATUS_VERIFIED === $current_options['verified'];
}
/**
* Calculate the number of seconds to wait after creating a sub-account and
* before operating on the new sub-account (MCA link and website claim).
*
* @return int
*/
public function get_seconds_to_wait_after_created(): int {
$state = $this->get( false );
$created_timestamp = $state['set_id']['data']['created_timestamp'] ?? 0;
$seconds_elapsed = time() - $created_timestamp;
return max( 0, self::MC_DELAY_AFTER_CREATE - $seconds_elapsed );
}
}
Options/MerchantSetupCompleted.php 0000644 00000002220 15153721357 0013334 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class MerchantSetupCompleted
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
class MerchantSetupCompleted implements OptionsAwareInterface, Registerable, Service {
use OptionsAwareTrait;
protected const OPTION = OptionsInterface::MC_SETUP_COMPLETED_AT;
/**
* Register a service.
*/
public function register(): void {
add_action(
'woocommerce_gla_mc_settings_sync',
function () {
$this->set_contact_information_setup();
$this->set_completed_timestamp();
}
);
}
/**
* Mark the contact information as setup.
*
* @since 1.4.0
*/
protected function set_contact_information_setup() {
$this->options->update( OptionsInterface::CONTACT_INFO_SETUP, true );
}
/**
* Set the timestamp when setup was completed.
*/
protected function set_completed_timestamp() {
$this->options->update( self::OPTION, time() );
}
}
Options/Options.php 0000644 00000012474 15153721357 0010364 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidOption;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\CastableValueInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ValueInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class Options
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
final class Options implements OptionsInterface, Service {
use PluginHelper;
/**
* Array of options that we have loaded.
*
* @var array
*/
protected $options = [];
/**
* Get an option.
*
* @param string $name The option name.
* @param mixed $default_value A default value for the option.
*
* @return mixed
*/
public function get( string $name, $default_value = null ) {
$this->validate_option_key( $name );
if ( ! array_key_exists( $name, $this->options ) ) {
$value = get_option( $this->prefix_name( $name ), $default_value );
$this->options[ $name ] = $this->maybe_cast_value( $name, $value );
}
return $this->raw_value( $this->options[ $name ] );
}
/**
* Add an option.
*
* @param string $name The option name.
* @param mixed $value The option value.
*
* @return bool
*/
public function add( string $name, $value ): bool {
$this->validate_option_key( $name );
$value = $this->maybe_convert_value( $name, $value );
$this->options[ $name ] = $value;
$result = add_option( $this->prefix_name( $name ), $this->raw_value( $value ) );
do_action( "woocommerce_gla_options_updated_{$name}", $value );
return $result;
}
/**
* Update an option.
*
* @param string $name The option name.
* @param mixed $value The option value.
*
* @return bool
*/
public function update( string $name, $value ): bool {
$this->validate_option_key( $name );
$value = $this->maybe_convert_value( $name, $value );
$this->options[ $name ] = $value;
$result = update_option( $this->prefix_name( $name ), $this->raw_value( $value ) );
do_action( "woocommerce_gla_options_updated_{$name}", $value );
return $result;
}
/**
* Delete an option.
*
* @param string $name The option name.
*
* @return bool
*/
public function delete( string $name ): bool {
$this->validate_option_key( $name );
unset( $this->options[ $name ] );
$result = delete_option( $this->prefix_name( $name ) );
do_action( "woocommerce_gla_options_deleted_{$name}" );
return $result;
}
/**
* Helper function to retrieve the Ads Account ID.
*
* @return int
*/
public function get_ads_id(): int {
// TODO: Remove overriding with default once ConnectionTest is removed.
$default = intval( $_GET['customer_id'] ?? 0 ); // phpcs:ignore WordPress.Security
return $default ?: $this->get( self::ADS_ID );
}
/**
* Helper function to retrieve the Merchant Account ID.
*
* @return int
*/
public function get_merchant_id(): int {
// TODO: Remove overriding with default once ConnectionTest is removed.
$default = intval( $_GET['merchant_id'] ?? 0 ); // phpcs:ignore WordPress.Security
return $default ?: $this->get( self::MERCHANT_ID );
}
/**
* Returns all available option keys.
*
* @return array
*/
public static function get_all_option_keys(): array {
return array_keys( self::VALID_OPTIONS );
}
/**
* Ensure that a given option key is valid.
*
* @param string $name The option name.
*
* @throws InvalidOption When the option key is not valid.
*/
protected function validate_option_key( string $name ) {
if ( ! array_key_exists( $name, self::VALID_OPTIONS ) ) {
throw InvalidOption::invalid_name( $name );
}
}
/**
* Cast to a specific value type.
*
* @param string $name The option name.
* @param mixed $value The option value.
*
* @return mixed
*/
protected function maybe_cast_value( string $name, $value ) {
if ( isset( self::OPTION_TYPES[ $name ] ) ) {
/** @var CastableValueInterface $class */
$class = self::OPTION_TYPES[ $name ];
$value = $class::cast( $value );
}
return $value;
}
/**
* Convert to a specific value type.
*
* @param string $name The option name.
* @param mixed $value The option value.
*
* @return mixed
* @throws InvalidValue When the value is invalid.
*/
protected function maybe_convert_value( string $name, $value ) {
if ( isset( self::OPTION_TYPES[ $name ] ) ) {
$class = self::OPTION_TYPES[ $name ];
$value = new $class( $value );
}
return $value;
}
/**
* Return raw value.
*
* @param mixed $value Possible object value.
*
* @return mixed
*/
protected function raw_value( $value ) {
return $value instanceof ValueInterface ? $value->get() : $value;
}
/**
* Prefix an option name with the plugin prefix.
*
* @param string $name
*
* @return string
*/
protected function prefix_name( string $name ): string {
return "{$this->get_slug()}_{$name}";
}
/**
* Checks if WPCOM API is Authorized.
*
* @return bool
*/
public function is_wpcom_api_authorized(): bool {
return $this->get( self::WPCOM_REST_API_STATUS ) === 'approved';
}
}
Options/OptionsAwareInterface.php 0000644 00000000700 15153721357 0013152 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
defined( 'ABSPATH' ) || exit;
/**
* Interface OptionsAwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
interface OptionsAwareInterface {
/**
* Set the Options object.
*
* @param OptionsInterface $options
*
* @return void
*/
public function set_options_object( OptionsInterface $options ): void;
}
Options/OptionsAwareTrait.php 0000644 00000001022 15153721357 0012333 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
defined( 'ABSPATH' ) || exit;
/**
* Trait OptionsAwareTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
trait OptionsAwareTrait {
/**
* The Options object.
*
* @var OptionsInterface
*/
protected $options;
/**
* Set the Options object.
*
* @param OptionsInterface $options
*/
public function set_options_object( OptionsInterface $options ): void {
$this->options = $options;
}
}
Options/OptionsInterface.php 0000644 00000014726 15153721357 0012207 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PositiveInteger;
/**
* Interface OptionsInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
interface OptionsInterface {
public const ADS_ACCOUNT_CURRENCY = 'ads_account_currency';
public const ADS_ACCOUNT_OCID = 'ads_account_ocid';
public const ADS_ACCOUNT_STATE = 'ads_account_state';
public const ADS_BILLING_URL = 'ads_billing_url';
public const ADS_ID = 'ads_id';
public const ADS_CONVERSION_ACTION = 'ads_conversion_action';
public const ADS_SETUP_COMPLETED_AT = 'ads_setup_completed_at';
public const CAMPAIGN_CONVERT_STATUS = 'campaign_convert_status';
public const CLAIMED_URL_HASH = 'claimed_url_hash';
public const CONTACT_INFO_SETUP = 'contact_info_setup';
public const DELAYED_ACTIVATE = 'delayed_activate';
public const DB_VERSION = 'db_version';
public const FILE_VERSION = 'file_version';
public const GOOGLE_CONNECTED = 'google_connected';
public const GOOGLE_WPCOM_AUTH_NONCE = 'google_wpcom_auth_nonce';
public const INSTALL_TIMESTAMP = 'install_timestamp';
public const INSTALL_VERSION = 'install_version';
public const JETPACK_CONNECTED = 'jetpack_connected';
public const MC_SETUP_COMPLETED_AT = 'mc_setup_completed_at';
public const MERCHANT_ACCOUNT_STATE = 'merchant_account_state';
public const MERCHANT_CENTER = 'merchant_center';
public const MERCHANT_ID = 'merchant_id';
public const REDIRECT_TO_ONBOARDING = 'redirect_to_onboarding';
public const SHIPPING_RATES = 'shipping_rates';
public const SHIPPING_TIMES = 'shipping_times';
public const SITE_VERIFICATION = 'site_verification';
public const SYNCABLE_PRODUCTS_COUNT = 'syncable_products_count';
public const SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA = 'syncable_products_count_intermediate_data';
public const PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA = 'product_statuses_count_intermediate_data';
public const TARGET_AUDIENCE = 'target_audience';
public const TOURS = 'tours';
public const UPDATE_ALL_PRODUCTS_LAST_SYNC = 'update_all_products_last_sync';
public const WP_TOS_ACCEPTED = 'wp_tos_accepted';
public const WPCOM_REST_API_STATUS = 'wpcom_rest_api_status';
public const GTIN_MIGRATION_STATUS = 'gtin_migration_status';
public const VALID_OPTIONS = [
self::ADS_ACCOUNT_CURRENCY => true,
self::ADS_ACCOUNT_OCID => true,
self::ADS_ACCOUNT_STATE => true,
self::ADS_BILLING_URL => true,
self::ADS_ID => true,
self::ADS_CONVERSION_ACTION => true,
self::ADS_SETUP_COMPLETED_AT => true,
self::CAMPAIGN_CONVERT_STATUS => true,
self::CLAIMED_URL_HASH => true,
self::CONTACT_INFO_SETUP => true,
self::DB_VERSION => true,
self::FILE_VERSION => true,
self::GOOGLE_CONNECTED => true,
self::INSTALL_TIMESTAMP => true,
self::INSTALL_VERSION => true,
self::JETPACK_CONNECTED => true,
self::MC_SETUP_COMPLETED_AT => true,
self::MERCHANT_ACCOUNT_STATE => true,
self::MERCHANT_CENTER => true,
self::MERCHANT_ID => true,
self::DELAYED_ACTIVATE => true,
self::SHIPPING_RATES => true,
self::SHIPPING_TIMES => true,
self::REDIRECT_TO_ONBOARDING => true,
self::SITE_VERIFICATION => true,
self::SYNCABLE_PRODUCTS_COUNT => true,
self::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA => true,
self::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA => true,
self::TARGET_AUDIENCE => true,
self::TOURS => true,
self::UPDATE_ALL_PRODUCTS_LAST_SYNC => true,
self::WP_TOS_ACCEPTED => true,
self::WPCOM_REST_API_STATUS => true,
self::GOOGLE_WPCOM_AUTH_NONCE => true,
self::GTIN_MIGRATION_STATUS => true,
];
public const OPTION_TYPES = [
self::ADS_ID => PositiveInteger::class,
self::MERCHANT_ID => PositiveInteger::class,
];
/**
* Get an option.
*
* @param string $name The option name.
* @param mixed $default_value A default value for the option.
*
* @return mixed
*/
public function get( string $name, $default_value = null );
/**
* Add an option.
*
* @param string $name The option name.
* @param mixed $value The option value.
*
* @return bool
*/
public function add( string $name, $value ): bool;
/**
* Update an option.
*
* @param string $name The option name.
* @param mixed $value The option value.
*
* @return bool
*/
public function update( string $name, $value ): bool;
/**
* Delete an option.
*
* @param string $name The option name.
*
* @return bool
*/
public function delete( string $name ): bool;
/**
* Helper function to retrieve the Merchant Account ID.
*
* @return int
*/
public function get_merchant_id(): int;
/**
* Returns all available option keys.
*
* @return array
*/
public static function get_all_option_keys(): array;
/**
* Helper function to retrieve the Ads Account ID.
*
* @return int
*/
public function get_ads_id(): int;
/**
* If the WPCOM API is authorized
*
* @return bool
*/
public function is_wpcom_api_authorized(): bool;
}
Options/Transients.php 0000644 00000005615 15153721357 0011062 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidOption;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class Transients
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure
*/
final class Transients implements TransientsInterface, Service {
use PluginHelper;
/**
* Array of transients that we have loaded.
*
* @var array
*/
protected $transients = [];
/**
* Get a transient.
*
* @param string $name The transient name.
* @param mixed $default_value A default value for the transient.
*
* @return mixed
*/
public function get( string $name, $default_value = null ) {
$this->validate_transient_key( $name );
if ( ! array_key_exists( $name, $this->transients ) ) {
$value = get_transient( $this->prefix_name( $name ) );
if ( false === $value ) {
$value = $default_value;
}
$this->transients[ $name ] = $value;
}
return $this->transients[ $name ];
}
/**
* Add or update a transient.
*
* @param string $name The transient name.
* @param mixed $value The transient value.
* @param int $expiration Time until expiration in seconds.
*
* @return bool
*
* @throws InvalidValue If a boolean $value is provided.
*/
public function set( string $name, $value, int $expiration = 0 ): bool {
if ( is_bool( $value ) ) {
throw new InvalidValue( 'Transients cannot have boolean values.' );
}
$this->validate_transient_key( $name );
$this->transients[ $name ] = $value;
return boolval( set_transient( $this->prefix_name( $name ), $value, $expiration ) );
}
/**
* Delete a transient.
*
* @param string $name The transient name.
*
* @return bool
*/
public function delete( string $name ): bool {
$this->validate_transient_key( $name );
unset( $this->transients[ $name ] );
return boolval( delete_transient( $this->prefix_name( $name ) ) );
}
/**
* Returns all available transient keys.
*
* @return array
*
* @since 1.3.0
*/
public static function get_all_transient_keys(): array {
return array_keys( self::VALID_OPTIONS );
}
/**
* Ensure that a given transient key is valid.
*
* @param string $name The transient name.
*
* @throws InvalidOption When the transient key is not valid.
*/
protected function validate_transient_key( string $name ) {
if ( ! array_key_exists( $name, self::VALID_OPTIONS ) ) {
throw InvalidOption::invalid_name( $name );
}
}
/**
* Prefix a transient name with the plugin prefix.
*
* @param string $name
*
* @return string
*/
protected function prefix_name( string $name ): string {
return "{$this->get_slug()}_{$name}";
}
}
Options/TransientsInterface.php 0000644 00000003515 15153721357 0012700 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;
/**
* Interface TransientsInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
interface TransientsInterface {
public const ADS_CAMPAIGN_COUNT = 'ads_campaign_count';
public const ADS_METRICS = 'ads_metrics';
public const FREE_LISTING_METRICS = 'free_listing_metrics';
public const MC_ACCOUNT_REVIEW = 'mc_account_review';
public const MC_IS_SUBACCOUNT = 'mc_is_subaccount';
public const MC_STATUSES = 'mc_statuses';
public const URL_MATCHES = 'url_matches';
public const WPCOM_API_STATUS = 'wpcom_api_status';
public const VALID_OPTIONS = [
self::ADS_CAMPAIGN_COUNT => true,
self::ADS_METRICS => true,
self::FREE_LISTING_METRICS => true,
self::MC_ACCOUNT_REVIEW => true,
self::MC_IS_SUBACCOUNT => true,
self::MC_STATUSES => true,
self::URL_MATCHES => true,
self::WPCOM_API_STATUS => true,
];
/**
* Get a transient.
*
* @param string $name The transient name.
* @param mixed $default_value A default value for the transient.
*
* @return mixed
*/
public function get( string $name, $default_value = null );
/**
* Add or update a transient.
*
* @param string $name The transient name.
* @param mixed $value The transient value.
* @param int $expiration Time until expiration in seconds.
*
* @return bool
*/
public function set( string $name, $value, int $expiration = 0 ): bool;
/**
* Delete a transient.
*
* @param string $name The transient name.
*
* @return bool
*/
public function delete( string $name ): bool;
/**
* Returns all available transient keys.
*
* @return array
*
* @since 1.3.0
*/
public static function get_all_transient_keys(): array;
}
PluginFactory.php 0000644 00000001246 15153721357 0010057 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\GoogleListingsAndAdsPlugin;
/**
* PluginFactory class.
*
* This is responsible for instantiating a Plugin object and returning the same object.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds
*/
final class PluginFactory {
/**
* Get the instance of the Plugin object.
*
* @return GoogleListingsAndAdsPlugin
*/
public static function instance() {
static $plugin = null;
if ( null === $plugin ) {
$plugin = new GoogleListingsAndAdsPlugin( woogle_get_container() );
}
return $plugin;
}
}
PluginHelper.php 0000644 00000012771 15153721357 0007674 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds;
/**
* Trait PluginHelper
*
* Helper functions that are useful throughout the plugin.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds
*/
trait PluginHelper {
/**
* Get the root directory for the plugin.
*
* @return string
*/
protected function get_root_dir(): string {
return dirname( __DIR__ );
}
/**
* Get the full path to the main plugin file.
*
* @return string
*/
protected function get_main_file(): string {
return "{$this->get_root_dir()}/{$this->get_main_filename()}";
}
/**
* Get the main file for this plugin.
*
* @return string
*/
protected function get_main_filename(): string {
return 'google-listings-and-ads.php';
}
/**
* Get the client name for this plugin.
*
* @return string
*/
protected function get_client_name(): string {
return 'google-listings-and-ads';
}
/**
* Get the plugin slug.
*
* @return string
*/
protected function get_slug(): string {
// This value is also hard-coded in uninstall.php
return 'gla';
}
/**
* Get the plugin URL, possibly with an added path.
*
* @param string $path
*
* @return string
*/
protected function get_plugin_url( string $path = '' ): string {
return plugins_url( $path, $this->get_main_file() );
}
/**
* Get the plugin version.
*
* @return string
*/
protected function get_version(): string {
return WC_GLA_VERSION;
}
/**
* Get the prefix used for plugin's metadata keys in the database.
*
* @return string
*/
protected function get_meta_key_prefix(): string {
// This value is also hard-coded in uninstall.php
return "_wc_{$this->get_slug()}";
}
/**
* Prefix a meta data key with the plugin prefix.
*
* @param string $key
*
* @return string
*/
protected function prefix_meta_key( string $key ): string {
$prefix = $this->get_meta_key_prefix();
return "{$prefix}_{$key}";
}
/**
* Get the plugin basename
*
* @return string
*/
protected function get_plugin_basename(): string {
return plugin_basename( $this->get_main_file() );
}
/**
* Get the plugin start URL
*
* @return string
*/
protected function get_start_url(): string {
return admin_url( 'admin.php?page=wc-admin&path=/google/start' );
}
/**
* Get the URL to connect an Ads account
*
* @return string
*/
protected function get_setup_ads_url(): string {
return admin_url( 'admin.php?page=wc-admin&path=/google/setup-ads' );
}
/**
* Get the plugin settings URL
*
* @return string
*/
protected function get_settings_url(): string {
return admin_url( 'admin.php?page=wc-admin&path=/google/settings' );
}
/**
* Get the coupon list view URL
*
* @return string
*/
protected function get_coupons_url(): string {
return admin_url( 'edit.php?post_type=shop_coupon' );
}
/**
* Get the plugin documentation URL
*
* @return string
*/
protected function get_documentation_url(): string {
return 'https://woocommerce.com/document/google-for-woocommerce/?utm_source=wordpress&utm_medium=all-plugins-page&utm_campaign=doc-link&utm_content=google-listings-and-ads';
}
/**
* Check whether debugging mode is enabled.
*
* @return bool Whether debugging mode is enabled.
*/
protected function is_debug_mode(): bool {
return defined( 'WP_DEBUG' ) && WP_DEBUG;
}
/**
* Get the WooCommerce Connect Server URL
*
* @return string
*/
protected function get_connect_server_url(): string {
if ( defined( 'WOOCOMMERCE_GLA_CONNECT_SERVER_URL' ) ) {
return apply_filters( 'woocommerce_gla_wcs_url', WOOCOMMERCE_GLA_CONNECT_SERVER_URL );
}
return apply_filters( 'woocommerce_gla_wcs_url', 'https://api.woocommerce.com' );
}
/**
* Gets the main site URL which is used for the home page.
*
* @since 1.1.0
*
* @return string
*/
protected function get_site_url(): string {
return apply_filters( 'woocommerce_gla_site_url', get_home_url() );
}
/**
* Removes the protocol (http:// or https://) and trailing slash from the provided URL.
*
* @param string $url
*
* @return string
*/
protected function strip_url_protocol( string $url ): string {
return preg_replace( '#^https?://#', '', untrailingslashit( $url ) );
}
/**
* It tries to convert a string to a decimal number using the dot as the decimal separator.
* This is useful with functions like is_numeric as it doesn't recognize commas as decimal separators or any thousands separator.
* Note: Using wc_format_decimal with a dot as the decimal separator (WC -> Settings -> General) will strip out commas but won’t replace them with dots.
* For example, wc_format_decimal('2,4') will return 24 instead of 2.4.
*
* @param string $numeric_string The number to convert.
*
* @return string The number as a standard decimal. 1.245,63 -> 1245.65
*/
protected function convert_to_standard_decimal( string $numeric_string ): string {
$locale = localeconv();
$separators = [ wc_get_price_decimal_separator(), wc_get_price_thousand_separator(), $locale['thousands_sep'], $locale['mon_thousands_sep'], $locale['decimal_point'], $locale['mon_decimal_point'], ',' ];
if ( wc_get_price_decimals() > 0 ) {
// Replace all posible separators with dots.
$numeric_string = str_replace( $separators, '.', $numeric_string );
// Leave only the last dot that is the decimal separator.
return (string) preg_replace( '/\.(?=.*\.)/', '', $numeric_string );
} else {
// If no decimals remove all separators.
return str_replace( $separators, '', $numeric_string );
}
}
}
Product/AttributeMapping/AttributeMappingHelper.php 0000644 00000010013 15153721357 0016577 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Adult;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AgeGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Brand;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Color;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Condition;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Gender;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\GTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\IsBundle;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Material;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\MPN;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Multipack;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Pattern;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Size;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeSystem;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeType;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\WithMappingInterface;
defined( 'ABSPATH' ) || exit;
/**
* Helper Class for Attribute Mapping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping
*/
class AttributeMappingHelper implements Service {
private const ATTRIBUTES_AVAILABLE_FOR_MAPPING = [
Adult::class,
AgeGroup::class,
Brand::class,
Color::class,
Condition::class,
Gender::class,
GTIN::class,
IsBundle::class,
Material::class,
MPN::class,
Multipack::class,
Pattern::class,
Size::class,
SizeSystem::class,
SizeType::class,
];
public const CATEGORY_CONDITION_TYPE_ALL = 'ALL';
public const CATEGORY_CONDITION_TYPE_ONLY = 'ONLY';
public const CATEGORY_CONDITION_TYPE_EXCEPT = 'EXCEPT';
/**
* Gets all the available attributes for mapping
*
* @return array
*/
public function get_attributes(): array {
$destinations = [];
/**
* @var WithMappingInterface $attribute
*/
foreach ( self::ATTRIBUTES_AVAILABLE_FOR_MAPPING as $attribute ) {
array_push(
$destinations,
[
'id' => $attribute::get_id(),
'label' => $attribute::get_name(),
'enum' => $attribute::is_enum(),
]
);
}
return $destinations;
}
/**
* Get the attribute class based on attribute ID.
*
* @param string $attribute_id The attribute ID to get the class
* @return string|null The attribute class path or null if it's not found
*/
public static function get_attribute_by_id( string $attribute_id ): ?string {
foreach ( self::ATTRIBUTES_AVAILABLE_FOR_MAPPING as $class ) {
if ( $class::get_id() === $attribute_id ) {
return $class;
}
}
return null;
}
/**
* Get the sources for an attribute
*
* @param string $attribute_id The attribute ID to get the sources from.
* @return array The sources for the attribute
*/
public function get_sources_for_attribute( string $attribute_id ): array {
/**
* @var AttributeInterface $attribute
*/
$attribute = self::get_attribute_by_id( $attribute_id );
$attribute_sources = [];
if ( is_null( $attribute ) ) {
return $attribute_sources;
}
foreach ( $attribute::get_sources() as $key => $value ) {
array_push(
$attribute_sources,
[
'id' => $key,
'label' => $value,
]
);
}
return $attribute_sources;
}
/**
* Get the available conditions for the category.
*
* @return string[] The list of available category conditions
*/
public function get_category_condition_types(): array {
return [
self::CATEGORY_CONDITION_TYPE_ALL,
self::CATEGORY_CONDITION_TYPE_EXCEPT,
self::CATEGORY_CONDITION_TYPE_ONLY,
];
}
}
Product/AttributeMapping/Traits/IsEnumTrait.php 0000644 00000001104 15153721357 0015633 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits;
defined( 'ABSPATH' ) || exit;
/**
* Trait for enums
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits
*/
trait IsEnumTrait {
/**
* Returns true for the is_enum property
*
* @return true
*/
public static function is_enum(): bool {
return true;
}
/**
* Returns the attribute sources
*
* @return array
*/
public static function get_sources(): array {
return self::get_value_options();
}
}
Product/AttributeMapping/Traits/IsFieldTrait.php 0000644 00000007572 15153721357 0015771 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits;
defined( 'ABSPATH' ) || exit;
/**
* Trait for fields
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits
*/
trait IsFieldTrait {
/**
* Returns false for the is_enum property
*
* @return false
*/
public static function is_enum(): bool {
return false;
}
/**
* Returns the attribute sources
*
* @return array The available sources
*/
public static function get_sources(): array {
return apply_filters(
'woocommerce_gla_attribute_mapping_sources',
array_merge(
self::get_source_product_fields(),
self::get_source_taxonomies(),
self::get_source_custom_attributes()
),
self::get_id()
);
}
/**
* Gets the taxonomies and global attributes to render them as options in the frontend.
*
* @return array An array with the taxonomies and global attributes
*/
public static function get_source_taxonomies(): array {
$object_taxonomies = get_object_taxonomies( 'product', 'objects' );
$taxonomies = [];
$attributes = [];
$sources = [];
foreach ( $object_taxonomies as $taxonomy ) {
if ( taxonomy_is_product_attribute( $taxonomy->name ) ) {
$attributes[ 'taxonomy:' . $taxonomy->name ] = $taxonomy->label;
continue;
}
$taxonomies[ 'taxonomy:' . $taxonomy->name ] = $taxonomy->label;
}
asort( $taxonomies );
asort( $attributes );
$attributes = apply_filters( 'woocommerce_gla_attribute_mapping_sources_global_attributes', $attributes );
$taxonomies = apply_filters( 'woocommerce_gla_attribute_mapping_sources_taxonomies', $taxonomies );
if ( ! empty( $attributes ) ) {
$sources = array_merge(
[
'disabled:attributes' => __( '- Global attributes -', 'google-listings-and-ads' ),
],
$attributes
);
}
if ( ! empty( $taxonomies ) ) {
$sources = array_merge(
$sources,
[
'disabled:taxonomies' => __( '- Taxonomies -', 'google-listings-and-ads' ),
],
$taxonomies
);
}
return $sources;
}
/**
* Get a list of the available product sources.
*
* @return array An array with the available product sources.
*/
public static function get_source_product_fields(): array {
$fields = [
'product:backorders' => __( 'Allow backorders setting', 'google-listings-and-ads' ),
'product:title' => __( 'Product title', 'google-listings-and-ads' ),
'product:sku' => __( 'SKU', 'google-listings-and-ads' ),
'product:stock_quantity' => __( 'Stock Qty', 'google-listings-and-ads' ),
'product:stock_status' => __( 'Stock Status', 'google-listings-and-ads' ),
'product:tax_class' => __( 'Tax class', 'google-listings-and-ads' ),
'product:name' => __( 'Variation title', 'google-listings-and-ads' ),
'product:weight' => __( 'Weight (raw value, no units)', 'google-listings-and-ads' ),
'product:weight_with_unit' => __( 'Weight (with units)', 'google-listings-and-ads' ),
];
asort( $fields );
$fields = array_merge(
[
'disabled:product' => __( '- Product fields -', 'google-listings-and-ads' ),
],
$fields
);
return apply_filters( 'woocommerce_gla_attribute_mapping_sources_product_fields', $fields );
}
/**
* Allowing to register custom attributes by using a filter.
*
* @return array The custom attributes
*/
public static function get_source_custom_attributes(): array {
$attributes = [];
$attribute_keys = apply_filters( 'woocommerce_gla_attribute_mapping_sources_custom_attributes', [] );
foreach ( $attribute_keys as $key ) {
$attributes[ 'attribute:' . $key ] = $key;
}
if ( ! empty( $attributes ) ) {
$attributes = array_merge(
[
'disabled:attribute' => __( '- Custom Attributes -', 'google-listings-and-ads' ),
],
$attributes
);
}
return $attributes;
}
}
Product/Attributes/AbstractAttribute.php 0000644 00000003562 15153721357 0014471 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractAttribute
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
abstract class AbstractAttribute implements AttributeInterface {
/**
* @var mixed
*/
protected $value = null;
/**
* AbstractAttribute constructor.
*
* @param mixed $value
*/
public function __construct( $value = null ) {
$this->set_value( $value );
}
/**
* Return the attribute type. Must be a valid PHP type.
*
* @return string
*
* @link https://www.php.net/manual/en/function.settype.php
*/
public static function get_value_type(): string {
return 'string';
}
/**
* Returns the attribute value.
*
* @return mixed
*/
public function get_value() {
return $this->value;
}
/**
* @param mixed $value
*
* @return $this
*/
public function set_value( $value ): AbstractAttribute {
$this->value = $this->cast_value( $value );
return $this;
}
/**
* Casts the value to the attribute value type and returns the result.
*
* @param mixed $value
*
* @return mixed
*/
protected function cast_value( $value ) {
if ( is_string( $value ) ) {
$value = trim( $value );
if ( '' === $value ) {
return null;
}
}
$value_type = static::get_value_type();
if ( in_array( $value_type, [ 'bool', 'boolean' ], true ) ) {
$value = wc_string_to_bool( $value );
} else {
settype( $value, $value_type );
}
return $value;
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variable', 'variation' ];
}
/**
* @return string
*/
public function __toString() {
return (string) $this->get_value();
}
}
Product/Attributes/Adult.php 0000644 00000003630 15153721357 0012107 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AdultInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Adult
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Adult extends AbstractAttribute implements WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'adult';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variable', 'variation' ];
}
/**
* Return the attribute type. Must be a valid PHP type.
*
* @return string
*
* @link https://www.php.net/manual/en/function.settype.php
*/
public static function get_value_type(): string {
return 'boolean';
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return AdultInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Adult', 'google-listings-and-ads' );
}
/**
* Returns the attribute sources
*
* @return array
*/
public static function get_sources(): array {
return [
'yes' => __( 'Yes', 'google-listings-and-ads' ),
'no' => __( 'No', 'google-listings-and-ads' ),
];
}
}
Product/Attributes/AgeGroup.php 0000644 00000003760 15153721357 0012553 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AgeGroupInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class AgeGroup
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class AgeGroup extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'ageGroup';
}
/**
* Return an array of values available to choose for the attribute.
*
* Note: array key is used as the option key.
*
* @return array
*/
public static function get_value_options(): array {
return [
'newborn' => __( 'Newborn', 'google-listings-and-ads' ),
'infant' => __( 'Infant', 'google-listings-and-ads' ),
'toddler' => __( 'Toddler', 'google-listings-and-ads' ),
'kids' => __( 'Kids', 'google-listings-and-ads' ),
'adult' => __( 'Adult', 'google-listings-and-ads' ),
];
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return AgeGroupInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Age group', 'google-listings-and-ads' );
}
}
Product/Attributes/AttributeInterface.php 0000644 00000002563 15153721357 0014626 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AttributeInputInterface;
defined( 'ABSPATH' ) || exit;
/**
* Interface AttributeInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
interface AttributeInterface {
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string;
/**
* Return the attribute's value type. Must be a valid PHP type.
*
* @return string
*
* @link https://www.php.net/manual/en/function.settype.php
*/
public static function get_value_type(): string;
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string;
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array;
/**
* Returns the attribute value.
*
* @return mixed
*/
public function get_value();
}
Product/Attributes/AttributeManager.php 0000644 00000026252 15153721357 0014301 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidClass;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use WC_Product;
use WC_Product_Variation;
defined( 'ABSPATH' ) || exit;
/**
* Class AttributeManager
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class AttributeManager implements Service {
use PluginHelper;
use ValidateInterface;
protected const ATTRIBUTES = [
GTIN::class,
MPN::class,
Brand::class,
Condition::class,
Gender::class,
Size::class,
SizeSystem::class,
SizeType::class,
Color::class,
Material::class,
Pattern::class,
AgeGroup::class,
Multipack::class,
IsBundle::class,
AvailabilityDate::class,
Adult::class,
];
/**
* @var array Attribute types mapped to product types
*/
protected $attribute_types_map;
/**
* @var AttributeMappingRulesQuery
*/
protected $attribute_mapping_rules_query;
/**
* @var WC
*/
protected $wc;
/**
* AttributeManager constructor.
*
* @param AttributeMappingRulesQuery $attribute_mapping_rules_query
* @param WC $wc
*/
public function __construct( AttributeMappingRulesQuery $attribute_mapping_rules_query, WC $wc ) {
$this->attribute_mapping_rules_query = $attribute_mapping_rules_query;
$this->wc = $wc;
}
/**
* @param WC_Product $product
* @param AttributeInterface $attribute
*
* @throws InvalidValue If the attribute is invalid for the given product.
*/
public function update( WC_Product $product, AttributeInterface $attribute ) {
$this->validate( $product, $attribute::get_id() );
if ( null === $attribute->get_value() || '' === $attribute->get_value() ) {
$this->delete( $product, $attribute::get_id() );
return;
}
$value = $attribute->get_value();
if ( in_array( $attribute::get_value_type(), [ 'bool', 'boolean' ], true ) ) {
$value = wc_bool_to_string( $value );
}
$product->update_meta_data( $this->prefix_meta_key( $attribute::get_id() ), $value );
$product->save_meta_data();
}
/**
* @param WC_Product $product
* @param string $attribute_id
*
* @return AttributeInterface|null
*
* @throws InvalidValue If the attribute ID is invalid for the given product.
*/
public function get( WC_Product $product, string $attribute_id ): ?AttributeInterface {
$this->validate( $product, $attribute_id );
$value = null;
if ( $this->exists( $product, $attribute_id ) ) {
$value = $product->get_meta( $this->prefix_meta_key( $attribute_id ), true );
}
if ( null === $value || '' === $value ) {
return null;
}
$attribute_class = $this->get_attribute_types_for_product( $product )[ $attribute_id ];
return new $attribute_class( $value );
}
/**
* Return all attribute values for the given product, after the mapping rules, GLA attributes, and filters have been applied.
* GLA Attributes has priority over the product attributes.
*
* @since 2.8.0
*
* @param WC_Product $product
*
* @return array of attribute values
* @throws InvalidValue When the product does not exist.
*/
public function get_all_aggregated_values( WC_Product $product ) {
$attributes = $this->get_all_values( $product );
$parent_product = null;
// merge with parent's attributes if it's a variation product
if ( $product instanceof WC_Product_Variation ) {
$parent_product = $this->wc->get_product( $product->get_parent_id() );
$parent_attributes = $this->get_all_values( $parent_product );
$attributes = array_merge( $parent_attributes, $attributes );
}
$mapping_rules = $this->attribute_mapping_rules_query->get_results();
$adapted_product = new WCProductAdapter(
[
'wc_product' => $product,
'parent_wc_product' => $parent_product,
'targetCountry' => 'US', // targetCountry is required to create a new WCProductAdapter instance, but it's not used in the attributes context.
'gla_attributes' => $attributes,
'mapping_rules' => $mapping_rules,
]
);
foreach ( self::ATTRIBUTES as $attribute_class ) {
$attribute_id = $attribute_class::get_id();
if ( $attribute_id === 'size' ) {
$attribute_id = 'sizes';
}
if ( isset( $adapted_product->$attribute_id ) ) {
$attributes[ $attribute_id ] = $adapted_product->$attribute_id;
}
}
return $attributes;
}
/**
* Return attribute value.
*
* @param WC_Product $product
* @param string $attribute_id
*
* @return mixed|null
*/
public function get_value( WC_Product $product, string $attribute_id ) {
$attribute = $this->get( $product, $attribute_id );
return $attribute instanceof AttributeInterface ? $attribute->get_value() : null;
}
/**
* Return all attributes for the given product
*
* @param WC_Product $product
*
* @return AttributeInterface[]
*/
public function get_all( WC_Product $product ): array {
$all_attributes = [];
foreach ( array_keys( $this->get_attribute_types_for_product( $product ) ) as $attribute_id ) {
$attribute = $this->get( $product, $attribute_id );
if ( null !== $attribute ) {
$all_attributes[ $attribute_id ] = $attribute;
}
}
return $all_attributes;
}
/**
* Return all attribute values for the given product
*
* @param WC_Product $product
*
* @return array of attribute values
*/
public function get_all_values( WC_Product $product ): array {
$all_attributes = [];
foreach ( array_keys( $this->get_attribute_types_for_product( $product ) ) as $attribute_id ) {
$attribute = $this->get_value( $product, $attribute_id );
if ( null !== $attribute ) {
$all_attributes[ $attribute_id ] = $attribute;
}
}
return $all_attributes;
}
/**
* @param WC_Product $product
* @param string $attribute_id
*
* @throws InvalidValue If the attribute ID is invalid for the given product.
*/
public function delete( WC_Product $product, string $attribute_id ) {
$this->validate( $product, $attribute_id );
$product->delete_meta_data( $this->prefix_meta_key( $attribute_id ) );
$product->save_meta_data();
}
/**
* Whether the attribute exists and has been set for the product.
*
* @param WC_Product $product
* @param string $attribute_id
*
* @return bool
*
* @since 1.2.0
*/
public function exists( WC_Product $product, string $attribute_id ): bool {
return $product->meta_exists( $this->prefix_meta_key( $attribute_id ) );
}
/**
* Returns an array of attribute types for the given product
*
* @param WC_Product $product
*
* @return string[] of attribute classes mapped to attribute IDs
*/
public function get_attribute_types_for_product( WC_Product $product ): array {
return $this->get_attribute_types_for_product_types( [ $product->get_type() ] );
}
/**
* Returns an array of attribute types for the given product types
*
* @param string[] $product_types array of WooCommerce product types
*
* @return string[] of attribute classes mapped to attribute IDs
*/
public function get_attribute_types_for_product_types( array $product_types ): array {
// flip the product types array to have them as array keys
$product_types_keys = array_flip( $product_types );
// intersect the product types with our stored attributes map to get arrays of attributes matching the given product types
$match_attributes = array_intersect_key( $this->get_attribute_types_map(), $product_types_keys );
// re-index the attributes map array to avoid string ($product_type) array keys
$match_attributes = array_values( $match_attributes );
if ( empty( $match_attributes ) ) {
return [];
}
// merge all of the attribute arrays from the map (there might be duplicates) and return the results
return array_merge( ...$match_attributes );
}
/**
* Returns all available attribute IDs.
*
* @return array
*
* @since 1.3.0
*/
public static function get_available_attribute_ids(): array {
$attributes = [];
foreach ( self::get_available_attribute_types() as $attribute_type ) {
if ( method_exists( $attribute_type, 'get_id' ) ) {
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
$attributes[ $attribute_id ] = $attribute_id;
}
}
return $attributes;
}
/**
* Return an array of all available attribute class names.
*
* @return string[] Attribute class names
*
* @since 1.3.0
*/
public static function get_available_attribute_types(): array {
/**
* Filters the list of available product attributes.
*
* @param string[] $attributes Array of attribute class names (FQN)
*/
return apply_filters( 'woocommerce_gla_product_attribute_types', self::ATTRIBUTES );
}
/**
* Returns an array of attribute types for all product types
*
* @return string[][] of attribute classes mapped to product types
*/
protected function get_attribute_types_map(): array {
if ( ! isset( $this->attribute_types_map ) ) {
$this->map_attribute_types();
}
return $this->attribute_types_map;
}
/**
* @param WC_Product $product
* @param string $attribute_id
*
* @throws InvalidValue If the attribute type is invalid for the given product.
*/
protected function validate( WC_Product $product, string $attribute_id ) {
$attribute_types = $this->get_attribute_types_for_product( $product );
if ( ! isset( $attribute_types[ $attribute_id ] ) ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Attribute "%s" is not supported for a "%s" product (ID: %s).', $attribute_id, $product->get_type(), $product->get_id() ),
__METHOD__
);
throw InvalidValue::not_in_allowed_list( 'attribute_id', array_keys( $attribute_types ) );
}
}
/**
* @throws InvalidClass If any of the given attribute classes do not implement the AttributeInterface.
*/
protected function map_attribute_types(): void {
$this->attribute_types_map = [];
foreach ( self::get_available_attribute_types() as $attribute_type ) {
$this->validate_interface( $attribute_type, AttributeInterface::class );
$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
$applicable_types = call_user_func( [ $attribute_type, 'get_applicable_product_types' ] );
/**
* Filters the list of applicable product types for each attribute.
*
* @param string[] $applicable_types Array of WooCommerce product types
* @param string $attribute_type Attribute class name (FQN)
*/
$applicable_types = apply_filters( "woocommerce_gla_attribute_applicable_product_types_{$attribute_id}", $applicable_types, $attribute_type );
foreach ( $applicable_types as $product_type ) {
$this->attribute_types_map[ $product_type ] = $this->attribute_types_map[ $product_type ] ?? [];
$this->attribute_types_map[ $product_type ][ $attribute_id ] = $attribute_type;
}
}
}
}
Product/Attributes/AvailabilityDate.php 0000644 00000003350 15153721357 0014245 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AvailabilityDateInput;
defined( 'ABSPATH' ) || exit;
/**
* Class AvailabilityDate
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*
* @since 1.5.0
*/
class AvailabilityDate extends AbstractAttribute {
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'availabilityDate';
}
/**
* Returns a name for the attribute. Used in attribute's input.
*
* @return string
*/
public static function get_name(): string {
return __( 'Availability Date', 'google-listings-and-ads' );
}
/**
* Returns a short description for the attribute. Used in attribute's input.
*
* @return string
*/
public static function get_description(): string {
return __( 'The date a preordered or backordered product becomes available for delivery. Required if product availability is preorder or backorder', 'google-listings-and-ads' );
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*/
public static function get_input_type(): string {
return AvailabilityDateInput::class;
}
}
Product/Attributes/Brand.php 0000644 00000002673 15153721357 0012072 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\BrandInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Brand
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Brand extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'brand';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variable' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return BrandInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Brand', 'google-listings-and-ads' );
}
}
Product/Attributes/Color.php 0000644 00000002674 15153721357 0012123 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\ColorInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Color
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Color extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'color';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return ColorInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Color', 'google-listings-and-ads' );
}
}
Product/Attributes/Condition.php 0000644 00000003613 15153721357 0012765 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\ConditionInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Condition
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Condition extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'condition';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return an array of values available to choose for the attribute.
*
* Note: array key is used as the option key.
*
* @return array
*/
public static function get_value_options(): array {
return [
'new' => __( 'New', 'google-listings-and-ads' ),
'refurbished' => __( 'Refurbished', 'google-listings-and-ads' ),
'used' => __( 'Used', 'google-listings-and-ads' ),
];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return ConditionInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Condition', 'google-listings-and-ads' );
}
}
Product/Attributes/GTIN.php 0000644 00000002666 15153721357 0011607 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\GTINInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class GTIN
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class GTIN extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'gtin';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return GTINInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'GTIN', 'google-listings-and-ads' );
}
}
Product/Attributes/Gender.php 0000644 00000003550 15153721357 0012243 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\GenderInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Gender
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Gender extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'gender';
}
/**
* Return an array of values available to choose for the attribute.
*
* Note: array key is used as the option key.
*
* @return array
*/
public static function get_value_options(): array {
return [
'male' => __( 'Male', 'google-listings-and-ads' ),
'female' => __( 'Female', 'google-listings-and-ads' ),
'unisex' => __( 'Unisex', 'google-listings-and-ads' ),
];
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return GenderInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Gender', 'google-listings-and-ads' );
}
}
Product/Attributes/IsBundle.php 0000644 00000003640 15153721357 0012544 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\IsBundleInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class IsBundle
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class IsBundle extends AbstractAttribute implements WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'isBundle';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute type. Must be a valid PHP type.
*
* @return string
*
* @link https://www.php.net/manual/en/function.settype.php
*/
public static function get_value_type(): string {
return 'boolean';
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return IsBundleInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Is Bundle', 'google-listings-and-ads' );
}
/**
* Returns the attribute sources
*
* @return array
*/
public static function get_sources(): array {
return [
'yes' => __( 'Yes', 'google-listings-and-ads' ),
'no' => __( 'No', 'google-listings-and-ads' ),
];
}
}
Product/Attributes/MPN.php 0000644 00000002660 15153721357 0011472 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\MPNInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class MPN
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class MPN extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'mpn';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return MPNInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'MPN', 'google-listings-and-ads' );
}
}
Product/Attributes/Material.php 0000644 00000002716 15153721357 0012600 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\MaterialInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Material
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Material extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'material';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return MaterialInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Material', 'google-listings-and-ads' );
}
}
Product/Attributes/Multipack.php 0000644 00000003272 15153721357 0012771 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\MultipackInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Multipack
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Multipack extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'multipack';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute type. Must be a valid PHP type.
*
* @return string
*
* @link https://www.php.net/manual/en/function.settype.php
*/
public static function get_value_type(): string {
return 'integer';
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return MultipackInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Multipack', 'google-listings-and-ads' );
}
}
Product/Attributes/Pattern.php 0000644 00000002710 15153721357 0012451 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\PatternInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Pattern
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Pattern extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'pattern';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return PatternInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Pattern', 'google-listings-and-ads' );
}
}
Product/Attributes/Size.php 0000644 00000002666 15153721357 0011760 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\SizeInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class Size
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class Size extends AbstractAttribute implements WithMappingInterface {
use IsFieldTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'size';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return SizeInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Size', 'google-listings-and-ads' );
}
}
Product/Attributes/SizeSystem.php 0000644 00000004407 15153721357 0013160 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\SizeSystemInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class SizeSystem
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class SizeSystem extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'sizeSystem';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return an array of values available to choose for the attribute.
*
* Note: array key is used as the option key.
*
* @return array
*/
public static function get_value_options(): array {
return [
'US' => __( 'US', 'google-listings-and-ads' ),
'EU' => __( 'EU', 'google-listings-and-ads' ),
'UK' => __( 'UK', 'google-listings-and-ads' ),
'DE' => __( 'DE', 'google-listings-and-ads' ),
'FR' => __( 'FR', 'google-listings-and-ads' ),
'IT' => __( 'IT', 'google-listings-and-ads' ),
'AU' => __( 'AU', 'google-listings-and-ads' ),
'BR' => __( 'BR', 'google-listings-and-ads' ),
'CN' => __( 'CN', 'google-listings-and-ads' ),
'JP' => __( 'JP', 'google-listings-and-ads' ),
'MEX' => __( 'MEX', 'google-listings-and-ads' ),
];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return SizeSystemInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Size System', 'google-listings-and-ads' );
}
}
Product/Attributes/SizeType.php 0000644 00000004160 15153721357 0012611 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\SizeTypeInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;
defined( 'ABSPATH' ) || exit;
/**
* Class SizeType
*
* @see https://support.google.com/merchants/answer/6324497
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
class SizeType extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {
use IsEnumTrait;
/**
* Returns the attribute ID.
*
* Must be the same as a Google product's property name to be set automatically.
*
* @return string
*
* @see \Google\Service\ShoppingContent\Product for the list of properties.
*/
public static function get_id(): string {
return 'sizeType';
}
/**
* Return an array of WooCommerce product types that this attribute can be applied to.
*
* @return array
*/
public static function get_applicable_product_types(): array {
return [ 'simple', 'variation' ];
}
/**
* Return an array of values available to choose for the attribute.
*
* Note: array key is used as the option key.
*
* @return array
*/
public static function get_value_options(): array {
return [
'regular' => __( 'Regular', 'google-listings-and-ads' ),
'petite' => __( 'Petite', 'google-listings-and-ads' ),
'plus' => __( 'Plus', 'google-listings-and-ads' ),
'tall' => __( 'Tall', 'google-listings-and-ads' ),
'big' => __( 'Big', 'google-listings-and-ads' ),
'maternity' => __( 'Maternity', 'google-listings-and-ads' ),
];
}
/**
* Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
*
* @return string
*
* @see AttributeInputInterface
*
* @since 1.5.0
*/
public static function get_input_type(): string {
return SizeTypeInput::class;
}
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string {
return __( 'Size Type', 'google-listings-and-ads' );
}
}
Product/Attributes/WithMappingInterface.php 0000644 00000001227 15153721357 0015106 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
defined( 'ABSPATH' ) || exit;
/**
* Interface with specific options for mapping
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
interface WithMappingInterface {
/**
* Returns the attribute name
*
* @return string
*/
public static function get_name(): string;
/**
* Returns true if the attribute is enum type
*
* @return boolean
*/
public static function is_enum(): bool;
/**
* Returns the available attribute sources
*
* @return array
*/
public static function get_sources(): array;
}
Product/Attributes/WithValueOptionsInterface.php 0000644 00000000775 15153721357 0016152 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;
defined( 'ABSPATH' ) || exit;
/**
* Interface WithValueOptionsInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
*/
interface WithValueOptionsInterface {
/**
* Return an array of values available to choose for the attribute.
*
* Note: array key is used as the option key.
*
* @return array
*/
public static function get_value_options(): array;
}
Product/BatchProductHelper.php 0000644 00000023624 15153721357 0012437 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchInvalidProductEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use WC_Product;
use WC_Product_Variable;
defined( 'ABSPATH' ) || exit;
/**
* Class BatchProductHelper
*
* Contains helper methods for batch processing products.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class BatchProductHelper implements Service {
use ValidateInterface;
/**
* @var ProductMetaHandler
*/
protected $meta_handler;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* @var ValidatorInterface
*/
protected $validator;
/**
* @var ProductFactory
*/
protected $product_factory;
/**
* @var TargetAudience
*/
protected $target_audience;
/**
* @var AttributeMappingRulesQuery
*/
protected $attribute_mapping_rules_query;
/**
* BatchProductHelper constructor.
*
* @param ProductMetaHandler $meta_handler
* @param ProductHelper $product_helper
* @param ValidatorInterface $validator
* @param ProductFactory $product_factory
* @param TargetAudience $target_audience
* @param AttributeMappingRulesQuery $attribute_mapping_rules_query
*/
public function __construct(
ProductMetaHandler $meta_handler,
ProductHelper $product_helper,
ValidatorInterface $validator,
ProductFactory $product_factory,
TargetAudience $target_audience,
AttributeMappingRulesQuery $attribute_mapping_rules_query
) {
$this->meta_handler = $meta_handler;
$this->product_helper = $product_helper;
$this->validator = $validator;
$this->product_factory = $product_factory;
$this->target_audience = $target_audience;
$this->attribute_mapping_rules_query = $attribute_mapping_rules_query;
}
/**
* Filters and returns only the products already synced with Google Merchant Center.
*
* @param WC_Product[] $products
*
* @return WC_Product[] The synced products.
*/
public function filter_synced_products( array $products ): array {
return array_filter( $products, [ $this->product_helper, 'is_product_synced' ] );
}
/**
* @param BatchProductEntry $product_entry
*/
public function mark_as_synced( BatchProductEntry $product_entry ) {
$wc_product = $this->product_helper->get_wc_product( $product_entry->get_wc_product_id() );
$google_product = $product_entry->get_google_product();
$this->validate_instanceof( $google_product, GoogleProduct::class );
$this->product_helper->mark_as_synced( $wc_product, $google_product );
}
/**
* @param BatchProductEntry $product_entry
*/
public function mark_as_unsynced( BatchProductEntry $product_entry ) {
try {
$wc_product = $this->product_helper->get_wc_product( $product_entry->get_wc_product_id() );
} catch ( InvalidValue $exception ) {
return;
}
$this->product_helper->mark_as_unsynced( $wc_product );
}
/**
* Mark a batch of WooCommerce product IDs as unsynced.
* Invalid products will be skipped.
*
* @since 1.12.0
*
* @param array $product_ids
*/
public function mark_batch_as_unsynced( array $product_ids ) {
foreach ( $product_ids as $product_id ) {
try {
$product = $this->product_helper->get_wc_product( $product_id );
} catch ( InvalidValue $exception ) {
continue;
}
$this->product_helper->mark_as_unsynced( $product );
}
}
/**
* Marks a WooCommerce product as invalid and stores the errors in a meta data key.
*
* Note: If a product variation is invalid then the parent product is also marked as invalid.
*
* @param BatchInvalidProductEntry $product_entry
*/
public function mark_as_invalid( BatchInvalidProductEntry $product_entry ) {
$wc_product = $this->product_helper->get_wc_product( $product_entry->get_wc_product_id() );
$errors = $product_entry->get_errors();
$this->product_helper->mark_as_invalid( $wc_product, $errors );
}
/**
* Generates an array map containing the Google product IDs as key and the WooCommerce product IDs as values.
*
* @param WC_Product[] $products
*
* @return BatchProductIDRequestEntry[]
*/
public function generate_delete_request_entries( array $products ): array {
$request_entries = [];
foreach ( $products as $product ) {
$this->validate_instanceof( $product, WC_Product::class );
if ( $product instanceof WC_Product_Variable ) {
$request_entries = array_merge( $request_entries, $this->generate_delete_request_entries( $product->get_available_variations( 'objects' ) ) );
continue;
}
$google_ids = $this->product_helper->get_synced_google_product_ids( $product );
if ( empty( $google_ids ) ) {
continue;
}
foreach ( $google_ids as $google_id ) {
$request_entries[ $google_id ] = new BatchProductIDRequestEntry(
$product->get_id(),
$google_id
);
}
}
return $request_entries;
}
/**
* @param WC_Product[] $products
*
* @return BatchProductRequestEntry[]
*/
public function validate_and_generate_update_request_entries( array $products ): array {
$request_entries = [];
$mapping_rules = $this->attribute_mapping_rules_query->get_results();
foreach ( $products as $product ) {
$this->validate_instanceof( $product, WC_Product::class );
try {
if ( ! $this->product_helper->is_sync_ready( $product ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Skipping product (ID: %s) because it is not ready to be synced.', $product->get_id() ),
__METHOD__
);
continue;
}
if ( $product instanceof WC_Product_Variable ) {
$request_entries = array_merge( $request_entries, $this->validate_and_generate_update_request_entries( $product->get_available_variations( 'objects' ) ) );
continue;
}
$target_countries = $this->target_audience->get_target_countries();
$main_target_country = $this->target_audience->get_main_target_country();
// validate the product
$adapted_product = $this->product_factory->create( $product, $main_target_country, $mapping_rules );
$validation_result = $this->validate_product( $adapted_product );
if ( $validation_result instanceof BatchInvalidProductEntry ) {
$this->mark_as_invalid( $validation_result );
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Skipping product (ID: %s) because it does not pass validation: %s', $product->get_id(), wp_json_encode( $validation_result ) ),
__METHOD__
);
continue;
}
// add shipping for all selected target countries
array_walk( $target_countries, [ $adapted_product, 'add_shipping_country' ] );
$request_entries[] = new BatchProductRequestEntry(
$product->get_id(),
$adapted_product
);
} catch ( GoogleListingsAndAdsException $exception ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Skipping product (ID: %s) due to exception: %s', $product->get_id(), $exception->getMessage() ),
__METHOD__
);
continue;
}
}
return $request_entries;
}
/**
* @param WCProductAdapter $product
*
* @return BatchInvalidProductEntry|true
*/
protected function validate_product( WCProductAdapter $product ) {
$violations = $this->validator->validate( $product );
if ( 0 !== count( $violations ) ) {
$invalid_product = new BatchInvalidProductEntry( $product->get_wc_product()->get_id() );
$invalid_product->map_validation_violations( $violations );
return $invalid_product;
}
return true;
}
/**
* Filters and returns an array of request entries for Google products that should no longer be submitted for the selected target audience.
*
* @param WC_Product[] $products
*
* @return BatchProductIDRequestEntry[]
*/
public function generate_stale_products_request_entries( array $products ): array {
$target_audience = $this->target_audience->get_target_countries();
$request_entries = [];
foreach ( $products as $product ) {
$google_ids = $this->meta_handler->get_google_ids( $product ) ?: [];
$stale_ids = array_diff_key( $google_ids, array_flip( $target_audience ) );
foreach ( $stale_ids as $stale_id ) {
$request_entries[ $stale_id ] = new BatchProductIDRequestEntry(
$product->get_id(),
$stale_id
);
}
}
return $request_entries;
}
/**
* Returns an array of request entries for Google products that should no
* longer be submitted for every target country.
*
* @since 1.1.0
*
* @param WC_Product[] $products
*
* @return BatchProductIDRequestEntry[]
*/
public function generate_stale_countries_request_entries( array $products ): array {
$main_target_country = $this->target_audience->get_main_target_country();
$request_entries = [];
foreach ( $products as $product ) {
$google_ids = $this->meta_handler->get_google_ids( $product ) ?: [];
$stale_ids = array_diff_key( $google_ids, array_flip( [ $main_target_country ] ) );
foreach ( $stale_ids as $stale_id ) {
$request_entries[ $stale_id ] = new BatchProductIDRequestEntry(
$product->get_id(),
$stale_id
);
}
}
return $request_entries;
}
}
Product/FilteredProductList.php 0000644 00000003170 15153721357 0012642 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Countable;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class FilteredProductList
*
* A list of filtered products and their total count before filtering.
*
* @since 1.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class FilteredProductList implements Countable {
/**
* List of product objects or IDs.
*
* @var WC_Product[]
*/
protected $products = [];
/**
* Count before filtering.
*
* @var int
*/
protected $unfiltered_count;
/**
* FilteredProductList constructor.
*
* @param WC_Product[] $products List of filtered products.
* @param int $unfiltered_count Product count before filtering.
*/
public function __construct( array $products, int $unfiltered_count ) {
$this->products = $products;
$this->unfiltered_count = $unfiltered_count;
}
/**
* Get the list of products.
*
* @return WC_Product[]
*/
public function get(): array {
return $this->products;
}
/**
* Get product IDs.
*
* @return int[]
*/
public function get_product_ids(): array {
return array_map(
function ( $product ) {
if ( $product instanceof WC_Product ) {
return $product->get_id();
}
return $product;
},
$this->products
);
}
/**
* Get the unfiltered amount of results.
*
* @return int
*/
public function get_unfiltered_count(): int {
return $this->unfiltered_count;
}
/**
* Count products for Countable.
*
* @return int
*/
public function count(): int {
return count( $this->products );
}
}
Product/ProductFactory.php 0000644 00000004417 15153721357 0011664 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use WC_Product;
use WC_Product_Variable;
use WC_Product_Variation;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductFactory
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ProductFactory {
use ValidateInterface;
/**
* @var AttributeManager
*/
protected $attribute_manager;
/**
* @var WC
*/
protected $wc;
/**
* ProductFactory constructor.
*
* @param AttributeManager $attribute_manager
* @param WC $wc
*/
public function __construct( AttributeManager $attribute_manager, WC $wc ) {
$this->attribute_manager = $attribute_manager;
$this->wc = $wc;
}
/**
* @param WC_Product $product
* @param string $target_country
* @param array $mapping_rules The mapping rules setup by the user
*
* @return WCProductAdapter
*
* @throws InvalidValue When the product is a variation and its parent does not exist.
*/
public function create( WC_Product $product, string $target_country, array $mapping_rules ): WCProductAdapter {
// We do not support syncing the parent variable product. Each variation is synced individually instead.
$this->validate_not_instanceof( $product, WC_Product_Variable::class );
$attributes = $this->attribute_manager->get_all_values( $product );
$parent_product = null;
// merge with parent's attributes if it's a variation product
if ( $product instanceof WC_Product_Variation ) {
$parent_product = $this->wc->get_product( $product->get_parent_id() );
$parent_attributes = $this->attribute_manager->get_all_values( $parent_product );
$attributes = array_merge( $parent_attributes, $attributes );
}
return new WCProductAdapter(
[
'wc_product' => $product,
'parent_wc_product' => $parent_product,
'targetCountry' => $target_country,
'gla_attributes' => $attributes,
'mapping_rules' => $mapping_rules,
]
);
}
}
Product/ProductFilter.php 0000644 00000004471 15153721357 0011502 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use WC_Product;
/**
* Class ProductFilter
*
* Filters a list of products retrieved from the repository.
*
* @since 1.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ProductFilter implements Service {
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* ProductFilter constructor.
*
* @param ProductHelper $product_helper
*/
public function __construct( ProductHelper $product_helper ) {
$this->product_helper = $product_helper;
}
/**
* Filters and returns a list of products that are ready to be submitted to Google Merchant Center.
*
* @param WC_Product[] $products
*
* @return FilteredProductList
*/
public function filter_sync_ready_products( array $products ): FilteredProductList {
$unfiltered_count = count( $products );
/**
* Filters the list of products ready to be synced (before applying filters to check failures and sync-ready status).
*
* @param WC_Product[] $products Sync-ready WooCommerce products
*/
$products = apply_filters( 'woocommerce_gla_get_sync_ready_products_pre_filter', $products );
$results = array_values(
array_filter(
$products,
function ( $product ) {
return $this->product_helper->is_sync_ready( $product ) && ! $this->product_helper->is_sync_failed_recently( $product );
}
)
);
/**
* Filters the list of products ready to be synced (after applying filters to check failures and sync-ready status).
*
* @param WC_Product[] $results Sync-ready WooCommerce products
*/
$results = apply_filters( 'woocommerce_gla_get_sync_ready_products_filter', $results );
return new FilteredProductList( $results, $unfiltered_count );
}
/**
* Filter and return a list of products that can be deleted.
*
* @since 1.12.0
*
* @param WC_Product[] $products
*
* @return FilteredProductList
*/
public function filter_products_for_delete( array $products ): FilteredProductList {
$results = array_values(
array_filter(
$products,
function ( $product ) {
return ! $this->product_helper->is_delete_failed_threshold_reached( $product );
}
)
);
return new FilteredProductList( $results, count( $products ) );
}
}
Product/ProductHelper.php 0000644 00000061474 15153721357 0011502 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\HelperNotificationInterface;
use WC_Product;
use WC_Product_Variation;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductHelper
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ProductHelper implements Service, HelperNotificationInterface {
use PluginHelper;
/**
* @var ProductMetaHandler
*/
protected $meta_handler;
/**
* @var WC
*/
protected $wc;
/**
* @var TargetAudience
*/
protected $target_audience;
/**
* ProductHelper constructor.
*
* @param ProductMetaHandler $meta_handler
* @param WC $wc
* @param TargetAudience $target_audience
*/
public function __construct( ProductMetaHandler $meta_handler, WC $wc, TargetAudience $target_audience ) {
$this->meta_handler = $meta_handler;
$this->wc = $wc;
$this->target_audience = $target_audience;
}
/**
* Mark the item as notified.
*
* @param WC_Product $product
*
* @return void
*/
public function mark_as_notified( $product ): void {
$this->meta_handler->delete_failed_delete_attempts( $product );
$this->meta_handler->update_synced_at( $product, time() );
$this->meta_handler->update_sync_status( $product, SyncStatus::SYNCED );
$this->update_empty_visibility( $product );
// mark the parent product as synced if it's a variation
if ( $product instanceof WC_Product_Variation ) {
try {
$parent_product = $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
return;
}
$this->mark_as_notified( $parent_product );
}
}
/**
* Mark a product as synced in the local database.
* This function also handles the following cleanup tasks:
* - Remove any failed delete attempts
* - Update the visibility (if it was previously empty)
* - Remove any previous product errors (if it was synced for all target countries)
*
* @param WC_Product $product
* @param GoogleProduct $google_product
*/
public function mark_as_synced( WC_Product $product, GoogleProduct $google_product ) {
$this->meta_handler->delete_failed_delete_attempts( $product );
$this->meta_handler->update_synced_at( $product, time() );
$this->meta_handler->update_sync_status( $product, SyncStatus::SYNCED );
$this->update_empty_visibility( $product );
// merge and update all google product ids
$current_google_ids = $this->meta_handler->get_google_ids( $product );
$current_google_ids = ! empty( $current_google_ids ) ? $current_google_ids : [];
$google_ids = array_unique( array_merge( $current_google_ids, [ $google_product->getTargetCountry() => $google_product->getId() ] ) );
$this->meta_handler->update_google_ids( $product, $google_ids );
// check if product is synced for main target country and remove any previous errors if it is
$synced_countries = array_keys( $google_ids );
$target_countries = $this->target_audience->get_target_countries();
if ( empty( array_diff( $synced_countries, $target_countries ) ) ) {
$this->meta_handler->delete_errors( $product );
$this->meta_handler->delete_failed_sync_attempts( $product );
$this->meta_handler->delete_sync_failed_at( $product );
}
// mark the parent product as synced if it's a variation
if ( $product instanceof WC_Product_Variation ) {
try {
$parent_product = $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
return;
}
$this->mark_as_synced( $parent_product, $google_product );
}
}
/**
* @param WC_Product $product
*/
public function mark_as_unsynced( $product ): void {
$this->meta_handler->delete_synced_at( $product );
if ( ! $this->is_sync_ready( $product ) ) {
$this->meta_handler->delete_sync_status( $product );
} else {
$this->meta_handler->update_sync_status( $product, SyncStatus::NOT_SYNCED );
}
$this->meta_handler->delete_google_ids( $product );
$this->meta_handler->delete_errors( $product );
$this->meta_handler->delete_failed_sync_attempts( $product );
$this->meta_handler->delete_sync_failed_at( $product );
// mark the parent product as un-synced if it's a variation
if ( $product instanceof WC_Product_Variation ) {
try {
$parent_product = $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
return;
}
$this->mark_as_unsynced( $parent_product );
}
}
/**
* @param WC_Product $product
* @param string $google_id
*/
public function remove_google_id( WC_Product $product, string $google_id ) {
$google_ids = $this->meta_handler->get_google_ids( $product );
if ( empty( $google_ids ) ) {
return;
}
$idx = array_search( $google_id, $google_ids, true );
if ( false === $idx ) {
return;
}
unset( $google_ids[ $idx ] );
if ( ! empty( $google_ids ) ) {
$this->meta_handler->update_google_ids( $product, $google_ids );
} else {
// if there are no Google IDs left then this product is no longer considered "synced"
$this->mark_as_unsynced( $product );
}
}
/**
* Marks a WooCommerce product as invalid and stores the errors in a meta data key.
*
* Note: If a product variation is invalid then the parent product is also marked as invalid.
*
* @param WC_Product $product
* @param string[] $errors
*/
public function mark_as_invalid( WC_Product $product, array $errors ) {
// bail if no errors exist
if ( empty( $errors ) ) {
return;
}
$this->meta_handler->update_errors( $product, $errors );
$this->meta_handler->update_sync_status( $product, SyncStatus::HAS_ERRORS );
$this->update_empty_visibility( $product );
if ( ! empty( $errors[ GoogleProductService::INTERNAL_ERROR_REASON ] ) ) {
// update failed sync attempts count in case of internal errors
$failed_attempts = ! empty( $this->meta_handler->get_failed_sync_attempts( $product ) ) ?
$this->meta_handler->get_failed_sync_attempts( $product ) :
0;
$this->meta_handler->update_failed_sync_attempts( $product, $failed_attempts + 1 );
$this->meta_handler->update_sync_failed_at( $product, time() );
}
// mark the parent product as invalid if it's a variation
if ( $product instanceof WC_Product_Variation ) {
try {
$parent_product = $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
return;
}
$parent_errors = ! empty( $this->meta_handler->get_errors( $parent_product ) ) ?
$this->meta_handler->get_errors( $parent_product ) :
[];
$parent_errors[ $product->get_id() ] = $errors;
$this->mark_as_invalid( $parent_product, $parent_errors );
}
}
/**
* Marks a WooCommerce product as pending synchronization.
*
* Note: If a product variation is pending then the parent product is also marked as pending.
*
* @param WC_Product $product
*/
public function mark_as_pending( WC_Product $product ) {
$this->meta_handler->update_sync_status( $product, SyncStatus::PENDING );
// mark the parent product as pending if it's a variation
if ( $product instanceof WC_Product_Variation ) {
try {
$parent_product = $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
return;
}
$this->mark_as_pending( $parent_product );
}
}
/**
* Update empty (NOT EXIST) visibility meta values to SYNC_AND_SHOW.
*
* @param WC_Product $product
*/
protected function update_empty_visibility( WC_Product $product ): void {
try {
$product = $this->maybe_swap_for_parent( $product );
} catch ( InvalidValue $exception ) {
return;
}
$visibility = $this->meta_handler->get_visibility( $product );
if ( empty( $visibility ) ) {
$this->meta_handler->update_visibility( $product, ChannelVisibility::SYNC_AND_SHOW );
}
}
/**
* Update a product's channel visibility.
*
* @param WC_Product $product
* @param string $visibility
*/
public function update_channel_visibility( WC_Product $product, string $visibility ): void {
try {
$product = $this->maybe_swap_for_parent( $product );
} catch ( InvalidValue $exception ) {
// The error has been logged within the call of maybe_swap_for_parent
return;
}
try {
$visibility = ChannelVisibility::cast( $visibility )->get();
} catch ( InvalidValue $exception ) {
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
return;
}
$this->meta_handler->update_visibility( $product, $visibility );
}
/**
* @param WC_Product $product
*
* @return string[]|null An array of Google product IDs stored for each WooCommerce product
*/
public function get_synced_google_product_ids( WC_Product $product ): ?array {
return $this->meta_handler->get_google_ids( $product );
}
/**
* See: WCProductAdapter::map_wc_product_id()
*
* @param string $mc_product_id Simple product ID (`merchant_center_id`) or
* namespaced product ID (`online:en:GB:merchant_center_id`)
*
* @return int the ID for the WC product linked to the provided Google product ID (0 if not found)
*/
public function get_wc_product_id( string $mc_product_id ): int {
// Maybe remove everything before the last colon ':'
$mc_product_id_tokens = explode( ':', $mc_product_id );
$mc_product_id = end( $mc_product_id_tokens );
// Support a fully numeric ID both with and without the `gla_` prefix.
$wc_product_id = 0;
$pattern = '/^(' . preg_quote( $this->get_slug(), '/' ) . '_)?(\d+)$/';
if ( preg_match( $pattern, $mc_product_id, $matches ) ) {
$wc_product_id = (int) $matches[2];
}
/**
* Filters the WooCommerce product ID that was determined to be associated with the
* given Merchant Center product ID.
*
* @param string $wc_product_id The WooCommerce product ID as determined by default.
* @param string $mc_product_id Simple Merchant Center product ID (without any prefixes).
* @since 2.4.6
*
* @return string Merchant Center product ID as normally generated by the plugin (e.g., gla_1234).
*/
return (int) apply_filters( 'woocommerce_gla_get_wc_product_id', $wc_product_id, $mc_product_id );
}
/**
* Attempt to get the WooCommerce product title.
* The MC ID is converted to a WC ID before retrieving the product.
* If we can't retrieve the title we fallback to the original MC ID.
*
* @param string $mc_product_id Merchant Center product ID.
*
* @return string
*/
public function get_wc_product_title( string $mc_product_id ): string {
try {
$product = $this->get_wc_product( $this->get_wc_product_id( $mc_product_id ) );
} catch ( InvalidValue $e ) {
return $mc_product_id;
}
return $product->get_title();
}
/**
* Get WooCommerce product
*
* @param int $product_id
*
* @return WC_Product
*
* @throws InvalidValue If the given ID doesn't reference a valid product.
*/
public function get_wc_product( int $product_id ): WC_Product {
return $this->wc->get_product( $product_id );
}
/**
* Get WooCommerce product by WP get_post
*
* @param int $product_id
*
* @return WP_Post|null
*/
public function get_wc_product_by_wp_post( int $product_id ): ?WP_Post {
return get_post( $product_id );
}
/**
* @param WC_Product $product
*
* @return bool
*/
public function is_product_synced( WC_Product $product ): bool {
$synced_at = $this->meta_handler->get_synced_at( $product );
$google_ids = $this->meta_handler->get_google_ids( $product );
return ! empty( $synced_at ) && ! empty( $google_ids );
}
/**
* Indicates if a product is ready for sending Notifications.
* A product is ready to send notifications if DONT_SYNC_AND_SHOW is not enabled and the post status is publish.
*
* @param WC_Product $product
*
* @return bool
*/
public function is_ready_to_notify( WC_Product $product ): bool {
$is_ready = ChannelVisibility::DONT_SYNC_AND_SHOW !== $this->get_channel_visibility( $product ) &&
$product->get_status() === 'publish' &&
in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true );
if ( $is_ready && $product instanceof WC_Product_Variation ) {
$parent = $this->maybe_swap_for_parent( $product );
$is_ready = $this->is_ready_to_notify( $parent );
}
/**
* Allow users to filter if a product is ready to notify.
*
* @since 2.8.0
*
* @param bool $value The current filter value.
* @param WC_Product $product The product for the notification.
*/
return apply_filters( 'woocommerce_gla_product_is_ready_to_notify', $is_ready, $product );
}
/**
* Indicates if a product is ready for sending a create Notification.
* A product is ready to send create notifications if is ready to notify and has not sent create notification yet.
*
* @param WC_Product $product
*
* @return bool
*/
public function should_trigger_create_notification( $product ): bool {
return ! $product instanceof WC_Product_Variation && $this->is_ready_to_notify( $product ) && ! $this->has_notified_creation( $product );
}
/**
* Indicates if a product is ready for sending an update Notification.
* A product is ready to send update notifications if is ready to notify and has sent create notification already.
*
* @param WC_Product $product
*
* @return bool
*/
public function should_trigger_update_notification( $product ): bool {
return ! $product instanceof WC_Product_Variation && $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
}
/**
* Indicates if a product is ready for sending a delete Notification.
* A product is ready to send delete notifications if it is not ready to notify and has sent create notification already.
*
* @param WC_Product $product
*
* @return bool
*/
public function should_trigger_delete_notification( $product ): bool {
return ! $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
}
/**
* Indicates if a product was already notified about its creation.
* Notice we consider synced products in MC as notified for creation.
*
* @param WC_Product $product
*
* @return bool
*/
public function has_notified_creation( WC_Product $product ): bool {
if ( $product instanceof WC_Product_Variation ) {
return $this->has_notified_creation( $this->maybe_swap_for_parent( $product ) );
}
$valid_has_notified_creation_statuses = [
NotificationStatus::NOTIFICATION_CREATED,
NotificationStatus::NOTIFICATION_UPDATED,
NotificationStatus::NOTIFICATION_PENDING_UPDATE,
NotificationStatus::NOTIFICATION_PENDING_DELETE,
];
return in_array(
$this->meta_handler->get_notification_status( $product ),
$valid_has_notified_creation_statuses,
true
) || $this->is_product_synced( $product );
}
/**
* Set the notification status for a WooCommerce product.
*
* @param WC_Product $product
* @param string $status
*/
public function set_notification_status( $product, $status ): void {
$this->meta_handler->update_notification_status( $product, $status );
}
/**
* @param WC_Product $product
*
* @return bool
*/
public function is_sync_ready( WC_Product $product ): bool {
$product_visibility = $product->is_visible();
$product_status = $product->get_status();
if ( $product instanceof WC_Product_Variation ) {
// Check the post status of the parent product if it's a variation
try {
$parent = $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Cannot sync an orphaned variation (ID: %s).', $product->get_id() ),
__METHOD__
);
return false;
}
$product_status = $parent->get_status();
/**
* Optionally hide invisible variations (disabled variations and variations with empty price).
*
* @see WC_Product_Variable::get_available_variations for filter documentation
*/
if ( apply_filters( 'woocommerce_hide_invisible_variations', true, $parent->get_id(), $product ) && ! $product->variation_is_visible() ) {
$product_visibility = false;
}
}
return ( ChannelVisibility::DONT_SYNC_AND_SHOW !== $this->get_channel_visibility( $product ) ) &&
( in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true ) ) &&
( 'publish' === $product_status ) &&
$product_visibility;
}
/**
* Whether the sync has failed repeatedly for the product within the given timeframe.
*
* @param WC_Product $product
*
* @return bool
*
* @see ProductSyncer::FAILURE_THRESHOLD The number of failed attempts allowed per timeframe
* @see ProductSyncer::FAILURE_THRESHOLD_WINDOW The specified timeframe
*/
public function is_sync_failed_recently( WC_Product $product ): bool {
$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product );
$failed_at = $this->meta_handler->get_sync_failed_at( $product );
// if it has failed more times than the specified threshold AND if syncing it has failed within the specified window
return $failed_attempts > ProductSyncer::FAILURE_THRESHOLD &&
$failed_at > strtotime( sprintf( '-%s', ProductSyncer::FAILURE_THRESHOLD_WINDOW ) );
}
/**
* Increment failed delete attempts.
*
* @since 1.12.0
*
* @param WC_Product $product
*/
public function increment_failed_delete_attempt( WC_Product $product ) {
$failed_attempts = $this->meta_handler->get_failed_delete_attempts( $product ) ?? 0;
$this->meta_handler->update_failed_delete_attempts( $product, $failed_attempts + 1 );
}
/**
* Whether deleting has failed more times than the specified threshold.
*
* @since 1.12.0
*
* @param WC_Product $product
*
* @return boolean
*/
public function is_delete_failed_threshold_reached( WC_Product $product ): bool {
$failed_attempts = $this->meta_handler->get_failed_delete_attempts( $product ) ?? 0;
return $failed_attempts >= ProductSyncer::FAILURE_THRESHOLD;
}
/**
* Increment failed delete attempts.
*
* @since 1.12.2
*
* @param WC_Product $product
*/
public function increment_failed_update_attempt( WC_Product $product ) {
$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product ) ?? 0;
$this->meta_handler->update_failed_sync_attempts( $product, $failed_attempts + 1 );
}
/**
* Whether deleting has failed more times than the specified threshold.
*
* @since 1.12.2
*
* @param WC_Product $product
*
* @return boolean
*/
public function is_update_failed_threshold_reached( WC_Product $product ): bool {
$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product ) ?? 0;
return $failed_attempts >= ProductSyncer::FAILURE_THRESHOLD;
}
/**
* @param WC_Product $wc_product
*
* @return string|null
*/
public function get_channel_visibility( WC_Product $wc_product ): ?string {
try {
// todo: we might need to define visibility per variation later.
return $this->meta_handler->get_visibility( $this->maybe_swap_for_parent( $wc_product ) );
} catch ( InvalidValue $exception ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Channel visibility forced to "%s" for invalid product (ID: %s).', ChannelVisibility::DONT_SYNC_AND_SHOW, $wc_product->get_id() ),
__METHOD__
);
return ChannelVisibility::DONT_SYNC_AND_SHOW;
}
}
/**
* Return a string indicating sync status based on several factors.
*
* @param WC_Product $wc_product
*
* @return string|null
*/
public function get_sync_status( WC_Product $wc_product ): ?string {
return $this->meta_handler->get_sync_status( $wc_product );
}
/**
* Return the string indicating the product status as reported by the Merchant Center.
*
* @param WC_Product $wc_product
*
* @return string|null
*/
public function get_mc_status( WC_Product $wc_product ): ?string {
try {
// If the mc_status is not set, return NOT_SYNCED.
return $this->meta_handler->get_mc_status( $this->maybe_swap_for_parent( $wc_product ) ) ?: MCStatus::NOT_SYNCED;
} catch ( InvalidValue $exception ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Product status returned null for invalid product (ID: %s).', $wc_product->get_id() ),
__METHOD__
);
return null;
}
}
/**
* If an item from the provided list of products has a parent, replace it with the parent ID.
*
* @param int[] $product_ids A list of WooCommerce product ID.
* @param bool $check_product_status (Optional) Check if the product status is publish.
* @param bool $ignore_product_on_error (Optional) Ignore the product when invalid value error occurs.
*
* @return int[] A list of parent ID or product ID if it doesn't have a parent.
*
* @throws InvalidValue If the given param ignore_product_on_error is false and any of a given ID doesn't reference a valid product.
* Or if a variation product does not have a valid parent ID (i.e. it's an orphan).
*
* @since 2.2.0
*/
public function maybe_swap_for_parent_ids( array $product_ids, bool $check_product_status = true, bool $ignore_product_on_error = true ) {
$new_product_ids = [];
foreach ( $product_ids as $index => $product_id ) {
try {
$product = $this->get_wc_product( $product_id );
$new_product = $this->maybe_swap_for_parent( $product );
if ( ! $check_product_status || 'publish' === $new_product->get_status() ) {
$new_product_ids[ $index ] = $new_product->get_id();
}
} catch ( InvalidValue $exception ) {
if ( ! $ignore_product_on_error ) {
throw $exception;
}
}
}
return array_unique( $new_product_ids );
}
/**
* If the provided product has a parent, return its ID. Otherwise, return the given (valid product) ID.
*
* @param int $product_id WooCommerce product ID.
*
* @return int The parent ID or product ID if it doesn't have a parent.
*
* @throws InvalidValue If a given ID doesn't reference a valid product. Or if a variation product does not have a
* valid parent ID (i.e. it's an orphan).
*/
public function maybe_swap_for_parent_id( int $product_id ): int {
$product = $this->get_wc_product( $product_id );
return $this->maybe_swap_for_parent( $product )->get_id();
}
/**
* If the provided product has a parent, return its parent object. Otherwise, return the given product.
*
* @param WC_Product $product WooCommerce product object.
*
* @return WC_Product The parent product object or the given product object if it doesn't have a parent.
*
* @throws InvalidValue If a variation product does not have a valid parent ID (i.e. it's an orphan).
*
* @since 1.3.0
*/
public function maybe_swap_for_parent( WC_Product $product ): WC_Product {
if ( $product instanceof WC_Product_Variation ) {
try {
return $this->get_wc_product( $product->get_parent_id() );
} catch ( InvalidValue $exception ) {
do_action(
'woocommerce_gla_error',
sprintf( 'An orphaned variation found (ID: %s). Please delete it via "WooCommerce > Status > Tools > Delete orphaned variations".', $product->get_id() ),
__METHOD__
);
throw $exception;
}
}
return $product;
}
/**
* Get validation errors for a specific product.
* Combines errors for variable products, which have a variation-indexed array of errors.
*
* @param WC_Product $product
*
* @return array
*/
public function get_validation_errors( WC_Product $product ): array {
$errors = $this->meta_handler->get_errors( $product ) ?: [];
$first_key = array_key_first( $errors );
if ( ! empty( $errors ) && is_numeric( $first_key ) && 0 !== $first_key ) {
$errors = array_unique( array_merge( ...$errors ) );
}
return $errors;
}
/**
* Get categories list for a specific product.
*
* @param WC_Product $product
*
* @return array
*/
public function get_categories( WC_Product $product ): array {
$terms = get_the_terms( $product->get_id(), 'product_cat' );
return ( empty( $terms ) || is_wp_error( $terms ) ) ? [] : wp_list_pluck( $terms, 'name' );
}
/**
* Get the offer id for a product
*
* @since 2.8.0
* @param int $product_id The product id to get the offer id.
*
* @return string The offer id
*/
public function get_offer_id( int $product_id ) {
return WCProductAdapter::get_google_product_offer_id( $this->get_slug(), $product_id );
}
}
Product/ProductMetaHandler.php 0000644 00000023210 15153721357 0012431 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidMeta;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use BadMethodCallException;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductMetaHandler
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*
* @method update_synced_at( WC_Product $product, $value )
* @method delete_synced_at( WC_Product $product )
* @method get_synced_at( WC_Product $product ): int|null
* @method update_google_ids( WC_Product $product, array $value )
* @method delete_google_ids( WC_Product $product )
* @method get_google_ids( WC_Product $product ): array|null
* @method update_visibility( WC_Product $product, $value )
* @method delete_visibility( WC_Product $product )
* @method get_visibility( WC_Product $product ): string|null
* @method update_errors( WC_Product $product, array $value )
* @method delete_errors( WC_Product $product )
* @method get_errors( WC_Product $product ): array|null
* @method update_failed_delete_attempts( WC_Product $product, int $value )
* @method delete_failed_delete_attempts( WC_Product $product )
* @method get_failed_delete_attempts( WC_Product $product ): int|null
* @method update_failed_sync_attempts( WC_Product $product, int $value )
* @method delete_failed_sync_attempts( WC_Product $product )
* @method get_failed_sync_attempts( WC_Product $product ): int|null
* @method update_sync_failed_at( WC_Product $product, int $value )
* @method delete_sync_failed_at( WC_Product $product )
* @method get_sync_failed_at( WC_Product $product ): int|null
* @method update_sync_status( WC_Product $product, string $value )
* @method delete_sync_status( WC_Product $product )
* @method get_sync_status( WC_Product $product ): string|null
* @method update_mc_status( WC_Product $product, string $value )
* @method delete_mc_status( WC_Product $product )
* @method get_mc_status( WC_Product $product ): string|null
* @method update_notification_status( WC_Product $product, string $value )
* @method delete_notification_status( WC_Product $product )
* @method get_notification_status( WC_Product $product ): string|null
*/
class ProductMetaHandler implements Service, Registerable {
use PluginHelper;
public const KEY_SYNCED_AT = 'synced_at';
public const KEY_GOOGLE_IDS = 'google_ids';
public const KEY_VISIBILITY = 'visibility';
public const KEY_ERRORS = 'errors';
public const KEY_FAILED_DELETE_ATTEMPTS = 'failed_delete_attempts';
public const KEY_FAILED_SYNC_ATTEMPTS = 'failed_sync_attempts';
public const KEY_SYNC_FAILED_AT = 'sync_failed_at';
public const KEY_SYNC_STATUS = 'sync_status';
public const KEY_MC_STATUS = 'mc_status';
public const KEY_NOTIFICATION_STATUS = 'notification_status';
protected const TYPES = [
self::KEY_SYNCED_AT => 'int',
self::KEY_GOOGLE_IDS => 'array',
self::KEY_VISIBILITY => 'string',
self::KEY_ERRORS => 'array',
self::KEY_FAILED_DELETE_ATTEMPTS => 'int',
self::KEY_FAILED_SYNC_ATTEMPTS => 'int',
self::KEY_SYNC_FAILED_AT => 'int',
self::KEY_SYNC_STATUS => 'string',
self::KEY_MC_STATUS => 'string',
self::KEY_NOTIFICATION_STATUS => 'string',
];
/**
* @param string $name
* @param mixed $arguments
*
* @return mixed
*
* @throws BadMethodCallException If the method that's called doesn't exist.
* @throws InvalidMeta If the meta key is invalid.
*/
public function __call( string $name, $arguments ) {
$found_matches = preg_match( '/^([a-z]+)_([\w\d]+)$/i', $name, $matches );
if ( ! $found_matches ) {
throw new BadMethodCallException( sprintf( 'The method %s does not exist in class ProductMetaHandler', $name ) );
}
[ $function_name, $method, $key ] = $matches;
// validate the method
if ( ! in_array( $method, [ 'update', 'delete', 'get' ], true ) ) {
throw new BadMethodCallException( sprintf( 'The method %s does not exist in class ProductMetaHandler', $function_name ) );
}
// set the value as the third argument if method is `update`
if ( 'update' === $method ) {
$arguments[2] = $arguments[1];
}
// set the key as the second argument
$arguments[1] = $key;
return call_user_func_array( [ $this, $method ], $arguments );
}
/**
* @param WC_Product $product
* @param string $key
* @param mixed $value
*
* @throws InvalidMeta If the meta key is invalid.
*/
public function update( WC_Product $product, string $key, $value ) {
self::validate_meta_key( $key );
if ( isset( self::TYPES[ $key ] ) ) {
if ( in_array( self::TYPES[ $key ], [ 'bool', 'boolean' ], true ) ) {
$value = wc_bool_to_string( $value );
} else {
settype( $value, self::TYPES[ $key ] );
}
}
$product->update_meta_data( $this->prefix_meta_key( $key ), $value );
$product->save_meta_data();
}
/**
* @param WC_Product $product
* @param string $key
*
* @throws InvalidMeta If the meta key is invalid.
*/
public function delete( WC_Product $product, string $key ) {
self::validate_meta_key( $key );
$product->delete_meta_data( $this->prefix_meta_key( $key ) );
$product->save_meta_data();
}
/**
* @param WC_Product $product
* @param string $key
*
* @return mixed The value, or null if the meta key doesn't exist.
*
* @throws InvalidMeta If the meta key is invalid.
*/
public function get( WC_Product $product, string $key ) {
self::validate_meta_key( $key );
$value = null;
if ( $product->meta_exists( $this->prefix_meta_key( $key ) ) ) {
$value = $product->get_meta( $this->prefix_meta_key( $key ), true );
if ( isset( self::TYPES[ $key ] ) && in_array( self::TYPES[ $key ], [ 'bool', 'boolean' ], true ) ) {
$value = wc_string_to_bool( $value );
}
}
return $value;
}
/**
* @param string $key
*
* @throws InvalidMeta If the meta key is invalid.
*/
protected static function validate_meta_key( string $key ) {
if ( ! self::is_meta_key_valid( $key ) ) {
do_action(
'woocommerce_gla_error',
sprintf( 'Product meta key is invalid: %s', $key ),
__METHOD__
);
throw InvalidMeta::invalid_key( $key );
}
}
/**
* @param string $key
*
* @return bool Whether the meta key is valid.
*/
public static function is_meta_key_valid( string $key ): bool {
return isset( self::TYPES[ $key ] );
}
/**
* Register a service.
*/
public function register(): void {
add_filter(
'woocommerce_product_data_store_cpt_get_products_query',
function ( array $query, array $query_vars ) {
return $this->handle_query_vars( $query, $query_vars );
},
10,
2
);
}
/**
* Handle the WooCommerce product's meta data query vars.
*
* @hooked handle_query_vars
*
* @param array $query Args for WP_Query.
* @param array $query_vars Query vars from WC_Product_Query.
*
* @return array modified $query
*/
protected function handle_query_vars( array $query, array $query_vars ): array {
if ( ! empty( $query_vars['meta_query'] ) ) {
$meta_query = $this->sanitize_meta_query( $query_vars['meta_query'] );
if ( ! empty( $meta_query ) ) {
$query['meta_query'] = array_merge( $query['meta_query'], $meta_query );
}
}
return $query;
}
/**
* Ensure the 'meta_query' argument passed to self::handle_query_vars is well-formed.
*
* @param array $queries Array of meta query clauses.
*
* @return array Sanitized array of meta query clauses.
*/
protected function sanitize_meta_query( $queries ): array {
$prefixed_valid_keys = array_map( [ $this, 'prefix_meta_key' ], array_keys( self::TYPES ) );
$clean_queries = [];
if ( ! is_array( $queries ) ) {
return $clean_queries;
}
foreach ( $queries as $key => $meta_query ) {
if ( 'relation' !== $key && ! is_array( $meta_query ) ) {
continue;
}
if ( 'relation' === $key && is_string( $meta_query ) ) {
$clean_queries[ $key ] = $meta_query;
// First-order clause.
} elseif ( isset( $meta_query['key'] ) || isset( $meta_query['value'] ) ) {
if ( in_array( $meta_query['key'], $prefixed_valid_keys, true ) ) {
$clean_queries[ $key ] = $meta_query;
}
// Otherwise, it's a nested meta_query, so we recurse.
} else {
$cleaned_query = $this->sanitize_meta_query( $meta_query );
if ( ! empty( $cleaned_query ) ) {
$clean_queries[ $key ] = $cleaned_query;
}
}
}
return $clean_queries;
}
/**
* @param array $meta_queries
*
* @return array
*/
public function prefix_meta_query_keys( $meta_queries ): array {
$updated_queries = [];
if ( ! is_array( $meta_queries ) ) {
return $updated_queries;
}
foreach ( $meta_queries as $key => $meta_query ) {
// First-order clause.
if ( 'relation' === $key && is_string( $meta_query ) ) {
$updated_queries[ $key ] = $meta_query;
// First-order clause.
} elseif ( isset( $meta_query['key'] ) || isset( $meta_query['value'] ) ) {
if ( self::is_meta_key_valid( $meta_query['key'] ) ) {
$meta_query['key'] = $this->prefix_meta_key( $meta_query['key'] );
}
} else {
// Otherwise, it's a nested meta_query, so we recurse.
$meta_query = $this->prefix_meta_query_keys( $meta_query );
}
$updated_queries[ $key ] = $meta_query;
}
return $updated_queries;
}
/**
* Returns all available meta keys.
*
* @return array
*/
public static function get_all_meta_keys(): array {
return array_keys( self::TYPES );
}
}
Product/ProductRepository.php 0000644 00000030522 15153721357 0012430 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductRepository
*
* Contains methods to find and retrieve products from database.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ProductRepository implements Service {
use PluginHelper;
/**
* @var ProductMetaHandler
*/
protected $meta_handler;
/**
* @var ProductFilter
*/
protected $product_filter;
/**
* ProductRepository constructor.
*
* @param ProductMetaHandler $meta_handler
* @param ProductFilter $product_filter
*/
public function __construct( ProductMetaHandler $meta_handler, ProductFilter $product_filter ) {
$this->meta_handler = $meta_handler;
$this->product_filter = $product_filter;
}
/**
* Find and return an array of WooCommerce product objects based on the provided arguments.
*
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @see execute_woocommerce_query For more information about the arguments.
*
* @return WC_Product[] Array of WooCommerce product objects
*/
public function find( array $args = [], int $limit = -1, int $offset = 0 ): array {
$args['return'] = 'objects';
return $this->execute_woocommerce_query( $args, $limit, $offset );
}
/**
* Find and return an array of WooCommerce product IDs based on the provided arguments.
*
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @see execute_woocommerce_query For more information about the arguments.
*
* @return int[] Array of WooCommerce product IDs
*/
public function find_ids( array $args = [], int $limit = -1, int $offset = 0 ): array {
$args['return'] = 'ids';
return $this->execute_woocommerce_query( $args, $limit, $offset );
}
/**
* Find and return an array of WooCommerce product objects based on the provided product IDs.
*
* @param int[] $ids Array of WooCommerce product IDs
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return WC_Product[] Array of WooCommerce product objects
*/
public function find_by_ids( array $ids, array $args = [], int $limit = -1, int $offset = 0 ): array {
// If no product IDs are supplied then return early to avoid querying and loading every product.
if ( empty( $ids ) ) {
return [];
}
$args['include'] = $ids;
return $this->find( $args, $limit, $offset );
}
/**
* Find and return an associative array of products with the product ID as the key.
*
* @param int[] $ids Array of WooCommerce product IDs
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return WC_Product[] Array of WooCommerce product objects
*/
public function find_by_ids_as_associative_array( array $ids, array $args = [], int $limit = -1, int $offset = 0 ): array {
$products = $this->find_by_ids( $ids, $args, $limit, $offset );
$map = [];
foreach ( $products as $product ) {
$map[ $product->get_id() ] = $product;
}
return $map;
}
/**
* Find and return an array of WooCommerce product objects already submitted to Google Merchant Center.
*
* @param array $args Array of WooCommerce args (except 'return' and 'meta_query').
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return WC_Product[] Array of WooCommerce product objects
*/
public function find_synced_products( array $args = [], int $limit = -1, int $offset = 0 ): array {
$args['meta_query'] = $this->get_synced_products_meta_query();
return $this->find( $args, $limit, $offset );
}
/**
* Find and return an array of WooCommerce product IDs already submitted to Google Merchant Center.
*
* Note: Includes product variations.
*
* @param array $args Array of WooCommerce args (except 'return' and 'meta_query').
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return int[] Array of WooCommerce product IDs
*/
public function find_synced_product_ids( array $args = [], int $limit = -1, int $offset = 0 ): array {
$args['meta_query'] = $this->get_synced_products_meta_query();
return $this->find_ids( $args, $limit, $offset );
}
/**
* @return array
*/
protected function get_synced_products_meta_query(): array {
return [
[
'key' => ProductMetaHandler::KEY_GOOGLE_IDS,
'compare' => 'EXISTS',
],
];
}
/**
* Find and return an array of WooCommerce product objects ready to be submitted to Google Merchant Center.
*
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return FilteredProductList List of WooCommerce product objects after filtering.
*/
public function find_sync_ready_products( array $args = [], int $limit = - 1, int $offset = 0 ): FilteredProductList {
$results = $this->find( $this->get_sync_ready_products_query_args( $args ), $limit, $offset );
return $this->product_filter->filter_sync_ready_products( $results );
}
/**
* Find and return an array of WooCommerce product ID's ready to be deleted from the Google Merchant Center.
*
* @since 1.12.0
*
* @param int[] $ids Array of WooCommerce product IDs
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return array
*/
public function find_delete_product_ids( array $ids, int $limit = - 1, int $offset = 0 ): array {
// Default status query args in WC_Product_Query plus status trash.
$args = [ 'status' => [ 'draft', 'pending', 'private', 'publish', 'trash' ] ];
$results = $this->find_by_ids( $ids, $args, $limit, $offset );
return $this->product_filter->filter_products_for_delete( $results )->get_product_ids();
}
/**
* @return array
*/
protected function get_sync_ready_products_meta_query(): array {
return [
'relation' => 'OR',
[
'key' => ProductMetaHandler::KEY_VISIBILITY,
'compare' => 'NOT EXISTS',
],
[
'key' => ProductMetaHandler::KEY_VISIBILITY,
'compare' => '!=',
'value' => ChannelVisibility::DONT_SYNC_AND_SHOW,
],
];
}
/**
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
*
* @return array
*/
protected function get_sync_ready_products_query_args( array $args = [] ): array {
$args['meta_query'] = $this->get_sync_ready_products_meta_query();
// don't include variable products in query
$args['type'] = array_diff( ProductSyncer::get_supported_product_types(), [ 'variable' ] );
// only include published products
if ( empty( $args['status'] ) ) {
$args['status'] = [ 'publish' ];
}
return $args;
}
/**
* @return array
*/
protected function get_valid_products_meta_query(): array {
return [
'relation' => 'OR',
[
'key' => ProductMetaHandler::KEY_ERRORS,
'compare' => 'NOT EXISTS',
],
[
'key' => ProductMetaHandler::KEY_ERRORS,
'compare' => '=',
'value' => '',
],
];
}
/**
* Find and return an array of WooCommerce product IDs nearly expired and ready to be re-submitted to Google Merchant Center.
*
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return int[] Array of WooCommerce product IDs
*/
public function find_expiring_product_ids( int $limit = - 1, int $offset = 0 ): array {
$args['meta_query'] = [
'relation' => 'AND',
$this->get_sync_ready_products_meta_query(),
$this->get_valid_products_meta_query(),
[
[
'key' => ProductMetaHandler::KEY_SYNCED_AT,
'compare' => '<',
'value' => strtotime( '-25 days' ),
],
],
];
return $this->find_ids( $args, $limit, $offset );
}
/**
* Find all simple and variable product IDs regardless of MC status or visibility.
*
* @since 2.6.4
*
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @return int[] Array of WooCommerce product IDs
*/
public function find_all_product_ids( int $limit = -1, int $offset = 0 ): array {
$args = [
'status' => 'publish',
'return' => 'ids',
'type' => 'any',
];
return $this->find_ids( $args, $limit, $offset );
}
/**
* Returns an array of Google Product IDs associated with all synced WooCommerce products.
* Note: excludes variable parent products as only the child variation products are actually synced
* to Merchant Center
*
* @since 1.1.0
*
* @return array Google Product IDS
*/
public function find_all_synced_google_ids(): array {
// Don't include variable parent products as they aren't actually synced to Merchant Center.
$args['type'] = array_diff( ProductSyncer::get_supported_product_types(), [ 'variable' ] );
$synced_product_ids = $this->find_synced_product_ids( $args );
$google_ids_meta_key = $this->prefix_meta_key( ProductMetaHandler::KEY_GOOGLE_IDS );
$synced_google_ids = [];
foreach ( $synced_product_ids as $product_id ) {
$meta_google_ids = get_post_meta( $product_id, $google_ids_meta_key, true );
if ( ! is_array( $meta_google_ids ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Invalid Google IDs retrieve for product %d', $product_id ),
__METHOD__
);
continue;
}
$synced_google_ids = array_merge( $synced_google_ids, array_values( $meta_google_ids ) );
}
return $synced_google_ids;
}
/**
* Find and return an array of WooCommerce products based on the provided arguments.
*
* @param array $args Array of WooCommerce args (see below), and product metadata.
* @param int $limit Maximum number of results to retrieve or -1 for unlimited.
* @param int $offset Amount to offset product results.
*
* @link https://github.com/woocommerce/woocommerce/wiki/wc_get_products-and-WC_Product_Query
* @see ProductMetaHandler::TYPES For the list of meta data that can be used as query arguments.
*
* @return WC_Product[]|int[] Array of WooCommerce product objects or IDs, depending on the 'return' argument.
*/
protected function execute_woocommerce_query( array $args = [], int $limit = -1, int $offset = 0 ): array {
$args['limit'] = $limit;
$args['offset'] = $offset;
return wc_get_products( $this->prepare_query_args( $args ) );
}
/**
* @param array $args Array of WooCommerce args (except 'return'), and product metadata.
*
* @see execute_woocommerce_query For more information about the arguments.
*
* @return array
*/
protected function prepare_query_args( array $args = [] ): array {
if ( empty( $args ) ) {
return [];
}
if ( ! empty( $args['meta_query'] ) ) {
$args['meta_query'] = $this->meta_handler->prefix_meta_query_keys( $args['meta_query'] );
}
// only include supported product types
if ( empty( $args['type'] ) ) {
$args['type'] = ProductSyncer::get_supported_product_types();
}
// It'll fetch all products with the post_type of 'product', excluding variations.
if ( $args['type'] === 'any' ) {
unset( $args['type'] );
}
// use no ordering unless specified in arguments. overrides the default WooCommerce query args
if ( empty( $args['orderby'] ) ) {
$args['orderby'] = 'none';
}
$args = apply_filters( 'woocommerce_gla_product_query_args', $args );
return $args;
}
}
Product/ProductSyncer.php 0000644 00000031111 15153721357 0011507 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchInvalidProductEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Exception;
use WC_Product;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductSyncer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ProductSyncer implements Service {
public const FAILURE_THRESHOLD = 5; // Number of failed attempts allowed per FAILURE_THRESHOLD_WINDOW
public const FAILURE_THRESHOLD_WINDOW = '3 hours'; // PHP supported Date and Time format: https://www.php.net/manual/en/datetime.formats.php
/**
* @var GoogleProductService
*/
protected $google_service;
/**
* @var BatchProductHelper
*/
protected $batch_helper;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var WC
*/
protected $wc;
/**
* @var ProductRepository
*/
protected $product_repository;
/**
* ProductSyncer constructor.
*
* @param GoogleProductService $google_service
* @param BatchProductHelper $batch_helper
* @param ProductHelper $product_helper
* @param MerchantCenterService $merchant_center
* @param WC $wc
* @param ProductRepository $product_repository
*/
public function __construct(
GoogleProductService $google_service,
BatchProductHelper $batch_helper,
ProductHelper $product_helper,
MerchantCenterService $merchant_center,
WC $wc,
ProductRepository $product_repository
) {
$this->google_service = $google_service;
$this->batch_helper = $batch_helper;
$this->product_helper = $product_helper;
$this->merchant_center = $merchant_center;
$this->wc = $wc;
$this->product_repository = $product_repository;
}
/**
* Submits an array of WooCommerce products to Google Merchant Center.
*
* @param WC_Product[] $products
*
* @return BatchProductResponse Containing both the synced and invalid products.
*
* @throws ProductSyncerException If there are any errors while syncing products with Google Merchant Center.
*/
public function update( array $products ): BatchProductResponse {
$this->validate_merchant_center_setup();
// prepare and validate products
$product_entries = $this->batch_helper->validate_and_generate_update_request_entries( $products );
return $this->update_by_batch_requests( $product_entries );
}
/**
* Submits an array of WooCommerce products to Google Merchant Center.
*
* @param BatchProductRequestEntry[] $product_entries
*
* @return BatchProductResponse Containing both the synced and invalid products.
*
* @throws ProductSyncerException If there are any errors while syncing products with Google Merchant Center.
*/
public function update_by_batch_requests( array $product_entries ): BatchProductResponse {
$this->validate_merchant_center_setup();
// bail if no valid products provided
if ( empty( $product_entries ) ) {
return new BatchProductResponse( [], [] );
}
$updated_products = [];
$invalid_products = [];
foreach ( array_chunk( $product_entries, GoogleProductService::BATCH_SIZE ) as $batch_entries ) {
try {
$response = $this->google_service->insert_batch( $batch_entries );
$updated_products = array_merge( $updated_products, $response->get_products() );
$invalid_products = array_merge( $invalid_products, $response->get_errors() );
// update the meta data for the synced and invalid products
array_walk( $updated_products, [ $this->batch_helper, 'mark_as_synced' ] );
array_walk( $invalid_products, [ $this->batch_helper, 'mark_as_invalid' ] );
} catch ( Exception $exception ) {
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
throw new ProductSyncerException( sprintf( 'Error updating Google products: %s', $exception->getMessage() ), 0, $exception );
}
}
$this->handle_update_errors( $invalid_products );
do_action(
'woocommerce_gla_batch_updated_products',
$updated_products,
$invalid_products
);
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Submitted %s products:\n%s",
count( $updated_products ),
wp_json_encode( $updated_products )
),
__METHOD__
);
if ( ! empty( $invalid_products ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
"%s products failed to sync with Merchant Center:\n%s",
count( $invalid_products ),
wp_json_encode( $invalid_products )
),
__METHOD__
);
}
return new BatchProductResponse( $updated_products, $invalid_products );
}
/**
* Deletes an array of WooCommerce products from Google Merchant Center.
*
* @param WC_Product[] $products
*
* @return BatchProductResponse Containing both the deleted and invalid products.
*
* @throws ProductSyncerException If there are any errors while deleting products from Google Merchant Center.
*/
public function delete( array $products ): BatchProductResponse {
$this->validate_merchant_center_setup();
$synced_products = $this->batch_helper->filter_synced_products( $products );
$product_entries = $this->batch_helper->generate_delete_request_entries( $synced_products );
return $this->delete_by_batch_requests( $product_entries );
}
/**
* Deletes an array of WooCommerce products from Google Merchant Center.
*
* Note: This method does not automatically delete variations of a parent product. They each must be provided via the $product_entries argument.
*
* @param BatchProductIDRequestEntry[] $product_entries
*
* @return BatchProductResponse Containing both the deleted and invalid products (including their variation).
*
* @throws ProductSyncerException If there are any errors while deleting products from Google Merchant Center.
*/
public function delete_by_batch_requests( array $product_entries ): BatchProductResponse {
$this->validate_merchant_center_setup();
// return empty response if no synced product found
if ( empty( $product_entries ) ) {
return new BatchProductResponse( [], [] );
}
$deleted_products = [];
$invalid_products = [];
foreach ( array_chunk( $product_entries, GoogleProductService::BATCH_SIZE ) as $batch_entries ) {
try {
$response = $this->google_service->delete_batch( $batch_entries );
$deleted_products = array_merge( $deleted_products, $response->get_products() );
$invalid_products = array_merge( $invalid_products, $response->get_errors() );
array_walk( $deleted_products, [ $this->batch_helper, 'mark_as_unsynced' ] );
} catch ( Exception $exception ) {
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
throw new ProductSyncerException( sprintf( 'Error deleting Google products: %s', $exception->getMessage() ), 0, $exception );
}
}
$this->handle_delete_errors( $invalid_products );
do_action(
'woocommerce_gla_batch_deleted_products',
$deleted_products,
$invalid_products
);
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Deleted %s products:\n%s",
count( $deleted_products ),
wp_json_encode( $deleted_products ),
),
__METHOD__
);
if ( ! empty( $invalid_products ) ) {
do_action(
'woocommerce_gla_debug_message',
sprintf(
"Failed to delete %s products from Merchant Center:\n%s",
count( $invalid_products ),
wp_json_encode( $invalid_products )
),
__METHOD__
);
}
return new BatchProductResponse( $deleted_products, $invalid_products );
}
/**
* Return the list of supported product types.
*
* @return array
*/
public static function get_supported_product_types(): array {
return (array) apply_filters( 'woocommerce_gla_supported_product_types', [ 'simple', 'variable', 'variation' ] );
}
/**
* @param BatchInvalidProductEntry[] $invalid_products
*/
protected function handle_update_errors( array $invalid_products ) {
$error_products = [];
foreach ( $invalid_products as $invalid_product ) {
if ( $invalid_product->has_error( GoogleProductService::INTERNAL_ERROR_REASON ) ) {
$wc_product_id = $invalid_product->get_wc_product_id();
$wc_product = $this->wc->maybe_get_product( $wc_product_id );
// Only schedule for retry if the failure threshold has not been reached.
if (
$wc_product instanceof WC_Product &&
! $this->product_helper->is_update_failed_threshold_reached( $wc_product )
) {
$error_products[ $wc_product_id ] = $wc_product_id;
}
}
}
if ( ! empty( $error_products ) && apply_filters( 'woocommerce_gla_products_update_retry_on_failure', true, $invalid_products ) ) {
do_action( 'woocommerce_gla_batch_retry_update_products', $error_products );
do_action(
'woocommerce_gla_error',
sprintf( 'Internal API errors while submitting the following products: %s', join( ', ', $error_products ) ),
__METHOD__
);
}
}
/**
* @param BatchInvalidProductEntry[] $invalid_products
*/
protected function handle_delete_errors( array $invalid_products ) {
$internal_error_ids = [];
foreach ( $invalid_products as $invalid_product ) {
$google_product_id = $invalid_product->get_google_product_id();
$wc_product_id = $invalid_product->get_wc_product_id();
$wc_product = $this->wc->maybe_get_product( $wc_product_id );
if ( ! $wc_product instanceof WC_Product || empty( $google_product_id ) ) {
continue;
}
// not found
if ( $invalid_product->has_error( GoogleProductService::NOT_FOUND_ERROR_REASON ) ) {
do_action(
'woocommerce_gla_error',
sprintf(
'Attempted to delete product "%s" (WooCommerce Product ID: %s) but it did not exist in Google Merchant Center, removing the synced product ID from database.',
$google_product_id,
$wc_product_id
),
__METHOD__
);
$this->product_helper->remove_google_id( $wc_product, $google_product_id );
}
// internal error
if ( $invalid_product->has_error( GoogleProductService::INTERNAL_ERROR_REASON ) ) {
$this->product_helper->increment_failed_delete_attempt( $wc_product );
// Only schedule for retry if the failure threshold has not been reached.
if ( ! $this->product_helper->is_delete_failed_threshold_reached( $wc_product ) ) {
$internal_error_ids[ $google_product_id ] = $wc_product_id;
}
}
}
// Exclude any ID's which are not ready to delete or are not available in the DB.
$product_ids = array_values( $internal_error_ids );
$ready_ids = $this->product_repository->find_delete_product_ids( $product_ids );
$internal_error_ids = array_intersect( $internal_error_ids, $ready_ids );
// call an action to retry if any products with internal errors exist
if ( ! empty( $internal_error_ids ) && apply_filters( 'woocommerce_gla_products_delete_retry_on_failure', true, $invalid_products ) ) {
do_action( 'woocommerce_gla_batch_retry_delete_products', $internal_error_ids );
do_action(
'woocommerce_gla_error',
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
sprintf( 'Internal API errors while deleting the following products: %s', print_r( $internal_error_ids, true ) ),
__METHOD__
);
}
}
/**
* Validates whether Merchant Center is connected and ready for pushing data.
*
* @throws ProductSyncerException If the Google Merchant Center connection is not ready or cannot push data.
*/
protected function validate_merchant_center_setup(): void {
if ( ! $this->merchant_center->is_ready_for_syncing() ) {
do_action( 'woocommerce_gla_error', 'Cannot sync any products before setting up Google Merchant Center.', __METHOD__ );
throw new ProductSyncerException( __( 'Google Merchant Center has not been set up correctly. Please review your configuration.', 'google-listings-and-ads' ) );
}
if ( ! $this->merchant_center->should_push() ) {
do_action(
'woocommerce_gla_error',
'Cannot push any products because they are being fetched automatically.',
__METHOD__
);
throw new ProductSyncerException(
__(
'Pushing products will not run if the automatic data fetching is enabled. Please review your configuration in Google Listing and Ads settings.',
'google-listings-and-ads'
)
);
}
}
}
Product/ProductSyncerException.php 0000644 00000000661 15153721357 0013374 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductSyncerException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class ProductSyncerException extends Exception implements GoogleListingsAndAdsException {
}
Product/SyncerHooks.php 0000644 00000034471 15153721357 0011166 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\ProductNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use WC_Product;
use WC_Product_Variable;
defined( 'ABSPATH' ) || exit;
/**
* Class SyncerHooks
*
* Hooks to various WooCommerce and WordPress actions to provide automatic product sync functionality.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class SyncerHooks implements Service, Registerable {
use PluginHelper;
protected const SCHEDULE_TYPE_UPDATE = 'update';
protected const SCHEDULE_TYPE_DELETE = 'delete';
/**
* Array of strings mapped to product IDs indicating that they have been already
* scheduled for update or delete during current request. Used to avoid scheduling
* duplicate jobs.
*
* @var string[]
*/
protected $already_scheduled = [];
/**
* @var BatchProductIDRequestEntry[][]
*/
protected $delete_requests_map;
/**
* @var BatchProductHelper
*/
protected $batch_helper;
/**
* @var ProductHelper
*/
protected $product_helper;
/**
* @var JobRepository
*/
protected $job_repository;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var NotificationsService
*/
protected $notifications_service;
/**
* @var WC
*/
protected $wc;
/**
* SyncerHooks constructor.
*
* @param BatchProductHelper $batch_helper
* @param ProductHelper $product_helper
* @param JobRepository $job_repository
* @param MerchantCenterService $merchant_center
* @param NotificationsService $notifications_service
* @param WC $wc
*/
public function __construct(
BatchProductHelper $batch_helper,
ProductHelper $product_helper,
JobRepository $job_repository,
MerchantCenterService $merchant_center,
NotificationsService $notifications_service,
WC $wc
) {
$this->batch_helper = $batch_helper;
$this->product_helper = $product_helper;
$this->job_repository = $job_repository;
$this->merchant_center = $merchant_center;
$this->notifications_service = $notifications_service;
$this->wc = $wc;
}
/**
* Register a service.
*/
public function register(): void {
// only register the hooks if Merchant Center is connected correctly.
if ( ! $this->merchant_center->is_ready_for_syncing() ) {
return;
}
// when a product is added / updated, schedule an "update" job.
add_action( 'woocommerce_new_product', [ $this, 'update_by_id' ], 90 );
add_action( 'woocommerce_new_product_variation', [ $this, 'update_by_id' ], 90 );
add_action( 'woocommerce_update_product', [ $this, 'update_by_object' ], 90, 2 );
add_action( 'woocommerce_update_product_variation', [ $this, 'update_by_object' ], 90, 2 );
// if we don't attach to these we miss product gallery updates.
add_action( 'woocommerce_process_product_meta', [ $this, 'update_by_id' ], 90 );
// when a product is trashed or removed, schedule a "delete" job.
add_action( 'wp_trash_post', [ $this, 'pre_delete' ], 90 );
add_action( 'before_delete_post', [ $this, 'pre_delete' ], 90 );
add_action( 'woocommerce_before_delete_product_variation', [ $this, 'pre_delete' ], 90 );
add_action( 'trashed_post', [ $this, 'delete' ], 90 );
add_action( 'deleted_post', [ $this, 'delete' ], 90 );
// when a product is restored from the trash, schedule an "update" job.
add_action( 'untrashed_post', [ $this, 'update_by_id' ], 90 );
// exclude the sync metadata when duplicating the product
add_filter(
'woocommerce_duplicate_product_exclude_meta',
[ $this, 'duplicate_product_exclude_meta' ],
90
);
}
/**
* Update a Product by WC_Product
*
* @param int $product_id
* @param WC_Product $product
*/
public function update_by_object( int $product_id, WC_Product $product ) {
$this->handle_update_products( [ $product ] );
}
/**
* Update a Product by the ID
*
* @param int $product_id
*/
public function update_by_id( int $product_id ) {
$product = $this->wc->maybe_get_product( $product_id );
$this->handle_update_products( [ $product ] );
}
/**
* Pre delete a Product by the ID
*
* @param int $product_id
*/
public function pre_delete( int $product_id ) {
$this->handle_pre_delete_product( $product_id );
}
/**
* Delete a Product by the ID
*
* @param int $product_id
*/
public function delete( int $product_id ) {
$this->handle_delete_product( $product_id );
}
/**
* Filters woocommerce_duplicate_product_exclude_meta adding some custom prefix
*
* @param array $exclude_meta
* @return array
*/
public function duplicate_product_exclude_meta( array $exclude_meta ): array {
return $this->get_duplicated_product_excluded_meta( $exclude_meta );
}
/**
* Handle updating of a product.
*
* @param WC_Product[] $products The products being saved.
* @param bool $notify If true. It will try to handle notifications.
*
* @return void
*/
protected function handle_update_products( array $products, $notify = true ) {
$products_to_update = [];
$products_to_delete = [];
foreach ( $products as $product ) {
if ( ! $product instanceof WC_Product ) {
continue;
}
$product_id = $product->get_id();
// Avoid to handle variations directly. We handle them from the parent.
if ( $this->notifications_service->is_ready() && $notify ) {
$this->handle_update_product_notification( $product );
}
// Bail if an event is already scheduled for this product in the current request
if ( $this->is_already_scheduled_to_update( $product_id ) ) {
continue;
}
// If it's a variable product we handle each variation separately
if ( $product instanceof WC_Product_Variable ) {
// This is only for MC Push mechanism. We don't handle notifications here.
$this->handle_update_products( $product->get_available_variations( 'objects' ), false );
continue;
}
// Schedule an update job if product sync is enabled.
if ( $this->product_helper->is_sync_ready( $product ) ) {
$this->product_helper->mark_as_pending( $product );
$products_to_update[] = $product->get_id();
$this->set_already_scheduled_to_update( $product_id );
} elseif ( $this->product_helper->is_product_synced( $product ) ) {
// Delete the product from Google Merchant Center if it's already synced BUT it is not sync ready after the edit.
$products_to_delete[] = $product;
$this->set_already_scheduled_to_delete( $product_id );
do_action(
'woocommerce_gla_debug_message',
sprintf( 'Deleting product (ID: %s) from Google Merchant Center because it is not ready to be synced.', $product->get_id() ),
__METHOD__
);
} else {
$this->product_helper->mark_as_unsynced( $product );
}
}
if ( ! empty( $products_to_update ) ) {
$this->job_repository->get( UpdateProducts::class )->schedule( [ $products_to_update ] );
}
if ( ! empty( $products_to_delete ) ) {
$request_entries = $this->batch_helper->generate_delete_request_entries( $products_to_delete );
$this->job_repository->get( DeleteProducts::class )->schedule( [ BatchProductIDRequestEntry::convert_to_id_map( $request_entries )->get() ] );
}
}
/**
* Schedules notifications for an updated product
*
* @param WC_Product $product
*/
protected function handle_update_product_notification( WC_Product $product ) {
if ( $this->product_helper->should_trigger_create_notification( $product ) ) {
$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_PENDING_CREATE );
$this->job_repository->get( ProductNotificationJob::class )->schedule(
[
'item_id' => $product->get_id(),
'topic' => NotificationsService::TOPIC_PRODUCT_CREATED,
]
);
} elseif ( $this->product_helper->should_trigger_update_notification( $product ) ) {
$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_PENDING_UPDATE );
$this->job_repository->get( ProductNotificationJob::class )->schedule(
[
'item_id' => $product->get_id(),
'topic' => NotificationsService::TOPIC_PRODUCT_UPDATED,
]
);
} elseif ( $this->product_helper->should_trigger_delete_notification( $product ) ) {
$this->schedule_delete_notification( $product );
// Schedule variation deletion when the parent is deleted.
if ( $product instanceof WC_Product_Variable ) {
foreach ( $product->get_available_variations( 'objects' ) as $variation ) {
$this->handle_update_product_notification( $variation );
}
}
}
}
/**
* Handle deleting of a product.
*
* @param int $product_id
*/
protected function handle_delete_product( int $product_id ) {
if ( isset( $this->delete_requests_map[ $product_id ] ) ) {
$product_id_map = BatchProductIDRequestEntry::convert_to_id_map( $this->delete_requests_map[ $product_id ] )->get();
if ( ! empty( $product_id_map ) && ! $this->is_already_scheduled_to_delete( $product_id ) ) {
$this->job_repository->get( DeleteProducts::class )->schedule( [ $product_id_map ] );
$this->set_already_scheduled_to_delete( $product_id );
}
}
}
/**
* Maybe send the product deletion notification
* and mark the product as un-synced after.
*
* @since 2.8.0
* @param int $product_id
*/
protected function maybe_send_delete_notification( int $product_id ) {
$product = $this->wc->maybe_get_product( $product_id );
if ( $product instanceof WC_Product && $this->product_helper->has_notified_creation( $product ) ) {
$result = $this->notifications_service->notify( NotificationsService::TOPIC_PRODUCT_DELETED, $product_id, [ 'offer_id' => $this->product_helper->get_offer_id( $product_id ) ] );
if ( $result ) {
$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_DELETED );
$this->product_helper->mark_as_unsynced( $product );
}
}
}
/**
* Schedules a job to send the product deletion notification
*
* @since 2.8.0
* @param WC_Product $product
*/
protected function schedule_delete_notification( $product ) {
$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_PENDING_DELETE );
$this->job_repository->get( ProductNotificationJob::class )->schedule(
[
'item_id' => $product->get_id(),
'topic' => NotificationsService::TOPIC_PRODUCT_DELETED,
]
);
}
/**
* Create request entries for the product (containing its Google ID) so that we can schedule a delete job when the
* product is actually trashed / deleted.
*
* @param int $product_id
*/
protected function handle_pre_delete_product( int $product_id ) {
if ( $this->notifications_service->is_ready() ) {
/**
* For deletions, we do send directly the notification instead of scheduling it.
* This is because we want to avoid that the product is not in the database anymore when the scheduled action runs.
*/
$this->maybe_send_delete_notification( $product_id );
}
$product = $this->wc->maybe_get_product( $product_id );
// each variation is passed to this method separately so we don't need to delete the variable product
if ( $product instanceof WC_Product && ! $product instanceof WC_Product_Variable && $this->product_helper->is_product_synced( $product ) ) {
$this->delete_requests_map[ $product_id ] = $this->batch_helper->generate_delete_request_entries( [ $product ] );
}
}
/**
* Return the list of metadata keys to be excluded when duplicating a product.
*
* @param array $exclude_meta The keys to exclude from the duplicate.
*
* @return array
*/
protected function get_duplicated_product_excluded_meta( array $exclude_meta ): array {
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNCED_AT );
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_GOOGLE_IDS );
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_ERRORS );
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_FAILED_SYNC_ATTEMPTS );
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNC_FAILED_AT );
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNC_STATUS );
$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS );
return $exclude_meta;
}
/**
* @param int $product_id
* @param string $schedule_type
*
* @return bool
*/
protected function is_already_scheduled( int $product_id, string $schedule_type ): bool {
return isset( $this->already_scheduled[ $product_id ] ) && $this->already_scheduled[ $product_id ] === $schedule_type;
}
/**
* @param int $product_id
*
* @return bool
*/
protected function is_already_scheduled_to_update( int $product_id ): bool {
return $this->is_already_scheduled( $product_id, self::SCHEDULE_TYPE_UPDATE );
}
/**
* @param int $product_id
*
* @return bool
*/
protected function is_already_scheduled_to_delete( int $product_id ): bool {
return $this->is_already_scheduled( $product_id, self::SCHEDULE_TYPE_DELETE );
}
/**
* @param int $product_id
* @param string $schedule_type
*
* @return void
*/
protected function set_already_scheduled( int $product_id, string $schedule_type ): void {
$this->already_scheduled[ $product_id ] = $schedule_type;
}
/**
* @param int $product_id
*
* @return void
*/
protected function set_already_scheduled_to_update( int $product_id ): void {
$this->set_already_scheduled( $product_id, self::SCHEDULE_TYPE_UPDATE );
}
/**
* @param int $product_id
*
* @return void
*/
protected function set_already_scheduled_to_delete( int $product_id ): void {
$this->set_already_scheduled( $product_id, self::SCHEDULE_TYPE_DELETE );
}
}
Product/WCProductAdapter.php 0000644 00000113010 15153721357 0012055 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\AttributeMappingHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Condition;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeSystem;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeType;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AgeGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\GooglePriceConstraint;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\ImageUrlConstraint;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\Validatable;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Price as GooglePrice;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductShipping as GoogleProductShipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductShippingDimension as GoogleProductShippingDimension;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductShippingWeight as GoogleProductShippingWeight;
use DateInterval;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use WC_DateTime;
use WC_Product;
use WC_Product_Variable;
use WC_Product_Variation;
defined( 'ABSPATH' ) || exit;
/**
* Class WCProductAdapter
*
* This class adapts the WooCommerce Product class to the Google's Product class by mapping their attributes.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Product
*/
class WCProductAdapter extends GoogleProduct implements Validatable {
use PluginHelper;
public const AVAILABILITY_IN_STOCK = 'in_stock';
public const AVAILABILITY_OUT_OF_STOCK = 'out_of_stock';
public const AVAILABILITY_BACKORDER = 'backorder';
public const AVAILABILITY_PREORDER = 'preorder';
public const IMAGE_SIZE_FULL = 'full';
public const CHANNEL_ONLINE = 'online';
/**
* @var WC_Product WooCommerce product object
*/
protected $wc_product;
/**
* @var WC_Product WooCommerce parent product object if $wc_product is a variation
*/
protected $parent_wc_product;
/**
* @var bool Whether tax is excluded from product price
*/
protected $tax_excluded;
/**
* @var array Product category ids
*/
protected $product_category_ids;
/**
* Initialize this object's properties from an array.
*
* @param array $properties Used to seed this object's properties.
*
* @return void
*
* @throws InvalidValue When a WooCommerce product is not provided or it is invalid.
*/
public function mapTypes( $properties ) {
if ( empty( $properties['wc_product'] ) || ! $properties['wc_product'] instanceof WC_Product ) {
throw InvalidValue::not_instance_of( WC_Product::class, 'wc_product' );
}
// throw an exception if the parent product isn't provided and this is a variation
if ( $properties['wc_product'] instanceof WC_Product_Variation &&
( empty( $properties['parent_wc_product'] ) || ! $properties['parent_wc_product'] instanceof WC_Product_Variable )
) {
throw InvalidValue::not_instance_of( WC_Product_Variable::class, 'parent_wc_product' );
}
if ( empty( $properties['targetCountry'] ) ) {
throw InvalidValue::is_empty( 'targetCountry' );
}
$this->wc_product = $properties['wc_product'];
$this->parent_wc_product = $properties['parent_wc_product'] ?? null;
$mapping_rules = $properties['mapping_rules'] ?? [];
$gla_attributes = $properties['gla_attributes'] ?? [];
// Google doesn't expect extra fields, so it's best to remove them
unset( $properties['wc_product'] );
unset( $properties['parent_wc_product'] );
unset( $properties['gla_attributes'] );
unset( $properties['mapping_rules'] );
parent::mapTypes( $properties );
$this->map_woocommerce_product();
$this->map_attribute_mapping_rules( $mapping_rules );
$this->map_gla_attributes( $gla_attributes );
$this->map_gtin();
// Allow users to override the product's attributes using a WordPress filter.
$this->override_attributes();
}
/**
* Map the WooCommerce product attributes to the current class.
*
* @return void
*/
protected function map_woocommerce_product() {
$this->setChannel( self::CHANNEL_ONLINE );
$content_language = empty( get_locale() ) ? 'en' : strtolower( substr( get_locale(), 0, 2 ) ); // ISO 639-1.
$this->setContentLanguage( $content_language );
$this->map_wc_product_id()
->map_wc_general_attributes()
->map_product_categories()
->map_wc_product_image( self::IMAGE_SIZE_FULL )
->map_wc_availability()
->map_wc_product_shipping()
->map_wc_prices();
}
/**
* Overrides the product attributes by applying a filter and setting the provided values.
*
* @since 1.4.0
*/
protected function override_attributes() {
/**
* Filters the list of overridden attributes to set for this product.
*
* Note: This filter takes precedence over any other filter that modify products attributes. Including
* `woocommerce_gla_product_attribute_value_{$attribute_id}` defined in self::map_gla_attributes.
*
* @param array $attributes An array of values for the product properties. All properties of the
* `\Google\Service\ShoppingContent\Product` class can be set by providing
* the property name as key and its value as array item.
* For example:
* [ 'imageLink' => 'https://example.com/image.jpg' ] overrides the product's
* main image.
*
* @param WC_Product $wc_product The WooCommerce product object.
* @param WCProductAdapter $this The Adapted Google product object. All WooCommerce product properties
* are already mapped to this object.
*
* @see \Google\Service\ShoppingContent\Product for the list of product properties that can be overriden.
* @see WCProductAdapter::map_gla_attributes for the docuementation of `woocommerce_gla_product_attribute_value_{$attribute_id}`
* filter, which allows modifying some attributes such as GTIN, MPN, etc.
*
* @since 1.4.0
*/
$attributes = apply_filters( 'woocommerce_gla_product_attribute_values', [], $this->wc_product, $this );
if ( ! empty( $attributes ) ) {
parent::mapTypes( $attributes );
}
}
/**
* Map the general WooCommerce product attributes.
*
* @return $this
*/
protected function map_wc_general_attributes() {
$this->setTitle( $this->wc_product->get_title() );
$this->setDescription( $this->get_wc_product_description() );
$this->setLink( $this->wc_product->get_permalink() );
// set item group id for variations
if ( $this->is_variation() ) {
$this->setItemGroupId( $this->parent_wc_product->get_id() );
}
return $this;
}
/**
* Map WooCommerce product categories to Google product types.
*
* @return $this
*/
protected function map_product_categories() {
// set product type using merchants defined product categories
$base_product_id = $this->is_variation() ? $this->parent_wc_product->get_id() : $this->wc_product->get_id();
// Fetch only selected term ids without parents.
$this->product_category_ids = wc_get_product_term_ids( $base_product_id, 'product_cat' );
if ( ! empty( $this->product_category_ids ) ) {
$google_product_types = self::convert_product_types( $this->product_category_ids );
do_action(
'woocommerce_gla_debug_message',
sprintf(
'Product category (ID: %s): %s.',
$base_product_id,
wp_json_encode( $google_product_types )
),
__METHOD__
);
$google_product_types = array_slice( $google_product_types, 0, 10 );
$this->setProductTypes( $google_product_types );
}
return $this;
}
/**
* Covert WooCommerce product categories to product_type, which follows Google requirements:
* https://support.google.com/merchants/answer/6324406?hl=en#
*
* @param int[] $category_ids
*
* @return array
*/
public static function convert_product_types( $category_ids ): array {
$product_types = [];
foreach ( array_unique( $category_ids ) as $category_id ) {
if ( ! is_int( $category_id ) ) {
continue;
}
$product_type = self::get_product_type_by_id( $category_id );
array_push( $product_types, $product_type );
}
return $product_types;
}
/**
* Return category names including ancestors, separated by ">"
*
* @param int $category_id
*
* @return string
*/
protected static function get_product_type_by_id( int $category_id ): string {
$category_names = [];
do {
$term = get_term_by( 'id', $category_id, 'product_cat', 'ARRAY_A' );
array_push( $category_names, $term['name'] );
$category_id = $term['parent'];
} while ( ! empty( $term['parent'] ) );
return implode( ' > ', array_reverse( $category_names ) );
}
/**
* Map the WooCommerce product ID.
*
* @return $this
*/
protected function map_wc_product_id(): WCProductAdapter {
$this->setOfferId( self::get_google_product_offer_id( $this->get_slug(), $this->wc_product->get_id() ) );
return $this;
}
/**
*
* @param string $slug
* @param int $product_id
* @return string
*/
public static function get_google_product_offer_id( string $slug, int $product_id ): string {
/**
* Filters a WooCommerce product ID to be used as the Merchant Center product ID.
*
* @param string $mc_product_id Default generated Merchant Center product ID.
* @param int $product_id WooCommerce product ID.
* @since 2.4.6
*
* @return string Merchant Center product ID corresponding to the given WooCommerce product ID.
*/
return apply_filters( 'woocommerce_gla_get_google_product_offer_id', "{$slug}_{$product_id}", $product_id );
}
/**
* Get the description for the WooCommerce product.
*
* @return string
*/
protected function get_wc_product_description(): string {
/**
* Filters whether the short product description should be used for the synced product.
*
* @param bool $use_short_description
*/
$use_short_description = apply_filters( 'woocommerce_gla_use_short_description', false );
$description = ! empty( $this->wc_product->get_description() ) && ! $use_short_description ?
$this->wc_product->get_description() :
$this->wc_product->get_short_description();
// prepend the parent product description to the variation product
if ( $this->is_variation() ) {
$parent_description = ! empty( $this->parent_wc_product->get_description() ) && ! $use_short_description ?
$this->parent_wc_product->get_description() :
$this->parent_wc_product->get_short_description();
$new_line = ! empty( $description ) && ! empty( $parent_description ) ? PHP_EOL : '';
$description = $parent_description . $new_line . $description;
}
/**
* Filters whether the shortcodes should be applied for product descriptions when syncing a product or be stripped out.
*
* @since 1.4.0
*
* @param bool $apply_shortcodes Shortcodes are applied if set to `true` and stripped out if set to `false`.
* @param WC_Product $wc_product WooCommerce product object.
*/
$apply_shortcodes = apply_filters( 'woocommerce_gla_product_description_apply_shortcodes', false, $this->wc_product );
if ( $apply_shortcodes ) {
// Apply active shortcodes
$description = do_shortcode( $description );
} else {
// Strip out active shortcodes
$description = strip_shortcodes( $description );
}
// Strip out invalid unicode.
$description = mb_convert_encoding( $description, 'UTF-8', 'UTF-8' );
$description = preg_replace(
'/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/u',
'',
$description
);
// Strip out invalid HTML tags (e.g. script, style, canvas, etc.) along with attributes of all tags.
$valid_html_tags = array_keys( wp_kses_allowed_html( 'post' ) );
$kses_allowed_tags = array_fill_keys( $valid_html_tags, [] );
$description = wp_kses( $description, $kses_allowed_tags );
// Trim the description if it's more than 5000 characters.
$description = mb_substr( $description, 0, 5000, 'utf-8' );
/**
* Filters the product's description.
*
* @param string $description Product description.
* @param WC_Product $wc_product WooCommerce product object.
*/
return apply_filters( 'woocommerce_gla_product_attribute_value_description', $description, $this->wc_product );
}
/**
* Map the WooCommerce product images.
*
* @param string $image_size
*
* @return $this
*/
protected function map_wc_product_image( string $image_size ) {
$image_id = $this->wc_product->get_image_id();
$gallery_image_ids = $this->wc_product->get_gallery_image_ids() ?: [];
// check if we can use the parent product image if it's a variation
if ( $this->is_variation() ) {
$image_id = $image_id ?? $this->parent_wc_product->get_image_id();
$parent_gallery_images = $this->parent_wc_product->get_gallery_image_ids() ?: [];
$gallery_image_ids = ! empty( $gallery_image_ids ) ? $gallery_image_ids : $parent_gallery_images;
}
// use a gallery image as the main product image if no main image is available
if ( empty( $image_id ) && ! empty( $gallery_image_ids[0] ) ) {
$image_id = $gallery_image_ids[0];
// remove the recently set main image from the list of gallery images
unset( $gallery_image_ids[0] );
}
// set main image
$image_link = wp_get_attachment_image_url( $image_id, $image_size, false );
$this->setImageLink( $image_link );
// set additional images
$gallery_image_links = array_map(
function ( $gallery_image_id ) use ( $image_size ) {
return wp_get_attachment_image_url( $gallery_image_id, $image_size, false );
},
$gallery_image_ids
);
// Uniquify the set of additional images
$gallery_image_links = array_unique( $gallery_image_links, SORT_REGULAR );
// Limit additional image links up to 10
$gallery_image_links = array_slice( $gallery_image_links, 0, 10 );
$this->setAdditionalImageLinks( $gallery_image_links );
return $this;
}
/**
* Map the general WooCommerce product attributes.
*
* @return $this
*/
protected function map_wc_availability() {
if ( ! $this->wc_product->is_in_stock() ) {
$availability = self::AVAILABILITY_OUT_OF_STOCK;
} elseif ( $this->wc_product->is_on_backorder( 1 ) ) {
$availability = self::AVAILABILITY_BACKORDER;
} else {
$availability = self::AVAILABILITY_IN_STOCK;
}
$this->setAvailability( $availability );
return $this;
}
/**
* Map the shipping information for WooCommerce product.
*
* @return $this
*/
protected function map_wc_product_shipping(): WCProductAdapter {
$this->add_shipping_country( $this->getTargetCountry() );
if ( ! $this->is_virtual() ) {
$dimension_unit = apply_filters( 'woocommerce_gla_dimension_unit', get_option( 'woocommerce_dimension_unit' ) );
$weight_unit = apply_filters( 'woocommerce_gla_weight_unit', get_option( 'woocommerce_weight_unit' ) );
$this->map_wc_shipping_dimensions( $dimension_unit )
->map_wc_shipping_weight( $weight_unit );
}
// Set the product's shipping class slug as the shipping label.
$shipping_class = $this->wc_product->get_shipping_class();
if ( ! empty( $shipping_class ) ) {
$this->setShippingLabel( $shipping_class );
}
return $this;
}
/**
* Add a shipping country for the product.
*
* @param string $country
*/
public function add_shipping_country( string $country ): void {
$product_shipping = [
'country' => $country,
];
// Virtual products should override any country shipping cost.
if ( $this->is_virtual() ) {
$product_shipping['price'] = [
'currency' => get_woocommerce_currency(),
'value' => 0,
];
}
$new_shipping = [
new GoogleProductShipping( $product_shipping ),
];
if ( ! $this->shipping_country_exists( $country ) ) {
$current_shipping = $this->getShipping() ?? [];
$this->setShipping( array_merge( $current_shipping, $new_shipping ) );
}
}
/**
* Remove a shipping country from the product.
*
* @param string $country
*
* @since 1.2.0
*/
public function remove_shipping_country( string $country ): void {
$product_shippings = $this->getShipping() ?? [];
foreach ( $product_shippings as $index => $shipping ) {
if ( $country === $shipping->getCountry() ) {
unset( $product_shippings[ $index ] );
}
}
$this->setShipping( $product_shippings );
}
/**
* @param string $country
*
* @return bool
*/
protected function shipping_country_exists( string $country ): bool {
$current_shipping = $this->getShipping() ?? [];
foreach ( $current_shipping as $shipping ) {
if ( $country === $shipping->getCountry() ) {
return true;
}
}
return false;
}
/**
* Map the measurements for the WooCommerce product.
*
* @param string $unit
*
* @return $this
*/
protected function map_wc_shipping_dimensions( string $unit = 'cm' ): WCProductAdapter {
$length = $this->wc_product->get_length();
$width = $this->wc_product->get_width();
$height = $this->wc_product->get_height();
// Use cm if the unit isn't supported.
if ( ! in_array( $unit, [ 'in', 'cm' ], true ) ) {
$unit = 'cm';
}
$length = wc_get_dimension( (float) $length, $unit );
$width = wc_get_dimension( (float) $width, $unit );
$height = wc_get_dimension( (float) $height, $unit );
if ( $length > 0 && $width > 0 && $height > 0 ) {
$this->setShippingLength(
new GoogleProductShippingDimension(
[
'unit' => $unit,
'value' => $length,
]
)
);
$this->setShippingWidth(
new GoogleProductShippingDimension(
[
'unit' => $unit,
'value' => $width,
]
)
);
$this->setShippingHeight(
new GoogleProductShippingDimension(
[
'unit' => $unit,
'value' => $height,
]
)
);
}
return $this;
}
/**
* Map the weight for the WooCommerce product.
*
* @param string $unit
*
* @return $this
*/
protected function map_wc_shipping_weight( string $unit = 'g' ): WCProductAdapter {
// Use g if the unit isn't supported.
if ( ! in_array( $unit, [ 'g', 'lbs', 'oz' ], true ) ) {
$unit = 'g';
}
$weight = wc_get_weight( $this->wc_product->get_weight(), $unit );
// Use lb if the unit is lbs, since GMC uses lb.
if ( 'lbs' === $unit ) {
$unit = 'lb';
}
$this->setShippingWeight(
new GoogleProductShippingWeight(
[
'unit' => $unit,
'value' => $weight,
]
)
);
return $this;
}
/**
* Sets whether tax is excluded from product price.
*
* @return $this
*/
protected function map_tax_excluded(): WCProductAdapter {
// tax is excluded from price in US and CA
$this->tax_excluded = in_array( $this->getTargetCountry(), [ 'US', 'CA' ], true );
$this->tax_excluded = boolval( apply_filters( 'woocommerce_gla_tax_excluded', $this->tax_excluded ) );
return $this;
}
/**
* Map the prices (base and sale price) for the product.
*
* @return $this
*/
protected function map_wc_prices(): WCProductAdapter {
$this->map_tax_excluded();
$this->map_wc_product_price( $this->wc_product );
return $this;
}
/**
* Map the prices (base and sale price) for a given WooCommerce product.
*
* @param WC_Product $product
*
* @return $this
*/
protected function map_wc_product_price( WC_Product $product ): WCProductAdapter {
// set regular price
$regular_price = $product->get_regular_price();
if ( '' !== $regular_price ) {
$price = $this->tax_excluded ?
wc_get_price_excluding_tax( $product, [ 'price' => $regular_price ] ) :
wc_get_price_including_tax( $product, [ 'price' => $regular_price ] );
/**
* Filters the calculated product price.
*
* @param float $price Calculated price of the product
* @param WC_Product $product WooCommerce product
* @param bool $tax_excluded Whether tax is excluded from product price
*/
$price = apply_filters( 'woocommerce_gla_product_attribute_value_price', $price, $product, $this->tax_excluded );
$this->setPrice(
new GooglePrice(
[
'currency' => get_woocommerce_currency(),
'value' => $price,
]
)
);
}
// set sale price
$this->map_wc_product_sale_price( $product );
return $this;
}
/**
* Map the sale price and sale effective date for a given WooCommerce product.
*
* @param WC_Product $product
*
* @return $this
*/
protected function map_wc_product_sale_price( WC_Product $product ): WCProductAdapter {
// Grab the sale price of the base product. Some plugins (Dynamic
// pricing as an example) filter the active price, but not the sale
// price. If the active price < the regular price treat it as a sale
// price.
$regular_price = $product->get_regular_price();
$sale_price = $product->get_sale_price();
$active_price = $product->get_price();
if (
( empty( $sale_price ) && $active_price < $regular_price ) ||
( ! empty( $sale_price ) && $active_price < $sale_price )
) {
$sale_price = $active_price;
}
// set sale price and sale effective date if any
if ( '' !== $sale_price ) {
$sale_price = $this->tax_excluded ?
wc_get_price_excluding_tax( $product, [ 'price' => $sale_price ] ) :
wc_get_price_including_tax( $product, [ 'price' => $sale_price ] );
/**
* Filters the calculated product sale price.
*
* @param float $sale_price Calculated sale price of the product
* @param WC_Product $product WooCommerce product
* @param bool $tax_excluded Whether tax is excluded from product price
*/
$sale_price = apply_filters( 'woocommerce_gla_product_attribute_value_sale_price', $sale_price, $product, $this->tax_excluded );
// If the sale price dates no longer apply, make sure we don't include a sale price.
$now = new WC_DateTime();
$sale_price_end_date = $product->get_date_on_sale_to();
if ( empty( $sale_price_end_date ) || $sale_price_end_date >= $now ) {
$this->setSalePrice(
new GooglePrice(
[
'currency' => get_woocommerce_currency(),
'value' => $sale_price,
]
)
);
$this->setSalePriceEffectiveDate( $this->get_wc_product_sale_price_effective_date( $product ) );
}
}
return $this;
}
/**
* Return the sale effective dates for the WooCommerce product.
*
* @param WC_Product $product
*
* @return string|null
*/
protected function get_wc_product_sale_price_effective_date( WC_Product $product ): ?string {
$start_date = $product->get_date_on_sale_from();
$end_date = $product->get_date_on_sale_to();
$now = new WC_DateTime();
// if we have a sale end date in the future, but no start date, set the start date to now()
if (
! empty( $end_date ) &&
$end_date > $now &&
empty( $start_date )
) {
$start_date = $now;
}
// if we have a sale start date in the past, but no end date, do not include the start date.
if (
! empty( $start_date ) &&
$start_date < $now &&
empty( $end_date )
) {
$start_date = null;
}
// if we have a start date in the future, but no end date, assume a one-day sale.
if (
! empty( $start_date ) &&
$start_date > $now &&
empty( $end_date )
) {
$end_date = clone $start_date;
$end_date->add( new DateInterval( 'P1D' ) );
}
if ( empty( $start_date ) && empty( $end_date ) ) {
return null;
}
return sprintf( '%s/%s', (string) $start_date, (string) $end_date );
}
/**
* Return whether the WooCommerce product is a variation.
*
* @return bool
*/
public function is_variation(): bool {
return $this->wc_product instanceof WC_Product_Variation;
}
/**
* Return whether the WooCommerce product is virtual.
*
* @return bool
*/
public function is_virtual(): bool {
$is_virtual = $this->wc_product->is_virtual();
/**
* Filters the virtual property value of a product.
*
* @param bool $is_virtual Whether a product is virtual
* @param WC_Product $product WooCommerce product
*/
$is_virtual = apply_filters( 'woocommerce_gla_product_property_value_is_virtual', $is_virtual, $this->wc_product );
return false !== $is_virtual;
}
/**
* @param ClassMetadata $metadata
*/
public static function load_validator_metadata( ClassMetadata $metadata ) {
$metadata->addPropertyConstraint( 'offerId', new Assert\NotBlank() );
$metadata->addPropertyConstraint( 'title', new Assert\NotBlank() );
$metadata->addPropertyConstraint( 'description', new Assert\NotBlank() );
$metadata->addPropertyConstraint( 'link', new Assert\NotBlank() );
$metadata->addPropertyConstraint( 'link', new Assert\Url() );
$metadata->addPropertyConstraint( 'imageLink', new Assert\NotBlank() );
$metadata->addPropertyConstraint( 'imageLink', new ImageUrlConstraint() );
$metadata->addPropertyConstraint(
'additionalImageLinks',
new Assert\All(
[
'constraints' => [ new ImageUrlConstraint() ],
]
)
);
$metadata->addGetterConstraint( 'price', new Assert\NotNull() );
$metadata->addGetterConstraint( 'price', new GooglePriceConstraint() );
$metadata->addGetterConstraint( 'salePrice', new GooglePriceConstraint() );
$metadata->addConstraint( new Assert\Callback( 'validate_item_group_id' ) );
$metadata->addConstraint( new Assert\Callback( 'validate_availability' ) );
$metadata->addPropertyConstraint( 'gtin', new Assert\Regex( '/^\d{8}(?:\d{4,6})?$/' ) );
$metadata->addPropertyConstraint( 'mpn', new Assert\Type( 'string' ) );
$metadata->addPropertyConstraint( 'mpn', new Assert\Length( null, 0, 70 ) ); // maximum 70 characters
$metadata->addPropertyConstraint(
'sizes',
new Assert\All(
[
'constraints' => [
new Assert\Type( 'string' ),
new Assert\Length( null, 0, 100 ), // maximum 100 characters
],
]
)
);
$metadata->addPropertyConstraint( 'sizeSystem', new Assert\Choice( array_keys( SizeSystem::get_value_options() ) ) );
$metadata->addPropertyConstraint( 'sizeType', new Assert\Choice( array_keys( SizeType::get_value_options() ) ) );
$metadata->addPropertyConstraint( 'color', new Assert\Length( null, 0, 100 ) ); // maximum 100 characters
$metadata->addPropertyConstraint( 'material', new Assert\Length( null, 0, 200 ) ); // maximum 200 characters
$metadata->addPropertyConstraint( 'pattern', new Assert\Length( null, 0, 100 ) ); // maximum 200 characters
$metadata->addPropertyConstraint( 'ageGroup', new Assert\Choice( array_keys( AgeGroup::get_value_options() ) ) );
$metadata->addPropertyConstraint( 'adult', new Assert\Type( 'boolean' ) );
$metadata->addPropertyConstraint( 'condition', new Assert\Choice( array_keys( Condition::get_value_options() ) ) );
$metadata->addPropertyConstraint( 'multipack', new Assert\Type( 'integer' ) );
$metadata->addPropertyConstraint( 'multipack', new Assert\PositiveOrZero() );
$metadata->addPropertyConstraint( 'isBundle', new Assert\Type( 'boolean' ) );
}
/**
* Used by the validator to check if the variation product has an itemGroupId
*
* @param ExecutionContextInterface $context
*/
public function validate_item_group_id( ExecutionContextInterface $context ) {
if ( $this->is_variation() && empty( $this->getItemGroupId() ) ) {
$context->buildViolation( 'ItemGroupId needs to be set for variable products.' )
->atPath( 'itemGroupId' )
->addViolation();
}
}
/**
* Used by the validator to check if the availability date is set for product available as `backorder` or
* `preorder`.
*
* @param ExecutionContextInterface $context
*/
public function validate_availability( ExecutionContextInterface $context ) {
if (
( self::AVAILABILITY_BACKORDER === $this->getAvailability() || self::AVAILABILITY_PREORDER === $this->getAvailability() ) &&
empty( $this->getAvailabilityDate() )
) {
$context->buildViolation( 'Availability date is required if you set the product\'s availability to backorder or pre-order.' )
->atPath( 'availabilityDate' )
->addViolation();
}
}
/**
* @return WC_Product
*/
public function get_wc_product(): WC_Product {
return $this->wc_product;
}
/**
* @param array $attributes Attribute values
*
* @return $this
*/
protected function map_gla_attributes( array $attributes ): WCProductAdapter {
$gla_attributes = [];
foreach ( $attributes as $attribute_id => $attribute_value ) {
if ( property_exists( $this, $attribute_id ) ) {
/**
* Filters a product attribute's value.
*
* This only applies to the extra attributes defined in `AttributeManager::ATTRIBUTES`
* like GTIN, MPN, Brand, Size, etc. and it cannot modify other product attributes.
*
* This filter also cannot add or set a new attribute or modify one that isn't currently
* set for the product through WooCommerce's edit product page
*
* In order to override all product attributes and/or set new ones for the product use the
* `woocommerce_gla_product_attribute_values` filter.
*
* Note that the `woocommerce_gla_product_attribute_values` filter takes precedence over
* this filter, and it can be used to override any values defined here.
*
* @param mixed $attribute_value The attribute's current value
* @param WC_Product $wc_product The WooCommerce product object.
*
* @see AttributeManager::ATTRIBUTES for the list of attributes that their values can be modified using this filter.
* @see WCProductAdapter::override_attributes for the documentation of the `woocommerce_gla_product_attribute_values` filter.
*/
$gla_attributes[ $attribute_id ] = apply_filters( "woocommerce_gla_product_attribute_value_{$attribute_id}", $attribute_value, $this->get_wc_product() );
}
}
parent::mapTypes( $gla_attributes );
// Size
if ( ! empty( $attributes['size'] ) ) {
$this->setSizes( [ $attributes['size'] ] );
}
return $this;
}
/**
* Map the WooCommerce core global unique ID (GTIN) value if it's available.
*
* @since 2.9.0
*
* @return $this
*/
protected function map_gtin(): WCProductAdapter {
// compatibility-code "WC < 9.2" -- Core global unique ID field was added in 9.2
if ( ! method_exists( $this->wc_product, 'get_global_unique_id' ) ) {
return $this;
}
// avoid dashes and other unsupported format
$global_unique_id = preg_replace( '/[^0-9]/', '', $this->wc_product->get_global_unique_id() );
if ( ! empty( $global_unique_id ) ) {
$this->setGtin( $global_unique_id );
}
return $this;
}
/**
* @param string $targetCountry
*
* phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*/
public function setTargetCountry( $targetCountry ) {
// remove shipping for current target country
$this->remove_shipping_country( $this->getTargetCountry() );
// set the new target country
parent::setTargetCountry( $targetCountry );
// we need to reset the prices because tax is based on the country
$this->map_wc_prices();
// product shipping information is also country based
$this->map_wc_product_shipping();
}
/**
* Performs the attribute mapping.
* This function applies rules setting values for the different attributes in the product.
*
* @param array $mapping_rules The set of rules to apply
*/
protected function map_attribute_mapping_rules( array $mapping_rules ) {
$attributes = [];
if ( empty( $mapping_rules ) ) {
return $this;
}
foreach ( $mapping_rules as $mapping_rule ) {
if ( $this->rule_match_conditions( $mapping_rule ) ) {
$attribute_id = $mapping_rule['attribute'];
$attributes[ $attribute_id ] = $this->format_attribute(
apply_filters(
"woocommerce_gla_product_attribute_value_{$attribute_id}",
$this->get_source( $mapping_rule['source'] ),
$this->get_wc_product()
),
$attribute_id
);
}
}
parent::mapTypes( $attributes );
// Size
if ( ! empty( $attributes['size'] ) ) {
$this->setSizes( [ $attributes['size'] ] );
}
return $this;
}
/**
* Get a source value for attribute mapping
*
* @param string $source The source to get the value
* @return string The source value for this product
*/
protected function get_source( string $source ) {
$source_type = null;
$type_separator = strpos( $source, ':' );
if ( $type_separator ) {
$source_type = substr( $source, 0, $type_separator );
$source_value = substr( $source, $type_separator + 1 );
}
// Detect if the source_type is kind of product, taxonomy or attribute. Otherwise, we take it the full source as a static value.
switch ( $source_type ) {
case 'product':
return $this->get_product_field( $source_value );
case 'taxonomy':
return $this->get_product_taxonomy( $source_value );
case 'attribute':
return $this->get_custom_attribute( $source_value );
default:
return $source;
}
}
/**
* Check if the current product match the conditions for applying the Attribute mapping rule.
* For now the conditions are just matching with the product category conditions.
*
* @param array $rule The attribute mapping rule
* @return bool True if the rule is applicable
*/
protected function rule_match_conditions( array $rule ): bool {
$attribute = $rule['attribute'];
$category_condition_type = $rule['category_condition_type'];
if ( $category_condition_type === AttributeMappingHelper::CATEGORY_CONDITION_TYPE_ALL ) {
return true;
}
// size is not the real attribute, the real attribute is sizes
if ( ! property_exists( $this, $attribute ) && $attribute !== 'size' ) {
return false;
}
$categories = explode( ',', $rule['categories'] );
$contains_rules_categories = ! empty( array_intersect( $categories, $this->product_category_ids ) );
if ( $category_condition_type === AttributeMappingHelper::CATEGORY_CONDITION_TYPE_ONLY ) {
return $contains_rules_categories;
}
return ! $contains_rules_categories;
}
/**
* Get taxonomy source type for attribute mapping
*
* @param string $taxonomy The taxonomy to get
* @return string The taxonomy value
*/
protected function get_product_taxonomy( $taxonomy ) {
$product = $this->get_wc_product();
if ( $product->is_type( 'variation' ) ) {
$values = $product->get_attribute( $taxonomy );
if ( ! $values ) { // if taxonomy is not a global attribute (ie product_tag), attempt to get is with wc_get_product_terms
$values = $this->get_taxonomy_term_names( $product->get_id(), $taxonomy );
}
if ( ! $values ) { // if the value is still not available at this point, we try to get it from the parent
$parent = wc_get_product( $product->get_parent_id() );
$values = $parent->get_attribute( $taxonomy );
if ( ! $values ) {
$values = $this->get_taxonomy_term_names( $parent->get_id(), $taxonomy );
}
}
if ( is_string( $values ) ) {
$values = explode( ', ', $values );
}
} else {
$values = $this->get_taxonomy_term_names( $product->get_id(), $taxonomy );
}
if ( empty( $values ) || is_wp_error( $values ) ) {
return '';
}
return $values[0];
}
/**
* Get product source type for attribute mapping.
* Those are fields belonging to the product core data. Like title, weight, SKU...
*
* @param string $field The field to get
* @return string|null The field value (null if data is not available)
*/
protected function get_product_field( $field ) {
$product = $this->get_wc_product();
if ( 'weight_with_unit' === $field ) {
$weight = $product->get_weight();
return $weight ? $weight . ' ' . get_option( 'woocommerce_weight_unit' ) : null;
}
if ( is_callable( [ $product, 'get_' . $field ] ) ) {
$getter = 'get_' . $field;
return $product->$getter();
}
return '';
}
/**
*
* Formats the attribute for sending it via Google API
*
* @param string $value The value to format
* @param string $attribute_id The attribute ID for which this value belongs
* @return string|bool|int The attribute formatted based on theit attribute type
*/
protected function format_attribute( $value, $attribute_id ) {
$attribute = AttributeMappingHelper::get_attribute_by_id( $attribute_id );
if ( in_array( $attribute::get_value_type(), [ 'bool', 'boolean' ], true ) ) {
return wc_string_to_bool( $value );
}
if ( in_array( $attribute::get_value_type(), [ 'int', 'integer' ], true ) ) {
return (int) $value;
}
return $value;
}
/**
* Gets a custom attribute from a product
*
* @param string $attribute_name - The attribute name to get.
* @return string|null The attribute value or null if no value is found
*/
protected function get_custom_attribute( $attribute_name ) {
$product = $this->get_wc_product();
$attribute_value = $product->get_attribute( $attribute_name );
if ( ! $attribute_value ) {
$attribute_value = $product->get_meta( $attribute_name );
}
// We only support scalar values.
if ( ! is_scalar( $attribute_value ) ) {
return '';
}
$values = explode( WC_DELIMITER, (string) $attribute_value );
$values = array_filter( array_map( 'trim', $values ) );
return empty( $values ) ? '' : $values[0];
}
/**
* Get a taxonomy term names from a product using
*
* @param int $product_id - The product ID to get the taxonomy term
* @param string $taxonomy - The taxonomy to get.
* @return string[] An array of term names.
*/
protected function get_taxonomy_term_names( $product_id, $taxonomy ) {
$values = wc_get_product_terms( $product_id, $taxonomy );
return wp_list_pluck( $values, 'name' );
}
}
Proxies/GoogleGtagJs.php 0000644 00000005137 15153721357 0011241 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Proxies;
/**
* Class GoogleGtagJs
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Proxies
*/
class GoogleGtagJs {
/** @var array */
private $wcga_settings;
/** @var bool $ga4c_v2 True if Google Analytics for WooCommerce version 2 or higher is installed */
public $ga4w_v2;
/**
* GoogleGtagJs constructor.
*
* Load the WooCommerce Google Analytics for WooCommerce extension settings.
*/
public function __construct() {
$this->wcga_settings = get_option( 'woocommerce_google_analytics_settings', [] );
$this->ga4w_v2 = defined( '\WC_GOOGLE_ANALYTICS_INTEGRATION_VERSION' ) && version_compare( \WC_GOOGLE_ANALYTICS_INTEGRATION_VERSION, '2.0.0', '>=' );
// Prime some values.
if ( $this->ga4w_v2 ) {
$this->wcga_settings['ga_gtag_enabled'] = 'yes';
} elseif ( empty( $this->wcga_settings['ga_gtag_enabled'] ) ) {
$this->wcga_settings['ga_gtag_enabled'] = 'no';
}
if ( empty( $this->wcga_settings['ga_standard_tracking_enabled'] ) ) {
$this->wcga_settings['ga_standard_tracking_enabled'] = 'no';
}
if ( empty( $this->wcga_settings['ga_id'] ) ) {
$this->wcga_settings['ga_id'] = null;
}
}
/**
* Determine whether WooCommerce Google Analytics for WooCommerce is already
* injecting the gtag <script> code.
*
* @return bool True if the <script> code is present.
*/
public function is_adding_framework() {
// WooCommerce Google Analytics for WooCommerce is disabled for admin users.
$is_admin = is_admin() || current_user_can( 'manage_options' );
return ! $is_admin && class_exists( '\WC_Google_Gtag_JS' ) && $this->is_gtag_page() && $this->has_required_settings();
}
/**
* Determine whether the current page has WooCommerce Google Analytics for WooCommerce enabled.
*
* @return bool If the page is a Analytics-enabled page.
*/
private function is_gtag_page(): bool {
$standard_tracking_enabled = 'yes' === $this->wcga_settings['ga_standard_tracking_enabled'];
$is_wc_page = is_order_received_page() || is_woocommerce() || is_cart() || is_checkout();
return $this->ga4w_v2 || $standard_tracking_enabled || $is_wc_page;
}
/**
* In order for WooCommerce Google Analytics for WooCommerce to include the Global Site Tag
* framework, it needs to be enabled in the settings and a Measurement ID must be provided.
*
* @return bool True if Global Site Tag is enabled and a Measurement ID is provided.
*/
private function has_required_settings() {
return 'yes' === $this->wcga_settings['ga_gtag_enabled'] && $this->wcga_settings['ga_id'];
}
}
Proxies/Jetpack.php 0000644 00000001273 15153721357 0010303 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Proxies;
use Automattic\Jetpack\Connection\Client;
/**
* Class JP.
*
* This class provides proxy methods to wrap around Jetpack functions.
*
* @since 2.8.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Proxies
*/
class Jetpack {
/**
* Makes an authorized remote request using Jetpack_Signature
*
* @param array $args the arguments for the remote request.
* @param mixed $body the body of the request.
*
* @return array|WP_Error — WP HTTP response on success
*/
public function remote_request( $args, $body = null ) {
return Client::remote_request( $args, $body );
}
}
Proxies/RESTServer.php 0000644 00000004674 15153721357 0010676 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Proxies;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use WP_REST_Server as Server;
/**
* Class RESTServer
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Proxies
*/
class RESTServer {
/**
* The REST server instance.
*
* @var Server
*/
protected $server;
/**
* RESTServer constructor.
*
* @param Server|null $server
*/
public function __construct( ?Server $server = null ) {
$this->server = $server ?? rest_get_server();
}
/**
* Register a REST route.
*
* @param string $route_namespace The route namespace.
* @param string $route The route.
* @param array $args Arguments for the route.
*/
public function register_route( string $route_namespace, string $route, array $args ): void {
// Clean up namespace and route.
$route_namespace = trim( $route_namespace, '/' );
$route = trim( $route, '/' );
$full_route = "/{$route_namespace}/{$route}";
$this->server->register_route( $route_namespace, $full_route, $this->prepare_route_args( $args ) );
}
/**
* Get the registered REST routes.
*
* @param string $route_namespace Optionally, only return routes in the given namespace.
* @return array `'/path/regex' => array( $callback, $bitmask )` or
* `'/path/regex' => array( array( $callback, $bitmask ), ...)`.
*
* @since 1.4.0
*/
public function get_routes( string $route_namespace = '' ): array {
return $this->server->get_routes( $route_namespace );
}
/**
* Run an internal request.
*
* @param Request $request
*
* @return Response
*/
public function dispatch_request( Request $request ): Response {
return $this->server->dispatch( $request );
}
/**
* Prepare the route arguments.
*
* @param array $args The route args to prepare.
*
* @return array Prepared args.
*/
protected function prepare_route_args( array $args ): array {
$defaults = [
'methods' => TransportMethods::READABLE,
'callback' => null,
'args' => [],
];
$common_args = $args['args'] ?? [];
unset( $args['args'] );
foreach ( $args as $key => &$group ) {
if ( ! is_numeric( $key ) ) {
continue;
}
$group = array_merge( $defaults, $group );
$group['args'] = array_merge( $common_args, $group['args'] );
}
return $args;
}
}
Proxies/Tracks.php 0000644 00000001272 15153721357 0010150 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Proxies;
use WC_Tracks;
/**
* Class Tracks
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Proxies
*/
class Tracks {
/**
* Record a tracks event.
*
* @param string $name The event name to record.
* @param array $properties Array of properties to include with the event.
*/
public function record_event( string $name, array $properties = [] ): void {
if ( class_exists( WC_Tracks::class ) ) {
WC_Tracks::record_event( $name, $properties );
} elseif ( function_exists( 'wc_admin_record_tracks_event' ) ) {
wc_admin_record_tracks_event( $name, $properties );
}
}
}
Proxies/WC.php 0000644 00000010712 15153721357 0007231 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Proxies;
use Automattic\WooCommerce\Container;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use WC_Countries;
use WC_Coupon;
use WC_Product;
use WC_Shipping_Zone;
use WC_Shipping_Zones;
use WP_Term;
use function WC as WCCore;
defined( 'ABSPATH' ) || exit;
/**
* Class WC
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Proxies
*/
class WC {
/**
* The base location for the store.
*
* @var string
*/
protected $base_country;
/**
* @var array
*/
protected $countries;
/**
* List of countries the WC store sells to.
*
* @var array
*/
protected $allowed_countries;
/** @var WC_Countries */
protected $wc_countries;
/**
* @var array
*/
protected $continents;
/**
* WC constructor.
*
* @param WC_Countries|null $countries
*/
public function __construct( ?WC_Countries $countries = null ) {
$this->wc_countries = $countries ?? new WC_Countries();
}
/**
* Get WooCommerce countries.
*
* @return array
*/
public function get_countries(): array {
if ( null === $this->countries ) {
$this->countries = $this->wc_countries->get_countries() ?? [];
}
return $this->countries;
}
/**
* Get WooCommerce allowed countries.
*
* @return array
*/
public function get_allowed_countries(): array {
if ( null === $this->allowed_countries ) {
$this->allowed_countries = $this->wc_countries->get_allowed_countries() ?? [];
}
return $this->allowed_countries;
}
/**
* Get the base country for the store.
*
* @return string
*/
public function get_base_country(): string {
if ( null === $this->base_country ) {
$this->base_country = $this->wc_countries->get_base_country() ?? 'US';
}
return $this->base_country;
}
/**
* Get all continents.
*
* @return array
*/
public function get_continents(): array {
if ( null === $this->continents ) {
$this->continents = $this->wc_countries->get_continents() ?? [];
}
return $this->continents;
}
/**
* Get the WC_Countries object
*
* @return WC_Countries
*/
public function get_wc_countries(): WC_Countries {
return $this->wc_countries;
}
/**
* Get a WooCommerce product and confirm it exists.
*
* @param int $product_id
*
* @return WC_Product
*
* @throws InvalidValue When the product does not exist.
*/
public function get_product( int $product_id ): WC_Product {
$product = wc_get_product( $product_id );
if ( ! $product instanceof WC_Product ) {
throw InvalidValue::not_valid_product_id( $product_id );
}
return $product;
}
/**
* Get a WooCommerce product if it exists or return null if it doesn't
*
* @param int $product_id
*
* @return WC_Product|null
*/
public function maybe_get_product( int $product_id ): ?WC_Product {
$product = wc_get_product( $product_id );
if ( ! $product instanceof WC_Product ) {
return null;
}
return $product;
}
/**
* Get a WooCommerce coupon if it exists or return null if it doesn't
*
* @param int $coupon_id
*
* @return WC_Coupon|null
*/
public function maybe_get_coupon( int $coupon_id ): ?WC_Coupon {
$coupon = new WC_Coupon( $coupon_id );
if ( $coupon->get_id() === 0 ) {
return null;
}
return $coupon;
}
/**
* Get shipping zones from the database.
*
* @return array Array of arrays.
*
* @since 1.9.0
*/
public function get_shipping_zones(): array {
return WC_Shipping_Zones::get_zones();
}
/**
* Get shipping zone using it's ID
*
* @param int $zone_id Zone ID.
*
* @return WC_Shipping_Zone|bool
*
* @since 1.9.0
*/
public function get_shipping_zone( int $zone_id ): ?WC_Shipping_Zone {
return WC_Shipping_Zones::get_zone( $zone_id );
}
/**
* Get an array of shipping classes.
*
* @return array|WP_Term[]
*
* @since 1.10.0
*/
public function get_shipping_classes(): array {
return WCCore()->shipping()->get_shipping_classes();
}
/**
* Get Base Currency Code.
*
* @return string
*
* @since 1.10.0
*/
public function get_woocommerce_currency(): string {
return get_woocommerce_currency();
}
/**
* Get available payment gateways.
*/
public function get_available_payment_gateways(): array {
return WCCore()->payment_gateways->get_available_payment_gateways();
}
/**
* Returns the WooCommerce object container.
*
* @return Container
*
* @since 2.3.10
*/
public function wc_get_container(): Container {
return wc_get_container();
}
}
Proxies/WP.php 0000644 00000024277 15153721357 0007261 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Proxies;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use DateTimeZone;
use WP as WPCore;
use WP_Error;
use WP_Post;
use WP_Term;
use WP_Taxonomy;
use function dbDelta;
use function get_locale;
use function plugins_url;
/**
* Class WP.
*
* This class provides proxy methods to wrap around WP functions.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Proxies
*/
class WP {
use PluginHelper;
/** @var WPCore $wp */
protected $wp;
/**
* WP constructor.
*/
public function __construct() {
global $wp;
$this->wp =& $wp;
}
/**
* Get the plugin URL, possibly with an added path.
*
* @param string $path
*
* @return string
*/
public function plugins_url( string $path = '' ): string {
return plugins_url( $path, $this->get_main_file() );
}
/**
* Retrieve values from the WP query_vars property.
*
* @param string $key The key of the value to retrieve.
* @param null $default_value The default value to return if the key isn't found.
*
* @return mixed The query value if found, or the default value.
*/
public function get_query_vars( string $key, $default_value = null ) {
return $this->wp->query_vars[ $key ] ?? $default_value;
}
/**
* Get the locale of the site.
*
* @return string
*/
public function get_locale(): string {
return get_locale();
}
/**
* Get the locale of the current user.
*
* @return string
*/
public function get_user_locale(): string {
return get_user_locale();
}
/**
* Run the WP dbDelta() function.
*
* @param string|string[] $sql The query or queries to run.
*
* @return array Results of the query or queries.
*/
public function db_delta( $sql ): array {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
return dbDelta( $sql );
}
/**
* Retrieves the edit post link for post.
*
* @param int|WP_POST $id Post ID or post object.
* @param string $context How to output the '&' character.
*
* @return string The edit post link for the given post. Null if the post type does not exist or does not allow an editing UI.
*/
public function get_edit_post_link( $id, string $context = 'display' ): string {
return get_edit_post_link( $id, $context );
}
/**
* Retrieves the terms of the taxonomy that are attached to the post.
*
* @param int|WP_Post $post Post ID or object.
* @param string $taxonomy Taxonomy name.
*
* @return WP_Term[]|false|WP_Error Array of WP_Term objects on success, false if there are no terms
* or the post does not exist, WP_Error on failure.
*/
public function get_the_terms( $post, string $taxonomy ) {
return get_the_terms( $post, $taxonomy );
}
/**
* Checks whether the given variable is a WordPress Error.
*
* Returns whether `$thing` is an instance of the `WP_Error` class.
*
* @param mixed $thing The variable to check.
*
* @return bool Whether the variable is an instance of WP_Error.
*/
public function is_wp_error( $thing ): bool {
return is_wp_error( $thing );
}
/**
* Retrieves the timezone from site settings as a string.
*
* Uses the `timezone_string` option to get a proper timezone if available,
* otherwise falls back to an offset.
*
* @return string PHP timezone string or a ±HH:MM offset.
*
* @since 1.5.0
*/
public function wp_timezone_string(): string {
return wp_timezone_string();
}
/**
* Retrieves the timezone from site settings as a `DateTimeZone` object.
*
* Timezone can be based on a PHP timezone string or a ±HH:MM offset.
*
* @return DateTimeZone Timezone object.
*
* @since 1.7.0
*/
public function wp_timezone(): DateTimeZone {
return wp_timezone();
}
/**
* Convert float number to format based on the locale.
*
* @param float $number The number to convert based on locale.
* @param int $decimals Optional. Precision of the number of decimal places. Default 0.
*
* @return string Converted number in string format.
*
* @since 1.7.0
*/
public function number_format_i18n( float $number, int $decimals = 0 ): string {
return number_format_i18n( $number, $decimals );
}
/**
* Determines whether the current request is a WordPress Ajax request.
*
* @return bool True if it's a WordPress Ajax request, false otherwise.
*
* @since 1.10.0
*/
public function wp_doing_ajax(): bool {
return wp_doing_ajax();
}
/**
* Retrieves an array of the latest posts, or posts matching the given criteria.
*
* @since 2.4.0
*
* @see WP_Query
* @see WP_Query::parse_query()
*
* @param array $args {
* Arguments to retrieve posts. See WP_Query::parse_query() for all available arguments.
* }
* @return WP_Post[]|int[] Array of post objects or post IDs.
*/
public function get_posts( array $args ): array {
return get_posts( $args );
}
/**
* Gets a list of all registered post type objects.
*
* @since 2.4.0
*
* @param array|string $args Optional. An array of key => value arguments to match against
* the post type objects. Default empty array.
* @param string $output Optional. The type of output to return. Accepts post type 'names'
* or 'objects'. Default 'names'.
* @param string $operator Optional. The logical operation to perform. 'or' means only one
* element from the array needs to match; 'and' means all elements
* must match; 'not' means no elements may match. Default 'and'.
* @return string[]|WP_Post_Type[] An array of post type names or objects.
*/
public function get_post_types( $args = [], string $output = 'names', string $operator = 'and' ): array {
return get_post_types( $args, $output, $operator );
}
/**
* Retrieves a list of registered taxonomy names or objects.
*
* @since 2.4.0
*
* @param array $args Optional. An array of `key => value` arguments to match against the taxonomy objects.
* Default empty array.
* @param string $output Optional. The type of output to return in the array. Accepts either taxonomy 'names'
* or 'objects'. Default 'names'.
* @param string $operator Optional. The logical operation to perform. Accepts 'and' or 'or'. 'or' means only
* one element from the array needs to match; 'and' means all elements must match.
* Default 'and'.
* @return string[]|WP_Taxonomy[] An array of taxonomy names or objects.
*/
public function get_taxonomies( array $args = [], string $output = 'names', string $operator = 'and' ): array {
return get_taxonomies( $args, $output, $operator );
}
/**
* Retrieves the terms in a given taxonomy or list of taxonomies.
*
* @since 2.4.0
*
* @param array|string $args Optional. Array or string of arguments. See WP_Term_Query::__construct()
* for information on accepted arguments. Default empty array.
* @return WP_Term[]|int[]|string[]|string|WP_Error Array of terms, a count thereof as a numeric string,
* or WP_Error if any of the taxonomies do not exist.
* See the function description for more information.
*/
public function get_terms( $args = [] ) {
return get_terms( $args );
}
/**
* Get static homepage
*
* @since 2.4.0
*
* @see https://wordpress.org/support/article/creating-a-static-front-page/
*
* @return WP_Post|null Returns the Homepage post if it is set as a static otherwise null.
*/
public function get_static_homepage() {
$post_id = (int) get_option( 'page_on_front' );
// The front page contains a static home page
if ( $post_id > 0 ) {
return get_post( $post_id );
}
return null;
}
/**
* Get Shop page
*
* @since 2.4.0
*
* @return WP_Post|null Returns the Homepage post if it is set as a static otherwise null.
*/
public function get_shop_page() {
$post_id = wc_get_page_id( 'shop' );
if ( $post_id > 0 ) {
return get_post( $post_id );
}
return null;
}
/**
* If any of the currently registered image sub-sizes are missing,
* create them and update the image meta data.
*
* @since 2.4.0
*
* @param int $attachment_id The image attachment post ID.
* @return array|WP_Error The updated image meta data array or WP_Error object
* if both the image meta and the attached file are missing.
*/
public function wp_update_image_subsizes( int $attachment_id ) {
// It is required as wp_update_image_subsizes is not loaded automatically.
if ( ! function_exists( 'wp_update_image_subsizes' ) ) {
include ABSPATH . 'wp-admin/includes/image.php';
}
return wp_update_image_subsizes( $attachment_id );
}
/**
* Performs an HTTP request using the GET method and returns its response.
*
* @since 2.4.0
*
* @see wp_remote_request() For more information on the response array format.
* @see WP_Http::request() For default arguments information.
*
* @param string $url URL to retrieve.
* @param array $args Optional. Request arguments. Default empty array.
* @return array|WP_Error The response or WP_Error on failure.
*/
public function wp_remote_get( string $url, array $args = [] ) {
return wp_remote_get( $url, $args );
}
/**
* Adds extra code to a registered script.
*
* @param string $handle Name of the script to add the inline script to.
* @param string $data String containing the JavaScript to be added.
* @param string $position Whether to add the inline script before the handle or after. Default 'after'.
* @return boolean
*/
public function wp_add_inline_script( string $handle, string $data, string $position = 'after' ): bool {
return wp_add_inline_script( $handle, $data, $position );
}
/**
* Prints an inline script tag.
*
* @param string $data Data for script tag: JavaScript, importmap, speculationrules, etc.
* @param array $attributes Key-value pairs representing <script> tag attributes. Default:array()
*/
public function wp_print_inline_script_tag( string $data, array $attributes = [] ) {
return wp_print_inline_script_tag( $data, $attributes );
}
}
Settings/SyncerHooks.php 0000644 00000005335 15153721357 0011343 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\SettingsNotificationJob;
defined( 'ABSPATH' ) || exit;
/**
* Class SyncerHooks
*
* Hooks to various WooCommerce and WordPress actions to automatically sync WooCommerce General Settings.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Settings
*
* @since 2.8.0
*/
class SyncerHooks implements Service, Registerable {
/**
* @var NotificationsService $notifications_service
*/
protected $notifications_service;
/**
* @var JobRepository
*/
protected $job_repository;
/**
* WooCommerce General Settings IDs
* Copied from https://github.com/woocommerce/woocommerce/blob/af03815134385c72feb7a70abc597eca57442820/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php#L34
*/
protected const ALLOWED_SETTINGS = [
'store_address',
'woocommerce_store_address',
'woocommerce_store_address_2',
'woocommerce_store_city',
'woocommerce_default_country',
'woocommerce_store_postcode',
'store_address',
'general_options',
'woocommerce_allowed_countries',
'woocommerce_all_except_countries',
'woocommerce_specific_allowed_countries',
'woocommerce_ship_to_countries',
'woocommerce_specific_ship_to_countries',
'woocommerce_default_customer_address',
'woocommerce_calc_taxes',
'woocommerce_enable_coupons',
'woocommerce_calc_discounts_sequentially',
'general_options',
'pricing_options',
'woocommerce_currency',
'woocommerce_currency_pos',
'woocommerce_price_thousand_sep',
'woocommerce_price_decimal_sep',
'woocommerce_price_num_decimals',
'pricing_options',
];
/**
* SyncerHooks constructor.
*
* @param JobRepository $job_repository
* @param NotificationsService $notifications_service
*/
public function __construct( JobRepository $job_repository, NotificationsService $notifications_service ) {
$this->job_repository = $job_repository;
$this->notifications_service = $notifications_service;
}
/**
* Register the service.
*/
public function register(): void {
if ( ! $this->notifications_service->is_ready( false ) ) {
return;
}
$update_rest = function ( $option ) {
if ( in_array( $option, self::ALLOWED_SETTINGS, true ) ) {
$this->job_repository->get( SettingsNotificationJob::class )->schedule();
}
};
add_action( 'update_option', $update_rest, 90, 1 );
}
}
Shipping/CountryRatesCollection.php 0000644 00000006046 15153721357 0013533 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class CountryRatesCollection
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class CountryRatesCollection extends LocationRatesCollection {
/**
* @var string
*/
protected $country;
/**
* @var ServiceRatesCollection[]
*/
protected $services_groups;
/**
* CountryRatesCollection constructor.
*
* @param string $country
* @param LocationRate[] $location_rates
*/
public function __construct( string $country, array $location_rates = [] ) {
$this->country = $country;
parent::__construct( $location_rates );
}
/**
* @return string
*/
public function get_country(): string {
return $this->country;
}
/**
* Return collections of location rates grouped into shipping services.
*
* @return ServiceRatesCollection[]
*/
public function get_rates_grouped_by_service(): array {
$this->group_rates_by_service();
return array_values( $this->services_groups );
}
/**
* Groups the location rates into collections of rates based on how they fit into Merchant Center services.
*/
protected function group_rates_by_service(): void {
if ( isset( $this->services_groups ) ) {
return;
}
$this->services_groups = [];
foreach ( $this->location_rates as $location_rate ) {
$country = $location_rate->get_location()->get_country();
$shipping_area = $location_rate->get_location()->get_applicable_area();
$min_order_amount = $location_rate->get_shipping_rate()->get_min_order_amount();
// Group rates by their applicable country and affecting shipping area
$service_key = $country . $shipping_area;
// If the rate has a min order amount constraint, then it should be under a new service
if ( $location_rate->get_shipping_rate()->has_min_order_amount() ) {
$service_key .= $min_order_amount;
}
if ( ! isset( $this->services_groups[ $service_key ] ) ) {
$this->services_groups[ $service_key ] = new ServiceRatesCollection(
$country,
$shipping_area,
$min_order_amount,
[]
);
}
$this->services_groups[ $service_key ]->add_location_rate( $location_rate );
}
}
/**
* @param LocationRate $location_rate
*
* @throws InvalidValue If any of the location rates do not belong to the same country as the one provided for this class or if any of the rates are negative.
*/
protected function validate_rate( LocationRate $location_rate ) {
if ( $this->country !== $location_rate->get_location()->get_country() ) {
throw new InvalidValue( 'All location rates must be in the same country as the one provided for this collection.' );
}
if ( $location_rate->get_shipping_rate()->get_rate() < 0 ) {
throw new InvalidValue( 'Shipping rates cannot be negative.' );
}
}
/**
* Reset the internal mappings/groups
*/
protected function reset_rates_mappings(): void {
unset( $this->services_groups );
}
}
Shipping/GoogleAdapter/AbstractRateGroupAdapter.php 0000644 00000004343 15153721357 0016465 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\LocationRate;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Price;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RateGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Value;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractRateGroupAdapter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
abstract class AbstractRateGroupAdapter extends RateGroup {
/**
* Initialize this object's properties from an array.
*
* @param array $properties Used to seed this object's properties.
*
* @throws InvalidValue When the required parameters are not provided, or they are invalid.
*/
public function mapTypes( $properties ) {
if ( empty( $properties['currency'] ) || ! is_string( $properties['currency'] ) ) {
throw new InvalidValue( 'The value of "currency" must be a non empty string.' );
}
if ( empty( $properties['location_rates'] ) || ! is_array( $properties['location_rates'] ) ) {
throw new InvalidValue( 'The value of "location_rates" must be a non empty array.' );
}
$this->map_location_rates( $properties['location_rates'], $properties['currency'] );
// Remove the extra data before calling the parent method since it doesn't expect them.
unset( $properties['currency'] );
unset( $properties['location_rates'] );
parent::mapTypes( $properties );
}
/**
* @param float $rate
* @param string $currency
*
* @return Value
*/
protected function create_value_object( float $rate, string $currency ): Value {
$price = new Price(
[
'currency' => $currency,
'value' => $rate,
]
);
return new Value( [ 'flatRate' => $price ] );
}
/**
* Map the location rates to the class properties.
*
* @param LocationRate[] $location_rates
* @param string $currency
*
* @return void
*/
abstract protected function map_location_rates( array $location_rates, string $currency ): void;
}
Shipping/GoogleAdapter/AbstractShippingSettingsAdapter.php 0000644 00000006327 15153721357 0020063 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\DeliveryTime;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ShippingSettings as GoogleShippingSettings;
defined( 'ABSPATH' ) || exit;
/**
* Class AbstractShippingSettingsAdapter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
abstract class AbstractShippingSettingsAdapter extends GoogleShippingSettings {
/**
* @var string
*/
protected $currency;
/**
* @var array
*/
protected $delivery_times;
/**
* Initialize this object's properties from an array.
*
* @param array $properties Used to seed this object's properties.
*
* @return void
*
* @throws InvalidValue When the required parameters are not provided, or they are invalid.
*/
public function mapTypes( $properties ) {
$this->validate_gla_data( $properties );
$this->currency = $properties['currency'];
$this->delivery_times = $properties['delivery_times'];
$this->map_gla_data( $properties );
$this->unset_gla_data( $properties );
parent::mapTypes( $properties );
}
/**
* Return estimated delivery time for a given country in days.
*
* @param string $country
*
* @return DeliveryTime
*
* @throws InvalidValue If no delivery time can be found for the country.
*/
protected function get_delivery_time( string $country ): DeliveryTime {
if ( ! array_key_exists( $country, $this->delivery_times ) ) {
throw new InvalidValue( 'No estimated delivery time provided for country: ' . $country );
}
$time = new DeliveryTime();
$time->setMinHandlingTimeInDays( 0 );
$time->setMaxHandlingTimeInDays( 0 );
$time->setMinTransitTimeInDays( (int) $this->delivery_times[ $country ]['time'] );
$time->setMaxTransitTimeInDays( (int) $this->delivery_times[ $country ]['max_time'] );
return $time;
}
/**
* Validates the input array provided to this class.
*
* @param array $data
*
* @throws InvalidValue When the required parameters are not provided, or they are invalid.
*
* @link AbstractShippingSettingsAdapter::mapTypes() The $data input comes from this method.
*/
protected function validate_gla_data( array $data ): void {
if ( empty( $data['currency'] ) || ! is_string( $data['currency'] ) ) {
throw new InvalidValue( 'The value of "currency" must be a non empty string.' );
}
if ( empty( $data['delivery_times'] ) || ! is_array( $data['delivery_times'] ) ) {
throw new InvalidValue( 'The value of "delivery_times" must be a non empty array.' );
}
}
/**
* Remove the extra data we added to the input array since the MC API doesn't expect them (and it will fail).
*
* @param array $data
*/
protected function unset_gla_data( array &$data ): void {
unset( $data['currency'] );
unset( $data['delivery_times'] );
}
/**
* Parses the already validated input data and maps the provided shipping rates into MC shipping settings.
*
* @param array $data Validated data.
*/
abstract protected function map_gla_data( array $data ): void;
}
Shipping/GoogleAdapter/DBShippingSettingsAdapter.php 0000644 00000012501 15153721357 0016574 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Price;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RateGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Service as GoogleShippingService;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Value;
defined( 'ABSPATH' ) || exit;
/**
* Class DBShippingSettingsAdapter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class DBShippingSettingsAdapter extends AbstractShippingSettingsAdapter {
/**
* Parses the already validated input data and maps the provided shipping rates into MC shipping settings.
*
* @param array $data Validated data.
*/
protected function map_gla_data( array $data ): void {
$this->map_db_rates( $data['db_rates'] );
}
/**
* Validates the input array provided to this class.
*
* @param array $data
*
* @throws InvalidValue When the required parameters are not provided, or they are invalid.
*
* @link AbstractShippingSettingsAdapter::mapTypes() The $data input comes from this method.
*/
protected function validate_gla_data( array $data ): void {
parent::validate_gla_data( $data );
if ( empty( $data['db_rates'] ) || ! is_array( $data['db_rates'] ) ) {
throw new InvalidValue( 'The value of "db_rates" must be a non empty associated array of shipping rates.' );
}
}
/**
* Remove the extra data we added to the input array since the MC API doesn't expect them (and it will fail).
*
* @param array $data
*/
protected function unset_gla_data( array &$data ): void {
unset( $data['db_rates'] );
parent::unset_gla_data( $data );
}
/**
* Map the shipping rates stored for each country in DB to MC shipping settings.
*
* @param array[] $db_rates
*
* @return void
*/
protected function map_db_rates( array $db_rates ) {
$services = [];
foreach ( $db_rates as ['country' => $country, 'rate' => $rate, 'options' => $options] ) {
// No negative rates.
if ( $rate < 0 ) {
continue;
}
$service = $this->create_shipping_service( $country, $this->currency, (float) $rate );
if ( isset( $options['free_shipping_threshold'] ) ) {
$minimum_order_value = (float) $options['free_shipping_threshold'];
if ( $rate > 0 ) {
// Add a conditional free-shipping service if the current rate is not free.
$services[] = $this->create_conditional_free_shipping_service( $country, $this->currency, $minimum_order_value );
} else {
// Set the minimum order value if the current rate is free.
$service->setMinimumOrderValue(
new Price(
[
'value' => $minimum_order_value,
'currency' => $this->currency,
]
)
);
}
}
$services[] = $service;
}
$this->setServices( $services );
}
/**
* Create a rate group object for the shopping settings.
*
* @param string $currency
* @param float $rate
*
* @return RateGroup
*/
protected function create_rate_group_object( string $currency, float $rate ): RateGroup {
$price = new Price();
$price->setCurrency( $currency );
$price->setValue( $rate );
$value = new Value();
$value->setFlatRate( $price );
$rate_group = new RateGroup();
$rate_group->setSingleValue( $value );
$name = sprintf(
/* translators: %1 is the shipping rate, %2 is the currency (e.g. USD) */
__( 'Flat rate - %1$s %2$s', 'google-listings-and-ads' ),
$rate,
$currency
);
$rate_group->setName( $name );
return $rate_group;
}
/**
* Create a shipping service object.
*
* @param string $country
* @param string $currency
* @param float $rate
*
* @return GoogleShippingService
*/
protected function create_shipping_service( string $country, string $currency, float $rate ): GoogleShippingService {
$unique = sprintf( '%04x', wp_rand( 0, 0xffff ) );
$service = new GoogleShippingService();
$service->setActive( true );
$service->setDeliveryCountry( $country );
$service->setCurrency( $currency );
$service->setName(
sprintf(
/* translators: %1 is a random 4-digit string, %2 is the rate, %3 is the currency, %4 is the country code */
__( '[%1$s] Google for WooCommerce generated service - %2$s %3$s to %4$s', 'google-listings-and-ads' ),
$unique,
$rate,
$currency,
$country
)
);
$service->setRateGroups( [ $this->create_rate_group_object( $currency, $rate ) ] );
$service->setDeliveryTime( $this->get_delivery_time( $country ) );
return $service;
}
/**
* Create a free shipping service.
*
* @param string $country
* @param string $currency
* @param float $minimum_order_value
*
* @return GoogleShippingService
*/
protected function create_conditional_free_shipping_service( string $country, string $currency, float $minimum_order_value ): GoogleShippingService {
$service = $this->create_shipping_service( $country, $currency, 0 );
// Set the minimum order value to be eligible for free shipping.
$service->setMinimumOrderValue(
new Price(
[
'value' => $minimum_order_value,
'currency' => $currency,
]
)
);
return $service;
}
}
Shipping/GoogleAdapter/PostcodesRateGroupAdapter.php 0000644 00000003147 15153721357 0016666 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\LocationRate;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Headers;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Row;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Table;
defined( 'ABSPATH' ) || exit;
/**
* Class PostcodesRateGroupAdapter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class PostcodesRateGroupAdapter extends AbstractRateGroupAdapter {
/**
* Map the location rates to the class properties.
*
* @param LocationRate[] $location_rates
* @param string $currency
*
* @return void
*/
protected function map_location_rates( array $location_rates, string $currency ): void {
$postal_codes = [];
$rows = [];
foreach ( $location_rates as $location_rate ) {
$region = $location_rate->get_location()->get_shipping_region();
if ( empty( $region ) ) {
continue;
}
$postcode_name = $region->get_id();
$postal_codes[ $postcode_name ] = $postcode_name;
$rows[ $postcode_name ] = new Row( [ 'cells' => [ $this->create_value_object( $location_rate->get_shipping_rate()->get_rate(), $currency ) ] ] );
}
$table = new Table(
[
'rowHeaders' => new Headers( [ 'postalCodeGroupNames' => array_values( $postal_codes ) ] ),
'rows' => array_values( $rows ),
]
);
$this->setMainTable( $table );
}
}
Shipping/GoogleAdapter/StatesRateGroupAdapter.php 0000644 00000003236 15153721357 0016165 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\LocationRate;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Headers;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\LocationIdSet;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Row;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Table;
defined( 'ABSPATH' ) || exit;
/**
* Class StatesRateGroupAdapter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class StatesRateGroupAdapter extends AbstractRateGroupAdapter {
/**
* Map the location rates to the class properties.
*
* @param LocationRate[] $location_rates
* @param string $currency
*
* @return void
*/
protected function map_location_rates( array $location_rates, string $currency ): void {
$location_id_sets = [];
$rows = [];
foreach ( $location_rates as $location_rate ) {
$location_id = $location_rate->get_location()->get_google_id();
$location_id_sets[ $location_id ] = new LocationIdSet( [ 'locationIds' => [ $location_id ] ] );
$rows[ $location_id ] = new Row( [ 'cells' => [ $this->create_value_object( $location_rate->get_shipping_rate()->get_rate(), $currency ) ] ] );
}
$table = new Table(
[
'rowHeaders' => new Headers( [ 'locations' => array_values( $location_id_sets ) ] ),
'rows' => array_values( $rows ),
]
);
$this->setMainTable( $table );
}
}
Shipping/GoogleAdapter/WCShippingSettingsAdapter.php 0000644 00000020520 15153721357 0016620 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidArgument;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidClass;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\CountryRatesCollection;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\LocationRate;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ServiceRatesCollection;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingLocation;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\PostalCodeGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\PostalCodeRange;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Price;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RateGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Service as GoogleShippingService;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Value;
defined( 'ABSPATH' ) || exit;
/**
* Class WCShippingSettingsAdapter
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class WCShippingSettingsAdapter extends AbstractShippingSettingsAdapter {
/**
* Parses the already validated input data and maps the provided shipping rates into MC shipping settings.
*
* @param array $data Validated data.
*/
protected function map_gla_data( array $data ): void {
$this->map_rates_collections( $data['rates_collections'] );
}
/**
* Validates the input array provided to this class.
*
* @param array $data
*
* @throws InvalidValue When the required parameters are not provided, or they are invalid.
*
* @link AbstractShippingSettingsAdapter::mapTypes() The $data input comes from this method.
*/
protected function validate_gla_data( array $data ): void {
parent::validate_gla_data( $data );
if ( empty( $data['rates_collections'] ) || ! is_array( $data['rates_collections'] ) ) {
throw new InvalidValue( 'The value of "rates_collections" must be a non empty array of CountryRatesCollection objects.' );
} else {
$this->validate_rates_collections( $data['rates_collections'] );
}
}
/**
* Remove the extra data we added to the input array since the MC API doesn't expect them (and it will fail).
*
* @param array $data
*/
protected function unset_gla_data( array &$data ): void {
unset( $data['rates_collections'] );
parent::unset_gla_data( $data );
}
/**
* Map the collections of location rates for each country to the shipping settings.
*
* @param CountryRatesCollection[] $rates_collections
*
* @return void
*/
protected function map_rates_collections( array $rates_collections ) {
$postcode_groups = [];
$services = [];
foreach ( $rates_collections as $rates_collection ) {
$postcode_groups = array_merge( $postcode_groups, $this->get_location_rates_postcode_groups( $rates_collection->get_location_rates() ) );
foreach ( $rates_collection->get_rates_grouped_by_service() as $service_collection ) {
$services[] = $this->create_shipping_service( $service_collection );
}
}
$this->setServices( $services );
$this->setPostalCodeGroups( array_values( $postcode_groups ) );
}
/**
* @param LocationRate[] $location_rates
* @param string $shipping_area
* @param array $applicable_classes
*
* @return RateGroup
*
* @throws InvalidArgument If an invalid value is provided for the shipping_area argument.
*/
protected function create_rate_group( array $location_rates, string $shipping_area, array $applicable_classes = [] ): RateGroup {
switch ( $shipping_area ) {
case ShippingLocation::COUNTRY_AREA:
// Each country can only have one global rate.
$country_rate = $location_rates[ array_key_first( $location_rates ) ];
$rate_group = $this->create_single_value_rate_group( $country_rate, $applicable_classes );
break;
case ShippingLocation::POSTCODE_AREA:
$rate_group = new PostcodesRateGroupAdapter(
[
'location_rates' => $location_rates,
'currency' => $this->currency,
'applicableShippingLabels' => $applicable_classes,
]
);
break;
case ShippingLocation::STATE_AREA:
$rate_group = new StatesRateGroupAdapter(
[
'location_rates' => $location_rates,
'currency' => $this->currency,
'applicableShippingLabels' => $applicable_classes,
]
);
break;
default:
throw new InvalidArgument( 'Invalid shipping area.' );
}
return $rate_group;
}
/**
* Create a shipping service object.
*
* @param ServiceRatesCollection $service_collection
*
* @return GoogleShippingService
*/
protected function create_shipping_service( ServiceRatesCollection $service_collection ): GoogleShippingService {
$rate_groups = [];
$shipping_area = $service_collection->get_shipping_area();
foreach ( $service_collection->get_rates_grouped_by_shipping_class() as $class => $location_rates ) {
$applicable_classes = ! empty( $class ) ? [ $class ] : [];
$rate_groups[ $class ] = $this->create_rate_group( $location_rates, $shipping_area, $applicable_classes );
}
$country = $service_collection->get_country();
$name = sprintf(
/* translators: %1 is a random 4-digit string, %2 is the country code */
__( '[%1$s] Google for WooCommerce generated service - %2$s', 'google-listings-and-ads' ),
sprintf( '%04x', wp_rand( 0, 0xffff ) ),
$country
);
$service = new GoogleShippingService(
[
'active' => true,
'deliveryCountry' => $country,
'currency' => $this->currency,
'name' => $name,
'deliveryTime' => $this->get_delivery_time( $country ),
'rateGroups' => array_values( $rate_groups ),
]
);
$min_order_amount = $service_collection->get_min_order_amount();
if ( $min_order_amount ) {
$min_order_value = new Price(
[
'currency' => $this->currency,
'value' => $min_order_amount,
]
);
$service->setMinimumOrderValue( $min_order_value );
}
return $service;
}
/**
* Extract and return the postcode groups for the given location rates.
*
* @param LocationRate[] $location_rates
*
* @return PostalCodeGroup[]
*/
protected function get_location_rates_postcode_groups( array $location_rates ): array {
$postcode_groups = [];
foreach ( $location_rates as $location_rate ) {
$location = $location_rate->get_location();
if ( empty( $location->get_shipping_region() ) ) {
continue;
}
$region = $location->get_shipping_region();
$postcode_ranges = [];
foreach ( $region->get_postcode_ranges() as $postcode_range ) {
$postcode_ranges[] = new PostalCodeRange(
[
'postalCodeRangeBegin' => $postcode_range->get_start_code(),
'postalCodeRangeEnd' => $postcode_range->get_end_code(),
]
);
}
$postcode_groups[ $region->get_id() ] = new PostalCodeGroup(
[
'name' => $region->get_id(),
'country' => $location->get_country(),
'postalCodeRanges' => $postcode_ranges,
]
);
}
return $postcode_groups;
}
/**
* @param LocationRate $location_rate
* @param string[] $shipping_classes
*
* @return RateGroup
*/
protected function create_single_value_rate_group( LocationRate $location_rate, array $shipping_classes = [] ): RateGroup {
$price = new Price(
[
'currency' => $this->currency,
'value' => $location_rate->get_shipping_rate()->get_rate(),
]
);
return new RateGroup(
[
'singleValue' => new Value( [ 'flatRate' => $price ] ),
'applicableShippingLabels' => $shipping_classes,
]
);
}
/**
* @param array $rates_collections
*
* @throws InvalidClass If any of the objects in the array is not an instance of CountryRatesCollection.
*/
protected function validate_rates_collections( array $rates_collections ) {
array_walk(
$rates_collections,
function ( $obj ) {
if ( ! $obj instanceof CountryRatesCollection ) {
throw new InvalidValue( 'All values of the "rates_collections" array must be an instance of CountryRatesCollection.' );
}
}
);
}
}
Shipping/LocationRate.php 0000644 00000002347 15153721357 0011441 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use JsonSerializable;
defined( 'ABSPATH' ) || exit;
/**
* Class LocationRate
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class LocationRate implements JsonSerializable {
/**
* @var ShippingLocation
*/
protected $location;
/**
* @var ShippingRate
*/
protected $shipping_rate;
/**
* LocationRate constructor.
*
* @param ShippingLocation $location
* @param ShippingRate $shipping_rate
*/
public function __construct( ShippingLocation $location, ShippingRate $shipping_rate ) {
$this->location = $location;
$this->shipping_rate = $shipping_rate;
}
/**
* @return ShippingLocation
*/
public function get_location(): ShippingLocation {
return $this->location;
}
/**
* @return ShippingRate
*/
public function get_shipping_rate(): ShippingRate {
return $this->shipping_rate;
}
/**
* Specify data which should be serialized to JSON
*/
public function jsonSerialize(): array {
$rate_serialized = $this->shipping_rate->jsonSerialize();
return array_merge(
$rate_serialized,
[
'country' => $this->location->get_country(),
]
);
}
}
Shipping/LocationRatesCollection.php 0000644 00000003451 15153721357 0013635 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class LocationRatesCollection
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
abstract class LocationRatesCollection {
/**
* @var LocationRate[]
*/
protected $location_rates = [];
/**
* LocationRatesCollection constructor.
*
* @param LocationRate[] $location_rates
*/
public function __construct( array $location_rates = [] ) {
$this->set_location_rates( $location_rates );
}
/**
* @return LocationRate[]
*/
public function get_location_rates(): array {
return $this->location_rates;
}
/**
* @param LocationRate[] $location_rates
*
* @return LocationRatesCollection
*/
public function set_location_rates( array $location_rates ): LocationRatesCollection {
foreach ( $location_rates as $location_rate ) {
$this->validate_rate( $location_rate );
}
$this->location_rates = $location_rates;
$this->reset_rates_mappings();
return $this;
}
/**
* @param LocationRate $location_rate
*
* @return LocationRatesCollection
*/
public function add_location_rate( LocationRate $location_rate ): LocationRatesCollection {
$this->validate_rate( $location_rate );
$this->location_rates[] = $location_rate;
$this->reset_rates_mappings();
return $this;
}
/**
* @param LocationRate $location_rate
*
* @throws InvalidValue If any of the location rates do not belong to the same country as the one provided for this class.
*/
abstract protected function validate_rate( LocationRate $location_rate );
/**
* Reset the internal mappings/groups
*/
abstract protected function reset_rates_mappings(): void;
}
Shipping/LocationRatesProcessor.php 0000644 00000004320 15153721357 0013515 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
defined( 'ABSPATH' ) || exit;
/**
* Class LocationRatesProcessor
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class LocationRatesProcessor {
/**
* Process the shipping rates data for output.
*
* @param LocationRate[] $location_rates Array of shipping rates belonging to a specific location.
*
* @return LocationRate[] Array of processed location rates.
*/
public function process( array $location_rates ): array {
/** @var LocationRate[] $grouped_rates */
$grouped_rates = [];
foreach ( $location_rates as $location_rate ) {
$shipping_rate = $location_rate->get_shipping_rate();
$type = 'flat_rate';
// If there are conditional free shipping rates, we need to group and compare them together.
if ( $shipping_rate->is_free() && $shipping_rate->has_min_order_amount() ) {
$type = 'conditional_free';
}
// Append the shipping class names to the type key to group and compare the class rates together.
$classes = ! empty( $shipping_rate->get_applicable_classes() ) ? join( ',', $shipping_rate->get_applicable_classes() ) : '';
$type .= $classes;
if ( ! isset( $grouped_rates[ $type ] ) || $this->should_rate_be_replaced( $shipping_rate, $grouped_rates[ $type ]->get_shipping_rate() ) ) {
$grouped_rates[ $type ] = $location_rate;
}
}
// Ignore the conditional free rate if there are no flat rate or if the existing flat rate is free.
if ( ! isset( $grouped_rates['flat_rate'] ) || $grouped_rates['flat_rate']->get_shipping_rate()->is_free() ) {
unset( $grouped_rates['conditional_free'] );
}
return array_values( $grouped_rates );
}
/**
* Checks whether the existing shipping rate should be replaced with a more suitable one. Used when grouping the rates.
*
* @param ShippingRate $new_rate
* @param ShippingRate $existing_rate
*
* @return bool
*/
protected function should_rate_be_replaced( ShippingRate $new_rate, ShippingRate $existing_rate ): bool {
return $new_rate->get_rate() > $existing_rate->get_rate() ||
(float) $new_rate->get_min_order_amount() > (float) $existing_rate->get_min_order_amount();
}
}
Shipping/PostcodeRange.php 0000644 00000003225 15153721357 0011606 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
defined( 'ABSPATH' ) || exit;
/**
* Class PostcodeRange
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class PostcodeRange {
/**
* @var string
*/
protected $start_code;
/**
* @var string
*/
protected $end_code;
/**
* PostcodeRange constructor.
*
* @param string $start_code Beginning of the range.
* @param string|null $end_code End of the range.
*/
public function __construct( string $start_code, ?string $end_code = null ) {
$this->start_code = $start_code;
$this->end_code = $end_code;
}
/**
* @return string
*/
public function get_start_code(): string {
return $this->start_code;
}
/**
* @return string|null
*/
public function get_end_code(): ?string {
return $this->end_code;
}
/**
* Returns a PostcodeRange object from a string representation of the postcode.
*
* @param string $postcode String representation of the postcode. If it's a range it should be separated by "...". E.g. "12345...12345".
*
* @return PostcodeRange
*/
public static function from_string( string $postcode ): PostcodeRange {
$postcode_range = explode( '...', $postcode );
if ( 2 === count( $postcode_range ) ) {
return new PostcodeRange( $postcode_range[0], $postcode_range[1] );
}
return new PostcodeRange( $postcode );
}
/**
* Returns the string representation of this postcode.
*
* @return string
*/
public function __toString() {
if ( ! empty( $this->end_code ) ) {
return "$this->start_code...$this->end_code";
}
return $this->start_code;
}
}
Shipping/ServiceRatesCollection.php 0000644 00000005057 15153721357 0013471 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
defined( 'ABSPATH' ) || exit;
/**
* Class ServiceRatesCollection
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ServiceRatesCollection extends CountryRatesCollection {
/**
* @var string
*/
protected $shipping_area;
/**
* @var float|null
*/
protected $min_order_amount;
/**
* @var LocationRate[][]
*/
protected $class_groups;
/**
* ServiceRatesCollection constructor.
*
* @param string $country
* @param string $shipping_area
* @param float|null $min_order_amount
* @param array $location_rates
*/
public function __construct( string $country, string $shipping_area, ?float $min_order_amount = null, array $location_rates = [] ) {
$this->shipping_area = $shipping_area;
$this->min_order_amount = $min_order_amount;
parent::__construct( $country, $location_rates );
}
/**
* @return float|null
*/
public function get_min_order_amount(): ?float {
return $this->min_order_amount;
}
/**
* @return string
*/
public function get_shipping_area(): string {
return $this->shipping_area;
}
/**
* Return array of location rates grouped by their applicable shipping classes. Multiple rates might be returned per class.
*
* @return LocationRate[][] Arrays of location rates grouped by their applicable shipping class. Shipping class name is used as array keys.
*/
public function get_rates_grouped_by_shipping_class(): array {
$this->group_rates_by_shipping_class();
return $this->class_groups;
}
/**
* Group the location rates by their applicable shipping classes.
*/
public function group_rates_by_shipping_class(): void {
if ( isset( $this->class_groups ) ) {
return;
}
$this->class_groups = [];
foreach ( $this->location_rates as $location_rate ) {
if ( ! empty( $location_rate->get_shipping_rate()->get_applicable_classes() ) ) {
// For every rate defined in the location_rate, create a new shipping rate and add it to the array
foreach ( $location_rate->get_shipping_rate()->get_applicable_classes() as $class ) {
$this->class_groups[ $class ][] = $location_rate;
}
} else {
$this->class_groups[''][] = $location_rate;
}
}
// Sort the groups so that the rate with no shipping class is placed at the end.
krsort( $this->class_groups );
}
/**
* Reset the internal mappings/groups
*/
protected function reset_rates_mappings(): void {
parent::reset_rates_mappings();
unset( $this->class_groups );
}
}
Shipping/ShippingLocation.php 0000644 00000005306 15153721357 0012325 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingLocation
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ShippingLocation {
public const COUNTRY_AREA = 'country_area';
public const STATE_AREA = 'state_area';
public const POSTCODE_AREA = 'postcode_area';
/**
* @var int
*/
protected $google_id;
/**
* @var string
*/
protected $country;
/**
* @var string
*/
protected $state;
/**
* @var ShippingRegion
*/
protected $shipping_region;
/**
* ShippingLocation constructor.
*
* @param int $google_id
* @param string $country
* @param string|null $state
* @param ShippingRegion|null $shipping_region
*/
public function __construct( int $google_id, string $country, ?string $state = null, ?ShippingRegion $shipping_region = null ) {
$this->google_id = $google_id;
$this->country = $country;
$this->state = $state;
$this->shipping_region = $shipping_region;
}
/**
* @return int
*/
public function get_google_id(): int {
return $this->google_id;
}
/**
* @return string
*/
public function get_country(): string {
return $this->country;
}
/**
* @return string|null
*/
public function get_state(): ?string {
return $this->state;
}
/**
* @return ShippingRegion|null
*/
public function get_shipping_region(): ?ShippingRegion {
return $this->shipping_region;
}
/**
* Return the applicable shipping area for this shipping location. e.g. whether it applies to a whole country, state, or postcodes.
*
* @return string
*/
public function get_applicable_area(): string {
if ( ! empty( $this->get_shipping_region() ) ) {
// ShippingLocation applies to a select postal code ranges of a country
return self::POSTCODE_AREA;
} elseif ( ! empty( $this->get_state() ) ) {
// ShippingLocation applies to a state/province of a country
return self::STATE_AREA;
} else {
// ShippingLocation applies to a whole country
return self::COUNTRY_AREA;
}
}
/**
* Returns the string representation of this ShippingLocation.
*
* @return string
*/
public function __toString() {
$code = $this->get_country();
if ( ! empty( $this->get_shipping_region() ) ) {
// We assume that each postcode is unique within any supported country (a requirement set by Google API).
// Therefore, there is no need to include the state name in the location string even if it's provided.
$code .= '::' . $this->get_shipping_region();
} elseif ( ! empty( $this->get_state() ) ) {
$code .= '_' . $this->get_state();
}
return $code;
}
}
Shipping/ShippingRate.php 0000644 00000004742 15153721357 0011453 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use JsonSerializable;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRate
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ShippingRate implements JsonSerializable {
/**
* @var float
*/
protected $rate;
/**
* @var float|null
*/
protected $min_order_amount;
/**
* @var array
*/
protected $applicable_classes = [];
/**
* ShippingRate constructor.
*
* @param float $rate The shipping cost in store currency.
*/
public function __construct( float $rate ) {
// Google only accepts rates with two decimal places.
// We avoid using wc_format_decimal or number_format_i18n because these functions format numbers according to locale settings, which may include thousands separators and different decimal separators.
// At this stage, we want to ensure the number is formatted strictly as a float, with no thousands separators and a dot as the decimal separator.
$this->rate = (float) number_format( $rate, 2 );
}
/**
* @return float
*/
public function get_rate(): float {
return $this->rate;
}
/**
* @param float $rate
*
* @return ShippingRate
*/
public function set_rate( float $rate ): ShippingRate {
$this->rate = $rate;
return $this;
}
/**
* Returns whether the shipping rate is free.
*
* @return bool
*/
public function is_free(): bool {
return 0.0 === $this->get_rate();
}
/**
* @return float|null
*/
public function get_min_order_amount(): ?float {
return $this->min_order_amount;
}
/**
* @param float|null $min_order_amount
*/
public function set_min_order_amount( ?float $min_order_amount ): void {
$this->min_order_amount = $min_order_amount;
}
/**
* Returns whether the shipping rate has a minimum order amount constraint.
*
* @return bool
*/
public function has_min_order_amount(): bool {
return ! is_null( $this->get_min_order_amount() );
}
/**
* @return string[]
*/
public function get_applicable_classes(): array {
return $this->applicable_classes;
}
/**
* @param string[] $applicable_classes
*
* @return ShippingRate
*/
public function set_applicable_classes( array $applicable_classes ): ShippingRate {
$this->applicable_classes = $applicable_classes;
return $this;
}
/**
* Specify data which should be serialized to JSON
*/
public function jsonSerialize(): array {
return [
'rate' => $this->get_rate(),
];
}
}
Shipping/ShippingRegion.php 0000644 00000003307 15153721357 0011777 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingRegion
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ShippingRegion {
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $country;
/**
* @var PostcodeRange[]
*/
protected $postcode_ranges;
/**
* ShippingRegion constructor.
*
* @param string $id
* @param string $country
* @param PostcodeRange[] $postcode_ranges
*/
public function __construct( string $id, string $country, array $postcode_ranges ) {
$this->id = $id;
$this->country = $country;
$this->postcode_ranges = $postcode_ranges;
}
/**
* @return string
*/
public function get_id(): string {
return $this->id;
}
/**
* @return string
*/
public function get_country(): string {
return $this->country;
}
/**
* @return PostcodeRange[]
*/
public function get_postcode_ranges(): array {
return $this->postcode_ranges;
}
/**
* Generate a random ID for the region.
*
* For privacy reasons, the region ID value must be a randomized set of numbers (minimum 6 digits)
*
* @return string
*
* @throws \Exception If generating a random ID fails.
*
* @link https://support.google.com/merchants/answer/9698880?hl=en#requirements
*/
public static function generate_random_id(): string {
return (string) random_int( 100000, PHP_INT_MAX );
}
/**
* Returns the string representation of this object.
*
* @return string
*/
public function __toString() {
return $this->get_country() . join( ',', $this->get_postcode_ranges() );
}
}
Shipping/ShippingSuggestionService.php 0000644 00000005320 15153721357 0014221 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingSuggestionService
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ShippingSuggestionService implements Service {
/**
* @var ShippingZone
*/
protected $shipping_zone;
/**
* @var WC
*/
protected $wc;
/**
* ShippingSuggestionService constructor.
*
* @param ShippingZone $shipping_zone
* @param WC $wc
*/
public function __construct( ShippingZone $shipping_zone, WC $wc ) {
$this->shipping_zone = $shipping_zone;
$this->wc = $wc;
}
/**
* Get shipping rate suggestions.
*
* @param string $country_code
*
* @return array A multidimensional array of shipping rate suggestions. {
* Array of shipping rate suggestion arguments.
*
* @type string $country The shipping country.
* @type string $currency The suggested rate currency (this is the same as the store's currency).
* @type float $rate The cost of the shipping method.
* @type array $options Array of options for the shipping method.
* }
*/
public function get_suggestions( string $country_code ): array {
$location_rates = $this->shipping_zone->get_shipping_rates_grouped_by_country( $country_code );
$suggestions = [];
$free_threshold = null;
foreach ( $location_rates as $location_rate ) {
$serialized = $location_rate->jsonSerialize();
// Check if there is a conditional free shipping rate (with minimum order amount).
// We will set the minimum order amount as the free shipping threshold for other rates.
$shipping_rate = $location_rate->get_shipping_rate();
// Ignore rates with shipping classes.
if ( ! empty( $shipping_rate->get_applicable_classes() ) ) {
continue;
}
if ( $shipping_rate->is_free() && $shipping_rate->has_min_order_amount() ) {
$free_threshold = $shipping_rate->get_min_order_amount();
// Ignore the conditional free rate if there are other rates.
if ( count( $location_rates ) > 1 ) {
continue;
}
}
// Add the store currency to each rate.
$serialized['currency'] = $this->wc->get_woocommerce_currency();
$suggestions[] = $serialized;
}
if ( null !== $free_threshold ) {
// Set the free shipping threshold for all suggestions if there is one.
foreach ( $suggestions as $key => $suggestion ) {
$suggestion['options'] = [
'free_shipping_threshold' => $free_threshold,
];
$suggestions[ $key ] = $suggestion;
}
}
return $suggestions;
}
}
Shipping/ShippingZone.php 0000644 00000012005 15153721357 0011462 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
defined( 'ABSPATH' ) || exit;
/**
* Class ShippingZone
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 1.9.0
*/
class ShippingZone implements Service {
/**
* @var WC
*/
protected $wc;
/**
* @var ZoneLocationsParser
*/
protected $locations_parser;
/**
* @var ZoneMethodsParser
*/
protected $methods_parser;
/**
* @var LocationRatesProcessor
*/
protected $rates_processor;
/**
* @var array[][]|null Array of shipping rates for each location.
*/
protected $location_rates = null;
/**
* ShippingZone constructor.
*
* @param WC $wc
* @param ZoneLocationsParser $location_parser
* @param ZoneMethodsParser $methods_parser
* @param LocationRatesProcessor $rates_processor
*/
public function __construct(
WC $wc,
ZoneLocationsParser $location_parser,
ZoneMethodsParser $methods_parser,
LocationRatesProcessor $rates_processor
) {
$this->wc = $wc;
$this->locations_parser = $location_parser;
$this->methods_parser = $methods_parser;
$this->rates_processor = $rates_processor;
}
/**
* Gets the shipping countries from the WooCommerce shipping zones.
*
* Note: This method only returns the countries that have at least one shipping method.
*
* @return string[]
*/
public function get_shipping_countries(): array {
$this->parse_shipping_zones();
$countries = array_keys( $this->location_rates );
return array_values( $countries );
}
/**
* Get the number of shipping rates enable in WooCommerce.
*
* @return int
*/
public function get_shipping_rates_count(): int {
$this->parse_shipping_zones();
return count( $this->location_rates ?? [] );
}
/**
* Returns the available shipping rates for a country and its subdivisions.
*
* @param string $country_code
*
* @return LocationRate[]
*/
public function get_shipping_rates_for_country( string $country_code ): array {
$this->parse_shipping_zones();
if ( empty( $this->location_rates[ $country_code ] ) ) {
return [];
}
// Process the rates for each country subdivision separately.
$location_rates = array_map( [ $this->rates_processor, 'process' ], $this->location_rates[ $country_code ] );
// Convert the string array keys to integers.
$country_rates = array_values( $location_rates );
// Flatten and merge the country shipping rates.
$country_rates = array_merge( [], ...$country_rates );
return array_values( $country_rates );
}
/**
* Returns the available shipping rates for a country.
*
* If there are separate rates for the country's subdivisions (e.g. state,province, postcode etc.), they will be
* grouped by their parent country.
*
* @param string $country_code
*
* @return LocationRate[]
*/
public function get_shipping_rates_grouped_by_country( string $country_code ): array {
$this->parse_shipping_zones();
if ( empty( $this->location_rates[ $country_code ] ) ) {
return [];
}
// Convert the string array keys to integers.
$country_rates = array_values( $this->location_rates[ $country_code ] );
// Flatten and merge the country shipping rates.
$country_rates = array_merge( [], ...$country_rates );
return $this->rates_processor->process( $country_rates );
}
/**
* Parses the WooCommerce shipping zones.
*/
protected function parse_shipping_zones(): void {
// Don't parse if already parsed.
if ( null !== $this->location_rates ) {
return;
}
$this->location_rates = [];
foreach ( $this->wc->get_shipping_zones() as $zone ) {
$zone = $this->wc->get_shipping_zone( $zone['zone_id'] );
$zone_locations = $this->locations_parser->parse( $zone );
$shipping_rates = $this->methods_parser->parse( $zone );
$this->map_rates_to_locations( $shipping_rates, $zone_locations );
}
}
/**
* Maps each shipping method to its related shipping locations.
*
* @param ShippingRate[] $shipping_rates The shipping rates.
* @param ShippingLocation[] $locations The shipping locations.
*
* @since 2.1.0
*/
protected function map_rates_to_locations( array $shipping_rates, array $locations ): void {
if ( empty( $shipping_rates ) || empty( $locations ) ) {
return;
}
foreach ( $locations as $location ) {
$location_rates = [];
foreach ( $shipping_rates as $shipping_rate ) {
$location_rates[] = new LocationRate( $location, $shipping_rate );
}
$country_code = $location->get_country();
// Initialize the array if it doesn't exist.
$this->location_rates[ $country_code ] = $this->location_rates[ $country_code ] ?? [];
$location_key = (string) $location;
// Group the rates by their parent country and a location key. The location key is used to prevent duplicate rates for the same location.
$this->location_rates[ $country_code ][ $location_key ] = $location_rates;
}
}
}
Shipping/SyncerHooks.php 0000644 00000012174 15153721357 0011323 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings as GoogleSettings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\ShippingNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateShippingSettings;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
defined( 'ABSPATH' ) || exit;
/**
* Class SyncerHooks
*
* Hooks to various WooCommerce and WordPress actions to automatically sync shipping settings.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class SyncerHooks implements Service, Registerable {
/**
* This property is used to avoid scheduling duplicate jobs in the same request.
*
* @var bool
*/
protected $already_scheduled = false;
/**
* @var GoogleSettings
*/
protected $google_settings;
/**
* @var MerchantCenterService
*/
protected $merchant_center;
/**
* @var JobRepository
*/
protected $job_repository;
/**
* @var NotificationsService $notifications_service
*/
protected $notifications_service;
/**
* SyncerHooks constructor.
*
* @param MerchantCenterService $merchant_center
* @param GoogleSettings $google_settings
* @param JobRepository $job_repository
* @param NotificationsService $notifications_service
*/
public function __construct( MerchantCenterService $merchant_center, GoogleSettings $google_settings, JobRepository $job_repository, NotificationsService $notifications_service ) {
$this->google_settings = $google_settings;
$this->merchant_center = $merchant_center;
$this->job_repository = $job_repository;
$this->notifications_service = $notifications_service;
}
/**
* Register the service.
*/
public function register(): void {
// only register the hooks if Merchant Center account is connected and the user has chosen for the shipping rates to be synced from WooCommerce settings.
if ( ! $this->merchant_center->is_connected() || ! $this->google_settings->should_get_shipping_rates_from_woocommerce() ) {
return;
}
$update_settings = function () {
$this->handle_update_shipping_settings();
};
// After a shipping zone object is saved to database.
add_action( 'woocommerce_after_shipping_zone_object_save', $update_settings, 90 );
// After a shipping zone is deleted.
add_action( 'woocommerce_delete_shipping_zone', $update_settings, 90 );
// After a shipping method is added to or deleted from a shipping zone.
add_action( 'woocommerce_shipping_zone_method_added', $update_settings, 90 );
add_action( 'woocommerce_shipping_zone_method_deleted', $update_settings, 90 );
// After a shipping method is enabled or disabled.
add_action( 'woocommerce_shipping_zone_method_status_toggled', $update_settings, 90 );
// After a shipping class is updated/deleted.
add_action( 'woocommerce_shipping_classes_save_class', $update_settings, 90 );
add_action( 'saved_product_shipping_class', $update_settings, 90 );
add_action( 'delete_product_shipping_class', $update_settings, 90 );
// After free_shipping and flat_rate method options are updated.
add_action( 'woocommerce_update_options_shipping_free_shipping', $update_settings, 90 );
add_action( 'woocommerce_update_options_shipping_flat_rate', $update_settings, 90 );
// The shipping options can also be updated using other methods (e.g. by calling WC_Shipping_Method::process_admin_options).
// Those methods may not fire any hooks, so we need to watch the base WordPress hooks for when those options are updated.
$on_option_change = function ( $option ) {
/**
* This Regex checks for the shipping options key generated by the `WC_Shipping_Method::get_instance_option_key` method.
* We check for the shipping method IDs supported by GLA (flat_rate or free_shipping), and an integer instance_id.
*
* @see \WC_Shipping_Method::get_instance_option_key for more information about this key.
*/
if ( preg_match( '/^woocommerce_(flat_rate|free_shipping)_\d+_settings$/', $option ) ) {
$this->handle_update_shipping_settings();
}
};
add_action(
'updated_option',
$on_option_change,
90
);
add_action(
'added_option',
$on_option_change,
90
);
}
/**
* Handle updating of Merchant Center shipping settings.
*
* @return void
*/
protected function handle_update_shipping_settings() {
// Bail if an event is already scheduled in the current request
if ( $this->already_scheduled ) {
return;
}
if ( $this->notifications_service->is_ready() ) {
$this->job_repository->get( ShippingNotificationJob::class )->schedule( [ 'topic' => NotificationsService::TOPIC_SHIPPING_UPDATED ] );
}
$this->job_repository->get( UpdateShippingSettings::class )->schedule();
$this->already_scheduled = true;
}
}
Shipping/ZoneLocationsParser.php 0000644 00000010145 15153721357 0013014 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use WC_Shipping_Zone;
defined( 'ABSPATH' ) || exit;
/**
* Class ZoneLocationsParser
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ZoneLocationsParser implements Service {
/**
* @var GoogleHelper
*/
protected $google_helper;
/**
* ZoneLocationsParser constructor.
*
* @param GoogleHelper $google_helper
*/
public function __construct( GoogleHelper $google_helper ) {
$this->google_helper = $google_helper;
}
/**
* Returns the supported locations for the given WooCommerce shipping zone.
*
* @param WC_Shipping_Zone $zone
*
* @return ShippingLocation[] Array of supported locations.
*/
public function parse( WC_Shipping_Zone $zone ): array {
$locations = [];
$postcodes = $this->get_postcodes_from_zone( $zone );
foreach ( $zone->get_zone_locations() as $location ) {
switch ( $location->type ) {
case 'country':
$country = $location->code;
if ( $this->google_helper->is_country_supported( $country ) ) {
$google_id = $this->google_helper->find_country_id_by_code( $country );
$region = $this->maybe_create_region_for_postcodes( $country, $postcodes );
$locations[ $location->code ] = new ShippingLocation( $google_id, $country, null, $region );
}
break;
case 'continent':
foreach ( $this->google_helper->get_supported_countries_from_continent( $location->code ) as $country ) {
$google_id = $this->google_helper->find_country_id_by_code( $country );
$region = $this->maybe_create_region_for_postcodes( $country, $postcodes );
$locations[ $country ] = new ShippingLocation( $google_id, $country, null, $region );
}
break;
case 'state':
[ $country, $state ] = explode( ':', $location->code );
// Ignore if the country is not supported.
if ( ! $this->google_helper->is_country_supported( $country ) ) {
break;
}
$region = $this->maybe_create_region_for_postcodes( $country, $postcodes );
// Only add the state if the regional shipping is supported for the country.
if ( $this->google_helper->does_country_support_regional_shipping( $country ) ) {
$google_id = $this->google_helper->find_subdivision_id_by_code( $state, $country );
if ( ! is_null( $google_id ) ) {
$locations[ $location->code ] = new ShippingLocation( $google_id, $country, $state, $region );
}
} else {
$google_id = $this->google_helper->find_country_id_by_code( $country );
$locations[ $country ] = new ShippingLocation( $google_id, $country, null, $region );
}
break;
default:
break;
}
}
return array_values( $locations );
}
/**
* Returns the applicable postcodes for the given WooCommerce shipping zone.
*
* @param WC_Shipping_Zone $zone
*
* @return PostcodeRange[] Array of postcodes.
*/
protected function get_postcodes_from_zone( WC_Shipping_Zone $zone ): array {
$postcodes = array_filter(
$zone->get_zone_locations(),
function ( $location ) {
return 'postcode' === $location->type;
}
);
return array_map(
function ( $postcode ) {
return PostcodeRange::from_string( $postcode->code );
},
$postcodes
);
}
/**
* Returns the applicable shipping region including postcodes for the given WooCommerce shipping zone.
*
* @param string $country
* @param array $postcode_ranges
*
* @return ShippingRegion|null
*/
protected function maybe_create_region_for_postcodes( string $country, array $postcode_ranges ): ?ShippingRegion {
// Do not return a region if the country does not support regional shipping, or if no postcode ranges provided.
if ( ! $this->google_helper->does_country_support_regional_shipping( $country ) || empty( $postcode_ranges ) ) {
return null;
}
$region_id = ShippingRegion::generate_random_id();
return new ShippingRegion( $region_id, $country, $postcode_ranges );
}
}
Shipping/ZoneMethodsParser.php 0000644 00000012730 15153721357 0012466 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use WC_Shipping_Method;
use WC_Shipping_Zone;
defined( 'ABSPATH' ) || exit;
/**
* Class ZoneMethodsParser
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Shipping
*
* @since 2.1.0
*/
class ZoneMethodsParser implements Service {
use PluginHelper;
public const METHOD_FLAT_RATE = 'flat_rate';
public const METHOD_FREE = 'free_shipping';
/**
* @var WC
*/
private $wc;
/**
* ZoneMethodsParser constructor.
*
* @param WC $wc
*/
public function __construct( WC $wc ) {
$this->wc = $wc;
}
/**
* Parses the given shipping method and returns its properties if it's supported. Returns null otherwise.
*
* @param WC_Shipping_Zone $zone
*
* @return ShippingRate[] Returns an array of parsed shipping rates, or null if the shipping method is not supported.
*/
public function parse( WC_Shipping_Zone $zone ): array {
$parsed_rates = [];
foreach ( $zone->get_shipping_methods( true ) as $method ) {
$parsed_rates = array_merge( $parsed_rates, $this->shipping_method_to_rates( $method ) );
}
return $parsed_rates;
}
/**
* Parses the given shipping method and returns its properties if it's supported. Returns null otherwise.
*
* @param object|WC_Shipping_Method $method
*
* @return ShippingRate[] Returns an array of parsed shipping rates, or empty if the shipping method is not supported.
*/
protected function shipping_method_to_rates( object $method ): array {
$shipping_rates = [];
switch ( $method->id ) {
case self::METHOD_FLAT_RATE:
$flat_rate = $this->get_flat_rate_method_rate( $method );
$shipping_class_rates = $this->get_flat_rate_method_class_rates( $method );
// If the flat-rate method has no rate AND no shipping classes, we don't return it.
if ( null === $flat_rate && empty( $shipping_class_rates ) ) {
return [];
}
$shipping_rates[] = new ShippingRate( (float) $flat_rate );
if ( ! empty( $shipping_class_rates ) ) {
foreach ( $shipping_class_rates as ['class' => $class, 'rate' => $rate] ) {
$shipping_rate = new ShippingRate( $rate );
$shipping_rate->set_applicable_classes( [ $class ] );
$shipping_rates[] = $shipping_rate;
}
}
break;
case self::METHOD_FREE:
$shipping_rate = new ShippingRate( 0 );
// Check if free shipping requires a minimum order amount.
$requires = $method->get_option( 'requires' );
if ( in_array( $requires, [ 'min_amount', 'either' ], true ) ) {
$shipping_rate->set_min_order_amount( (float) $method->get_option( 'min_amount' ) );
} elseif ( in_array( $requires, [ 'coupon', 'both' ], true ) ) {
// We can't sync this method if free shipping requires a coupon.
return [];
}
$shipping_rates[] = $shipping_rate;
break;
default:
/**
* Filter the shipping rates for a shipping method that is not supported.
*
* @param ShippingRate[] $shipping_rates The shipping rates.
* @param object|WC_Shipping_Method $method The shipping method.
*/
return apply_filters(
'woocommerce_gla_handle_shipping_method_to_rates',
$shipping_rates,
$method
);
}
return $shipping_rates;
}
/**
* Get the flat-rate shipping method rate.
*
* @param object|WC_Shipping_Method $method
*
* @return float|null
*/
protected function get_flat_rate_method_rate( object $method ): ?float {
$rate = null;
$flat_cost = 0;
$cost = $this->convert_to_standard_decimal( (string) $method->get_option( 'cost' ) );
// Check if the cost is a numeric value (and not null or a math expression).
if ( is_numeric( $cost ) ) {
$flat_cost = (float) $cost;
$rate = $flat_cost;
}
// Add the no class cost.
$no_class_cost = $this->convert_to_standard_decimal( (string) $method->get_option( 'no_class_cost' ) );
if ( is_numeric( $no_class_cost ) ) {
$rate = $flat_cost + (float) $no_class_cost;
}
return $rate;
}
/**
* Get the array of options of the flat-rate shipping method.
*
* @param object|WC_Shipping_Method $method
*
* @return array A multidimensional array of shipping class rates. {
* Array of shipping method arguments.
*
* @type string $class The shipping class slug/id.
* @type float $rate The cost of the shipping method for the class in WooCommerce store currency.
* }
*/
protected function get_flat_rate_method_class_rates( object $method ): array {
$class_rates = [];
$flat_cost = 0;
$cost = $this->convert_to_standard_decimal( (string) $method->get_option( 'cost' ) );
// Check if the cost is a numeric value (and not null or a math expression).
if ( is_numeric( $cost ) ) {
$flat_cost = (float) $cost;
}
// Add shipping class costs.
$shipping_classes = $this->wc->get_shipping_classes();
foreach ( $shipping_classes as $shipping_class ) {
$shipping_class_cost = $this->convert_to_standard_decimal( (string) $method->get_option( 'class_cost_' . $shipping_class->term_id ) );
if ( is_numeric( $shipping_class_cost ) ) {
// Add the flat rate cost to the shipping class cost.
$class_rates[ $shipping_class->slug ] = [
'class' => $shipping_class->slug,
'rate' => $flat_cost + (float) $shipping_class_cost,
];
}
}
return array_values( $class_rates );
}
}
TaskList/CompleteSetupTask.php 0000644 00000004466 15153721357 0012452 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\TaskList;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterAwareTrait;
/**
* Complete Setup Task to be added to the extended task list.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\TaskList
*/
class CompleteSetupTask extends Task implements Service, Registerable, MerchantCenterAwareInterface {
use MerchantCenterAwareTrait;
/**
* Register a service.
*
* Add itself to the extended task list on init.
*/
public function register(): void {
add_action(
'init',
function () {
$list_id = 'extended';
$this->task_list = TaskLists::get_list( $list_id );
TaskLists::add_task( $list_id, $this );
}
);
}
/**
* Get the task id.
*
* @return string
*/
public function get_id() {
return 'gla_complete_setup';
}
/**
* Get the task name.
*
* @return string
*/
public function get_title() {
return __(
'Set up Google for WooCommerce',
'google-listings-and-ads'
);
}
/**
* Get the task description.
*
* @return string empty string
*/
public function get_content() {
return '';
}
/**
* Get the task completion time.
*
* @return string
*/
public function get_time() {
return __( '20 minutes', 'google-listings-and-ads' );
}
/**
* Always dismissable.
*
* @return bool
*/
public function is_dismissable() {
return true;
}
/**
* Get completion status.
* Forwards from the merchant center setup status.
*
* @return bool
*/
public function is_complete() {
return $this->merchant_center->is_setup_complete();
}
/**
* Get the action URL.
*
* @return string Start page or dashboard is the setup is completed.
*/
public function get_action_url() {
if ( ! $this->is_complete() ) {
return admin_url( 'admin.php?page=wc-admin&path=/google/start' );
}
return admin_url( 'admin.php?page=wc-admin&path=/google/dashboard' );
}
}
Tracking/EventTracking.php 0000644 00000003667 15153721357 0011610 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\ActivatedEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\BaseEvent;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\GenericEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\SiteClaimEvents;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events\SiteVerificationEvents;
/**
* Wire up the Google for WooCommerce events to Tracks.
* Add all new events to `$events`.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class EventTracking implements ContainerAwareInterface, Registerable, Service {
use ContainerAwareTrait;
use ValidateInterface;
/**
* Individual events classes to load.
*
* @var string[]
*/
protected $events = [
ActivatedEvents::class,
GenericEvents::class,
SiteClaimEvents::class,
SiteVerificationEvents::class,
];
/**
* Hook extension tracker data into the WC tracker data.
*/
public function register(): void {
add_action(
'init',
function () {
$this->register_events();
},
20 // After WC_Admin loads WC_Tracks class (init 10).
);
}
/**
* Register all of our event tracking classes.
*/
protected function register_events() {
foreach ( $this->events as $class ) {
/** @var BaseEvent $instance */
$instance = $this->container->get( $class );
$this->validate_instanceof( $instance, BaseEvent::class );
$instance->register();
}
}
}
Tracking/Events/ActivatedEvents.php 0000644 00000004237 15153721357 0013373 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Activateable;
/**
* This class adds actions to track when the extension is activated.
* *
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class ActivatedEvents extends BaseEvent implements Activateable {
/**
* The page where activation with a source can occur.
*/
public const ACTIVATION_PAGE = 'plugin-install.php';
/**
* The query parameters used to determine activation source details.
*/
public const SOURCE_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
/**
* @var array The request SERVER variables.
*/
private $server_vars;
/**
* ActivatedEvents constructor.
*
* @param array $server_vars The request SERVER variables.
*/
public function __construct( array $server_vars ) {
$this->server_vars = $server_vars;
}
/**
* Nothing to register (method invoked manually).
*/
public function register(): void {}
/**
* Track when the extension is activated from a source.
*/
public function maybe_track_activation_source(): void {
// Skip WP-CLI activations
if ( empty( $this->server_vars['HTTP_REFERER'] ) ) {
return;
}
$url_components = wp_parse_url( $this->server_vars['HTTP_REFERER'] );
// Skip invalid URLs or URLs missing parts
if ( ! is_array( $url_components ) || empty( $url_components['query'] ) || empty( $url_components['path'] ) ) {
return;
}
// Skip activations from anywhere except the Add Plugins page
if ( false === strstr( $url_components['path'], self::ACTIVATION_PAGE ) ) {
return;
}
wp_parse_str( $url_components['query'], $query_vars );
$available_source_params = array_intersect_key( $query_vars, array_flip( self::SOURCE_PARAMS ) );
// Skip if no source params are present
if ( empty( $available_source_params ) ) {
return;
}
$this->record_event( 'activated_from_source', $available_source_params );
}
/**
* Activate the service.
*
* @return void
*/
public function activate(): void {
$this->maybe_track_activation_source();
}
}
Tracking/Events/BaseEvent.php 0000644 00000001564 15153721357 0012156 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\TracksAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Tracking\TracksAwareTrait;
/**
* Class BaseEvent
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events
*/
abstract class BaseEvent implements TracksEventInterface, TracksAwareInterface {
use TracksAwareTrait;
use PluginHelper;
/**
* Record an event using the Tracks instance.
*
* @param string $event_name The event name to record.
* @param array $properties (Optional) Properties to record with the event.
*/
protected function record_event( string $event_name, $properties = [] ) {
$this->tracks->record_event( $event_name, $properties );
}
}
Tracking/Events/GenericEvents.php 0000644 00000001637 15153721357 0013044 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events;
/**
* This class adds an action to track a generic event, which can be triggered by:
* `do_action( 'woocommerce_gla_track_event', 'event_name', $properties )`
*
* @since 2.5.16
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class GenericEvents extends BaseEvent {
/**
* Register the tracking class.
*/
public function register(): void {
add_action( 'woocommerce_gla_track_event', [ $this, 'track_event' ], 10, 2 );
}
/**
* Track a generic event providing event name and optional list of properties.
*
* @param string $event_name Event name to record.
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_event( string $event_name, array $properties = [] ) {
$this->record_event( $event_name, $properties );
}
}
Tracking/Events/SiteClaimEvents.php 0000644 00000005725 15153721357 0013344 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events;
/**
* This class adds actions to track for Site Claim actions:
* - Site claim required
* - Site claim success
* - Site claim failure
* - Merchant Center URL switch
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class SiteClaimEvents extends BaseEvent {
/**
* Register the tracking class.
*/
public function register(): void {
add_action( 'woocommerce_gla_site_claim_overwrite_required', [ $this, 'track_site_claim_overwrite_required' ] );
add_action( 'woocommerce_gla_site_claim_success', [ $this, 'track_site_claim_success' ] );
add_action( 'woocommerce_gla_site_claim_failure', [ $this, 'track_site_claim_failure' ] );
add_action( 'woocommerce_gla_url_switch_required', [ $this, 'track_url_switch_required' ] );
add_action( 'woocommerce_gla_url_switch_success', [ $this, 'track_url_switch_success' ] );
}
/**
* Track when a site claim needs to be overwritten.
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_site_claim_overwrite_required( array $properties = [] ): void {
$properties['action'] = 'overwrite_required';
$this->track_site_claim_event( $properties );
}
/**
* Track when a site is claimed successfully.
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_site_claim_success( array $properties = [] ): void {
$properties['action'] = 'success';
$this->track_site_claim_event( $properties );
}
/**
* Track when a site fails to be claimed.
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_site_claim_failure( array $properties = [] ): void {
$properties['action'] = 'failure';
$this->track_site_claim_event( $properties );
}
/**
* Track the generic site claim event with the action property.
*
* @param array $properties
*/
protected function track_site_claim_event( array $properties = [] ): void {
$this->record_event( 'site_claim', $properties );
}
/**
* Track when a site requires a URL switch
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_url_switch_required( array $properties = [] ): void {
$properties['action'] = 'required';
$this->track_url_switch_event( $properties );
}
/**
* Track when a site executes a successful URL switch
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_url_switch_success( array $properties = [] ): void {
$properties['action'] = 'success';
$this->track_url_switch_event( $properties );
}
/**
* Track the generic url switch event with the action property.
*
* @param array $properties
*/
protected function track_url_switch_event( array $properties = [] ): void {
$this->record_event( 'mc_url_switch', $properties );
}
}
Tracking/Events/SiteVerificationEvents.php 0000644 00000002202 15153721357 0014724 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events;
/**
* This class adds actions to track when Site Verification is attempted (succeeds/fails).
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class SiteVerificationEvents extends BaseEvent {
/**
* Register the tracking class.
*/
public function register(): void {
add_action( 'woocommerce_gla_site_verify_success', [ $this, 'track_site_verify_success' ] );
add_action( 'woocommerce_gla_site_verify_failure', [ $this, 'track_site_verify_failure' ] );
}
/**
* Track when a site is verified
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_site_verify_success( array $properties = [] ): void {
$this->record_event( 'site_verify_success', $properties );
}
/**
* Track when a site fails to be verified.
*
* @param array $properties Optional additional properties to pass with the event.
*/
public function track_site_verify_failure( array $properties = [] ): void {
$this->record_event( 'site_verify_failure', $properties );
}
}
Tracking/Events/TracksEventInterface.php 0000644 00000000553 15153721357 0014351 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking\Events;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
/**
* Interface describing an event tracker class.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
interface TracksEventInterface extends Registerable {}
Tracking/TrackerSnapshot.php 0000644 00000010704 15153721357 0012145 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantMetrics;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
/**
* Include Google for WooCommerce data in the WC Tracker snapshot.
*
* ContainerAware used to access:
* - AdsService
* - MerchantCenterService
* - MerchantMetrics
* - TargetAudience
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class TrackerSnapshot implements ContainerAwareInterface, OptionsAwareInterface, Registerable, Service {
use ContainerAwareTrait;
use OptionsAwareTrait;
use PluginHelper;
/**
* Hook extension tracker data into the WC tracker data.
*/
public function register(): void {
add_filter(
'woocommerce_tracker_data',
function ( $data ) {
return $this->include_snapshot_data( $data );
}
);
}
/**
* Add extension data to the WC Tracker snapshot.
*
* @param array $data The existing array of tracker data.
*
* @return array The updated array of tracker data.
*/
protected function include_snapshot_data( $data = [] ): array {
if ( ! isset( $data['extensions'] ) ) {
$data['extensions'] = [];
}
$data['extensions'][ $this->get_slug() ] = [
'settings' => $this->get_settings(),
];
return $data;
}
/**
* Get general extension and settings data for the extension.
*
* @return array
*/
protected function get_settings(): array {
/** @var TargetAudience $target_audience */
$target_audience = $this->container->get( TargetAudience::class );
$mc_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER );
/** @var AdsService $ads_service */
$ads_service = $this->container->get( AdsService::class );
/** @var MerchantCenterService $mc_service */
$mc_service = $this->container->get( MerchantCenterService::class );
/** @var MerchantMetrics $merchant_metrics */
$merchant_metrics = $this->container->get( MerchantMetrics::class );
return [
'version' => $this->get_version(),
'db_version' => $this->options->get( OptionsInterface::DB_VERSION ),
'tos_accepted' => $this->get_boolean_value( OptionsInterface::WP_TOS_ACCEPTED ),
'google_connected' => $this->get_boolean_value( OptionsInterface::GOOGLE_CONNECTED ),
'mc_setup' => $this->get_boolean_value( OptionsInterface::MC_SETUP_COMPLETED_AT ),
'ads_setup' => $this->get_boolean_value( OptionsInterface::ADS_SETUP_COMPLETED_AT ),
'target_audience' => $target_audience->get_target_countries(),
'shipping_rate' => $mc_settings['shipping_rate'] ?? '',
'shipping_time' => $mc_settings['shipping_time'] ?? '',
'tax_rate' => $mc_settings['tax_rate'] ?? '',
'has_account_issue' => $mc_service->is_connected() && $mc_service->has_account_issues() ? 'yes' : 'no',
'has_at_least_one_synced_product' => $mc_service->is_connected() && $mc_service->has_at_least_one_synced_product() ? 'yes' : 'no',
'ads_setup_started' => $ads_service->is_setup_started() ? 'yes' : 'no',
'ads_customer_id' => $this->options->get_ads_id(),
'ads_campaign_count' => $merchant_metrics->get_campaign_count(),
'wpcom_api_authorized' => $this->options->is_wpcom_api_authorized(),
];
}
/**
* Get boolean value from options, return as yes or no.
*
* @param string $key Option key name.
*
* @return string
*/
protected function get_boolean_value( string $key ): string {
return (bool) $this->options->get( $key ) ? 'yes' : 'no';
}
}
Tracking/Tracks.php 0000644 00000003363 15153721357 0010264 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\Tracks as TracksProxy;
/**
* Tracks implementation for Google for WooCommerce.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
class Tracks implements TracksInterface, OptionsAwareInterface {
use OptionsAwareTrait;
use PluginHelper;
/**
* @var TracksProxy
*/
protected $tracks;
/**
* Tracks constructor.
*
* @param TracksProxy $tracks The proxy tracks object.
*/
public function __construct( TracksProxy $tracks ) {
$this->tracks = $tracks;
}
/**
* Record an event in Tracks - this is the preferred way to record events from PHP.
*
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
*/
public function record_event( $event_name, $properties = [] ) {
// Include base properties.
$base_properties = [
"{$this->get_slug()}_version" => $this->get_version(),
];
// Include connected accounts (if connected).
if ( $this->options->get_ads_id() ) {
$base_properties[ "{$this->get_slug()}_ads_id" ] = $this->options->get_ads_id();
}
if ( $this->options->get_merchant_id() ) {
$base_properties[ "{$this->get_slug()}_mc_id" ] = $this->options->get_merchant_id();
}
$properties = array_merge( $base_properties, $properties );
$full_event_name = "{$this->get_slug()}_{$event_name}";
$this->tracks->record_event( $full_event_name, $properties );
}
}
Tracking/TracksAwareInterface.php 0000644 00000000611 15153721357 0013056 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking;
/**
* Interface TracksAwareInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
interface TracksAwareInterface {
/**
* Set the tracks interface object.
*
* @param TracksInterface $tracks
*/
public function set_tracks( TracksInterface $tracks ): void;
}
Tracking/TracksAwareTrait.php 0000644 00000000753 15153721357 0012250 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking;
/**
* Trait TracksAwareTrait
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
trait TracksAwareTrait {
/**
* The tracks object.
*
* @var TracksInterface
*/
protected $tracks;
/**
* Set the tracks interface object.
*
* @param TracksInterface $tracks
*/
public function set_tracks( TracksInterface $tracks ): void {
$this->tracks = $tracks;
}
}
Tracking/TracksInterface.php 0000644 00000001036 15153721357 0012100 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Tracking;
/**
* Tracks interface for Google for WooCommerce.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tracking
*/
interface TracksInterface {
/**
* Record an event in Tracks - this is the preferred way to record events from PHP.
*
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
*/
public function record_event( $event_name, $properties = [] );
}
Utility/AddressUtility.php 0000644 00000002462 15153721357 0011706 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
defined( 'ABSPATH' ) || exit;
/**
* Class AddressUtility
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Utility
*
* @since 1.4.0
*/
class AddressUtility implements Service {
/**
* Checks whether two account addresses are the same and returns true if they are.
*
* @param AccountAddress $address_1
* @param AccountAddress $address_2
*
* @return bool True if the two addresses are the same, false otherwise.
*/
public function compare_addresses( AccountAddress $address_1, AccountAddress $address_2 ): bool {
$cmp_street_address = $address_1->getStreetAddress() === $address_2->getStreetAddress();
$cmp_locality = $address_1->getLocality() === $address_2->getLocality();
$cmp_region = $address_1->getRegion() === $address_2->getRegion();
$cmp_postal_code = $address_1->getPostalCode() === $address_2->getPostalCode();
$cmp_country = $address_1->getCountry() === $address_2->getCountry();
return $cmp_street_address && $cmp_locality && $cmp_region && $cmp_postal_code && $cmp_country;
}
}
Utility/ArrayUtil.php 0000644 00000000703 15153721357 0010645 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
/**
* A class of utilities for dealing with arrays.
*
* @since 2.4.0
*/
class ArrayUtil {
/**
* Remove empty values from array.
*
* @param array $strings A list of strings.
*
* @return array A list of strings without empty strings.
*/
public static function remove_empty_values( array $strings ): array {
return array_values( array_filter( $strings ) );
}
}
Utility/DateTimeUtility.php 0000644 00000002027 15153721357 0012012 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
defined( 'ABSPATH' ) || exit;
/**
* Class DateTimeUtility
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Utility
*
* @since 1.5.0
*/
class DateTimeUtility implements Service {
/**
* Convert a timezone offset to the closest matching timezone string.
*
* @param string $timezone
*
* @return string
*/
public function maybe_convert_tz_string( string $timezone ): string {
if ( preg_match( '/^([+-]\d{1,2}):?(\d{1,2})$/', $timezone, $matches ) ) {
[ $timezone, $hours, $minutes ] = $matches;
$sign = (int) $hours >= 0 ? 1 : - 1;
$seconds = $sign * ( absint( $hours ) * 60 * 60 + absint( $minutes ) * 60 );
$tz_name = timezone_name_from_abbr( '', $seconds, 0 );
$timezone = $tz_name !== false ? $tz_name : date_default_timezone_get();
}
if ( 'UTC' === $timezone ) {
$timezone = 'Etc/GMT';
}
return $timezone;
}
}
Utility/DimensionUtility.php 0000644 00000002562 15153721357 0012247 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
/**
* A class for dealing with Dimensions.
*
* @since 2.4.0
*/
class DimensionUtility {
/**
* Width.
*
* @var int
*/
public int $x;
/**
* Height.
*
* @var int
*/
public int $y;
/**
* DimensionUtility constructor.
*
* @param int $x Width.
* @param int $y Height.
*/
public function __construct( int $x, int $y ) {
$this->x = $x;
$this->y = $y;
}
/**
* Checks if the dimension fulfils the minimum size.
*
* @param DimensionUtility $minimum_size The minimum size.
*
* @return bool true if the dimension is bigger or equal than the the minimum size otherwise false.
*/
public function is_minimum_size( DimensionUtility $minimum_size ): bool {
return $this->x >= $minimum_size->x && $this->y >= $minimum_size->y;
}
/**
* Checks if the dimension is equal to the other one with a specific precision.
*
* @param DimensionUtility $target The dimension to be compared.
* @param int|float $precision The precision to use when comparing the two numbers.
*
* @return bool true if the dimension is equal than the other one otherwise false.
*/
public function equals( DimensionUtility $target, $precision = 1 ): bool {
return wp_fuzzy_number_match( $this->x, $target->x, $precision ) && wp_fuzzy_number_match( $this->y, $target->y, $precision );
}
}
Utility/ISOUtility.php 0000644 00000003551 15153721357 0010753 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\Exception\ISO3166Exception;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\ISO3166\ISO3166DataProvider;
defined( 'ABSPATH' ) || exit;
/**
* Class ISOUtility
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Utility
*
* @since 1.5.0
*/
class ISOUtility implements Service {
/**
* @var ISO3166DataProvider
*/
protected $iso3166_data_provider;
/**
* ISOUtility constructor.
*
* @param ISO3166DataProvider $iso3166_data_provider
*/
public function __construct( ISO3166DataProvider $iso3166_data_provider ) {
$this->iso3166_data_provider = $iso3166_data_provider;
}
/**
* Validate that the provided input is valid ISO 3166-1 alpha-2 country code.
*
* @param string $country_code
*
* @return bool
*
* @see https://wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements ISO 3166-1 alpha-2
* officially assigned codes.
*/
public function is_iso3166_alpha2_country_code( string $country_code ): bool {
try {
$this->iso3166_data_provider->alpha2( $country_code );
return true;
} catch ( ISO3166Exception $exception ) {
return false;
}
}
/**
* Converts WordPress language code to IETF BCP 47 format.
*
* @param string $wp_locale
*
* @return string IETF BCP 47 language code or 'en-US' if the language code cannot be converted.
*
* @see https://tools.ietf.org/html/bcp47 IETF BCP 47 language codes.
*/
public function wp_locale_to_bcp47( string $wp_locale ): string {
if ( empty( $wp_locale ) || ! preg_match( '/^[-_a-zA-Z0-9]{2,}$/', $wp_locale, $matches ) ) {
return 'en-US';
}
return str_replace( '_', '-', $wp_locale );
}
}
Utility/ImageUtility.php 0000644 00000004617 15153721357 0011347 0 ustar 00 <?php
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\DimensionUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
/**
* A class of utilities for dealing with images.
*
* @since 2.4.0
*/
class ImageUtility implements Service {
/**
* The WP Proxy.
*
* @var WP
*/
protected WP $wp;
/**
* ImageUtility constructor.
*
* @param WP $wp WP Proxy.
*/
public function __construct( WP $wp ) {
$this->wp = $wp;
}
/**
* Maybe add a new subsize image.
*
* @param int $attachment_id Attachment ID.
* @param string $subsize_key The subsize key that we are trying to generate.
* @param DimensionUtility $size The new size for the subsize key.
* @param bool $crop Whether to crop the image.
*
* @return bool True if the subsize has been added to the attachment metadata otherwise false.
*/
public function maybe_add_subsize_image( int $attachment_id, string $subsize_key, DimensionUtility $size, bool $crop = true ): bool {
add_image_size( $subsize_key, $size->x, $size->y, $crop );
$metadata = $this->wp->wp_update_image_subsizes( $attachment_id );
remove_image_size( $subsize_key );
if ( is_wp_error( $metadata ) ) {
return false;
}
return isset( $metadata['sizes'][ $subsize_key ] );
}
/**
* Try to recommend a size using the real size, the recommended and the minimum.
*
* @param DimensionUtility $size Image size.
* @param DimensionUtility $recommended Recommended image size.
* @param DimensionUtility $minimum Minimum image size.
*
* @return DimensionUtility|bool False if does not fulfil the minimum size otherwise returns the suggested size.
*/
public function recommend_size( DimensionUtility $size, DimensionUtility $recommended, DimensionUtility $minimum ) {
if ( ! $size->is_minimum_size( $minimum ) ) {
return false;
}
$image_ratio = $size->x / $size->y;
$recommended_ratio = $recommended->x / $recommended->y;
if ( $recommended_ratio > $image_ratio ) {
$x = $size->x > $recommended->x ? $recommended->x : $size->x;
$y = (int) floor( $x / $recommended_ratio );
} else {
$y = $size->y > $recommended->y ? $recommended->y : $size->y;
$x = (int) floor( $y * $recommended_ratio );
}
return new DimensionUtility( $x, $y );
}
}
Utility/WPCLIMigrationGTIN.php 0000644 00000014312 15153721357 0012144 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Utility;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\GTINMigrationUtilities;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Exception;
use WP_CLI;
defined( 'ABSPATH' ) || exit;
/**
* Class WPCLIMigrationGTIN
* Creates a set of utility commands in WP CLI for GTIN Migration
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Utility
*
* @since 2.9.0
*/
class WPCLIMigrationGTIN implements Service, Registerable, Conditional {
use GTINMigrationUtilities;
/** @var AttributeManager */
public AttributeManager $attribute_manager;
/** @var ProductRepository */
public ProductRepository $product_repository;
/**
* Constructor
*
* @param ProductRepository $product_repository
* @param AttributeManager $attribute_manager
*/
public function __construct( ProductRepository $product_repository, AttributeManager $attribute_manager ) {
$this->product_repository = $product_repository;
$this->attribute_manager = $attribute_manager;
}
/**
* Register service and initialize hooks.
*/
public function register(): void {
WP_CLI::add_hook( 'after_wp_load', [ $this, 'register_commands' ] );
}
/**
* Register the commands
*/
public function register_commands(): void {
WP_CLI::add_command( 'wc g4wc gtin-migration start', [ $this, 'gtin_migration_start' ] );
}
/**
* Starts the GTIN migration in batches
*/
public function gtin_migration_start(): void {
$batch_size = $this->get_batch_size();
$num_products = $this->get_total_products_count();
WP_CLI::log( sprintf( 'Starting GTIN migration for %s products in the store.', $num_products ) );
$progress = WP_CLI\Utils\make_progress_bar( 'GTIN Migration', $num_products / $batch_size );
$processed = 0;
$batch_number = 1;
$start_time = microtime( true );
// First batch
$items = $this->get_items( $batch_number );
$processed += $this->process_items( $items );
$progress->tick();
// Next batches
while ( ! empty( $items ) ) {
++$batch_number;
$items = $this->get_items( $batch_number );
$processed += $this->process_items( $items );
$progress->tick();
}
$progress->finish();
$total_time = microtime( true ) - $start_time;
// Issue a warning if nothing is migrated.
if ( ! $processed ) {
WP_CLI::warning( __( 'No GTIN were migrated.', 'google-listings-and-ads' ) );
return;
}
WP_CLI::success(
sprintf(
/* Translators: %1$d is the number of migrated GTINS and %2$d is the execution time in seconds. */
_n(
'%1$d GTIN was migrated in %2$d seconds.',
'%1$d GTIN were migrated in %2$d seconds.',
$processed,
'google-listings-and-ads'
),
$processed,
$total_time
)
);
}
/**
* Get total of products in the store to be migrated.
*
* @return int The total number of products.
*/
private function get_total_products_count(): int {
$args = [
'status' => 'publish',
'return' => 'ids',
'type' => [ 'simple', 'variation' ],
];
return count( $this->product_repository->find_ids( $args ) );
}
/**
* Get the items for the current batch
*
* @param int $batch_number
* @return int[] Array of WooCommerce product IDs
*/
private function get_items( int $batch_number ): array {
return $this->product_repository->find_all_product_ids( $this->get_batch_size(), $this->get_query_offset( $batch_number ) );
}
/**
* Get the query offset based on a given batch number and the specified batch size.
*
* @param int $batch_number
*
* @return int
*/
protected function get_query_offset( int $batch_number ): int {
return $this->get_batch_size() * ( $batch_number - 1 );
}
/**
* Get the batch size. By default, 100.
*
* @return int The batch size.
*/
private function get_batch_size(): int {
return apply_filters( 'woocommerce_gla_batched_cli_size', 100 );
}
/**
* Process batch items.
*
* @param int[] $items A single batch of WooCommerce product IDs from the get_batch() method.
* @return int The number of items processed.
*/
protected function process_items( array $items ): int {
// update the product core GTIN using G4W GTIN
$products = $this->product_repository->find_by_ids( $items );
$processed = 0;
foreach ( $products as $product ) {
// process variations
if ( $product instanceof \WC_Product_Variable ) {
$variations = $product->get_children();
$processed += $this->process_items( $variations );
continue;
}
// void if core GTIN is already set.
if ( $product->get_global_unique_id() ) {
$this->debug( $this->error_gtin_already_set( $product ) );
continue;
}
$gtin = $this->get_gtin( $product );
if ( ! $gtin ) {
$this->debug( $this->error_gtin_not_found( $product ) );
continue;
}
$gtin = $this->prepare_gtin( $gtin );
if ( ! is_numeric( $gtin ) ) {
$this->debug( $this->error_gtin_invalid( $product, $gtin ) );
continue;
}
try {
$product->set_global_unique_id( $gtin );
$product->save();
++$processed;
$this->debug( $this->successful_migrated_gtin( $product, $gtin ) );
} catch ( Exception $e ) {
$this->error( $this->error_gtin_not_saved( $product, $gtin, $e ) );
}
}
return $processed;
}
/**
* Check if this Service is needed.
*
* @see https://make.wordpress.org/cli/handbook/guides/commands-cookbook/#include-in-a-plugin-or-theme
* @return bool
*/
public static function is_needed(): bool {
return defined( 'WP_CLI' ) && WP_CLI;
}
/**
* Add some info in the debug console.
* Add --debug to see these logs in WP CLI
*
* @param string $message
* @return void
*/
protected function debug( string $message ): void {
WP_CLI::debug( $message );
}
/**
* Add some info in the error console.
*
* @param string $message
* @return void
*/
protected function error( string $message ): void {
WP_CLI::error( $message, false );
}
}
Validator/GooglePriceConstraint.php 0000644 00000000655 15153721357 0013465 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Validator;
use Symfony\Component\Validator\Constraint;
defined( 'ABSPATH' ) || exit;
/**
* Class GooglePriceConstraint
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Validator
*/
class GooglePriceConstraint extends Constraint {
/**
* @var string
*/
public $message = 'Product must have a valid price and currency.';
}
Validator/GooglePriceConstraintValidator.php 0000644 00000002765 15153721357 0015337 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Validator;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Price as GooglePrice;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
defined( 'ABSPATH' ) || exit;
/**
* Class GooglePriceConstraintValidator
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Validator
*/
class GooglePriceConstraintValidator extends ConstraintValidator {
/**
* Checks if the passed value is valid.
*
* @param GooglePrice $value The value that should be validated
* @param Constraint $constraint
*
* @throws UnexpectedTypeException If invalid constraint provided.
* @throws UnexpectedValueException If invalid value provided.
*/
public function validate( $value, Constraint $constraint ) {
if ( ! $constraint instanceof GooglePriceConstraint ) {
throw new UnexpectedTypeException( $constraint, GooglePriceConstraint::class );
}
if ( null === $value || '' === $value ) {
return;
}
if ( ! $value instanceof GooglePrice ) {
throw new UnexpectedValueException( $value, GooglePrice::class );
}
if ( empty( $value->getValue() ) || empty( $value->getCurrency() ) ) {
$this->context->buildViolation( $constraint->message )
->atPath( 'value' )
->addViolation();
}
}
}
Validator/ImageUrlConstraint.php 0000644 00000000702 15153721357 0012764 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Validator;
use Symfony\Component\Validator\Constraints\Url as UrlConstraint;
defined( 'ABSPATH' ) || exit;
/**
* Class ImageUrlConstraint
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Validator
*/
class ImageUrlConstraint extends UrlConstraint {
/**
* @var string
*/
public $message = 'Product image "{{ name }}" is not a valid name.';
}
Validator/ImageUrlConstraintValidator.php 0000644 00000011436 15153721357 0014640 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Validator;
use Normalizer;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\UrlValidator as UrlConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
defined( 'ABSPATH' ) || exit;
/**
* Class ImageUrlConstraintValidator
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Validator
*/
class ImageUrlConstraintValidator extends UrlConstraintValidator {
public const IMAGE_PATTERN = '~^
(%s):// # protocol
(((?:[\_\.\pL\pN-]|%%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|%%[0-9A-Fa-f]{2})+)@)? # basic auth
(
(?:
(?:xn--[a-z0-9-]++\.)*+xn--[a-z0-9-]++ # a domain name using punycode
|
(?:[\pL\pN\pS\pM\-\_]++\.)+[\pL\pN\pM]++ # a multi-level domain name
|
[a-z0-9\-\_]++ # a single-level domain name
)\.?
| # or
\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} # an IP address
| # or
\[
(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))
\] # an IPv6 address
)
(:[0-9]+)? # a port (optional)
(?:/ (?:[\pL\pN\-._\~!$&\'()*+,;=:@]|%%[0-9A-Fa-f]{2})* )* # a path
(?:\? (?:[\pL\pN\-._\~!$&\'\[\]()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a query (optional)
(?:\# (?:[\pL\pN\-._\~!$&\'()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a fragment (optional)
$~ixuD';
/**
* Checks if the passed value is valid.
*
* @param string $value The value that should be validated
* @param Constraint $constraint
*
* @throws UnexpectedTypeException If invalid constraint provided.
* @throws UnexpectedValueException If invalid value provided.
*/
public function validate( $value, Constraint $constraint ) {
if ( ! $constraint instanceof ImageUrlConstraint ) {
throw new UnexpectedTypeException( $constraint, ImageUrlConstraint::class );
}
if ( null === $value || '' === $value ) {
return;
}
if ( ! is_scalar( $value ) && ! ( is_object( $value ) && method_exists( $value, '__toString' ) ) ) {
throw new UnexpectedValueException( $value, 'string' );
}
$value = (string) $value;
if ( '' === $value ) {
return;
}
$value = Normalizer::normalize( $value );
$pattern = sprintf( static::IMAGE_PATTERN, implode( '|', $constraint->protocols ) );
if ( ! preg_match( $pattern, $value ) ) {
$this->context->buildViolation( $constraint->message )
->setParameter( '{{ name }}', wp_basename( $value ) )
->setCode( ImageUrlConstraint::INVALID_URL_ERROR )
->addViolation();
return;
}
}
}
Validator/Validatable.php 0000644 00000000664 15153721357 0011431 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Validator;
use Symfony\Component\Validator\Mapping\ClassMetadata;
defined( 'ABSPATH' ) || exit;
/**
* Interface Validatable
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Validator
*/
interface Validatable {
/**
* @param ClassMetadata $metadata
*/
public static function load_validator_metadata( ClassMetadata $metadata );
}
Value/ArrayWithRequiredKeys.php 0000644 00000002166 15153721357 0012616 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidArray;
defined( 'ABSPATH' ) || exit;
/**
* Class ArrayWithRequiredKeys
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
abstract class ArrayWithRequiredKeys {
/**
* The provided data.
*
* @var array
*/
protected $data;
/**
* Array of required keys. Should be in key => value format.
*
* @var array
*/
protected $required_keys = [];
/**
* ArrayWithRequiredKeys constructor.
*
* @param array $data
*/
public function __construct( array $data ) {
$this->validate_keys( $data );
$this->data = $data;
}
/**
* Validate that we have all of the keys that we require.
*
* @param array $data Array of provided data.
*
* @throws InvalidArray When any of the required keys are missing.
*/
protected function validate_keys( array $data ) {
$missing = array_diff_key( $this->required_keys, $data );
if ( ! empty( $missing ) ) {
throw InvalidArray::missing_keys( static::class, array_keys( $missing ) );
}
}
}
Value/BuiltScriptDependencyArray.php 0000644 00000001710 15153721357 0013603 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
defined( 'ABSPATH' ) || exit;
/**
* Class BuiltScriptDependencyArray
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class BuiltScriptDependencyArray extends ArrayWithRequiredKeys implements ValueInterface {
/**
* Array of required keys. Should be in key => value format.
*
* @var array
*/
protected $required_keys = [
'version' => true,
'dependencies' => true,
];
/**
* Get the value of the object.
*
* @return array
*/
public function get(): array {
return $this->data;
}
/**
* Get the version from the dependency array.
*
* @return string
*/
public function get_version(): string {
return $this->data['version'];
}
/**
* Get the array of dependencies from the dependency array.
*
* @return array
*/
public function get_dependencies(): array {
return $this->data['dependencies'];
}
}
Value/CastableValueInterface.php 0000644 00000000721 15153721357 0012676 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
defined( 'ABSPATH' ) || exit;
/**
* Interface CastableValueInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
interface CastableValueInterface {
/**
* Cast a value and return a new instance of the class.
*
* @param mixed $value Mixed value to cast to class type.
*
* @return self
*/
public static function cast( $value );
}
Value/ChannelVisibility.php 0000644 00000003602 15153721357 0011763 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class ChannelVisibility
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class ChannelVisibility implements CastableValueInterface, ValueInterface {
public const SYNC_AND_SHOW = 'sync-and-show';
public const DONT_SYNC_AND_SHOW = 'dont-sync-and-show';
public const ALLOWED_VALUES = [
self::SYNC_AND_SHOW,
self::DONT_SYNC_AND_SHOW,
];
/**
* @var string
*/
protected $visibility;
/**
* ChannelVisibility constructor.
*
* @param string $visibility The value.
*
* @throws InvalidValue When an invalid visibility type is provided.
*/
public function __construct( string $visibility ) {
if ( ! in_array( $visibility, self::ALLOWED_VALUES, true ) ) {
throw InvalidValue::not_in_allowed_list( $visibility, self::ALLOWED_VALUES );
}
$this->visibility = $visibility;
}
/**
* Get the value of the object.
*
* @return string
*/
public function get(): string {
return $this->visibility;
}
/**
* Cast a value and return a new instance of the class.
*
* @param string $value Mixed value to cast to class type.
*
* @return ChannelVisibility
*
* @throws InvalidValue When an invalid visibility type is provided.
*/
public static function cast( $value ): ChannelVisibility {
return new self( $value );
}
/**
* Return an array of the values with option labels.
*
* @return array
*/
public static function get_value_options(): array {
return [
self::SYNC_AND_SHOW => __( 'Sync and show', 'google-listings-and-ads' ),
self::DONT_SYNC_AND_SHOW => __( 'Don\'t Sync and show', 'google-listings-and-ads' ),
];
}
/**
* @return string
*/
public function __toString(): string {
return $this->get();
}
}
Value/EnumeratedValues.php 0000644 00000002100 15153721357 0011604 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidType;
defined( 'ABSPATH' ) || exit;
/**
* Class EnumeratedValues
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
abstract class EnumeratedValues {
/** @var string */
protected $value;
/**
* Array of possible valid values.
*
* Data will be validated to ensure it is one of these values.
*
* @var array
*/
protected $valid_values = [];
/**
* EnumeratedValues constructor.
*
* @param string $value
*/
public function __construct( string $value ) {
$this->validate_value( $value );
$this->value = $value;
}
/**
* Validate the that value we received is one of the valid types.
*
* @param string $value
*
* @throws InvalidType When the value is not valid.
*/
protected function validate_value( string $value ) {
if ( ! array_key_exists( $value, $this->valid_values ) ) {
throw InvalidType::from_type( $value, array_keys( $this->valid_values ) );
}
}
}
Value/MCStatus.php 0000644 00000002631 15153721357 0010047 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class MCStatus
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class MCStatus implements ValueInterface {
public const APPROVED = 'approved';
public const PARTIALLY_APPROVED = 'partially_approved';
public const EXPIRING = 'expiring';
public const PENDING = 'pending';
public const DISAPPROVED = 'disapproved';
public const NOT_SYNCED = 'not_synced';
public const ALLOWED_VALUES = [
self::APPROVED,
self::PARTIALLY_APPROVED,
self::PENDING,
self::EXPIRING,
self::DISAPPROVED,
self::NOT_SYNCED,
];
/**
* @var string
*/
protected $status;
/**
* MCStatus constructor.
*
* @param string $status The value.
*
* @throws InvalidValue When an invalid status type is provided.
*/
public function __construct( string $status ) {
if ( ! in_array( $status, self::ALLOWED_VALUES, true ) ) {
throw InvalidValue::not_in_allowed_list( $status, self::ALLOWED_VALUES );
}
$this->status = $status;
}
/**
* Get the value of the object.
*
* @return string
*/
public function get(): string {
return $this->status;
}
/**
* @return string
*/
public function __toString(): string {
return $this->get();
}
}
Value/NotificationStatus.php 0000644 00000003160 15153721357 0012174 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class Notification Status defining statues related to Partner Notifications
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class NotificationStatus implements ValueInterface {
public const NOTIFICATION_PENDING_CREATE = 'pending_create';
public const NOTIFICATION_PENDING_UPDATE = 'pending_update';
public const NOTIFICATION_PENDING_DELETE = 'pending_delete';
public const NOTIFICATION_CREATED = 'created';
public const NOTIFICATION_UPDATED = 'updated';
public const NOTIFICATION_DELETED = 'deleted';
public const ALLOWED_VALUES = [
self::NOTIFICATION_PENDING_CREATE,
self::NOTIFICATION_PENDING_UPDATE,
self::NOTIFICATION_PENDING_DELETE,
self::NOTIFICATION_CREATED,
self::NOTIFICATION_DELETED,
self::NOTIFICATION_UPDATED,
];
/**
* @var string
*/
protected $status;
/**
* NotificationStatus constructor.
*
* @param string $status The value.
*
* @throws InvalidValue When an invalid status type is provided.
*/
public function __construct( string $status ) {
if ( ! in_array( $status, self::ALLOWED_VALUES, true ) ) {
throw InvalidValue::not_in_allowed_list( $status, self::ALLOWED_VALUES );
}
$this->status = $status;
}
/**
* Get the value of the object.
*
* @return string
*/
public function get(): string {
return $this->status;
}
/**
* @return string
*/
public function __toString(): string {
return $this->get();
}
}
Value/PhoneNumber.php 0000644 00000004041 15153721357 0010563 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class PhoneNumber
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*
* @since 1.5.0
*/
class PhoneNumber implements CastableValueInterface, ValueInterface {
/**
* @var string
*/
protected $value;
/**
* PhoneNumber constructor.
*
* @param string $value The value.
*
* @throws InvalidValue When an invalid phone number is provided.
*/
public function __construct( string $value ) {
if ( ! self::validate_phone_number( $value ) ) {
throw new InvalidValue( 'Invalid phone number!' );
}
$this->value = self::sanitize_phone_number( $value );
}
/**
* Get the value of the object.
*
* @return string
*/
public function get(): string {
return $this->value;
}
/**
* Cast a value and return a new instance of the class.
*
* @param mixed $value Mixed value to cast to class type.
*
* @return PhoneNumber
*/
public static function cast( $value ): PhoneNumber {
return new self( self::sanitize_phone_number( $value ) );
}
/**
* Validate that the phone number doesn't contain invalid characters.
* Allowed: ()-.0123456789 and space
*
* @param string|int $phone_number The phone number to validate.
*
* @return bool
*/
public static function validate_phone_number( $phone_number ): bool {
// Disallowed characters.
if ( is_string( $phone_number ) && preg_match( '/[^0-9() \-.+]/', $phone_number ) ) {
return false;
}
// Don't allow integer 0
return ! empty( $phone_number );
}
/**
* Sanitize the phone number, leaving only `+` (plus) and numbers.
*
* @param string|int $phone_number The phone number to sanitize.
*
* @return string
*/
public static function sanitize_phone_number( $phone_number ): string {
return preg_replace( '/[^+0-9]/', '', "$phone_number" );
}
/**
* @return string
*/
public function __toString() {
return $this->get();
}
}
Value/PositiveInteger.php 0000644 00000002165 15153721357 0011466 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class PositiveInteger
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class PositiveInteger implements CastableValueInterface, ValueInterface {
/**
* @var int
*/
protected $value;
/**
* PositiveInteger constructor.
*
* @param int $value The value.
*
* @throws InvalidValue When a negative integer is provided.
*/
public function __construct( int $value ) {
$abs_value = absint( $value );
if ( $abs_value !== $value ) {
throw InvalidValue::negative_integer( __METHOD__ );
}
$this->value = $value;
}
/**
* Get the value of the object.
*
* @return int
*/
public function get() {
return $this->value;
}
/**
* Cast a value and return a new instance of the class.
*
* @param mixed $value Mixed value to cast to class type.
*
* @return PositiveInteger
*/
public static function cast( $value ): PositiveInteger {
return new self( absint( $value ) );
}
}
Value/ProductIDMap.php 0000644 00000003745 15153721357 0010646 0 ustar 00 <?php
declare( strict_types=1 );
// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use ArrayObject;
defined( 'ABSPATH' ) || exit;
/**
* Class ProductIDMap
*
* Represents an array of WooCommerce product IDs mapped to Google product IDs as their key.
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class ProductIDMap extends ArrayObject implements ValueInterface {
/**
* ProductIDMap constructor.
*
* @param int[] $product_ids An array of WooCommerce product IDs mapped to Google product IDs as their key.
* @param int $flags Flags to control the behaviour of the ArrayObject object.
* @param string $iteratorClass Specify the class that will be used for iteration of the ArrayObject object. ArrayIterator is the default class used.
*
* @throws InvalidValue When an invalid WooCommerce product ID or Google product ID is provided in the map.
*/
public function __construct( $product_ids = [], $flags = 0, $iteratorClass = 'ArrayIterator' ) {
$this->validate( $product_ids );
parent::__construct( $product_ids, $flags, $iteratorClass );
}
/**
* Get the value of the object.
*
* @return array
*/
public function get(): array {
return $this->getArrayCopy();
}
/**
* @param int[] $product_ids An array of WooCommerce product IDs mapped to Google product IDs as their key.
*
* @throws InvalidValue When an invalid WooCommerce product ID or Google product ID is provided in the map.
*/
protected function validate( array $product_ids ) {
foreach ( $product_ids as $google_id => $wc_product_id ) {
$wc_product_id = filter_var( $wc_product_id, FILTER_VALIDATE_INT );
if ( false === $wc_product_id ) {
throw InvalidValue::not_integer( 'product_id' );
}
if ( ! is_string( $google_id ) ) {
throw InvalidValue::not_string( 'google_id' );
}
}
}
}
Value/RateType.php 0000644 00000001154 15153721357 0010100 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
defined( 'ABSPATH' ) || exit;
/**
* Class RateType
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class RateType extends EnumeratedValues implements ValueInterface {
/**
* Array of possible valid values.
*
* Data will be validated to ensure it is one of these values.
*
* @var array
*/
protected $valid_values = [
'flat' => true,
'manual' => true,
];
/**
* Get the value of the object.
*
* @return mixed
*/
public function get(): string {
return $this->value;
}
}
Value/SyncStatus.php 0000644 00000002343 15153721357 0010464 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
defined( 'ABSPATH' ) || exit;
/**
* Class SyncStatus
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class SyncStatus implements ValueInterface {
public const SYNCED = 'synced';
public const NOT_SYNCED = 'not-synced';
public const HAS_ERRORS = 'has-errors';
public const PENDING = 'pending';
public const ALLOWED_VALUES = [
self::SYNCED,
self::PENDING,
self::HAS_ERRORS,
self::NOT_SYNCED,
];
/**
* @var string
*/
protected $status;
/**
* SyncStatus constructor.
*
* @param string $status The value.
*
* @throws InvalidValue When an invalid status type is provided.
*/
public function __construct( string $status ) {
if ( ! in_array( $status, self::ALLOWED_VALUES, true ) ) {
throw InvalidValue::not_in_allowed_list( $status, self::ALLOWED_VALUES );
}
$this->status = $status;
}
/**
* Get the value of the object.
*
* @return string
*/
public function get(): string {
return $this->status;
}
/**
* @return string
*/
public function __toString(): string {
return $this->get();
}
}
Value/TosAccepted.php 0000644 00000001712 15153721357 0010541 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
defined( 'ABSPATH' ) || exit;
/**
* Class TosAccepted
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
class TosAccepted implements ValueInterface {
/**
* @var bool
*/
protected $accepted;
/**
* @var string
*/
protected $message;
/**
* TosAccepted constructor.
*
* @param bool $accepted
* @param string $message
*/
public function __construct( bool $accepted, string $message = '' ) {
$this->accepted = $accepted;
$this->message = $message;
}
/**
* Get the value of the object.
*
* @return mixed
*/
public function get(): array {
return [
'accepted' => $this->accepted,
'message' => $this->message,
];
}
/**
* @return bool
*/
public function accepted(): bool {
return $this->accepted;
}
/**
* @return string
*/
public function message(): string {
return $this->message;
}
}
Value/ValueInterface.php 0000644 00000000533 15153721357 0011240 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Value;
defined( 'ABSPATH' ) || exit;
/**
* Interface ValueInterface
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Value
*/
interface ValueInterface {
/**
* Get the value of the object.
*
* @return mixed
*/
public function get();
}
View/PHPView.php 0000644 00000014470 15153721357 0007470 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\View;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\View;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\ViewFactory;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class PHPView
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\View
*/
class PHPView implements View {
use PluginHelper;
/**
* Extension to use for view files.
*/
protected const VIEW_EXTENSION = 'php';
/**
* Path to the view file to render.
*
* @var string
*/
protected $path;
/**
* Internal storage for passed-in context.
*
* @var array
*/
protected $context = [];
/**
* @var ViewFactory
*/
protected $view_factory;
/**
* PHPView constructor.
*
* @param string $path Path to the view file to render.
* @param ViewFactory $view_factory View factory instance to use.
*
* @throws ViewException If an invalid path was passed into the View.
*/
public function __construct( string $path, ViewFactory $view_factory ) {
$this->path = $this->validate( $path );
$this->view_factory = $view_factory;
}
/**
* Render the current view with a given context.
*
* @param array $context Context in which to render.
*
* @return string Rendered HTML.
*
* @throws ViewException If the view could not be loaded.
*/
public function render( array $context = [] ): string {
// Add entire context as array to the current instance to pass onto
// partial views.
$this->context = $context;
// Save current buffering level so we can backtrack in case of an error.
// This is needed because the view itself might also add an unknown
// number of output buffering levels.
$buffer_level = ob_get_level();
ob_start();
try {
include $this->path;
} catch ( Exception $exception ) {
// Remove whatever levels were added up until now.
while ( ob_get_level() > $buffer_level ) {
ob_end_clean();
}
do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
throw ViewException::invalid_view_exception(
$this->path,
$exception
);
}
return ob_get_clean() ?: '';
}
/**
* Render a partial view.
*
* This can be used from within a currently rendered view, to include
* nested partials.
*
* The passed-in context is optional, and will fall back to the parent's
* context if omitted.
*
* @param string $path Path of the partial to render.
* @param array|null $context Context in which to render the partial.
*
* @return string Rendered HTML.
*
* @throws ViewException If the view could not be loaded or the provided path was not valid.
*/
public function render_partial( string $path, ?array $context = null ): string {
return $this->view_factory->create( $path )->render( $context ?: $this->context );
}
/**
* Return the raw value of a context property.
*
* By default, properties are automatically escaped when accessing them
* within the view. This method allows direct access to the raw value
* instead to bypass this automatic escaping.
*
* @param string $property Property for which to return the raw value.
*
* @return mixed Raw context property value.
*
* @throws ViewException If a requested property is not recognized (only in debugging mode).
*/
public function raw( string $property ) {
if ( array_key_exists( $property, $this->context ) ) {
return $this->context[ $property ];
}
do_action( 'woocommerce_gla_error', sprintf( 'View property "%s" is missing or undefined.', $property ), __METHOD__ );
/*
* We only throw an exception here if we are in debugging mode, as we
* don't want to take the server down when trying to render a missing
* property.
*/
if ( $this->is_debug_mode() ) {
throw ViewException::invalid_context_property( $property );
}
return null;
}
/**
* Validate a path.
*
* @param string $path Path to validate.
*
* @return string Validated path.
*
* @throws ViewException If an invalid path was passed into the View.
*/
protected function validate( string $path ): string {
$path = $this->check_extension( $path, static::VIEW_EXTENSION );
$path = path_join( $this->get_views_base_path(), $path );
if ( ! is_readable( $path ) ) {
do_action( 'woocommerce_gla_error', sprintf( 'View not found in path "%s".', $path ), __METHOD__ );
throw ViewException::invalid_path( $path );
}
return $path;
}
/**
* Check that the path has the correct extension.
*
* Optionally adds the extension if none was detected.
*
* @param string $path Path to check the extension of.
* @param string $extension Extension to use.
*
* @return string Path with correct extension.
*/
protected function check_extension( string $path, string $extension ): string {
$detected_extension = pathinfo( $path, PATHINFO_EXTENSION );
if ( $extension !== $detected_extension ) {
$path .= '.' . $extension;
}
return $path;
}
/**
* Use magic getter to provide automatic escaping by default.
*
* Use the raw() method to skip automatic escaping.
*
* @param string $property Property to get.
*
* @return mixed
*
* @throws ViewException If a requested property is not recognized (only in debugging mode).
*/
public function __get( string $property ) {
if ( array_key_exists( $property, $this->context ) ) {
return $this->sanitize_context_variable( $this->context[ $property ] );
}
do_action( 'woocommerce_gla_error', sprintf( 'View property "%s" is missing or undefined.', $property ), __METHOD__ );
/*
* We only throw an exception here if we are in debugging mode, as we
* don't want to take the server down when trying to render a missing
* property.
*/
if ( $this->is_debug_mode() ) {
throw ViewException::invalid_context_property( $property );
}
return null;
}
/**
* @param mixed $variable
*/
protected function sanitize_context_variable( $variable ) {
if ( is_array( $variable ) ) {
return array_map( [ $this, 'sanitize_context_variable' ], $variable );
} else {
return ! is_bool( $variable ) && is_scalar( $variable ) ? sanitize_text_field( $variable ) : $variable;
}
}
/**
* @return string
*/
protected function get_views_base_path(): string {
return path_join( dirname( __DIR__, 2 ), 'views' );
}
}
View/PHPViewFactory.php 0000644 00000001460 15153721357 0011013 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\View;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\View;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\ViewFactory;
defined( 'ABSPATH' ) || exit;
/**
* Class PHPViewFactory
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\View
*/
final class PHPViewFactory implements Service, ViewFactory {
/**
* Create a new view object.
*
* @param string $path Path to the view file to render.
*
* @return View Instantiated view object.
*
* @throws ViewException If an invalid path was passed into the View.
*/
public function create( string $path ): View {
return new PHPView( $path, $this );
}
}
View/ViewException.php 0000644 00000003555 15153721357 0011001 0 ustar 00 <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\View;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Class ViewException
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Exception
*/
class ViewException extends Exception implements GoogleListingsAndAdsException {
/**
* Create a new instance of the exception if the view file itself created
* an exception.
*
* @param string $uri URI of the file that is not accessible or
* not readable.
* @param Exception $exception Exception that was thrown by the view file.
*
* @return static
*/
public static function invalid_view_exception( string $uri, Exception $exception ) {
$message = sprintf(
'Could not load the View URI "%1$s". Reason: "%2$s".',
$uri,
$exception->getMessage()
);
return new static( $message, (int) $exception->getCode(), $exception );
}
/**
* Create a new instance of the exception for a file that is not accessible
* or not readable.
*
* @param string $path Path of the file that is not accessible or not
* readable.
*
* @return static
*/
public static function invalid_path( $path ) {
$message = sprintf(
'The view path "%s" is not accessible or readable.',
$path
);
return new static( $message );
}
/**
* Create a new instance of the exception for a context property that is
* not recognized.
*
* @param string $property Name of the context property that was not recognized.
*
* @return static
*/
public static function invalid_context_property( string $property ) {
$message = sprintf(
'The property "%s" could not be found within the context of the currently rendered view.',
$property
);
return new static( $message );
}
}
activation.cls.php 0000644 00000035636 15153741266 0010225 0 ustar 00 <?php
/**
* The plugin activation class.
*
* @since 1.1.0
* @since 1.5 Moved into /inc
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Activation extends Base
{
const TYPE_UPGRADE = 'upgrade';
const TYPE_INSTALL_3RD = 'install_3rd';
const TYPE_INSTALL_ZIP = 'install_zip';
const TYPE_DISMISS_RECOMMENDED = 'dismiss_recommended';
const NETWORK_TRANSIENT_COUNT = 'lscwp_network_count';
private static $_data_file;
/**
* Construct
*
* @since 4.1
*/
public function __construct()
{
self::$_data_file = LSCWP_CONTENT_DIR . '/' . self::CONF_FILE;
}
/**
* The activation hook callback.
*
* @since 1.0.0
* @access public
*/
public static function register_activation()
{
global $wp_version;
$advanced_cache = LSCWP_CONTENT_DIR . '/advanced-cache.php';
if (version_compare($wp_version, '5.3', '<') && !file_exists($advanced_cache)) {
$file_pointer = fopen($advanced_cache, 'w');
fwrite($file_pointer, "<?php\n\n// A compatibility placeholder for WordPress < v5.3\n// Created by LSCWP v6.1+");
fclose($file_pointer);
}
$count = 0;
!defined('LSCWP_LOG_TAG') && define('LSCWP_LOG_TAG', 'Activate_' . get_current_blog_id());
/* Network file handler */
if (is_multisite()) {
$count = self::get_network_count();
if ($count !== false) {
$count = intval($count) + 1;
set_site_transient(self::NETWORK_TRANSIENT_COUNT, $count, DAY_IN_SECONDS);
}
if (!is_network_admin()) {
if ($count === 1) {
// Only itself is activated, set .htaccess with only CacheLookUp
try {
Htaccess::cls()->insert_ls_wrapper();
} catch (\Exception $ex) {
Admin_Display::error($ex->getMessage());
}
}
}
}
self::cls()->update_files();
if (defined('LSCWP_REF') && LSCWP_REF == 'whm') {
GUI::update_option(GUI::WHM_MSG, GUI::WHM_MSG_VAL);
}
}
/**
* Uninstall plugin
* @since 1.1.0
*/
public static function uninstall_litespeed_cache()
{
Task::destroy();
// Delete options
foreach (Conf::cls()->load_default_vals() as $k => $v) {
Base::delete_option($k);
}
// Delete site options
if (is_multisite()) {
foreach (Conf::cls()->load_default_site_vals() as $k => $v) {
Base::delete_site_option($k);
}
}
// Delete avatar table
Data::cls()->tables_del();
if (file_exists(LITESPEED_STATIC_DIR)) {
File::rrmdir(LITESPEED_STATIC_DIR);
}
Cloud::version_check('uninstall');
// Files has been deleted when deactivated
}
/**
* Get the blog ids for the network. Accepts function arguments.
*
* Will use wp_get_sites for WP versions less than 4.6
*
* @since 1.0.12
* @access public
* @return array The array of blog ids.
*/
public static function get_network_ids($args = array())
{
global $wp_version;
if (version_compare($wp_version, '4.6', '<')) {
$blogs = wp_get_sites($args);
if (!empty($blogs)) {
foreach ($blogs as $key => $blog) {
$blogs[$key] = $blog['blog_id'];
}
}
} else {
$args['fields'] = 'ids';
$blogs = get_sites($args);
}
return $blogs;
}
/**
* Gets the count of active litespeed cache plugins on multisite.
*
* @since 1.0.12
* @access private
*/
private static function get_network_count()
{
$count = get_site_transient(self::NETWORK_TRANSIENT_COUNT);
if ($count !== false) {
return intval($count);
}
// need to update
$default = array();
$count = 0;
$sites = self::get_network_ids(array('deleted' => 0));
if (empty($sites)) {
return false;
}
foreach ($sites as $site) {
$bid = is_object($site) && property_exists($site, 'blog_id') ? $site->blog_id : $site;
$plugins = get_blog_option($bid, 'active_plugins', $default);
if (!empty($plugins) && in_array(LSCWP_BASENAME, $plugins, true)) {
$count++;
}
}
/**
* In case this is called outside the admin page
* @see https://codex.wordpress.org/Function_Reference/is_plugin_active_for_network
* @since 2.0
*/
if (!function_exists('is_plugin_active_for_network')) {
require_once ABSPATH . '/wp-admin/includes/plugin.php';
}
if (is_plugin_active_for_network(LSCWP_BASENAME)) {
$count++;
}
return $count;
}
/**
* Is this deactivate call the last active installation on the multisite network?
*
* @since 1.0.12
* @access private
*/
private static function is_deactivate_last()
{
$count = self::get_network_count();
if ($count === false) {
return false;
}
if ($count !== 1) {
// Not deactivating the last one.
$count--;
set_site_transient(self::NETWORK_TRANSIENT_COUNT, $count, DAY_IN_SECONDS);
return false;
}
delete_site_transient(self::NETWORK_TRANSIENT_COUNT);
return true;
}
/**
* The deactivation hook callback.
*
* Initializes all clean up functionalities.
*
* @since 1.0.0
* @access public
*/
public static function register_deactivation()
{
Task::destroy();
!defined('LSCWP_LOG_TAG') && define('LSCWP_LOG_TAG', 'Deactivate_' . get_current_blog_id());
Purge::purge_all();
if (is_multisite()) {
if (!self::is_deactivate_last()) {
if (is_network_admin()) {
// Still other activated subsite left, set .htaccess with only CacheLookUp
try {
Htaccess::cls()->insert_ls_wrapper();
} catch (\Exception $ex) {
Admin_Display::error($ex->getMessage());
}
}
return;
}
}
/* 1) wp-config.php; */
try {
self::cls()->_manage_wp_cache_const(false);
} catch (\Exception $ex) {
error_log('In wp-config.php: WP_CACHE could not be set to false during deactivation!');
Admin_Display::error($ex->getMessage());
}
/* 2) adv-cache.php; Dropped in v3.0.4 */
/* 3) object-cache.php; */
Object_Cache::cls()->del_file();
/* 4) .htaccess; */
try {
Htaccess::cls()->clear_rules();
} catch (\Exception $ex) {
Admin_Display::error($ex->getMessage());
}
/* 5) .litespeed_conf.dat; */
self::_del_conf_data_file();
// delete in case it's not deleted prior to deactivation.
GUI::dismiss_whm();
}
/**
* Manage related files based on plugin latest conf
*
* NOTE: Only trigger this in backend admin access for efficiency concern
*
* Handle files:
* 1) wp-config.php;
* 2) adv-cache.php;
* 3) object-cache.php;
* 4) .htaccess;
* 5) .litespeed_conf.dat;
*
* @since 3.0
* @access public
*/
public function update_files()
{
Debug2::debug('🗂️ [Activation] update_files');
// Update cache setting `_CACHE`
$this->cls('Conf')->define_cache();
// Site options applied already
$options = $this->get_options();
/* 1) wp-config.php; */
try {
$this->_manage_wp_cache_const($options[self::_CACHE]);
} catch (\Exception $ex) {
// Add msg to admin page or CLI
Admin_Display::error($ex->getMessage());
}
/* 2) adv-cache.php; Dropped in v3.0.4 */
/* 3) object-cache.php; */
if ($options[self::O_OBJECT] && (!$options[self::O_DEBUG_DISABLE_ALL] || is_multisite())) {
$this->cls('Object_Cache')->update_file($options);
} else {
$this->cls('Object_Cache')->del_file(); // Note: because it doesn't reconnect, which caused setting page OC option changes delayed, thus may meet Connect Test Failed issue (Next refresh will correct it). Not a big deal, will keep as is.
}
/* 4) .htaccess; */
try {
$this->cls('Htaccess')->update($options);
} catch (\Exception $ex) {
Admin_Display::error($ex->getMessage());
}
/* 5) .litespeed_conf.dat; */
if (($options[self::O_GUEST] || $options[self::O_OBJECT]) && (!$options[self::O_DEBUG_DISABLE_ALL] || is_multisite())) {
$this->_update_conf_data_file($options);
}
}
/**
* Delete data conf file
*
* @since 4.1
*/
private static function _del_conf_data_file()
{
if (file_exists(self::$_data_file)) {
unlink(self::$_data_file);
}
}
/**
* Update data conf file for guest mode & object cache
*
* @since 4.1
*/
private function _update_conf_data_file($options)
{
$ids = array();
if ($options[self::O_OBJECT]) {
$this_ids = array(
self::O_DEBUG,
self::O_OBJECT_KIND,
self::O_OBJECT_HOST,
self::O_OBJECT_PORT,
self::O_OBJECT_LIFE,
self::O_OBJECT_USER,
self::O_OBJECT_PSWD,
self::O_OBJECT_DB_ID,
self::O_OBJECT_PERSISTENT,
self::O_OBJECT_ADMIN,
self::O_OBJECT_TRANSIENTS,
self::O_OBJECT_GLOBAL_GROUPS,
self::O_OBJECT_NON_PERSISTENT_GROUPS,
);
$ids = array_merge($ids, $this_ids);
}
if ($options[self::O_GUEST]) {
$this_ids = array(self::HASH, self::O_CACHE_LOGIN_COOKIE, self::O_DEBUG_IPS, self::O_UTIL_NO_HTTPS_VARY, self::O_GUEST_UAS, self::O_GUEST_IPS);
$ids = array_merge($ids, $this_ids);
}
$data = array();
foreach ($ids as $v) {
$data[$v] = $options[$v];
}
$data = \json_encode($data);
$old_data = File::read(self::$_data_file);
if ($old_data != $data) {
defined('LSCWP_LOG') && Debug2::debug('[Activation] Updating .litespeed_conf.dat');
File::save(self::$_data_file, $data);
}
}
/**
* Update the WP_CACHE variable in the wp-config.php file.
*
* If enabling, check if the variable is defined, and if not, define it.
* Vice versa for disabling.
*
* @since 1.0.0
* @since 3.0 Refactored
* @access private
*/
private function _manage_wp_cache_const($enable)
{
if ($enable) {
if (defined('WP_CACHE') && WP_CACHE) {
return false;
}
} elseif (!defined('WP_CACHE') || (defined('WP_CACHE') && !WP_CACHE)) {
return false;
}
if (apply_filters('litespeed_wpconfig_readonly', false)) {
throw new \Exception('wp-config file is forbidden to modify due to API hook: litespeed_wpconfig_readonly');
}
/**
* Follow WP's logic to locate wp-config file
* @see wp-load.php
*/
$conf_file = ABSPATH . 'wp-config.php';
if (!file_exists($conf_file)) {
$conf_file = dirname(ABSPATH) . '/wp-config.php';
}
$content = File::read($conf_file);
if (!$content) {
throw new \Exception('wp-config file content is empty: ' . $conf_file);
}
// Remove the line `define('WP_CACHE', true/false);` first
if (defined('WP_CACHE')) {
$content = preg_replace('/define\(\s*([\'"])WP_CACHE\1\s*,\s*\w+\s*\)\s*;/sU', '', $content);
}
// Insert const
if ($enable) {
$content = preg_replace('/^<\?php/', "<?php\ndefine( 'WP_CACHE', true );", $content);
}
$res = File::save($conf_file, $content, false, false, false);
if ($res !== true) {
throw new \Exception('wp-config.php operation failed when changing `WP_CACHE` const: ' . $res);
}
return true;
}
/**
* Handle auto update
*
* @since 2.7.2
* @access public
*/
public function auto_update()
{
if (!$this->conf(Base::O_AUTO_UPGRADE)) {
return;
}
add_filter('auto_update_plugin', array($this, 'auto_update_hook'), 10, 2);
}
/**
* Auto upgrade hook
*
* @since 3.0
* @access public
*/
public function auto_update_hook($update, $item)
{
if (!empty($item->slug) && 'litespeed-cache' === $item->slug) {
$auto_v = Cloud::version_check('auto_update_plugin');
if (!empty($auto_v['latest']) && !empty($item->new_version) && $auto_v['latest'] === $item->new_version) {
return true;
}
}
return $update; // Else, use the normal API response to decide whether to update or not
}
/**
* Upgrade LSCWP
*
* @since 2.9
* @access public
*/
public function upgrade()
{
$plugin = Core::PLUGIN_FILE;
/**
* @see wp-admin/update.php
*/
include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
include_once ABSPATH . 'wp-admin/includes/file.php';
include_once ABSPATH . 'wp-admin/includes/misc.php';
try {
ob_start();
$skin = new \WP_Ajax_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->upgrade($plugin);
if (!is_plugin_active($plugin)) {
// todo: upgrade should reactivate the plugin again by WP. Need to check why disabled after upgraded.
activate_plugin($plugin, '', is_multisite());
}
ob_end_clean();
} catch (\Exception $e) {
Admin_Display::error(__('Failed to upgrade.', 'litespeed-cache'));
return;
}
if (is_wp_error($result)) {
Admin_Display::error(__('Failed to upgrade.', 'litespeed-cache'));
return;
}
Admin_Display::success(__('Upgraded successfully.', 'litespeed-cache'));
}
/**
* Detect if the plugin is active or not
*
* @since 1.0
*/
public function dash_notifier_is_plugin_active($plugin)
{
include_once ABSPATH . 'wp-admin/includes/plugin.php';
$plugin_path = $plugin . '/' . $plugin . '.php';
return is_plugin_active($plugin_path);
}
/**
* Detect if the plugin is installed or not
*
* @since 1.0
*/
public function dash_notifier_is_plugin_installed($plugin)
{
include_once ABSPATH . 'wp-admin/includes/plugin.php';
$plugin_path = $plugin . '/' . $plugin . '.php';
$valid = validate_plugin($plugin_path);
return !is_wp_error($valid);
}
/**
* Grab a plugin info from WordPress
*
* @since 1.0
*/
public function dash_notifier_get_plugin_info($slug)
{
include_once ABSPATH . 'wp-admin/includes/plugin-install.php';
$result = plugins_api('plugin_information', array('slug' => $slug));
if (is_wp_error($result)) {
return false;
}
return $result;
}
/**
* Install the 3rd party plugin
*
* @since 1.0
*/
public function dash_notifier_install_3rd()
{
!defined('SILENCE_INSTALL') && define('SILENCE_INSTALL', true);
$slug = !empty($_GET['plugin']) ? $_GET['plugin'] : false;
// Check if plugin is installed already
if (!$slug || $this->dash_notifier_is_plugin_active($slug)) {
return;
}
/**
* @see wp-admin/update.php
*/
include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
include_once ABSPATH . 'wp-admin/includes/file.php';
include_once ABSPATH . 'wp-admin/includes/misc.php';
$plugin_path = $slug . '/' . $slug . '.php';
if (!$this->dash_notifier_is_plugin_installed($slug)) {
$plugin_info = $this->dash_notifier_get_plugin_info($slug);
if (!$plugin_info) {
return;
}
// Try to install plugin
try {
ob_start();
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->install($plugin_info->download_link);
ob_end_clean();
} catch (\Exception $e) {
return;
}
}
if (!is_plugin_active($plugin_path)) {
activate_plugin($plugin_path);
}
}
/**
* Handle all request actions from main cls
*
* @since 2.9
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_UPGRADE:
$this->upgrade();
break;
case self::TYPE_INSTALL_3RD:
$this->dash_notifier_install_3rd();
break;
case self::TYPE_DISMISS_RECOMMENDED:
Cloud::reload_summary();
Cloud::save_summary(array('news.new' => 0));
break;
case self::TYPE_INSTALL_ZIP:
Cloud::reload_summary();
$summary = Cloud::get_summary();
if (!empty($summary['news.zip'])) {
Cloud::save_summary(array('news.new' => 0));
$this->cls('Debug2')->beta_test($summary['zip']);
}
break;
default:
break;
}
Admin::redirect();
}
}
admin-display.cls.php 0000644 00000106620 15153741266 0010607 0 ustar 00 <?php
/**
* The admin-panel specific functionality of the plugin.
*
*
* @since 1.0.0
* @package LiteSpeed
* @subpackage LiteSpeed/admin
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Admin_Display extends Base
{
const LOG_TAG = '👮♀️';
const NOTICE_BLUE = 'notice notice-info';
const NOTICE_GREEN = 'notice notice-success';
const NOTICE_RED = 'notice notice-error';
const NOTICE_YELLOW = 'notice notice-warning';
const DB_MSG = 'messages';
const DB_MSG_PIN = 'msg_pin';
const PURGEBY_CAT = '0';
const PURGEBY_PID = '1';
const PURGEBY_TAG = '2';
const PURGEBY_URL = '3';
const PURGEBYOPT_SELECT = 'purgeby';
const PURGEBYOPT_LIST = 'purgebylist';
const DB_DISMISS_MSG = 'dismiss';
const RULECONFLICT_ON = 'ExpiresDefault_1';
const RULECONFLICT_DISMISSED = 'ExpiresDefault_0';
const TYPE_QC_HIDE_BANNER = 'qc_hide_banner';
const COOKIE_QC_HIDE_BANNER = 'litespeed_qc_hide_banner';
protected $messages = array();
protected $default_settings = array();
protected $_is_network_admin = false;
protected $_is_multisite = false;
private $_btn_i = 0;
/**
* Initialize the class and set its properties.
*
* @since 1.0.7
*/
public function __construct()
{
// main css
add_action('admin_enqueue_scripts', array($this, 'enqueue_style'));
// Main js
add_action('admin_enqueue_scripts', array($this, 'enqueue_scripts'));
$this->_is_network_admin = is_network_admin();
$this->_is_multisite = is_multisite();
// Quick access menu
if (is_multisite() && $this->_is_network_admin) {
$manage = 'manage_network_options';
} else {
$manage = 'manage_options';
}
if (current_user_can($manage)) {
if (!defined('LITESPEED_DISABLE_ALL') || !LITESPEED_DISABLE_ALL) {
add_action('wp_before_admin_bar_render', array(GUI::cls(), 'backend_shortcut'));
}
// `admin_notices` is after `admin_enqueue_scripts`
// @see wp-admin/admin-header.php
add_action($this->_is_network_admin ? 'network_admin_notices' : 'admin_notices', array($this, 'display_messages'));
}
/**
* In case this is called outside the admin page
* @see https://codex.wordpress.org/Function_Reference/is_plugin_active_for_network
* @since 2.0
*/
if (!function_exists('is_plugin_active_for_network')) {
require_once ABSPATH . '/wp-admin/includes/plugin.php';
}
// add menus ( Also check for mu-plugins)
if ($this->_is_network_admin && (is_plugin_active_for_network(LSCWP_BASENAME) || defined('LSCWP_MU_PLUGIN'))) {
add_action('network_admin_menu', array($this, 'register_admin_menu'));
} else {
add_action('admin_menu', array($this, 'register_admin_menu'));
}
$this->cls('Metabox')->register_settings();
}
/**
* Show the title of one line
*
* @since 3.0
* @access public
*/
public function title($id)
{
echo Lang::title($id);
}
/**
* Register the admin menu display.
*
* @since 1.0.0
* @access public
*/
public function register_admin_menu()
{
$capability = $this->_is_network_admin ? 'manage_network_options' : 'manage_options';
if (current_user_can($capability)) {
// root menu
add_menu_page('LiteSpeed Cache', 'LiteSpeed Cache', 'manage_options', 'litespeed');
// sub menus
$this->_add_submenu(__('Dashboard', 'litespeed-cache'), 'litespeed', 'show_menu_dash');
!$this->_is_network_admin && $this->_add_submenu(__('Presets', 'litespeed-cache'), 'litespeed-presets', 'show_menu_presets');
$this->_add_submenu(__('General', 'litespeed-cache'), 'litespeed-general', 'show_menu_general');
$this->_add_submenu(__('Cache', 'litespeed-cache'), 'litespeed-cache', 'show_menu_cache');
!$this->_is_network_admin && $this->_add_submenu(__('CDN', 'litespeed-cache'), 'litespeed-cdn', 'show_menu_cdn');
$this->_add_submenu(__('Image Optimization', 'litespeed-cache'), 'litespeed-img_optm', 'show_img_optm');
!$this->_is_network_admin && $this->_add_submenu(__('Page Optimization', 'litespeed-cache'), 'litespeed-page_optm', 'show_page_optm');
$this->_add_submenu(__('Database', 'litespeed-cache'), 'litespeed-db_optm', 'show_db_optm');
!$this->_is_network_admin && $this->_add_submenu(__('Crawler', 'litespeed-cache'), 'litespeed-crawler', 'show_crawler');
$this->_add_submenu(__('Toolbox', 'litespeed-cache'), 'litespeed-toolbox', 'show_toolbox');
// sub menus under options
add_options_page('LiteSpeed Cache', 'LiteSpeed Cache', $capability, 'litespeed-cache-options', array($this, 'show_menu_cache'));
}
}
/**
* Helper function to set up a submenu page.
*
* @since 1.0.4
* @access private
* @param string $menu_title The title that appears on the menu.
* @param string $menu_slug The slug of the page.
* @param string $callback The callback to call if selected.
*/
private function _add_submenu($menu_title, $menu_slug, $callback)
{
add_submenu_page('litespeed', $menu_title, $menu_title, 'manage_options', $menu_slug, array($this, $callback));
}
/**
* Register the stylesheets for the admin area.
*
* @since 1.0.14
* @access public
*/
public function enqueue_style()
{
wp_enqueue_style(Core::PLUGIN_NAME, LSWCP_PLUGIN_URL . 'assets/css/litespeed.css', array(), Core::VER, 'all');
}
/**
* Register the JavaScript for the admin area.
*
* @since 1.0.0
* @access public
*/
public function enqueue_scripts()
{
wp_register_script(Core::PLUGIN_NAME, LSWCP_PLUGIN_URL . 'assets/js/litespeed-cache-admin.js', array(), Core::VER, false);
$localize_data = array();
if (GUI::has_whm_msg()) {
$ajax_url_dismiss_whm = Utility::build_url(Core::ACTION_DISMISS, GUI::TYPE_DISMISS_WHM, true);
$localize_data['ajax_url_dismiss_whm'] = $ajax_url_dismiss_whm;
}
if (GUI::has_msg_ruleconflict()) {
$ajax_url = Utility::build_url(Core::ACTION_DISMISS, GUI::TYPE_DISMISS_EXPIRESDEFAULT, true);
$localize_data['ajax_url_dismiss_ruleconflict'] = $ajax_url;
}
$promo_tag = GUI::cls()->show_promo(true);
if ($promo_tag) {
$ajax_url_promo = Utility::build_url(Core::ACTION_DISMISS, GUI::TYPE_DISMISS_PROMO, true, null, array('promo_tag' => $promo_tag));
$localize_data['ajax_url_promo'] = $ajax_url_promo;
}
// Injection to LiteSpeed pages
global $pagenow;
if ($pagenow == 'admin.php' && !empty($_GET['page']) && (strpos($_GET['page'], 'litespeed-') === 0 || $_GET['page'] == 'litespeed')) {
// Admin footer
add_filter('admin_footer_text', array($this, 'admin_footer_text'), 1);
if ($_GET['page'] == 'litespeed-crawler' || $_GET['page'] == 'litespeed-cdn') {
// Babel JS type correction
add_filter('script_loader_tag', array($this, 'babel_type'), 10, 3);
wp_enqueue_script(Core::PLUGIN_NAME . '-lib-react', LSWCP_PLUGIN_URL . 'assets/js/react.min.js', array(), Core::VER, false);
wp_enqueue_script(Core::PLUGIN_NAME . '-lib-babel', LSWCP_PLUGIN_URL . 'assets/js/babel.min.js', array(), Core::VER, false);
}
// Crawler Cookie Simulation
if ($_GET['page'] == 'litespeed-crawler') {
wp_enqueue_script(Core::PLUGIN_NAME . '-crawler', LSWCP_PLUGIN_URL . 'assets/js/component.crawler.js', array(), Core::VER, false);
$localize_data['lang'] = array();
$localize_data['lang']['cookie_name'] = __('Cookie Name', 'litespeed-cache');
$localize_data['lang']['cookie_value'] = __('Cookie Values', 'litespeed-cache');
$localize_data['lang']['one_per_line'] = Doc::one_per_line(true);
$localize_data['lang']['remove_cookie_simulation'] = __('Remove cookie simulation', 'litespeed-cache');
$localize_data['lang']['add_cookie_simulation_row'] = __('Add new cookie to simulate', 'litespeed-cache');
empty($localize_data['ids']) && ($localize_data['ids'] = array());
$localize_data['ids']['crawler_cookies'] = self::O_CRAWLER_COOKIES;
}
// CDN mapping
if ($_GET['page'] == 'litespeed-cdn') {
$home_url = home_url('/');
$parsed = parse_url($home_url);
$home_url = str_replace($parsed['scheme'] . ':', '', $home_url);
$cdn_url = 'https://cdn.' . substr($home_url, 2);
wp_enqueue_script(Core::PLUGIN_NAME . '-cdn', LSWCP_PLUGIN_URL . 'assets/js/component.cdn.js', array(), Core::VER, false);
$localize_data['lang'] = array();
$localize_data['lang']['cdn_mapping_url'] = Lang::title(self::CDN_MAPPING_URL);
$localize_data['lang']['cdn_mapping_inc_img'] = Lang::title(self::CDN_MAPPING_INC_IMG);
$localize_data['lang']['cdn_mapping_inc_css'] = Lang::title(self::CDN_MAPPING_INC_CSS);
$localize_data['lang']['cdn_mapping_inc_js'] = Lang::title(self::CDN_MAPPING_INC_JS);
$localize_data['lang']['cdn_mapping_filetype'] = Lang::title(self::CDN_MAPPING_FILETYPE);
$localize_data['lang']['cdn_mapping_url_desc'] = sprintf(__('CDN URL to be used. For example, %s', 'litespeed-cache'), '<code>' . $cdn_url . '</code>');
$localize_data['lang']['one_per_line'] = Doc::one_per_line(true);
$localize_data['lang']['cdn_mapping_remove'] = __('Remove CDN URL', 'litespeed-cache');
$localize_data['lang']['add_cdn_mapping_row'] = __('Add new CDN URL', 'litespeed-cache');
$localize_data['lang']['on'] = __('ON', 'litespeed-cache');
$localize_data['lang']['off'] = __('OFF', 'litespeed-cache');
empty($localize_data['ids']) && ($localize_data['ids'] = array());
$localize_data['ids']['cdn_mapping'] = self::O_CDN_MAPPING;
}
// If on Server IP setting page, append getIP link
if ($_GET['page'] == 'litespeed-general') {
$localize_data['ajax_url_getIP'] = function_exists('get_rest_url') ? get_rest_url(null, 'litespeed/v1/tool/check_ip') : '/';
$localize_data['nonce'] = wp_create_nonce('wp_rest');
}
// Activate or deactivate a specific crawler
if ($_GET['page'] == 'litespeed-crawler') {
$localize_data['ajax_url_crawler_switch'] = function_exists('get_rest_url') ? get_rest_url(null, 'litespeed/v1/toggle_crawler_state') : '/';
$localize_data['nonce'] = wp_create_nonce('wp_rest');
}
}
if ($localize_data) {
wp_localize_script(Core::PLUGIN_NAME, 'litespeed_data', $localize_data);
}
wp_enqueue_script(Core::PLUGIN_NAME);
}
/**
* Babel type for crawler
*
* @since 3.6
*/
public function babel_type($tag, $handle, $src)
{
if ($handle != Core::PLUGIN_NAME . '-crawler' && $handle != Core::PLUGIN_NAME . '-cdn') {
return $tag;
}
return '<script src="' . Str::trim_quotes($src) . '" type="text/babel"></script>';
}
/**
* Callback that adds LiteSpeed Cache's action links.
*
* @since 1.0.0
* @access public
* @param array $links Previously added links from other plugins.
* @return array Links array with the litespeed cache one appended.
*/
public function add_plugin_links($links)
{
// $links[] = '<a href="' . admin_url('options-general.php?page=litespeed-cache') . '">' . __('Settings', 'litespeed-cache') . '</a>';
$links[] = '<a href="' . admin_url('admin.php?page=litespeed-cache') . '">' . __('Settings', 'litespeed-cache') . '</a>';
return $links;
}
/**
* Change the admin footer text on LiteSpeed Cache admin pages.
*
* @since 1.0.13
* @param string $footer_text
* @return string
*/
public function admin_footer_text($footer_text)
{
require_once LSCWP_DIR . 'tpl/inc/admin_footer.php';
return $footer_text;
}
/**
* Builds the html for a single notice.
*
* @since 1.0.7
* @access public
* @param string $color The color to use for the notice.
* @param string $str The notice message.
* @return string The built notice html.
*/
public static function build_notice($color, $str, $irremovable = false, $additional_classes = '')
{
$cls = $color;
if ($irremovable) {
$cls .= ' litespeed-irremovable';
} else {
$cls .= ' is-dismissible';
}
if ($additional_classes) {
$cls .= ' ' . $additional_classes;
}
// possible translation
$str = Lang::maybe_translate($str);
return '<div class="litespeed_icon ' . $cls . '"><p>' . wp_kses_post($str) . '</p></div>';
}
/**
* Display info notice
*
* @since 1.6.5
* @access public
*/
public static function info($msg, $echo = false, $irremovable = false, $additional_classes = '')
{
self::add_notice(self::NOTICE_BLUE, $msg, $echo, $irremovable, $additional_classes);
}
/**
* Display note notice
*
* @since 1.6.5
* @access public
*/
public static function note($msg, $echo = false, $irremovable = false, $additional_classes = '')
{
self::add_notice(self::NOTICE_YELLOW, $msg, $echo, $irremovable, $additional_classes);
}
/**
* Display success notice
*
* @since 1.6
* @access public
*/
public static function success($msg, $echo = false, $irremovable = false, $additional_classes = '')
{
self::add_notice(self::NOTICE_GREEN, $msg, $echo, $irremovable, $additional_classes);
}
/** @deprecated 4.7 */
/** will drop in v7.5 */
public static function succeed($msg, $echo = false, $irremovable = false, $additional_classes = '')
{
self::success($msg, $echo, $irremovable, $additional_classes);
}
/**
* Display error notice
*
* @since 1.6
* @access public
*/
public static function error($msg, $echo = false, $irremovable = false, $additional_classes = '')
{
self::add_notice(self::NOTICE_RED, $msg, $echo, $irremovable, $additional_classes);
}
/**
* Add irremovable msg
* @since 4.7
*/
public static function add_unique_notice($color_mode, $msgs, $irremovable = false)
{
if (!is_array($msgs)) {
$msgs = array($msgs);
}
$color_map = array(
'info' => self::NOTICE_BLUE,
'note' => self::NOTICE_YELLOW,
'success' => self::NOTICE_GREEN,
'error' => self::NOTICE_RED,
);
if (empty($color_map[$color_mode])) {
self::debug('Wrong admin display color mode!');
return;
}
$color = $color_map[$color_mode];
// Go through to make sure unique
$filtered_msgs = array();
foreach ($msgs as $k => $str) {
if (is_numeric($k)) {
$k = md5($str);
} // Use key to make it overwritable to previous same msg
$filtered_msgs[$k] = $str;
}
self::add_notice($color, $filtered_msgs, false, $irremovable);
}
/**
* Adds a notice to display on the admin page
*
* @since 1.0.7
* @access public
*/
public static function add_notice($color, $msg, $echo = false, $irremovable = false, $additional_classes = '')
{
// self::debug("add_notice msg", $msg);
// Bypass adding for CLI or cron
if (defined('LITESPEED_CLI') || defined('DOING_CRON')) {
// WP CLI will show the info directly
if (defined('WP_CLI') && WP_CLI) {
if (!is_array($msg)) {
$msg = array($msg);
}
foreach ($msg as $v) {
$v = strip_tags($v);
if ($color == self::NOTICE_RED) {
\WP_CLI::error($v, false);
} else {
\WP_CLI::success($v);
}
}
}
return;
}
if ($echo) {
echo self::build_notice($color, $msg, $irremovable, $additional_classes);
return;
}
$msg_name = $irremovable ? self::DB_MSG_PIN : self::DB_MSG;
$messages = self::get_option($msg_name, array());
if (!is_array($messages)) {
$messages = array();
}
if (is_array($msg)) {
foreach ($msg as $k => $str) {
$messages[$k] = self::build_notice($color, $str, $irremovable, $additional_classes);
}
} else {
$messages[] = self::build_notice($color, $msg, $irremovable, $additional_classes);
}
$messages = array_unique($messages);
self::update_option($msg_name, $messages);
}
/**
* Display notices and errors in dashboard
*
* @since 1.1.0
* @access public
*/
public function display_messages()
{
if (!defined('LITESPEED_CONF_LOADED')) {
$this->_in_upgrading();
}
if (GUI::has_whm_msg()) {
$this->show_display_installed();
}
Data::cls()->check_upgrading_msg();
// If is in dev version, always check latest update
Cloud::cls()->check_dev_version();
// One time msg
$messages = self::get_option(self::DB_MSG, array());
$added_thickbox = false;
if (is_array($messages)) {
foreach ($messages as $msg) {
// Added for popup links
if (strpos($msg, 'TB_iframe') && !$added_thickbox) {
add_thickbox();
$added_thickbox = true;
}
echo wp_kses_post($msg);
}
}
if ($messages != -1) {
self::update_option(self::DB_MSG, -1);
}
// Pinned msg
$messages = self::get_option(self::DB_MSG_PIN, array());
if (is_array($messages)) {
foreach ($messages as $k => $msg) {
// Added for popup links
if (strpos($msg, 'TB_iframe') && !$added_thickbox) {
add_thickbox();
$added_thickbox = true;
}
// Append close btn
if (substr($msg, -6) == '</div>') {
$link = Utility::build_url(Core::ACTION_DISMISS, GUI::TYPE_DISMISS_PIN, false, null, array('msgid' => $k));
$msg =
substr($msg, 0, -6) .
'<p><a href="' .
$link .
'" class="button litespeed-btn-primary litespeed-btn-mini">' .
__('Dismiss', 'litespeed-cache') .
'</a>' .
'</p></div>';
}
echo wp_kses_post($msg);
}
}
// if ( $messages != -1 ) {
// self::update_option( self::DB_MSG_PIN, -1 );
// }
if (empty($_GET['page']) || strpos($_GET['page'], 'litespeed') !== 0) {
global $pagenow;
if ($pagenow != 'plugins.php') {
// && $pagenow != 'index.php'
return;
}
}
// Show disable all warning
if (defined('LITESPEED_DISABLE_ALL') && LITESPEED_DISABLE_ALL) {
Admin_Display::error(Error::msg('disabled_all'), true);
}
if (!$this->conf(self::O_NEWS)) {
return;
}
// Show promo from cloud
Cloud::cls()->show_promo();
/**
* Check promo msg first
* @since 2.9
*/
GUI::cls()->show_promo();
// Show version news
Cloud::cls()->news();
}
/**
* Dismiss pinned msg
*
* @since 3.5.2
* @access public
*/
public static function dismiss_pin()
{
if (!isset($_GET['msgid'])) {
return;
}
$messages = self::get_option(self::DB_MSG_PIN, array());
if (!is_array($messages) || empty($messages[$_GET['msgid']])) {
return;
}
unset($messages[$_GET['msgid']]);
if (!$messages) {
$messages = -1;
}
self::update_option(self::DB_MSG_PIN, $messages);
}
/**
* Dismiss pinned msg by msg content
*
* @since 7.0
* @access public
*/
public static function dismiss_pin_by_content($content, $color, $irremovable)
{
$content = self::build_notice($color, $content, $irremovable);
$messages = self::get_option(self::DB_MSG_PIN, array());
$hit = false;
if ($messages != -1) {
foreach ($messages as $k => $v) {
if ($v == $content) {
unset($messages[$k]);
$hit = true;
self::debug('✅ pinned msg content hit. Removed');
break;
}
}
}
if ($hit) {
if (!$messages) {
$messages = -1;
}
self::update_option(self::DB_MSG_PIN, $messages);
} else {
self::debug('❌ No pinned msg content hit');
}
}
/**
* Hooked to the in_widget_form action.
* Appends LiteSpeed Cache settings to the widget edit settings screen.
* This will append the esi on/off selector and ttl text.
*
* @since 1.1.0
* @access public
*/
public function show_widget_edit($widget, $return, $instance)
{
require LSCWP_DIR . 'tpl/esi_widget_edit.php';
}
/**
* Displays the dashboard page.
*
* @since 3.0
* @access public
*/
public function show_menu_dash()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/dash/entry.tpl.php';
}
/**
* Displays the General page.
*
* @since 5.3
* @access public
*/
public function show_menu_presets()
{
require_once LSCWP_DIR . 'tpl/presets/entry.tpl.php';
}
/**
* Displays the General page.
*
* @since 3.0
* @access public
*/
public function show_menu_general()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/general/entry.tpl.php';
}
/**
* Displays the CDN page.
*
* @since 3.0
* @access public
*/
public function show_menu_cdn()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/cdn/entry.tpl.php';
}
/**
* Outputs the LiteSpeed Cache settings page.
*
* @since 1.0.0
* @access public
*/
public function show_menu_cache()
{
if ($this->_is_network_admin) {
require_once LSCWP_DIR . 'tpl/cache/entry_network.tpl.php';
} else {
require_once LSCWP_DIR . 'tpl/cache/entry.tpl.php';
}
}
/**
* Tools page
*
* @since 3.0
* @access public
*/
public function show_toolbox()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/toolbox/entry.tpl.php';
}
/**
* Outputs the crawler operation page.
*
* @since 1.1.0
* @access public
*/
public function show_crawler()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/crawler/entry.tpl.php';
}
/**
* Outputs the optimization operation page.
*
* @since 1.6
* @access public
*/
public function show_img_optm()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/img_optm/entry.tpl.php';
}
/**
* Page optm page.
*
* @since 3.0
* @access public
*/
public function show_page_optm()
{
$this->cls('Cloud')->maybe_preview_banner();
require_once LSCWP_DIR . 'tpl/page_optm/entry.tpl.php';
}
/**
* DB optm page.
*
* @since 3.0
* @access public
*/
public function show_db_optm()
{
require_once LSCWP_DIR . 'tpl/db_optm/entry.tpl.php';
}
/**
* Outputs a notice to the admin panel when the plugin is installed
* via the WHM plugin.
*
* @since 1.0.12
* @access public
*/
public function show_display_installed()
{
require_once LSCWP_DIR . 'tpl/inc/show_display_installed.php';
}
/**
* Display error cookie msg.
*
* @since 1.0.12
* @access public
*/
public static function show_error_cookie()
{
require_once LSCWP_DIR . 'tpl/inc/show_error_cookie.php';
}
/**
* Display warning if lscache is disabled
*
* @since 2.1
* @access public
*/
public function cache_disabled_warning()
{
include LSCWP_DIR . 'tpl/inc/check_cache_disabled.php';
}
/**
* Display conf data upgrading banner
*
* @since 2.1
* @access private
*/
private function _in_upgrading()
{
include LSCWP_DIR . 'tpl/inc/in_upgrading.php';
}
/**
* Output litespeed form info
*
* @since 3.0
* @access public
*/
public function form_action($action = false, $type = false, $has_upload = false)
{
if (!$action) {
$action = Router::ACTION_SAVE_SETTINGS;
}
$has_upload = $has_upload ? 'enctype="multipart/form-data"' : '';
if (!defined('LITESPEED_CONF_LOADED')) {
echo '<div class="litespeed-relative"';
} else {
echo '<form method="post" action="' . wp_unslash($_SERVER['REQUEST_URI']) . '" class="litespeed-relative" ' . $has_upload . '>';
}
echo '<input type="hidden" name="' . Router::ACTION . '" value="' . $action . '" />';
if ($type) {
echo '<input type="hidden" name="' . Router::TYPE . '" value="' . $type . '" />';
}
wp_nonce_field($action, Router::NONCE);
}
/**
* Output litespeed form info END
*
* @since 3.0
* @access public
*/
public function form_end($disable_reset = false)
{
echo "<div class='litespeed-top20'></div>";
if (!defined('LITESPEED_CONF_LOADED')) {
submit_button(__('Save Changes', 'litespeed-cache'), 'secondary litespeed-duplicate-float', 'litespeed-submit', true, array('disabled' => 'disabled'));
echo '</div>';
} else {
submit_button(__('Save Changes', 'litespeed-cache'), 'primary litespeed-duplicate-float', 'litespeed-submit', true, array(
'id' => 'litespeed-submit-' . $this->_btn_i++,
));
echo '</form>';
}
}
/**
* Register this setting to save
*
* @since 3.0
* @access public
*/
public function enroll($id)
{
echo '<input type="hidden" name="' . Admin_Settings::ENROLL . '[]" value="' . $id . '" />';
}
/**
* Build a textarea
*
* @since 1.1.0
* @access public
*/
public function build_textarea($id, $cols = false, $val = null)
{
if ($val === null) {
$val = $this->conf($id, true);
if (is_array($val)) {
$val = implode("\n", $val);
}
}
if (!$cols) {
$cols = 80;
}
$rows = 5;
$lines = substr_count($val, "\n") + 2;
if ($lines > $rows) {
$rows = $lines;
}
if ($rows > 40) {
$rows = 40;
}
$this->enroll($id);
echo "<textarea name='$id' rows='$rows' cols='$cols'>" . esc_textarea($val) . '</textarea>';
$this->_check_overwritten($id);
}
/**
* Build a text input field
*
* @since 1.1.0
* @access public
*/
public function build_input($id, $cls = null, $val = null, $type = 'text', $disabled = false)
{
if ($val === null) {
$val = $this->conf($id, true);
// Mask pswds
if ($this->_conf_pswd($id) && $val) {
$val = str_repeat('*', strlen($val));
}
}
$label_id = preg_replace('/\W/', '', $id);
if ($type == 'text') {
$cls = "regular-text $cls";
}
if ($disabled) {
echo "<input type='$type' class='$cls' value='" . esc_textarea($val) . "' id='input_$label_id' disabled /> ";
} else {
$this->enroll($id);
echo "<input type='$type' class='$cls' name='$id' value='" . esc_textarea($val) . "' id='input_$label_id' /> ";
}
$this->_check_overwritten($id);
}
/**
* Build a checkbox html snippet
*
* @since 1.1.0
* @access public
* @param string $id
* @param string $title
* @param bool $checked
*/
public function build_checkbox($id, $title, $checked = null, $value = 1)
{
if ($checked === null && $this->conf($id, true)) {
$checked = true;
}
$checked = $checked ? ' checked ' : '';
$label_id = preg_replace('/\W/', '', $id);
if ($value !== 1) {
$label_id .= '_' . $value;
}
$this->enroll($id);
echo "<div class='litespeed-tick'>
<input type='checkbox' name='$id' id='input_checkbox_$label_id' value='$value' $checked />
<label for='input_checkbox_$label_id'>$title</label>
</div>";
$this->_check_overwritten($id);
}
/**
* Build a toggle checkbox html snippet
*
* @since 1.7
*/
public function build_toggle($id, $checked = null, $title_on = null, $title_off = null)
{
if ($checked === null && $this->conf($id, true)) {
$checked = true;
}
if ($title_on === null) {
$title_on = __('ON', 'litespeed-cache');
$title_off = __('OFF', 'litespeed-cache');
}
$cls = $checked ? 'primary' : 'default litespeed-toggleoff';
echo "<div class='litespeed-toggle litespeed-toggle-btn litespeed-toggle-btn-$cls' data-litespeed-toggle-on='primary' data-litespeed-toggle-off='default' data-litespeed_toggle_id='$id' >
<input name='$id' type='hidden' value='$checked' />
<div class='litespeed-toggle-group'>
<label class='litespeed-toggle-btn litespeed-toggle-btn-primary litespeed-toggle-on'>$title_on</label>
<label class='litespeed-toggle-btn litespeed-toggle-btn-default litespeed-toggle-active litespeed-toggle-off'>$title_off</label>
<span class='litespeed-toggle-handle litespeed-toggle-btn litespeed-toggle-btn-default'></span>
</div>
</div>";
}
/**
* Build a switch div html snippet
*
* @since 1.1.0
* @since 1.7 removed param $disable
* @access public
*/
public function build_switch($id, $title_list = false)
{
$this->enroll($id);
echo '<div class="litespeed-switch">';
if (!$title_list) {
$title_list = array(__('OFF', 'litespeed-cache'), __('ON', 'litespeed-cache'));
}
foreach ($title_list as $k => $v) {
$this->_build_radio($id, $k, $v);
}
echo '</div>';
$this->_check_overwritten($id);
}
/**
* Build a radio input html codes and output
*
* @since 1.1.0
* @access private
*/
private function _build_radio($id, $val, $txt)
{
$id_attr = 'input_radio_' . preg_replace('/\W/', '', $id) . '_' . $val;
$default = isset(self::$_default_options[$id]) ? self::$_default_options[$id] : self::$_default_site_options[$id];
if (!is_string($default)) {
$checked = (int) $this->conf($id, true) === (int) $val ? ' checked ' : '';
} else {
$checked = $this->conf($id, true) === $val ? ' checked ' : '';
}
echo "<input type='radio' autocomplete='off' name='$id' id='$id_attr' value='$val' $checked /> <label for='$id_attr'>$txt</label>";
}
/**
* Show overwritten msg if there is a const defined
*
* @since 3.0
*/
protected function _check_overwritten($id)
{
$const_val = $this->const_overwritten($id);
$primary_val = $this->primary_overwritten($id);
if ($const_val === null && $primary_val === null) {
return;
}
$val = $const_val !== null ? $const_val : $primary_val;
$default = isset(self::$_default_options[$id]) ? self::$_default_options[$id] : self::$_default_site_options[$id];
if (is_bool($default)) {
$val = $val ? __('ON', 'litespeed-cache') : __('OFF', 'litespeed-cache');
} else {
if (is_array($default)) {
$val = implode("\n", $val);
}
$val = esc_textarea($val);
}
echo '<div class="litespeed-desc litespeed-warning">⚠️ ';
if ($const_val !== null) {
echo sprintf(__('This setting is overwritten by the PHP constant %s', 'litespeed-cache'), '<code>' . Base::conf_const($id) . '</code>');
} else {
if (get_current_blog_id() != BLOG_ID_CURRENT_SITE && $this->conf(self::NETWORK_O_USE_PRIMARY)) {
echo __('This setting is overwritten by the primary site setting', 'litespeed-cache');
} else {
echo __('This setting is overwritten by the Network setting', 'litespeed-cache');
}
}
echo ', ' . sprintf(__('currently set to %s', 'litespeed-cache'), "<code>$val</code>") . '</div>';
}
/**
* Display seconds text and readable layout
*
* @since 3.0
* @access public
*/
public function readable_seconds()
{
echo __('seconds', 'litespeed-cache');
echo ' <span data-litespeed-readable=""></span>';
}
/**
* Display default value
*
* @since 1.1.1
* @access public
*/
public function recommended($id)
{
if (!$this->default_settings) {
$this->default_settings = $this->load_default_vals();
}
$val = $this->default_settings[$id];
if ($val) {
if (is_array($val)) {
$rows = 5;
$cols = 30;
// Flexible rows/cols
$lines = count($val) + 1;
$rows = min(max($lines, $rows), 40);
foreach ($val as $v) {
$cols = max(strlen($v), $cols);
}
$cols = min($cols, 150);
$val = implode("\n", $val);
$val = esc_textarea($val);
$val = '<div class="litespeed-desc">' . __('Default value', 'litespeed-cache') . ':</div>' . "<textarea readonly rows='$rows' cols='$cols'>$val</textarea>";
} else {
$val = esc_textarea($val);
$val = "<code>$val</code>";
$val = __('Default value', 'litespeed-cache') . ': ' . $val;
}
echo $val;
}
}
/**
* Validate rewrite rules regex syntax
*
* @since 3.0
*/
protected function _validate_syntax($id)
{
$val = $this->conf($id, true);
if (!$val) {
return;
}
if (!is_array($val)) {
$val = array($val);
}
foreach ($val as $v) {
if (!Utility::syntax_checker($v)) {
echo '<br /><font class="litespeed-warning"> ❌ ' . __('Invalid rewrite rule', 'litespeed-cache') . ': <code>' . $v . '</code></font>';
}
}
}
/**
* Validate if the htaccess path is valid
*
* @since 3.0
*/
protected function _validate_htaccess_path($id)
{
$val = $this->conf($id, true);
if (!$val) {
return;
}
if (substr($val, -10) !== '/.htaccess') {
echo '<br /><font class="litespeed-warning"> ❌ ' . sprintf(__('Path must end with %s', 'litespeed-cache'), '<code>/.htaccess</code>') . '</font>';
}
}
/**
* Check ttl instead of error when saving
*
* @since 3.0
*/
protected function _validate_ttl($id, $min = false, $max = false, $allow_zero = false)
{
$val = $this->conf($id, true);
if ($allow_zero && !$val) {
// return;
}
$tip = array();
if ($min && $val < $min && (!$allow_zero || $val != 0)) {
$tip[] = __('Minimum value', 'litespeed-cache') . ': <code>' . $min . '</code>.';
}
if ($max && $val > $max) {
$tip[] = __('Maximum value', 'litespeed-cache') . ': <code>' . $max . '</code>.';
}
echo '<br />';
if ($tip) {
echo '<font class="litespeed-warning"> ❌ ' . implode(' ', $tip) . '</font>';
}
$range = '';
if ($allow_zero) {
$range .= __('Zero, or', 'litespeed-cache') . ' ';
}
if ($min && $max) {
$range .= $min . ' - ' . $max;
} elseif ($min) {
$range .= __('Larger than', 'litespeed-cache') . ' ' . $min;
} elseif ($max) {
$range .= __('Smaller than', 'litespeed-cache') . ' ' . $max;
}
echo __('Value range', 'litespeed-cache') . ': <code>' . $range . '</code>';
}
/**
* Check if ip is valid
*
* @since 3.0
*/
protected function _validate_ip($id)
{
$val = $this->conf($id, true);
if (!$val) {
return;
}
if (!is_array($val)) {
$val = array($val);
}
$tip = array();
foreach ($val as $v) {
if (!$v) {
continue;
}
if (!\WP_Http::is_ip_address($v)) {
$tip[] = __('Invalid IP', 'litespeed-cache') . ': <code>' . esc_textarea($v) . '</code>.';
}
}
if ($tip) {
echo '<br /><font class="litespeed-warning"> ❌ ' . implode(' ', $tip) . '</font>';
}
}
/**
* Display API environment variable support
*
* @since 1.8.3
* @access protected
*/
protected function _api_env_var()
{
$args = func_get_args();
$s = '<code>' . implode('</code>, <code>', $args) . '</code>';
echo '<font class="litespeed-success"> ' .
__('API', 'litespeed-cache') .
': ' .
sprintf(__('Server variable(s) %s available to override this setting.', 'litespeed-cache'), $s);
Doc::learn_more('https://docs.litespeedtech.com/lscache/lscwp/admin/#limiting-the-crawler');
}
/**
* Display URI setting example
*
* @since 2.6.1
* @access protected
*/
protected function _uri_usage_example()
{
echo __('The URLs will be compared to the REQUEST_URI server variable.', 'litespeed-cache');
echo ' ' . sprintf(__('For example, for %s, %s can be used here.', 'litespeed-cache'), '<code>/mypath/mypage?aa=bb</code>', '<code>mypage?aa=</code>');
echo '<br /><i>';
echo sprintf(__('To match the beginning, add %s to the beginning of the item.', 'litespeed-cache'), '<code>^</code>');
echo ' ' . sprintf(__('To do an exact match, add %s to the end of the URL.', 'litespeed-cache'), '<code>$</code>');
echo ' ' . __('One per line.', 'litespeed-cache');
echo '</i>';
}
/**
* Return groups string
*
* @since 2.0
* @access public
*/
public static function print_plural($num, $kind = 'group')
{
if ($num > 1) {
switch ($kind) {
case 'group':
return sprintf(__('%s groups', 'litespeed-cache'), $num);
case 'image':
return sprintf(__('%s images', 'litespeed-cache'), $num);
default:
return $num;
}
}
switch ($kind) {
case 'group':
return sprintf(__('%s group', 'litespeed-cache'), $num);
case 'image':
return sprintf(__('%s image', 'litespeed-cache'), $num);
default:
return $num;
}
}
/**
* Return guidance html
*
* @since 2.0
* @access public
*/
public static function guidance($title, $steps, $current_step)
{
if ($current_step === 'done') {
$current_step = count($steps) + 1;
}
$percentage = ' (' . floor((($current_step - 1) * 100) / count($steps)) . '%)';
$html = '<div class="litespeed-guide">' . '<h2>' . $title . $percentage . '</h2>' . '<ol>';
foreach ($steps as $k => $v) {
$step = $k + 1;
if ($current_step > $step) {
$html .= '<li class="litespeed-guide-done">';
} else {
$html .= '<li>';
}
$html .= $v . '</li>';
}
$html .= '</ol></div>';
return $html;
}
/**
* Check if has qc hide banner cookie or not
* @since 7.1
*/
public static function has_qc_hide_banner()
{
return isset($_COOKIE[self::COOKIE_QC_HIDE_BANNER]);
}
/**
* Set qc hide banner cookie
* @since 7.1
*/
public static function set_qc_hide_banner()
{
$expire = time() + 86400 * 365;
self::debug('Set qc hide banner cookie');
setcookie(self::COOKIE_QC_HIDE_BANNER, time(), $expire, COOKIEPATH, COOKIE_DOMAIN);
}
/**
* Handle all request actions from main cls
*
* @since 7.1
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_QC_HIDE_BANNER:
self::set_qc_hide_banner();
break;
default:
break;
}
Admin::redirect();
}
}
admin-settings.cls.php 0000644 00000024032 15153741266 0010776 0 ustar 00 <?php
/**
* The admin settings handler of the plugin.
*
*
* @since 1.1.0
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Admin_Settings extends Base
{
const ENROLL = '_settings-enroll';
/**
* Save settings
*
* Both $_POST and CLI can use this way
*
* Import will directly call conf.cls
*
* @since 3.0
* @access public
*/
public function save($raw_data)
{
Debug2::debug('[Settings] saving');
if (empty($raw_data[self::ENROLL])) {
exit('No fields');
}
$raw_data = Admin::cleanup_text($raw_data);
// Convert data to config format
$the_matrix = array();
foreach (array_unique($raw_data[self::ENROLL]) as $id) {
$child = false;
// Drop array format
if (strpos($id, '[') !== false) {
if (strpos($id, self::O_CDN_MAPPING) === 0 || strpos($id, self::O_CRAWLER_COOKIES) === 0) {
// CDN child | Cookie Crawler settings
$child = substr($id, strpos($id, '[') + 1, strpos($id, ']') - strpos($id, '[') - 1);
$id = substr($id, 0, strpos($id, '[')); // Drop ending []; Compatible with xx[0] way from CLI
} else {
$id = substr($id, 0, strpos($id, '[')); // Drop ending []
}
}
if (!array_key_exists($id, self::$_default_options)) {
continue;
}
// Validate $child
if ($id == self::O_CDN_MAPPING) {
if (!in_array($child, array(self::CDN_MAPPING_URL, self::CDN_MAPPING_INC_IMG, self::CDN_MAPPING_INC_CSS, self::CDN_MAPPING_INC_JS, self::CDN_MAPPING_FILETYPE))) {
continue;
}
}
if ($id == self::O_CRAWLER_COOKIES) {
if (!in_array($child, array(self::CRWL_COOKIE_NAME, self::CRWL_COOKIE_VALS))) {
continue;
}
}
$data = false;
if ($child) {
$data = !empty($raw_data[$id][$child]) ? $raw_data[$id][$child] : false; // []=xxx or [0]=xxx
} else {
$data = !empty($raw_data[$id]) ? $raw_data[$id] : false;
}
/**
* Sanitize the value
*/
if ($id == self::O_CDN_MAPPING || $id == self::O_CRAWLER_COOKIES) {
// Use existing in queue data if existed (Only available when $child != false)
$data2 = array_key_exists($id, $the_matrix) ? $the_matrix[$id] : (defined('WP_CLI') && WP_CLI ? $this->conf($id) : array());
}
switch ($id) {
case self::O_CRAWLER_ROLES: // Don't allow Editor/admin to be used in crawler role simulator
$data = Utility::sanitize_lines($data);
if ($data) {
foreach ($data as $k => $v) {
if (user_can($v, 'edit_posts')) {
$msg = sprintf(
__('The user with id %s has editor access, which is not allowed for the role simulator.', 'litespeed-cache'),
'<code>' . $v . '</code>'
);
Admin_Display::error($msg);
unset($data[$k]);
}
}
}
break;
case self::O_CDN_MAPPING:
/**
* CDN setting
*
* Raw data format:
* cdn-mapping[url][] = 'xxx'
* cdn-mapping[url][2] = 'xxx2'
* cdn-mapping[inc_js][] = 1
*
* Final format:
* cdn-mapping[ 0 ][ url ] = 'xxx'
* cdn-mapping[ 2 ][ url ] = 'xxx2'
*/
if ($data) {
foreach ($data as $k => $v) {
if ($child == self::CDN_MAPPING_FILETYPE) {
$v = Utility::sanitize_lines($v);
}
if ($child == self::CDN_MAPPING_URL) {
# If not a valid URL, turn off CDN
if (strpos($v, 'https://') !== 0) {
self::debug('❌ CDN mapping set to OFF due to invalid URL');
$the_matrix[self::O_CDN] = false;
}
$v = trailingslashit($v);
}
if (in_array($child, array(self::CDN_MAPPING_INC_IMG, self::CDN_MAPPING_INC_CSS, self::CDN_MAPPING_INC_JS))) {
// Because these can't be auto detected in `config->update()`, need to format here
$v = $v === 'false' ? 0 : (bool) $v;
}
if (empty($data2[$k])) {
$data2[$k] = array();
}
$data2[$k][$child] = $v;
}
}
$data = $data2;
break;
case self::O_CRAWLER_COOKIES:
/**
* Cookie Crawler setting
* Raw Format:
* crawler-cookies[name][] = xxx
* crawler-cookies[name][2] = xxx2
* crawler-cookies[vals][] = xxx
*
* todo: need to allow null for values
*
* Final format:
* crawler-cookie[ 0 ][ name ] = 'xxx'
* crawler-cookie[ 0 ][ vals ] = 'xxx'
* crawler-cookie[ 2 ][ name ] = 'xxx2'
*
* empty line for `vals` use literal `_null`
*/
if ($data) {
foreach ($data as $k => $v) {
if ($child == self::CRWL_COOKIE_VALS) {
$v = Utility::sanitize_lines($v);
}
if (empty($data2[$k])) {
$data2[$k] = array();
}
$data2[$k][$child] = $v;
}
}
$data = $data2;
break;
case self::O_CACHE_EXC_CAT: // Cache exclude cat
$data2 = array();
$data = Utility::sanitize_lines($data);
foreach ($data as $v) {
$cat_id = get_cat_ID($v);
if (!$cat_id) {
continue;
}
$data2[] = $cat_id;
}
$data = $data2;
break;
case self::O_CACHE_EXC_TAG: // Cache exclude tag
$data2 = array();
$data = Utility::sanitize_lines($data);
foreach ($data as $v) {
$term = get_term_by('name', $v, 'post_tag');
if (!$term) {
// todo: can show the error in admin error msg
continue;
}
$data2[] = $term->term_id;
}
$data = $data2;
break;
default:
break;
}
$the_matrix[$id] = $data;
}
// Special handler for CDN/Crawler 2d list to drop empty rows
foreach ($the_matrix as $id => $data) {
/**
* cdn-mapping[ 0 ][ url ] = 'xxx'
* cdn-mapping[ 2 ][ url ] = 'xxx2'
*
* crawler-cookie[ 0 ][ name ] = 'xxx'
* crawler-cookie[ 0 ][ vals ] = 'xxx'
* crawler-cookie[ 2 ][ name ] = 'xxx2'
*/
if ($id == self::O_CDN_MAPPING || $id == self::O_CRAWLER_COOKIES) {
// Drop this line if all children elements are empty
foreach ($data as $k => $v) {
foreach ($v as $v2) {
if ($v2) {
continue 2;
}
}
// If hit here, means all empty
unset($the_matrix[$id][$k]);
}
}
// Don't allow repeated cookie name
if ($id == self::O_CRAWLER_COOKIES) {
$existed = array();
foreach ($the_matrix[$id] as $k => $v) {
if (!$v[self::CRWL_COOKIE_NAME] || in_array($v[self::CRWL_COOKIE_NAME], $existed)) {
// Filter repeated or empty name
unset($the_matrix[$id][$k]);
continue;
}
$existed[] = $v[self::CRWL_COOKIE_NAME];
}
}
// CDN mapping allow URL values repeated
// if ( $id == self::O_CDN_MAPPING ) {}
// tmp fix the 3rd part woo update hook issue when enabling vary cookie
if ($id == 'wc_cart_vary') {
if ($data) {
add_filter('litespeed_vary_cookies', function ($list) {
$list[] = 'woocommerce_cart_hash';
return array_unique($list);
});
} else {
add_filter('litespeed_vary_cookies', function ($list) {
if (in_array('woocommerce_cart_hash', $list)) {
unset($list[array_search('woocommerce_cart_hash', $list)]);
}
return array_unique($list);
});
}
}
}
// id validation will be inside
$this->cls('Conf')->update_confs($the_matrix);
$msg = __('Options saved.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Parses any changes made by the network admin on the network settings.
*
* @since 3.0
* @access public
*/
public function network_save($raw_data)
{
Debug2::debug('[Settings] network saving');
if (empty($raw_data[self::ENROLL])) {
exit('No fields');
}
$raw_data = Admin::cleanup_text($raw_data);
foreach (array_unique($raw_data[self::ENROLL]) as $id) {
// Append current field to setting save
if (!array_key_exists($id, self::$_default_site_options)) {
continue;
}
$data = !empty($raw_data[$id]) ? $raw_data[$id] : false;
// id validation will be inside
$this->cls('Conf')->network_update($id, $data);
}
// Update related files
Activation::cls()->update_files();
$msg = __('Options saved.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Hooked to the wp_redirect filter.
* This will only hook if there was a problem when saving the widget.
*
* @since 1.1.3
* @access public
* @param string $location The location string.
* @return string the updated location string.
*/
public static function widget_save_err($location)
{
return str_replace('?message=0', '?error=0', $location);
}
/**
* Hooked to the widget_update_callback filter.
* Validate the LiteSpeed Cache settings on edit widget save.
*
* @since 1.1.3
* @access public
* @param array $instance The new settings.
* @param array $new_instance
* @param array $old_instance The original settings.
* @param WP_Widget $widget The widget
* @return mixed Updated settings on success, false on error.
*/
public static function validate_widget_save($instance, $new_instance, $old_instance, $widget)
{
if (empty($new_instance)) {
return $instance;
}
if (!isset($new_instance[ESI::WIDGET_O_ESIENABLE]) || !isset($new_instance[ESI::WIDGET_O_TTL])) {
return $instance;
}
$esi = intval($new_instance[ESI::WIDGET_O_ESIENABLE]) % 3;
$ttl = (int) $new_instance[ESI::WIDGET_O_TTL];
if ($ttl != 0 && $ttl < 30) {
add_filter('wp_redirect', __CLASS__ . '::widget_save_err');
return false; // invalid ttl.
}
if (empty($instance[Conf::OPTION_NAME])) {
// todo: to be removed
$instance[Conf::OPTION_NAME] = array();
}
$instance[Conf::OPTION_NAME][ESI::WIDGET_O_ESIENABLE] = $esi;
$instance[Conf::OPTION_NAME][ESI::WIDGET_O_TTL] = $ttl;
$current = !empty($old_instance[Conf::OPTION_NAME]) ? $old_instance[Conf::OPTION_NAME] : false;
if (!strpos($_SERVER['HTTP_REFERER'], '/wp-admin/customize.php')) {
if (!$current || $esi != $current[ESI::WIDGET_O_ESIENABLE]) {
Purge::purge_all('Widget ESI_enable changed');
} elseif ($ttl != 0 && $ttl != $current[ESI::WIDGET_O_TTL]) {
Purge::add(Tag::TYPE_WIDGET . $widget->id);
}
Purge::purge_all('Widget saved');
}
return $instance;
}
}
admin.cls.php 0000644 00000010704 15153741266 0007141 0 ustar 00 <?php
/**
* The admin-panel specific functionality of the plugin.
*
*
* @since 1.0.0
* @package LiteSpeed_Cache
* @subpackage LiteSpeed_Cache/admin
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Admin extends Root
{
const LOG_TAG = '👮';
const PAGE_EDIT_HTACCESS = 'litespeed-edit-htaccess';
/**
* Initialize the class and set its properties.
* Run in hook `after_setup_theme` when is_admin()
*
* @since 1.0.0
*/
public function __construct()
{
// Define LSCWP_MU_PLUGIN if is mu-plugins
if (defined('WPMU_PLUGIN_DIR') && dirname(LSCWP_DIR) == WPMU_PLUGIN_DIR) {
define('LSCWP_MU_PLUGIN', true);
}
self::debug('No cache due to Admin page');
defined('DONOTCACHEPAGE') || define('DONOTCACHEPAGE', true);
// Additional litespeed assets on admin display
// Also register menu
$this->cls('Admin_Display');
// initialize admin actions
add_action('admin_init', array($this, 'admin_init'));
// add link to plugin list page
add_filter('plugin_action_links_' . LSCWP_BASENAME, array($this->cls('Admin_Display'), 'add_plugin_links'));
}
/**
* Callback that initializes the admin options for LiteSpeed Cache.
*
* @since 1.0.0
* @access public
*/
public function admin_init()
{
// Hook attachment upload
if ($this->conf(Base::O_IMG_OPTM_AUTO)) {
add_filter('wp_update_attachment_metadata', array($this, 'wp_update_attachment_metadata'), 9999, 2);
}
$this->_proceed_admin_action();
// Terminate if user doesn't have the access to settings
if (is_network_admin()) {
$capability = 'manage_network_options';
} else {
$capability = 'manage_options';
}
if (!current_user_can($capability)) {
return;
}
// Save setting from admin settings page
// NOTE: cli will call `validate_plugin_settings` manually. Cron activation doesn't need to validate
// Add privacy policy
// @since 2.2.6
if (function_exists('wp_add_privacy_policy_content')) {
wp_add_privacy_policy_content(Core::NAME, Doc::privacy_policy());
}
$this->cls('Media')->after_admin_init();
do_action('litspeed_after_admin_init');
if ($this->cls('Router')->esi_enabled()) {
add_action('in_widget_form', array($this->cls('Admin_Display'), 'show_widget_edit'), 100, 3);
add_filter('widget_update_callback', __NAMESPACE__ . '\Admin_Settings::validate_widget_save', 10, 4);
}
}
/**
* Handle attachment update
* @since 4.0
*/
public function wp_update_attachment_metadata($data, $post_id)
{
$this->cls('Img_Optm')->wp_update_attachment_metadata($data, $post_id);
return $data;
}
/**
* Run litespeed admin actions
*
* @since 1.1.0
*/
private function _proceed_admin_action()
{
// handle actions
switch (Router::get_action()) {
case Router::ACTION_SAVE_SETTINGS:
$this->cls('Admin_Settings')->save($_POST);
break;
// Save network settings
case Router::ACTION_SAVE_SETTINGS_NETWORK:
$this->cls('Admin_Settings')->network_save($_POST);
break;
default:
break;
}
}
/**
* Clean up the input string of any extra slashes/spaces.
*
* @since 1.0.4
* @access public
* @param string $input The input string to clean.
* @return string The cleaned up input.
*/
public static function cleanup_text($input)
{
if (is_array($input)) {
return array_map(__CLASS__ . '::cleanup_text', $input);
}
return stripslashes(trim($input));
}
/**
* After a LSCWP_CTRL action, need to redirect back to the same page
* without the nonce and action in the query string.
*
* If the redirect url cannot be determined, redirects to the homepage.
*
* @since 1.0.12
* @access public
* @global string $pagenow
*/
public static function redirect($url = false)
{
global $pagenow;
if (!empty($_GET['_litespeed_ori'])) {
wp_safe_redirect(wp_get_referer() ?: get_home_url());
exit();
}
$qs = '';
if (!$url) {
if (!empty($_GET)) {
if (isset($_GET[Router::ACTION])) {
unset($_GET[Router::ACTION]);
}
if (isset($_GET[Router::NONCE])) {
unset($_GET[Router::NONCE]);
}
if (isset($_GET[Router::TYPE])) {
unset($_GET[Router::TYPE]);
}
if (isset($_GET['litespeed_i'])) {
unset($_GET['litespeed_i']);
}
if (!empty($_GET)) {
$qs = '?' . http_build_query($_GET);
}
}
if (is_network_admin()) {
$url = network_admin_url($pagenow . $qs);
} else {
$url = admin_url($pagenow . $qs);
}
}
wp_redirect($url);
exit();
}
}
api.cls.php 0000644 00000026116 15153741266 0006626 0 ustar 00 <?php
/**
* The plugin API class.
*
* @since 1.1.3
* @since 1.4 Moved into /inc
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class API extends Base
{
const VERSION = Core::VER;
const TYPE_FEED = Tag::TYPE_FEED;
const TYPE_FRONTPAGE = Tag::TYPE_FRONTPAGE;
const TYPE_HOME = Tag::TYPE_HOME;
const TYPE_PAGES = Tag::TYPE_PAGES;
const TYPE_PAGES_WITH_RECENT_POSTS = Tag::TYPE_PAGES_WITH_RECENT_POSTS;
const TYPE_HTTP = Tag::TYPE_HTTP;
const TYPE_ARCHIVE_POSTTYPE = Tag::TYPE_ARCHIVE_POSTTYPE;
const TYPE_ARCHIVE_TERM = Tag::TYPE_ARCHIVE_TERM;
const TYPE_AUTHOR = Tag::TYPE_AUTHOR;
const TYPE_ARCHIVE_DATE = Tag::TYPE_ARCHIVE_DATE;
const TYPE_BLOG = Tag::TYPE_BLOG;
const TYPE_LOGIN = Tag::TYPE_LOGIN;
const TYPE_URL = Tag::TYPE_URL;
const TYPE_ESI = Tag::TYPE_ESI;
const PARAM_NAME = ESI::PARAM_NAME;
const WIDGET_O_ESIENABLE = ESI::WIDGET_O_ESIENABLE;
const WIDGET_O_TTL = ESI::WIDGET_O_TTL;
/**
* Instance
*
* @since 3.0
*/
public function __construct()
{
}
/**
* Define hooks to be used in other plugins.
*
* The benefit to use hooks other than functions is no need to detach if LSCWP enabled and function existed or not anymore
*
* @since 3.0
*/
public function init()
{
/**
* Init
*/
// Action `litespeed_init` // @previous API::hook_init( $hook )
/**
* Conf
*/
add_filter('litespeed_conf', array($this, 'conf')); // @previous API::config($id)
// Action `litespeed_conf_append` // @previous API::conf_append( $name, $default )
add_action('litespeed_conf_multi_switch', __NAMESPACE__ . '\Base::set_multi_switch', 10, 2);
// Action ``litespeed_conf_force` // @previous API::force_option( $k, $v )
/**
* Cache Control Hooks
*/
// Action `litespeed_control_finalize` // @previous API::hook_control($tags) && action `litespeed_api_control`
add_action('litespeed_control_set_private', __NAMESPACE__ . '\Control::set_private'); // @previous API::set_cache_private()
add_action('litespeed_control_set_nocache', __NAMESPACE__ . '\Control::set_nocache'); // @previous API::set_nocache( $reason = false )
add_action('litespeed_control_set_cacheable', array($this, 'set_cacheable')); // Might needed if not call hook `wp` // @previous API::set_cacheable( $reason )
add_action('litespeed_control_force_cacheable', __NAMESPACE__ . '\Control::force_cacheable'); // Set cache status to force cacheable ( Will ignore most kinds of non-cacheable conditions ) // @previous API::set_force_cacheable( $reason )
add_action('litespeed_control_force_public', __NAMESPACE__ . '\Control::set_public_forced'); // Set cache to force public cache if cacheable ( Will ignore most kinds of non-cacheable conditions ) // @previous API::set_force_public( $reason )
add_filter('litespeed_control_cacheable', __NAMESPACE__ . '\Control::is_cacheable', 3); // Note: Read-Only. Directly append to this filter won't work. Call actions above to set cacheable or not // @previous API::not_cacheable()
add_action('litespeed_control_set_ttl', __NAMESPACE__ . '\Control::set_custom_ttl', 10, 2); // @previous API::set_ttl( $val )
add_filter('litespeed_control_ttl', array($this, 'get_ttl'), 3); // @previous API::get_ttl()
/**
* Tag Hooks
*/
// Action `litespeed_tag_finalize` // @previous API::hook_tag( $hook )
add_action('litespeed_tag', __NAMESPACE__ . '\Tag::add'); // Shorter alias of `litespeed_tag_add`
add_action('litespeed_tag_post', __NAMESPACE__ . '\Tag::add_post'); // Shorter alias of `litespeed_tag_add_post`
add_action('litespeed_tag_widget', __NAMESPACE__ . '\Tag::add_widget'); // Shorter alias of `litespeed_tag_add_widget`
add_action('litespeed_tag_private', __NAMESPACE__ . '\Tag::add_private'); // Shorter alias of `litespeed_tag_add_private`
add_action('litespeed_tag_private_esi', __NAMESPACE__ . '\Tag::add_private_esi'); // Shorter alias of `litespeed_tag_add_private_esi`
add_action('litespeed_tag_add', __NAMESPACE__ . '\Tag::add'); // @previous API::tag_add( $tag )
add_action('litespeed_tag_add_post', __NAMESPACE__ . '\Tag::add_post');
add_action('litespeed_tag_add_widget', __NAMESPACE__ . '\Tag::add_widget');
add_action('litespeed_tag_add_private', __NAMESPACE__ . '\Tag::add_private'); // @previous API::tag_add_private( $tags )
add_action('litespeed_tag_add_private_esi', __NAMESPACE__ . '\Tag::add_private_esi');
/**
* Purge Hooks
*/
// Action `litespeed_purge_finalize` // @previous API::hook_purge($tags)
add_action('litespeed_purge', __NAMESPACE__ . '\Purge::add'); // @previous API::purge($tags)
add_action('litespeed_purge_all', __NAMESPACE__ . '\Purge::purge_all');
add_action('litespeed_purge_post', array($this, 'purge_post')); // @previous API::purge_post( $pid )
add_action('litespeed_purge_posttype', __NAMESPACE__ . '\Purge::purge_posttype');
add_action('litespeed_purge_url', array($this, 'purge_url'));
add_action('litespeed_purge_widget', __NAMESPACE__ . '\Purge::purge_widget');
add_action('litespeed_purge_esi', __NAMESPACE__ . '\Purge::purge_esi');
add_action('litespeed_purge_private', __NAMESPACE__ . '\Purge::add_private'); // @previous API::purge_private( $tags )
add_action('litespeed_purge_private_esi', __NAMESPACE__ . '\Purge::add_private_esi');
add_action('litespeed_purge_private_all', __NAMESPACE__ . '\Purge::add_private_all'); // @previous API::purge_private_all()
// Action `litespeed_api_purge_post` // Triggered when purge a post // @previous API::hook_purge_post($hook)
// Action `litespeed_purged_all` // Triggered after purged all.
add_action('litespeed_purge_all_object', __NAMESPACE__ . '\Purge::purge_all_object');
add_action('litespeed_purge_ucss', __NAMESPACE__ . '\Purge::purge_ucss');
/**
* ESI
*/
// Action `litespeed_nonce` // @previous API::nonce_action( $action ) & API::nonce( $action = -1, $defence_for_html_filter = true ) // NOTE: only available after `init` hook
add_filter('litespeed_esi_status', array($this, 'esi_enabled')); // Get ESI enable status // @previous API::esi_enabled()
add_filter('litespeed_esi_url', array($this, 'sub_esi_block'), 10, 8); // Generate ESI block url // @previous API::esi_url( $block_id, $wrapper, $params = array(), $control = 'private,no-vary', $silence = false, $preserved = false, $svar = false, $inline_val = false )
// Filter `litespeed_widget_default_options` // Hook widget default settings value. Currently used in Woo 3rd // @previous API::hook_widget_default_options( $hook )
// Filter `litespeed_esi_params` // @previous API::hook_esi_param( $hook )
// Action `litespeed_tpl_normal` // @previous API::hook_tpl_not_esi($hook) && Action `litespeed_is_not_esi_template`
// Action `litespeed_esi_load-$block` // @usage add_action( 'litespeed_esi_load-' . $block, $hook ) // @previous API::hook_tpl_esi($block, $hook)
add_action('litespeed_esi_combine', __NAMESPACE__ . '\ESI::combine');
/**
* Vary
*
* To modify default vary, There are two ways: Action `litespeed_vary_append` or Filter `litespeed_vary`
*/
add_action('litespeed_vary_ajax_force', __NAMESPACE__ . '\Vary::can_ajax_vary'); // API::force_vary() -> Action `litespeed_vary_ajax_force` // Force finalize vary even if its in an AJAX call
// Filter `litespeed_vary_curr_cookies` to generate current in use vary, which will be used for response vary header.
// Filter `litespeed_vary_cookies` to register the final vary cookies, which will be written to rewrite rule. (litespeed_vary_curr_cookies are always equal to or less than litespeed_vary_cookies)
// Filter `litespeed_vary` // Previous API::hook_vary_finalize( $hook )
add_action('litespeed_vary_no', __NAMESPACE__ . '\Control::set_no_vary'); // API::set_cache_no_vary() -> Action `litespeed_vary_no` // Set cache status to no vary
// add_filter( 'litespeed_is_mobile', __NAMESPACE__ . '\Control::is_mobile' ); // API::set_mobile() -> Filter `litespeed_is_mobile`
/**
* Cloud
*/
add_filter('litespeed_is_from_cloud', array($this, 'is_from_cloud')); // Check if current request is from QC (usually its to check REST access) // @see https://wordpress.org/support/topic/image-optimization-not-working-3/
/**
* Media
*/
add_action('litespeed_media_reset', __NAMESPACE__ . '\Media::delete_attachment'); // Reset one media row
/**
* GUI
*/
// API::clean_wrapper_begin( $counter = false ) -> Filter `litespeed_clean_wrapper_begin` // Start a to-be-removed html wrapper
add_filter('litespeed_clean_wrapper_begin', __NAMESPACE__ . '\GUI::clean_wrapper_begin');
// API::clean_wrapper_end( $counter = false ) -> Filter `litespeed_clean_wrapper_end` // End a to-be-removed html wrapper
add_filter('litespeed_clean_wrapper_end', __NAMESPACE__ . '\GUI::clean_wrapper_end');
/**
* Mist
*/
add_action('litespeed_debug', __NAMESPACE__ . '\Debug2::debug', 10, 2); // API::debug()-> Action `litespeed_debug`
add_action('litespeed_debug2', __NAMESPACE__ . '\Debug2::debug2', 10, 2); // API::debug2()-> Action `litespeed_debug2`
add_action('litespeed_disable_all', array($this, '_disable_all')); // API::disable_all( $reason ) -> Action `litespeed_disable_all`
add_action('litspeed_after_admin_init', array($this, '_after_admin_init'));
}
/**
* API for admin related
*
* @since 3.0
* @access public
*/
public function _after_admin_init()
{
/**
* GUI
*/
add_action('litespeed_setting_enroll', array($this->cls('Admin_Display'), 'enroll'), 10, 4); // API::enroll( $id ) // Register a field in setting form to save
add_action('litespeed_build_switch', array($this->cls('Admin_Display'), 'build_switch')); // API::build_switch( $id ) // Build a switch div html snippet
// API::hook_setting_content( $hook, $priority = 10, $args = 1 ) -> Action `litespeed_settings_content`
// API::hook_setting_tab( $hook, $priority = 10, $args = 1 ) -> Action `litespeed_settings_tab`
}
/**
* Disable All (Note: Not for direct call, always use Hooks)
*
* @since 2.9.7.2
* @access public
*/
public function _disable_all($reason)
{
do_action('litespeed_debug', '[API] Disabled_all due to ' . $reason);
!defined('LITESPEED_DISABLE_ALL') && define('LITESPEED_DISABLE_ALL', true);
}
/**
* @since 3.0
*/
public static function vary_append_commenter()
{
Vary::cls()->append_commenter();
}
/**
* Check if is from Cloud
*
* @since 4.2
*/
public function is_from_cloud()
{
return $this->cls('Cloud')->is_from_cloud();
}
public function purge_post($pid)
{
$this->cls('Purge')->purge_post($pid);
}
public function purge_url($url)
{
$this->cls('Purge')->purge_url($url);
}
public function set_cacheable($reason = false)
{
$this->cls('Control')->set_cacheable($reason);
}
public function esi_enabled()
{
return $this->cls('Router')->esi_enabled();
}
public function get_ttl()
{
return $this->cls('Control')->get_ttl();
}
public function sub_esi_block(
$block_id,
$wrapper,
$params = array(),
$control = 'private,no-vary',
$silence = false,
$preserved = false,
$svar = false,
$inline_param = array()
) {
return $this->cls('ESI')->sub_esi_block($block_id, $wrapper, $params, $control, $silence, $preserved, $svar, $inline_param);
}
}
avatar.cls.php 0000644 00000014107 15153741266 0007330 0 ustar 00 <?php
/**
* The avatar cache class
*
* @since 3.0
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Avatar extends Base
{
const TYPE_GENERATE = 'generate';
private $_conf_cache_ttl;
private $_tb;
private $_avatar_realtime_gen_dict = array();
protected $_summary;
/**
* Init
*
* @since 1.4
*/
public function __construct()
{
if (!$this->conf(self::O_DISCUSS_AVATAR_CACHE)) {
return;
}
Debug2::debug2('[Avatar] init');
$this->_tb = $this->cls('Data')->tb('avatar');
$this->_conf_cache_ttl = $this->conf(self::O_DISCUSS_AVATAR_CACHE_TTL);
add_filter('get_avatar_url', array($this, 'crawl_avatar'));
$this->_summary = self::get_summary();
}
/**
* Check if need db table or not
*
* @since 3.0
* @access public
*/
public function need_db()
{
if ($this->conf(self::O_DISCUSS_AVATAR_CACHE)) {
return true;
}
return false;
}
/**
* Get gravatar URL from DB and regenerate
*
* @since 3.0
* @access public
*/
public function serve_static($md5)
{
global $wpdb;
Debug2::debug('[Avatar] is avatar request');
if (strlen($md5) !== 32) {
Debug2::debug('[Avatar] wrong md5 ' . $md5);
return;
}
$q = "SELECT url FROM `$this->_tb` WHERE md5=%s";
$url = $wpdb->get_var($wpdb->prepare($q, $md5));
if (!$url) {
Debug2::debug('[Avatar] no matched url for md5 ' . $md5);
return;
}
$url = $this->_generate($url);
wp_redirect($url);
exit();
}
/**
* Localize gravatar
*
* @since 3.0
* @access public
*/
public function crawl_avatar($url)
{
if (!$url) {
return $url;
}
// Check if its already in dict or not
if (!empty($this->_avatar_realtime_gen_dict[$url])) {
Debug2::debug2('[Avatar] already in dict [url] ' . $url);
return $this->_avatar_realtime_gen_dict[$url];
}
$realpath = $this->_realpath($url);
if (file_exists($realpath) && time() - filemtime($realpath) <= $this->_conf_cache_ttl) {
Debug2::debug2('[Avatar] cache file exists [url] ' . $url);
return $this->_rewrite($url, filemtime($realpath));
}
if (!strpos($url, 'gravatar.com')) {
return $url;
}
// Send request
if (!empty($this->_summary['curr_request']) && time() - $this->_summary['curr_request'] < 300) {
Debug2::debug2('[Avatar] Bypass generating due to interval limit [url] ' . $url);
return $url;
}
// Generate immediately
$this->_avatar_realtime_gen_dict[$url] = $this->_generate($url);
return $this->_avatar_realtime_gen_dict[$url];
}
/**
* Read last time generated info
*
* @since 3.0
* @access public
*/
public function queue_count()
{
global $wpdb;
// If var not exists, mean table not exists // todo: not true
if (!$this->_tb) {
return false;
}
$q = "SELECT COUNT(*) FROM `$this->_tb` WHERE dateline<" . (time() - $this->_conf_cache_ttl);
return $wpdb->get_var($q);
}
/**
* Get the final URL of local avatar
*
* Check from db also
*
* @since 3.0
*/
private function _rewrite($url, $time = null)
{
return LITESPEED_STATIC_URL . '/avatar/' . $this->_filepath($url) . ($time ? '?ver=' . $time : '');
}
/**
* Generate realpath of the cache file
*
* @since 3.0
* @access private
*/
private function _realpath($url)
{
return LITESPEED_STATIC_DIR . '/avatar/' . $this->_filepath($url);
}
/**
* Get filepath
*
* @since 4.0
*/
private function _filepath($url)
{
$filename = md5($url) . '.jpg';
if (is_multisite()) {
$filename = get_current_blog_id() . '/' . $filename;
}
return $filename;
}
/**
* Cron generation
*
* @since 3.0
* @access public
*/
public static function cron($force = false)
{
global $wpdb;
$_instance = self::cls();
if (!$_instance->queue_count()) {
Debug2::debug('[Avatar] no queue');
return;
}
// For cron, need to check request interval too
if (!$force) {
if (!empty($_instance->_summary['curr_request']) && time() - $_instance->_summary['curr_request'] < 300) {
Debug2::debug('[Avatar] curr_request too close');
return;
}
}
$q = "SELECT url FROM `$_instance->_tb` WHERE dateline < %d ORDER BY id DESC LIMIT %d";
$q = $wpdb->prepare($q, array(time() - $_instance->_conf_cache_ttl, apply_filters('litespeed_avatar_limit', 30)));
$list = $wpdb->get_results($q);
Debug2::debug('[Avatar] cron job [count] ' . count($list));
foreach ($list as $v) {
Debug2::debug('[Avatar] cron job [url] ' . $v->url);
$_instance->_generate($v->url);
}
}
/**
* Remote generator
*
* @since 3.0
* @access private
*/
private function _generate($url)
{
global $wpdb;
// Record the data
$file = $this->_realpath($url);
// Update request status
self::save_summary(array('curr_request' => time()));
// Generate
$this->_maybe_mk_cache_folder('avatar');
$response = wp_safe_remote_get($url, array('timeout' => 180, 'stream' => true, 'filename' => $file));
Debug2::debug('[Avatar] _generate [url] ' . $url);
// Parse response data
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
file_exists($file) && unlink($file);
Debug2::debug('[Avatar] failed to get: ' . $error_message);
return $url;
}
// Save summary data
self::save_summary(array(
'last_spent' => time() - $this->_summary['curr_request'],
'last_request' => $this->_summary['curr_request'],
'curr_request' => 0,
));
// Update DB
$md5 = md5($url);
$q = "UPDATE `$this->_tb` SET dateline=%d WHERE md5=%s";
$existed = $wpdb->query($wpdb->prepare($q, array(time(), $md5)));
if (!$existed) {
$q = "INSERT INTO `$this->_tb` SET url=%s, md5=%s, dateline=%d";
$wpdb->query($wpdb->prepare($q, array($url, $md5, time())));
}
Debug2::debug('[Avatar] saved avatar ' . $file);
return $this->_rewrite($url);
}
/**
* Handle all request actions from main cls
*
* @since 3.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_GENERATE:
self::cron(true);
break;
default:
break;
}
Admin::redirect();
}
}
base.cls.php 0000644 00000075165 15153741266 0006777 0 ustar 00 <?php
/**
* The base consts
*
* @since 3.7
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Base extends Root
{
// This is redundant since v3.0
// New conf items are `litespeed.key`
const OPTION_NAME = 'litespeed-cache-conf';
const _CACHE = '_cache'; // final cache status from setting
## -------------------------------------------------- ##
## -------------- General ----------------- ##
## -------------------------------------------------- ##
const _VER = '_version'; // Not set-able
const HASH = 'hash'; // Not set-able
const O_AUTO_UPGRADE = 'auto_upgrade';
const O_API_KEY = 'api_key'; // Deprecated since v6.4. TODO: Will drop after v6.5
const O_SERVER_IP = 'server_ip';
const O_GUEST = 'guest';
const O_GUEST_OPTM = 'guest_optm';
const O_NEWS = 'news';
const O_GUEST_UAS = 'guest_uas';
const O_GUEST_IPS = 'guest_ips';
## -------------------------------------------------- ##
## -------------- Cache ----------------- ##
## -------------------------------------------------- ##
const O_CACHE = 'cache';
const O_CACHE_PRIV = 'cache-priv';
const O_CACHE_COMMENTER = 'cache-commenter';
const O_CACHE_REST = 'cache-rest';
const O_CACHE_PAGE_LOGIN = 'cache-page_login';
const O_CACHE_FAVICON = 'cache-favicon'; // Deprecated since v6.2. TODO: Will drop after v6.5
const O_CACHE_RES = 'cache-resources';
const O_CACHE_MOBILE = 'cache-mobile';
const O_CACHE_MOBILE_RULES = 'cache-mobile_rules';
const O_CACHE_BROWSER = 'cache-browser';
const O_CACHE_EXC_USERAGENTS = 'cache-exc_useragents';
const O_CACHE_EXC_COOKIES = 'cache-exc_cookies';
const O_CACHE_EXC_QS = 'cache-exc_qs';
const O_CACHE_EXC_CAT = 'cache-exc_cat';
const O_CACHE_EXC_TAG = 'cache-exc_tag';
const O_CACHE_FORCE_URI = 'cache-force_uri';
const O_CACHE_FORCE_PUB_URI = 'cache-force_pub_uri';
const O_CACHE_PRIV_URI = 'cache-priv_uri';
const O_CACHE_EXC = 'cache-exc';
const O_CACHE_EXC_ROLES = 'cache-exc_roles';
const O_CACHE_DROP_QS = 'cache-drop_qs';
const O_CACHE_TTL_PUB = 'cache-ttl_pub';
const O_CACHE_TTL_PRIV = 'cache-ttl_priv';
const O_CACHE_TTL_FRONTPAGE = 'cache-ttl_frontpage';
const O_CACHE_TTL_FEED = 'cache-ttl_feed';
const O_CACHE_TTL_REST = 'cache-ttl_rest';
const O_CACHE_TTL_STATUS = 'cache-ttl_status';
const O_CACHE_TTL_BROWSER = 'cache-ttl_browser';
const O_CACHE_AJAX_TTL = 'cache-ajax_ttl';
const O_CACHE_LOGIN_COOKIE = 'cache-login_cookie';
const O_CACHE_VARY_COOKIES = 'cache-vary_cookies';
const O_CACHE_VARY_GROUP = 'cache-vary_group';
## -------------------------------------------------- ##
## -------------- Purge ----------------- ##
## -------------------------------------------------- ##
const O_PURGE_ON_UPGRADE = 'purge-upgrade';
const O_PURGE_STALE = 'purge-stale';
const O_PURGE_POST_ALL = 'purge-post_all';
const O_PURGE_POST_FRONTPAGE = 'purge-post_f';
const O_PURGE_POST_HOMEPAGE = 'purge-post_h';
const O_PURGE_POST_PAGES = 'purge-post_p';
const O_PURGE_POST_PAGES_WITH_RECENT_POSTS = 'purge-post_pwrp';
const O_PURGE_POST_AUTHOR = 'purge-post_a';
const O_PURGE_POST_YEAR = 'purge-post_y';
const O_PURGE_POST_MONTH = 'purge-post_m';
const O_PURGE_POST_DATE = 'purge-post_d';
const O_PURGE_POST_TERM = 'purge-post_t'; // include category|tag|tax
const O_PURGE_POST_POSTTYPE = 'purge-post_pt';
const O_PURGE_TIMED_URLS = 'purge-timed_urls';
const O_PURGE_TIMED_URLS_TIME = 'purge-timed_urls_time';
const O_PURGE_HOOK_ALL = 'purge-hook_all';
## -------------------------------------------------- ##
## -------------- ESI ----------------- ##
## -------------------------------------------------- ##
const O_ESI = 'esi';
const O_ESI_CACHE_ADMBAR = 'esi-cache_admbar';
const O_ESI_CACHE_COMMFORM = 'esi-cache_commform';
const O_ESI_NONCE = 'esi-nonce';
## -------------------------------------------------- ##
## -------------- Utilities ----------------- ##
## -------------------------------------------------- ##
const O_UTIL_INSTANT_CLICK = 'util-instant_click';
const O_UTIL_NO_HTTPS_VARY = 'util-no_https_vary';
## -------------------------------------------------- ##
## -------------- Debug ----------------- ##
## -------------------------------------------------- ##
const O_DEBUG_DISABLE_ALL = 'debug-disable_all';
const O_DEBUG = 'debug';
const O_DEBUG_IPS = 'debug-ips';
const O_DEBUG_LEVEL = 'debug-level';
const O_DEBUG_FILESIZE = 'debug-filesize';
const O_DEBUG_COOKIE = 'debug-cookie'; // For backwards compatibility, will drop after v7.0
const O_DEBUG_COLLAPSE_QS = 'debug-collapse_qs';
const O_DEBUG_COLLAPS_QS = 'debug-collapse_qs'; // For backwards compatibility, will drop after v6.5
const O_DEBUG_INC = 'debug-inc';
const O_DEBUG_EXC = 'debug-exc';
const O_DEBUG_EXC_STRINGS = 'debug-exc_strings';
## -------------------------------------------------- ##
## -------------- DB Optm ----------------- ##
## -------------------------------------------------- ##
const O_DB_OPTM_REVISIONS_MAX = 'db_optm-revisions_max';
const O_DB_OPTM_REVISIONS_AGE = 'db_optm-revisions_age';
## -------------------------------------------------- ##
## -------------- HTML Optm ----------------- ##
## -------------------------------------------------- ##
const O_OPTM_CSS_MIN = 'optm-css_min';
const O_OPTM_CSS_COMB = 'optm-css_comb';
const O_OPTM_CSS_COMB_EXT_INL = 'optm-css_comb_ext_inl';
const O_OPTM_UCSS = 'optm-ucss';
const O_OPTM_UCSS_INLINE = 'optm-ucss_inline';
const O_OPTM_UCSS_SELECTOR_WHITELIST = 'optm-ucss_whitelist';
const O_OPTM_UCSS_FILE_EXC_INLINE = 'optm-ucss_file_exc_inline';
const O_OPTM_UCSS_EXC = 'optm-ucss_exc';
const O_OPTM_CSS_EXC = 'optm-css_exc';
const O_OPTM_JS_MIN = 'optm-js_min';
const O_OPTM_JS_COMB = 'optm-js_comb';
const O_OPTM_JS_COMB_EXT_INL = 'optm-js_comb_ext_inl';
const O_OPTM_JS_DELAY_INC = 'optm-js_delay_inc';
const O_OPTM_JS_EXC = 'optm-js_exc';
const O_OPTM_HTML_MIN = 'optm-html_min';
const O_OPTM_HTML_LAZY = 'optm-html_lazy';
const O_OPTM_HTML_SKIP_COMMENTS = 'optm-html_skip_comment';
const O_OPTM_QS_RM = 'optm-qs_rm';
const O_OPTM_GGFONTS_RM = 'optm-ggfonts_rm';
const O_OPTM_CSS_ASYNC = 'optm-css_async';
const O_OPTM_CCSS_PER_URL = 'optm-ccss_per_url';
const O_OPTM_CCSS_SEP_POSTTYPE = 'optm-ccss_sep_posttype';
const O_OPTM_CCSS_SEP_URI = 'optm-ccss_sep_uri';
const O_OPTM_CCSS_SELECTOR_WHITELIST = 'optm-ccss_whitelist';
const O_OPTM_CSS_ASYNC_INLINE = 'optm-css_async_inline';
const O_OPTM_CSS_FONT_DISPLAY = 'optm-css_font_display';
const O_OPTM_JS_DEFER = 'optm-js_defer';
const O_OPTM_LOCALIZE = 'optm-localize';
const O_OPTM_LOCALIZE_DOMAINS = 'optm-localize_domains';
const O_OPTM_EMOJI_RM = 'optm-emoji_rm';
const O_OPTM_NOSCRIPT_RM = 'optm-noscript_rm';
const O_OPTM_GGFONTS_ASYNC = 'optm-ggfonts_async';
const O_OPTM_EXC_ROLES = 'optm-exc_roles';
const O_OPTM_CCSS_CON = 'optm-ccss_con';
const O_OPTM_JS_DEFER_EXC = 'optm-js_defer_exc';
const O_OPTM_GM_JS_EXC = 'optm-gm_js_exc';
const O_OPTM_DNS_PREFETCH = 'optm-dns_prefetch';
const O_OPTM_DNS_PREFETCH_CTRL = 'optm-dns_prefetch_ctrl';
const O_OPTM_DNS_PRECONNECT = 'optm-dns_preconnect';
const O_OPTM_EXC = 'optm-exc';
const O_OPTM_GUEST_ONLY = 'optm-guest_only';
## -------------------------------------------------- ##
## -------------- Object Cache ----------------- ##
## -------------------------------------------------- ##
const O_OBJECT = 'object';
const O_OBJECT_KIND = 'object-kind';
const O_OBJECT_HOST = 'object-host';
const O_OBJECT_PORT = 'object-port';
const O_OBJECT_LIFE = 'object-life';
const O_OBJECT_PERSISTENT = 'object-persistent';
const O_OBJECT_ADMIN = 'object-admin';
const O_OBJECT_TRANSIENTS = 'object-transients';
const O_OBJECT_DB_ID = 'object-db_id';
const O_OBJECT_USER = 'object-user';
const O_OBJECT_PSWD = 'object-pswd';
const O_OBJECT_GLOBAL_GROUPS = 'object-global_groups';
const O_OBJECT_NON_PERSISTENT_GROUPS = 'object-non_persistent_groups';
## -------------------------------------------------- ##
## -------------- Discussion ----------------- ##
## -------------------------------------------------- ##
const O_DISCUSS_AVATAR_CACHE = 'discuss-avatar_cache';
const O_DISCUSS_AVATAR_CRON = 'discuss-avatar_cron';
const O_DISCUSS_AVATAR_CACHE_TTL = 'discuss-avatar_cache_ttl';
## -------------------------------------------------- ##
## -------------- Media ----------------- ##
## -------------------------------------------------- ##
const O_MEDIA_PRELOAD_FEATURED = 'media-preload_featured'; // Deprecated since v6.2. TODO: Will drop after v6.5
const O_MEDIA_LAZY = 'media-lazy';
const O_MEDIA_LAZY_PLACEHOLDER = 'media-lazy_placeholder';
const O_MEDIA_PLACEHOLDER_RESP = 'media-placeholder_resp';
const O_MEDIA_PLACEHOLDER_RESP_COLOR = 'media-placeholder_resp_color';
const O_MEDIA_PLACEHOLDER_RESP_SVG = 'media-placeholder_resp_svg';
const O_MEDIA_LQIP = 'media-lqip';
const O_MEDIA_LQIP_QUAL = 'media-lqip_qual';
const O_MEDIA_LQIP_MIN_W = 'media-lqip_min_w';
const O_MEDIA_LQIP_MIN_H = 'media-lqip_min_h';
const O_MEDIA_PLACEHOLDER_RESP_ASYNC = 'media-placeholder_resp_async';
const O_MEDIA_IFRAME_LAZY = 'media-iframe_lazy';
const O_MEDIA_ADD_MISSING_SIZES = 'media-add_missing_sizes';
const O_MEDIA_LAZY_EXC = 'media-lazy_exc';
const O_MEDIA_LAZY_CLS_EXC = 'media-lazy_cls_exc';
const O_MEDIA_LAZY_PARENT_CLS_EXC = 'media-lazy_parent_cls_exc';
const O_MEDIA_IFRAME_LAZY_CLS_EXC = 'media-iframe_lazy_cls_exc';
const O_MEDIA_IFRAME_LAZY_PARENT_CLS_EXC = 'media-iframe_lazy_parent_cls_exc';
const O_MEDIA_LAZY_URI_EXC = 'media-lazy_uri_exc';
const O_MEDIA_LQIP_EXC = 'media-lqip_exc';
const O_MEDIA_VPI = 'media-vpi';
const O_MEDIA_VPI_CRON = 'media-vpi_cron';
const O_IMG_OPTM_JPG_QUALITY = 'img_optm-jpg_quality';
## -------------------------------------------------- ##
## -------------- Image Optm ----------------- ##
## -------------------------------------------------- ##
const O_IMG_OPTM_AUTO = 'img_optm-auto';
const O_IMG_OPTM_CRON = 'img_optm-cron'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_IMG_OPTM_ORI = 'img_optm-ori';
const O_IMG_OPTM_RM_BKUP = 'img_optm-rm_bkup';
const O_IMG_OPTM_WEBP = 'img_optm-webp';
const O_IMG_OPTM_LOSSLESS = 'img_optm-lossless';
const O_IMG_OPTM_EXIF = 'img_optm-exif';
const O_IMG_OPTM_WEBP_ATTR = 'img_optm-webp_attr';
const O_IMG_OPTM_WEBP_REPLACE_SRCSET = 'img_optm-webp_replace_srcset';
## -------------------------------------------------- ##
## -------------- Crawler ----------------- ##
## -------------------------------------------------- ##
const O_CRAWLER = 'crawler';
const O_CRAWLER_USLEEP = 'crawler-usleep'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_RUN_DURATION = 'crawler-run_duration'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_RUN_INTERVAL = 'crawler-run_interval'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_CRAWL_INTERVAL = 'crawler-crawl_interval';
const O_CRAWLER_THREADS = 'crawler-threads'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_TIMEOUT = 'crawler-timeout'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_LOAD_LIMIT = 'crawler-load_limit';
const O_CRAWLER_SITEMAP = 'crawler-sitemap';
const O_CRAWLER_DROP_DOMAIN = 'crawler-drop_domain'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_MAP_TIMEOUT = 'crawler-map_timeout'; // @Deprecated since v7.0 TODO: remove after v7.5
const O_CRAWLER_ROLES = 'crawler-roles';
const O_CRAWLER_COOKIES = 'crawler-cookies';
## -------------------------------------------------- ##
## -------------- Misc ----------------- ##
## -------------------------------------------------- ##
const O_MISC_HEARTBEAT_FRONT = 'misc-heartbeat_front';
const O_MISC_HEARTBEAT_FRONT_TTL = 'misc-heartbeat_front_ttl';
const O_MISC_HEARTBEAT_BACK = 'misc-heartbeat_back';
const O_MISC_HEARTBEAT_BACK_TTL = 'misc-heartbeat_back_ttl';
const O_MISC_HEARTBEAT_EDITOR = 'misc-heartbeat_editor';
const O_MISC_HEARTBEAT_EDITOR_TTL = 'misc-heartbeat_editor_ttl';
## -------------------------------------------------- ##
## -------------- CDN ----------------- ##
## -------------------------------------------------- ##
const O_CDN = 'cdn';
const O_CDN_ORI = 'cdn-ori';
const O_CDN_ORI_DIR = 'cdn-ori_dir';
const O_CDN_EXC = 'cdn-exc';
const O_CDN_QUIC = 'cdn-quic'; // No more a visible setting since v7
const O_CDN_CLOUDFLARE = 'cdn-cloudflare';
const O_CDN_CLOUDFLARE_EMAIL = 'cdn-cloudflare_email';
const O_CDN_CLOUDFLARE_KEY = 'cdn-cloudflare_key';
const O_CDN_CLOUDFLARE_NAME = 'cdn-cloudflare_name';
const O_CDN_CLOUDFLARE_ZONE = 'cdn-cloudflare_zone';
const O_CDN_MAPPING = 'cdn-mapping';
const O_CDN_ATTR = 'cdn-attr';
const O_QC_NAMESERVERS = 'qc-nameservers';
const O_QC_CNAME = 'qc-cname';
const NETWORK_O_USE_PRIMARY = 'use_primary_settings';
/*** Other consts ***/
const O_GUIDE = 'litespeed-guide'; // Array of each guidance tag as key, step as val //xx todo: may need to remove
// Server variables
const ENV_CRAWLER_USLEEP = 'CRAWLER_USLEEP';
const ENV_CRAWLER_LOAD_LIMIT = 'CRAWLER_LOAD_LIMIT';
const ENV_CRAWLER_LOAD_LIMIT_ENFORCE = 'CRAWLER_LOAD_LIMIT_ENFORCE';
const CRWL_COOKIE_NAME = 'name';
const CRWL_COOKIE_VALS = 'vals';
const CDN_MAPPING_URL = 'url';
const CDN_MAPPING_INC_IMG = 'inc_img';
const CDN_MAPPING_INC_CSS = 'inc_css';
const CDN_MAPPING_INC_JS = 'inc_js';
const CDN_MAPPING_FILETYPE = 'filetype';
const VAL_OFF = 0;
const VAL_ON = 1;
const VAL_ON2 = 2;
/* This is for API hook usage */
const IMG_OPTM_BM_ORI = 1; // @Deprecated since v7.0
const IMG_OPTM_BM_WEBP = 2; // @Deprecated since v7.0
const IMG_OPTM_BM_LOSSLESS = 4; // @Deprecated since v7.0
const IMG_OPTM_BM_EXIF = 8; // @Deprecated since v7.0
const IMG_OPTM_BM_AVIF = 16; // @Deprecated since v7.0
/* Site related options (Will not overwrite other sites' config) */
protected static $SINGLE_SITE_OPTIONS = array(
self::O_CRAWLER,
self::O_CRAWLER_SITEMAP,
self::O_CDN,
self::O_CDN_ORI,
self::O_CDN_ORI_DIR,
self::O_CDN_EXC,
self::O_CDN_CLOUDFLARE,
self::O_CDN_CLOUDFLARE_EMAIL,
self::O_CDN_CLOUDFLARE_KEY,
self::O_CDN_CLOUDFLARE_NAME,
self::O_CDN_CLOUDFLARE_ZONE,
self::O_CDN_MAPPING,
self::O_CDN_ATTR,
self::O_QC_NAMESERVERS,
self::O_QC_CNAME,
);
protected static $_default_options = array(
self::_VER => '',
self::HASH => '',
self::O_API_KEY => '',
self::O_AUTO_UPGRADE => false,
self::O_SERVER_IP => '',
self::O_GUEST => false,
self::O_GUEST_OPTM => false,
self::O_NEWS => false,
self::O_GUEST_UAS => array(),
self::O_GUEST_IPS => array(),
// Cache
self::O_CACHE => false,
self::O_CACHE_PRIV => false,
self::O_CACHE_COMMENTER => false,
self::O_CACHE_REST => false,
self::O_CACHE_PAGE_LOGIN => false,
self::O_CACHE_RES => false,
self::O_CACHE_MOBILE => false,
self::O_CACHE_MOBILE_RULES => array(),
self::O_CACHE_BROWSER => false,
self::O_CACHE_EXC_USERAGENTS => array(),
self::O_CACHE_EXC_COOKIES => array(),
self::O_CACHE_EXC_QS => array(),
self::O_CACHE_EXC_CAT => array(),
self::O_CACHE_EXC_TAG => array(),
self::O_CACHE_FORCE_URI => array(),
self::O_CACHE_FORCE_PUB_URI => array(),
self::O_CACHE_PRIV_URI => array(),
self::O_CACHE_EXC => array(),
self::O_CACHE_EXC_ROLES => array(),
self::O_CACHE_DROP_QS => array(),
self::O_CACHE_TTL_PUB => 0,
self::O_CACHE_TTL_PRIV => 0,
self::O_CACHE_TTL_FRONTPAGE => 0,
self::O_CACHE_TTL_FEED => 0,
self::O_CACHE_TTL_REST => 0,
self::O_CACHE_TTL_BROWSER => 0,
self::O_CACHE_TTL_STATUS => array(),
self::O_CACHE_LOGIN_COOKIE => '',
self::O_CACHE_AJAX_TTL => array(),
self::O_CACHE_VARY_COOKIES => array(),
self::O_CACHE_VARY_GROUP => array(),
// Purge
self::O_PURGE_ON_UPGRADE => false,
self::O_PURGE_STALE => false,
self::O_PURGE_POST_ALL => false,
self::O_PURGE_POST_FRONTPAGE => false,
self::O_PURGE_POST_HOMEPAGE => false,
self::O_PURGE_POST_PAGES => false,
self::O_PURGE_POST_PAGES_WITH_RECENT_POSTS => false,
self::O_PURGE_POST_AUTHOR => false,
self::O_PURGE_POST_YEAR => false,
self::O_PURGE_POST_MONTH => false,
self::O_PURGE_POST_DATE => false,
self::O_PURGE_POST_TERM => false,
self::O_PURGE_POST_POSTTYPE => false,
self::O_PURGE_TIMED_URLS => array(),
self::O_PURGE_TIMED_URLS_TIME => '',
self::O_PURGE_HOOK_ALL => array(),
// ESI
self::O_ESI => false,
self::O_ESI_CACHE_ADMBAR => false,
self::O_ESI_CACHE_COMMFORM => false,
self::O_ESI_NONCE => array(),
// Util
self::O_UTIL_INSTANT_CLICK => false,
self::O_UTIL_NO_HTTPS_VARY => false,
// Debug
self::O_DEBUG_DISABLE_ALL => false,
self::O_DEBUG => false,
self::O_DEBUG_IPS => array(),
self::O_DEBUG_LEVEL => false,
self::O_DEBUG_FILESIZE => 0,
self::O_DEBUG_COLLAPSE_QS => false,
self::O_DEBUG_INC => array(),
self::O_DEBUG_EXC => array(),
self::O_DEBUG_EXC_STRINGS => array(),
// DB Optm
self::O_DB_OPTM_REVISIONS_MAX => 0,
self::O_DB_OPTM_REVISIONS_AGE => 0,
// HTML Optm
self::O_OPTM_CSS_MIN => false,
self::O_OPTM_CSS_COMB => false,
self::O_OPTM_CSS_COMB_EXT_INL => false,
self::O_OPTM_UCSS => false,
self::O_OPTM_UCSS_INLINE => false,
self::O_OPTM_UCSS_SELECTOR_WHITELIST => array(),
self::O_OPTM_UCSS_FILE_EXC_INLINE => array(),
self::O_OPTM_UCSS_EXC => array(),
self::O_OPTM_CSS_EXC => array(),
self::O_OPTM_JS_MIN => false,
self::O_OPTM_JS_COMB => false,
self::O_OPTM_JS_COMB_EXT_INL => false,
self::O_OPTM_JS_DELAY_INC => array(),
self::O_OPTM_JS_EXC => array(),
self::O_OPTM_HTML_MIN => false,
self::O_OPTM_HTML_LAZY => array(),
self::O_OPTM_HTML_SKIP_COMMENTS => array(),
self::O_OPTM_QS_RM => false,
self::O_OPTM_GGFONTS_RM => false,
self::O_OPTM_CSS_ASYNC => false,
self::O_OPTM_CCSS_PER_URL => false,
self::O_OPTM_CCSS_SEP_POSTTYPE => array(),
self::O_OPTM_CCSS_SEP_URI => array(),
self::O_OPTM_CCSS_SELECTOR_WHITELIST => array(),
self::O_OPTM_CSS_ASYNC_INLINE => false,
self::O_OPTM_CSS_FONT_DISPLAY => false,
self::O_OPTM_JS_DEFER => false,
self::O_OPTM_EMOJI_RM => false,
self::O_OPTM_NOSCRIPT_RM => false,
self::O_OPTM_GGFONTS_ASYNC => false,
self::O_OPTM_EXC_ROLES => array(),
self::O_OPTM_CCSS_CON => '',
self::O_OPTM_JS_DEFER_EXC => array(),
self::O_OPTM_GM_JS_EXC => array(),
self::O_OPTM_DNS_PREFETCH => array(),
self::O_OPTM_DNS_PREFETCH_CTRL => false,
self::O_OPTM_DNS_PRECONNECT => array(),
self::O_OPTM_EXC => array(),
self::O_OPTM_GUEST_ONLY => false,
// Object
self::O_OBJECT => false,
self::O_OBJECT_KIND => false,
self::O_OBJECT_HOST => '',
self::O_OBJECT_PORT => 0,
self::O_OBJECT_LIFE => 0,
self::O_OBJECT_PERSISTENT => false,
self::O_OBJECT_ADMIN => false,
self::O_OBJECT_TRANSIENTS => false,
self::O_OBJECT_DB_ID => 0,
self::O_OBJECT_USER => '',
self::O_OBJECT_PSWD => '',
self::O_OBJECT_GLOBAL_GROUPS => array(),
self::O_OBJECT_NON_PERSISTENT_GROUPS => array(),
// Discuss
self::O_DISCUSS_AVATAR_CACHE => false,
self::O_DISCUSS_AVATAR_CRON => false,
self::O_DISCUSS_AVATAR_CACHE_TTL => 0,
self::O_OPTM_LOCALIZE => false,
self::O_OPTM_LOCALIZE_DOMAINS => array(),
// Media
self::O_MEDIA_LAZY => false,
self::O_MEDIA_LAZY_PLACEHOLDER => '',
self::O_MEDIA_PLACEHOLDER_RESP => false,
self::O_MEDIA_PLACEHOLDER_RESP_COLOR => '',
self::O_MEDIA_PLACEHOLDER_RESP_SVG => '',
self::O_MEDIA_LQIP => false,
self::O_MEDIA_LQIP_QUAL => 0,
self::O_MEDIA_LQIP_MIN_W => 0,
self::O_MEDIA_LQIP_MIN_H => 0,
self::O_MEDIA_PLACEHOLDER_RESP_ASYNC => false,
self::O_MEDIA_IFRAME_LAZY => false,
self::O_MEDIA_ADD_MISSING_SIZES => false,
self::O_MEDIA_LAZY_EXC => array(),
self::O_MEDIA_LAZY_CLS_EXC => array(),
self::O_MEDIA_LAZY_PARENT_CLS_EXC => array(),
self::O_MEDIA_IFRAME_LAZY_CLS_EXC => array(),
self::O_MEDIA_IFRAME_LAZY_PARENT_CLS_EXC => array(),
self::O_MEDIA_LAZY_URI_EXC => array(),
self::O_MEDIA_LQIP_EXC => array(),
self::O_MEDIA_VPI => false,
self::O_MEDIA_VPI_CRON => false,
// Image Optm
self::O_IMG_OPTM_AUTO => false,
self::O_IMG_OPTM_ORI => false,
self::O_IMG_OPTM_RM_BKUP => false,
self::O_IMG_OPTM_WEBP => false,
self::O_IMG_OPTM_LOSSLESS => false,
self::O_IMG_OPTM_EXIF => false,
self::O_IMG_OPTM_WEBP_ATTR => array(),
self::O_IMG_OPTM_WEBP_REPLACE_SRCSET => false,
self::O_IMG_OPTM_JPG_QUALITY => 0,
// Crawler
self::O_CRAWLER => false,
self::O_CRAWLER_CRAWL_INTERVAL => 0,
self::O_CRAWLER_LOAD_LIMIT => 0,
self::O_CRAWLER_SITEMAP => '',
self::O_CRAWLER_ROLES => array(),
self::O_CRAWLER_COOKIES => array(),
// Misc
self::O_MISC_HEARTBEAT_FRONT => false,
self::O_MISC_HEARTBEAT_FRONT_TTL => 0,
self::O_MISC_HEARTBEAT_BACK => false,
self::O_MISC_HEARTBEAT_BACK_TTL => 0,
self::O_MISC_HEARTBEAT_EDITOR => false,
self::O_MISC_HEARTBEAT_EDITOR_TTL => 0,
// CDN
self::O_CDN => false,
self::O_CDN_ORI => array(),
self::O_CDN_ORI_DIR => array(),
self::O_CDN_EXC => array(),
self::O_CDN_QUIC => false,
self::O_CDN_CLOUDFLARE => false,
self::O_CDN_CLOUDFLARE_EMAIL => '',
self::O_CDN_CLOUDFLARE_KEY => '',
self::O_CDN_CLOUDFLARE_NAME => '',
self::O_CDN_CLOUDFLARE_ZONE => '',
self::O_CDN_MAPPING => array(),
self::O_CDN_ATTR => array(),
self::O_QC_NAMESERVERS => '',
self::O_QC_CNAME => '',
);
protected static $_default_site_options = array(
self::_VER => '',
self::O_CACHE => false,
self::NETWORK_O_USE_PRIMARY => false,
self::O_AUTO_UPGRADE => false,
self::O_GUEST => false,
self::O_CACHE_RES => false,
self::O_CACHE_BROWSER => false,
self::O_CACHE_MOBILE => false,
self::O_CACHE_MOBILE_RULES => array(),
self::O_CACHE_LOGIN_COOKIE => '',
self::O_CACHE_VARY_COOKIES => array(),
self::O_CACHE_EXC_COOKIES => array(),
self::O_CACHE_EXC_USERAGENTS => array(),
self::O_CACHE_TTL_BROWSER => 0,
self::O_PURGE_ON_UPGRADE => false,
self::O_OBJECT => false,
self::O_OBJECT_KIND => false,
self::O_OBJECT_HOST => '',
self::O_OBJECT_PORT => 0,
self::O_OBJECT_LIFE => 0,
self::O_OBJECT_PERSISTENT => false,
self::O_OBJECT_ADMIN => false,
self::O_OBJECT_TRANSIENTS => false,
self::O_OBJECT_DB_ID => 0,
self::O_OBJECT_USER => '',
self::O_OBJECT_PSWD => '',
self::O_OBJECT_GLOBAL_GROUPS => array(),
self::O_OBJECT_NON_PERSISTENT_GROUPS => array(),
// Debug
self::O_DEBUG_DISABLE_ALL => false,
self::O_DEBUG => false,
self::O_DEBUG_IPS => array(),
self::O_DEBUG_LEVEL => false,
self::O_DEBUG_FILESIZE => 0,
self::O_DEBUG_COLLAPSE_QS => false,
self::O_DEBUG_INC => array(),
self::O_DEBUG_EXC => array(),
self::O_DEBUG_EXC_STRINGS => array(),
self::O_IMG_OPTM_WEBP => false,
);
// NOTE: all the val of following items will be int while not bool
protected static $_multi_switch_list = array(
self::O_DEBUG => 2,
self::O_OPTM_JS_DEFER => 2,
self::O_IMG_OPTM_WEBP => 2,
);
/**
* Correct the option type
*
* TODO: add similar network func
*
* @since 3.0.3
*/
protected function type_casting($val, $id, $is_site_conf = false)
{
$default_v = !$is_site_conf ? self::$_default_options[$id] : self::$_default_site_options[$id];
if (is_bool($default_v)) {
if ($val === 'true') {
$val = true;
}
if ($val === 'false') {
$val = false;
}
$max = $this->_conf_multi_switch($id);
if ($max) {
$val = (int) $val;
$val %= $max + 1;
} else {
$val = (bool) $val;
}
} elseif (is_array($default_v)) {
// from textarea input
if (!is_array($val)) {
$val = Utility::sanitize_lines($val, $this->_conf_filter($id));
}
} elseif (!is_string($default_v)) {
$val = (int) $val;
} else {
// Check if the string has a limit set
$val = $this->_conf_string_val($id, $val);
}
return $val;
}
/**
* Load default network settings from data.ini
*
* @since 3.0
*/
public function load_default_site_vals()
{
// Load network_default.json
if (file_exists(LSCWP_DIR . 'data/const.network_default.json')) {
$default_ini_cfg = json_decode(File::read(LSCWP_DIR . 'data/const.network_default.json'), true);
foreach (self::$_default_site_options as $k => $v) {
if (!array_key_exists($k, $default_ini_cfg)) {
continue;
}
// Parse value in ini file
$ini_v = $this->type_casting($default_ini_cfg[$k], $k, true);
if ($ini_v == $v) {
continue;
}
self::$_default_site_options[$k] = $ini_v;
}
}
self::$_default_site_options[self::_VER] = Core::VER;
return self::$_default_site_options;
}
/**
* Load default values from default.json
*
* @since 3.0
* @access public
*/
public function load_default_vals()
{
// Load default.json
if (file_exists(LSCWP_DIR . 'data/const.default.json')) {
$default_ini_cfg = json_decode(File::read(LSCWP_DIR . 'data/const.default.json'), true);
foreach (self::$_default_options as $k => $v) {
if (!array_key_exists($k, $default_ini_cfg)) {
continue;
}
// Parse value in ini file
$ini_v = $this->type_casting($default_ini_cfg[$k], $k);
// NOTE: Multiple lines value must be stored as array
/**
* Special handler for CDN_mapping
*
* format in .ini:
* [cdn-mapping]
* url[0] = 'https://example.com/'
* inc_js[0] = true
* filetype[0] = '.css
* .js
* .jpg'
*
* format out:
* [0] = [ 'url' => 'https://example.com', 'inc_js' => true, 'filetype' => [ '.css', '.js', '.jpg' ] ]
*/
if ($k == self::O_CDN_MAPPING) {
$mapping_fields = array(
self::CDN_MAPPING_URL,
self::CDN_MAPPING_INC_IMG,
self::CDN_MAPPING_INC_CSS,
self::CDN_MAPPING_INC_JS,
self::CDN_MAPPING_FILETYPE, // Array
);
$ini_v2 = array();
foreach ($ini_v[self::CDN_MAPPING_URL] as $k2 => $v2) {
// $k2 is numeric
$this_row = array();
foreach ($mapping_fields as $v3) {
$this_v = !empty($ini_v[$v3][$k2]) ? $ini_v[$v3][$k2] : false;
if ($v3 == self::CDN_MAPPING_URL) {
$this_v = $this_v ?: '';
}
if ($v3 == self::CDN_MAPPING_FILETYPE) {
$this_v = $this_v ? Utility::sanitize_lines($this_v) : array(); // Note: Since v3.0 its already an array
}
$this_row[$v3] = $this_v;
}
$ini_v2[$k2] = $this_row;
}
$ini_v = $ini_v2;
}
if ($ini_v == $v) {
continue;
}
self::$_default_options[$k] = $ini_v;
}
}
// Load internal default vals
// Setting the default bool to int is also to avoid type casting override it back to bool
self::$_default_options[self::O_CACHE] = is_multisite() ? self::VAL_ON2 : self::VAL_ON; //For multi site, default is 2 (Use Network Admin Settings). For single site, default is 1 (Enabled).
// Load default vals containing variables
if (!self::$_default_options[self::O_CDN_ORI_DIR]) {
self::$_default_options[self::O_CDN_ORI_DIR] = LSCWP_CONTENT_FOLDER . "\nwp-includes";
self::$_default_options[self::O_CDN_ORI_DIR] = explode("\n", self::$_default_options[self::O_CDN_ORI_DIR]);
self::$_default_options[self::O_CDN_ORI_DIR] = array_map('trim', self::$_default_options[self::O_CDN_ORI_DIR]);
}
// Set security key if not initialized yet
if (!self::$_default_options[self::HASH]) {
self::$_default_options[self::HASH] = Str::rrand(32);
}
self::$_default_options[self::_VER] = Core::VER;
return self::$_default_options;
}
/**
* Format the string value
*
* @since 3.0
*/
protected function _conf_string_val($id, $val)
{
return $val;
}
/**
* If the switch setting is a triple value or not
*
* @since 3.0
*/
protected function _conf_multi_switch($id)
{
if (!empty(self::$_multi_switch_list[$id])) {
return self::$_multi_switch_list[$id];
}
if ($id == self::O_CACHE && is_multisite()) {
return self::VAL_ON2;
}
return false;
}
/**
* Append a new multi switch max limit for the bool option
*
* @since 3.0
*/
public static function set_multi_switch($id, $v)
{
self::$_multi_switch_list[$id] = $v;
}
/**
* Generate const name based on $id
*
* @since 3.0
*/
public static function conf_const($id)
{
return 'LITESPEED_CONF__' . strtoupper(str_replace('-', '__', $id));
}
/**
* Filter to be used when saving setting
*
* @since 3.0
*/
protected function _conf_filter($id)
{
$filters = array(
self::O_MEDIA_LAZY_EXC => 'uri',
self::O_DEBUG_INC => 'relative',
self::O_DEBUG_EXC => 'relative',
self::O_MEDIA_LAZY_URI_EXC => 'relative',
self::O_CACHE_PRIV_URI => 'relative',
self::O_PURGE_TIMED_URLS => 'relative',
self::O_CACHE_FORCE_URI => 'relative',
self::O_CACHE_FORCE_PUB_URI => 'relative',
self::O_CACHE_EXC => 'relative',
// self::O_OPTM_CSS_EXC => 'uri', // Need to comment out for inline & external CSS
// self::O_OPTM_JS_EXC => 'uri',
self::O_OPTM_EXC => 'relative',
self::O_OPTM_CCSS_SEP_URI => 'uri',
// self::O_OPTM_JS_DEFER_EXC => 'uri',
self::O_OPTM_DNS_PREFETCH => 'domain',
self::O_CDN_ORI => 'noprotocol,trailingslash', // `Original URLs`
// self::O_OPTM_LOCALIZE_DOMAINS => 'noprotocol', // `Localize Resources`
// self:: => '',
// self:: => '',
);
if (!empty($filters[$id])) {
return $filters[$id];
}
return false;
}
/**
* If the setting changes worth a purge or not
*
* @since 3.0
*/
protected function _conf_purge($id)
{
$check_ids = array(
self::O_MEDIA_LAZY_URI_EXC,
self::O_OPTM_EXC,
self::O_CACHE_PRIV_URI,
self::O_PURGE_TIMED_URLS,
self::O_CACHE_FORCE_URI,
self::O_CACHE_FORCE_PUB_URI,
self::O_CACHE_EXC,
);
return in_array($id, $check_ids);
}
/**
* If the setting changes worth a purge ALL or not
*
* @since 3.0
*/
protected function _conf_purge_all($id)
{
$check_ids = array(self::O_CACHE, self::O_ESI, self::O_DEBUG_DISABLE_ALL, self::NETWORK_O_USE_PRIMARY);
return in_array($id, $check_ids);
}
/**
* If the setting is a pswd or not
*
* @since 3.0
*/
protected function _conf_pswd($id)
{
$check_ids = array(self::O_CDN_CLOUDFLARE_KEY, self::O_OBJECT_PSWD);
return in_array($id, $check_ids);
}
/**
* If the setting is cron related or not
*
* @since 3.0
*/
protected function _conf_cron($id)
{
$check_ids = array(self::O_OPTM_CSS_ASYNC, self::O_MEDIA_PLACEHOLDER_RESP_ASYNC, self::O_DISCUSS_AVATAR_CRON, self::O_IMG_OPTM_AUTO, self::O_CRAWLER);
return in_array($id, $check_ids);
}
/**
* If the setting changes worth a purge, return the tag
*
* @since 3.0
*/
protected function _conf_purge_tag($id)
{
$check_ids = array(
self::O_CACHE_PAGE_LOGIN => Tag::TYPE_LOGIN,
);
if (!empty($check_ids[$id])) {
return $check_ids[$id];
}
return false;
}
/**
* Generate server vars
*
* @since 2.4.1
*/
public function server_vars()
{
$consts = array(
'WP_SITEURL',
'WP_HOME',
'WP_CONTENT_DIR',
'SHORTINIT',
'LSCWP_CONTENT_DIR',
'LSCWP_CONTENT_FOLDER',
'LSCWP_DIR',
'LITESPEED_TIME_OFFSET',
'LITESPEED_SERVER_TYPE',
'LITESPEED_CLI',
'LITESPEED_ALLOWED',
'LITESPEED_ON',
'LSWCP_TAG_PREFIX',
'COOKIEHASH',
);
$server_vars = array();
foreach ($consts as $v) {
$server_vars[$v] = defined($v) ? constant($v) : null;
}
return $server_vars;
}
}
cdn/cloudflare.cls.php 0000644 00000016030 15153741266 0010733 0 ustar 00 <?php
/**
* The cloudflare CDN class.
*
* @since 2.1
* @package LiteSpeed
* @subpackage LiteSpeed/src/cdn
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed\CDN;
use LiteSpeed\Core;
use LiteSpeed\Base;
use LiteSpeed\Debug2;
use LiteSpeed\Router;
use LiteSpeed\Admin;
use LiteSpeed\Admin_Display;
defined('WPINC') || exit();
class Cloudflare extends Base
{
const TYPE_PURGE_ALL = 'purge_all';
const TYPE_GET_DEVMODE = 'get_devmode';
const TYPE_SET_DEVMODE_ON = 'set_devmode_on';
const TYPE_SET_DEVMODE_OFF = 'set_devmode_off';
const ITEM_STATUS = 'status';
/**
* Update zone&name based on latest settings
*
* @since 3.0
* @access public
*/
public function try_refresh_zone()
{
if (!$this->conf(self::O_CDN_CLOUDFLARE)) {
return;
}
$zone = $this->_fetch_zone();
if ($zone) {
$this->cls('Conf')->update(self::O_CDN_CLOUDFLARE_NAME, $zone['name']);
$this->cls('Conf')->update(self::O_CDN_CLOUDFLARE_ZONE, $zone['id']);
Debug2::debug("[Cloudflare] Get zone successfully \t\t[ID] $zone[id]");
} else {
$this->cls('Conf')->update(self::O_CDN_CLOUDFLARE_ZONE, '');
Debug2::debug('[Cloudflare] ❌ Get zone failed, clean zone');
}
}
/**
* Get Cloudflare development mode
*
* @since 1.7.2
* @access private
*/
private function _get_devmode($show_msg = true)
{
Debug2::debug('[Cloudflare] _get_devmode');
$zone = $this->_zone();
if (!$zone) {
return;
}
$url = 'https://api.cloudflare.com/client/v4/zones/' . $zone . '/settings/development_mode';
$res = $this->_cloudflare_call($url, 'GET', false, $show_msg);
if (!$res) {
return;
}
Debug2::debug('[Cloudflare] _get_devmode result ', $res);
// Make sure is array: #992174
$curr_status = self::get_option(self::ITEM_STATUS, array()) ?: array();
$curr_status['devmode'] = $res['value'];
$curr_status['devmode_expired'] = $res['time_remaining'] + time();
// update status
self::update_option(self::ITEM_STATUS, $curr_status);
}
/**
* Set Cloudflare development mode
*
* @since 1.7.2
* @access private
*/
private function _set_devmode($type)
{
Debug2::debug('[Cloudflare] _set_devmode');
$zone = $this->_zone();
if (!$zone) {
return;
}
$url = 'https://api.cloudflare.com/client/v4/zones/' . $zone . '/settings/development_mode';
$new_val = $type == self::TYPE_SET_DEVMODE_ON ? 'on' : 'off';
$data = array('value' => $new_val);
$res = $this->_cloudflare_call($url, 'PATCH', $data);
if (!$res) {
return;
}
$res = $this->_get_devmode(false);
if ($res) {
$msg = sprintf(__('Notified Cloudflare to set development mode to %s successfully.', 'litespeed-cache'), strtoupper($new_val));
Admin_Display::success($msg);
}
}
/**
* Purge Cloudflare cache
*
* @since 1.7.2
* @access private
*/
private function _purge_all()
{
Debug2::debug('[Cloudflare] _purge_all');
$cf_on = $this->conf(self::O_CDN_CLOUDFLARE);
if (!$cf_on) {
$msg = __('Cloudflare API is set to off.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
$zone = $this->_zone();
if (!$zone) {
return;
}
$url = 'https://api.cloudflare.com/client/v4/zones/' . $zone . '/purge_cache';
$data = array('purge_everything' => true);
$res = $this->_cloudflare_call($url, 'DELETE', $data);
if ($res) {
$msg = __('Notified Cloudflare to purge all successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
}
/**
* Get current Cloudflare zone from cfg
*
* @since 1.7.2
* @access private
*/
private function _zone()
{
$zone = $this->conf(self::O_CDN_CLOUDFLARE_ZONE);
if (!$zone) {
$msg = __('No available Cloudflare zone', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
return $zone;
}
/**
* Get Cloudflare zone settings
*
* @since 1.7.2
* @access private
*/
private function _fetch_zone()
{
$kw = $this->conf(self::O_CDN_CLOUDFLARE_NAME);
$url = 'https://api.cloudflare.com/client/v4/zones?status=active&match=all';
// Try exact match first
if ($kw && strpos($kw, '.')) {
$zones = $this->_cloudflare_call($url . '&name=' . $kw, 'GET', false, false);
if ($zones) {
Debug2::debug('[Cloudflare] fetch_zone exact matched');
return $zones[0];
}
}
// Can't find, try to get default one
$zones = $this->_cloudflare_call($url, 'GET', false, false);
if (!$zones) {
Debug2::debug('[Cloudflare] fetch_zone no zone');
return false;
}
if (!$kw) {
Debug2::debug('[Cloudflare] fetch_zone no set name, use first one by default');
return $zones[0];
}
foreach ($zones as $v) {
if (strpos($v['name'], $kw) !== false) {
Debug2::debug('[Cloudflare] fetch_zone matched ' . $kw . ' [name] ' . $v['name']);
return $v;
}
}
// Can't match current name, return default one
Debug2::debug('[Cloudflare] fetch_zone failed match name, use first one by default');
return $zones[0];
}
/**
* Cloudflare API
*
* @since 1.7.2
* @access private
*/
private function _cloudflare_call($url, $method = 'GET', $data = false, $show_msg = true)
{
Debug2::debug("[Cloudflare] _cloudflare_call \t\t[URL] $url");
if (40 == strlen($this->conf(self::O_CDN_CLOUDFLARE_KEY))) {
$headers = array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->conf(self::O_CDN_CLOUDFLARE_KEY),
);
} else {
$headers = array(
'Content-Type' => 'application/json',
'X-Auth-Email' => $this->conf(self::O_CDN_CLOUDFLARE_EMAIL),
'X-Auth-Key' => $this->conf(self::O_CDN_CLOUDFLARE_KEY),
);
}
$wp_args = array(
'method' => $method,
'headers' => $headers,
);
if ($data) {
if (is_array($data)) {
$data = \json_encode($data);
}
$wp_args['body'] = $data;
}
$resp = wp_remote_request($url, $wp_args);
if (is_wp_error($resp)) {
Debug2::debug('[Cloudflare] error in response');
if ($show_msg) {
$msg = __('Failed to communicate with Cloudflare', 'litespeed-cache');
Admin_Display::error($msg);
}
return false;
}
$result = wp_remote_retrieve_body($resp);
$json = \json_decode($result, true);
if ($json && $json['success'] && $json['result']) {
Debug2::debug('[Cloudflare] _cloudflare_call called successfully');
if ($show_msg) {
$msg = __('Communicated with Cloudflare successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
return $json['result'];
}
Debug2::debug("[Cloudflare] _cloudflare_call called failed: $result");
if ($show_msg) {
$msg = __('Failed to communicate with Cloudflare', 'litespeed-cache');
Admin_Display::error($msg);
}
return false;
}
/**
* Handle all request actions from main cls
*
* @since 1.7.2
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_PURGE_ALL:
$this->_purge_all();
break;
case self::TYPE_GET_DEVMODE:
$this->_get_devmode();
break;
case self::TYPE_SET_DEVMODE_ON:
case self::TYPE_SET_DEVMODE_OFF:
$this->_set_devmode($type);
break;
default:
break;
}
Admin::redirect();
}
}
cdn/quic.cls.php 0000644 00000005700 15153741266 0007556 0 ustar 00 <?php
/**
* The quic.cloud class.
*
* @since 2.4.1
* @package LiteSpeed
* @subpackage LiteSpeed/src/cdn
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed\CDN;
use LiteSpeed\Cloud;
use LiteSpeed\Base;
defined('WPINC') || exit();
class Quic extends Base
{
const LOG_TAG = '☁️';
const TYPE_REG = 'reg';
protected $_summary;
private $_force = false;
public function __construct()
{
$this->_summary = self::get_summary();
}
/**
* Notify CDN new config updated
*
* @access public
*/
public function try_sync_conf($force = false)
{
if ($force) {
$this->_force = $force;
}
if (!$this->conf(self::O_CDN_QUIC)) {
if (!empty($this->_summary['conf_md5'])) {
self::debug('❌ No QC CDN, clear conf md5!');
self::save_summary(array('conf_md5' => ''));
}
return false;
}
// Notice: Sync conf must be after `wp_loaded` hook, to get 3rd party vary injected (e.g. `woocommerce_cart_hash`).
if (!did_action('wp_loaded')) {
add_action('wp_loaded', array($this, 'try_sync_conf'), 999);
self::debug('WP not loaded yet, delay sync to wp_loaded:999');
return;
}
$options = $this->get_options();
$options['_tp_cookies'] = apply_filters('litespeed_vary_cookies', array());
// Build necessary options only
$options_needed = array(
self::O_CACHE_DROP_QS,
self::O_CACHE_EXC_COOKIES,
self::O_CACHE_EXC_USERAGENTS,
self::O_CACHE_LOGIN_COOKIE,
self::O_CACHE_VARY_COOKIES,
self::O_CACHE_MOBILE_RULES,
self::O_CACHE_MOBILE,
self::O_CACHE_RES,
self::O_CACHE_BROWSER,
self::O_CACHE_TTL_BROWSER,
self::O_IMG_OPTM_WEBP,
self::O_GUEST,
'_tp_cookies',
);
$consts_needed = array('WP_CONTENT_DIR', 'LSCWP_CONTENT_DIR', 'LSCWP_CONTENT_FOLDER', 'LSWCP_TAG_PREFIX');
$options_for_md5 = array();
foreach ($options_needed as $v) {
if (isset($options[$v])) {
$options_for_md5[$v] = $options[$v];
// Remove overflow multi lines fields
if (is_array($options_for_md5[$v]) && count($options_for_md5[$v]) > 30) {
$options_for_md5[$v] = array_slice($options_for_md5[$v], 0, 30);
}
}
}
$server_vars = $this->server_vars();
foreach ($consts_needed as $v) {
if (isset($server_vars[$v])) {
if (empty($options_for_md5['_server'])) {
$options_for_md5['_server'] = array();
}
$options_for_md5['_server'][$v] = $server_vars[$v];
}
}
$conf_md5 = md5(\json_encode($options_for_md5));
if (!empty($this->_summary['conf_md5'])) {
if ($conf_md5 == $this->_summary['conf_md5']) {
if (!$this->_force) {
self::debug('Bypass sync conf to QC due to same md5', $conf_md5);
return;
}
self::debug('!!!Force sync conf even same md5');
} else {
self::debug('[conf_md5] ' . $conf_md5 . ' [existing_conf_md5] ' . $this->_summary['conf_md5']);
}
}
self::save_summary(array('conf_md5' => $conf_md5));
self::debug('sync conf to QC');
Cloud::post(Cloud::SVC_D_SYNC_CONF, $options_for_md5);
}
}
cdn.cls.php 0000644 00000032274 15153741266 0006623 0 ustar 00 <?php
/**
* The CDN class.
*
* @since 1.2.3
* @since 1.5 Moved into /inc
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class CDN extends Root
{
const BYPASS = 'LITESPEED_BYPASS_CDN';
private $content;
private $_cfg_cdn;
private $_cfg_url_ori;
private $_cfg_ori_dir;
private $_cfg_cdn_mapping = array();
private $_cfg_cdn_exclude;
private $cdn_mapping_hosts = array();
/**
* Init
*
* @since 1.2.3
*/
public function init()
{
Debug2::debug2('[CDN] init');
if (defined(self::BYPASS)) {
Debug2::debug2('CDN bypass');
return;
}
if (!Router::can_cdn()) {
if (!defined(self::BYPASS)) {
define(self::BYPASS, true);
}
return;
}
$this->_cfg_cdn = $this->conf(Base::O_CDN);
if (!$this->_cfg_cdn) {
if (!defined(self::BYPASS)) {
define(self::BYPASS, true);
}
return;
}
$this->_cfg_url_ori = $this->conf(Base::O_CDN_ORI);
// Parse cdn mapping data to array( 'filetype' => 'url' )
$mapping_to_check = array(Base::CDN_MAPPING_INC_IMG, Base::CDN_MAPPING_INC_CSS, Base::CDN_MAPPING_INC_JS);
foreach ($this->conf(Base::O_CDN_MAPPING) as $v) {
if (!$v[Base::CDN_MAPPING_URL]) {
continue;
}
$this_url = $v[Base::CDN_MAPPING_URL];
$this_host = parse_url($this_url, PHP_URL_HOST);
// Check img/css/js
foreach ($mapping_to_check as $to_check) {
if ($v[$to_check]) {
Debug2::debug2('[CDN] mapping ' . $to_check . ' -> ' . $this_url);
// If filetype to url is one to many, make url be an array
$this->_append_cdn_mapping($to_check, $this_url);
if (!in_array($this_host, $this->cdn_mapping_hosts)) {
$this->cdn_mapping_hosts[] = $this_host;
}
}
}
// Check file types
if ($v[Base::CDN_MAPPING_FILETYPE]) {
foreach ($v[Base::CDN_MAPPING_FILETYPE] as $v2) {
$this->_cfg_cdn_mapping[Base::CDN_MAPPING_FILETYPE] = true;
// If filetype to url is one to many, make url be an array
$this->_append_cdn_mapping($v2, $this_url);
if (!in_array($this_host, $this->cdn_mapping_hosts)) {
$this->cdn_mapping_hosts[] = $this_host;
}
}
Debug2::debug2('[CDN] mapping ' . implode(',', $v[Base::CDN_MAPPING_FILETYPE]) . ' -> ' . $this_url);
}
}
if (!$this->_cfg_url_ori || !$this->_cfg_cdn_mapping) {
if (!defined(self::BYPASS)) {
define(self::BYPASS, true);
}
return;
}
$this->_cfg_ori_dir = $this->conf(Base::O_CDN_ORI_DIR);
// In case user customized upload path
if (defined('UPLOADS')) {
$this->_cfg_ori_dir[] = UPLOADS;
}
// Check if need preg_replace
$this->_cfg_url_ori = Utility::wildcard2regex($this->_cfg_url_ori);
$this->_cfg_cdn_exclude = $this->conf(Base::O_CDN_EXC);
if (!empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_INC_IMG])) {
// Hook to srcset
if (function_exists('wp_calculate_image_srcset')) {
add_filter('wp_calculate_image_srcset', array($this, 'srcset'), 999);
}
// Hook to mime icon
add_filter('wp_get_attachment_image_src', array($this, 'attach_img_src'), 999);
add_filter('wp_get_attachment_url', array($this, 'url_img'), 999);
}
if (!empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_INC_CSS])) {
add_filter('style_loader_src', array($this, 'url_css'), 999);
}
if (!empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_INC_JS])) {
add_filter('script_loader_src', array($this, 'url_js'), 999);
}
add_filter('litespeed_buffer_finalize', array($this, 'finalize'), 30);
}
/**
* Associate all filetypes with url
*
* @since 2.0
* @access private
*/
private function _append_cdn_mapping($filetype, $url)
{
// If filetype to url is one to many, make url be an array
if (empty($this->_cfg_cdn_mapping[$filetype])) {
$this->_cfg_cdn_mapping[$filetype] = $url;
} elseif (is_array($this->_cfg_cdn_mapping[$filetype])) {
// Append url to filetype
$this->_cfg_cdn_mapping[$filetype][] = $url;
} else {
// Convert _cfg_cdn_mapping from string to array
$this->_cfg_cdn_mapping[$filetype] = array($this->_cfg_cdn_mapping[$filetype], $url);
}
}
/**
* If include css/js in CDN
*
* @since 1.6.2.1
* @return bool true if included in CDN
*/
public function inc_type($type)
{
if ($type == 'css' && !empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_INC_CSS])) {
return true;
}
if ($type == 'js' && !empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_INC_JS])) {
return true;
}
return false;
}
/**
* Run CDN process
* NOTE: As this is after cache finalized, can NOT set any cache control anymore
*
* @since 1.2.3
* @access public
* @return string The content that is after optimization
*/
public function finalize($content)
{
$this->content = $content;
$this->_finalize();
return $this->content;
}
/**
* Replace CDN url
*
* @since 1.2.3
* @access private
*/
private function _finalize()
{
if (defined(self::BYPASS)) {
return;
}
Debug2::debug('CDN _finalize');
// Start replacing img src
if (!empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_INC_IMG])) {
$this->_replace_img();
$this->_replace_inline_css();
}
if (!empty($this->_cfg_cdn_mapping[Base::CDN_MAPPING_FILETYPE])) {
$this->_replace_file_types();
}
}
/**
* Parse all file types
*
* @since 1.2.3
* @access private
*/
private function _replace_file_types()
{
$ele_to_check = $this->conf(Base::O_CDN_ATTR);
foreach ($ele_to_check as $v) {
if (!$v || strpos($v, '.') === false) {
Debug2::debug2('[CDN] replace setting bypassed: no . attribute ' . $v);
continue;
}
Debug2::debug2('[CDN] replace attribute ' . $v);
$v = explode('.', $v);
$attr = preg_quote($v[1], '#');
if ($v[0]) {
$pattern = '#<' . preg_quote($v[0], '#') . '([^>]+)' . $attr . '=([\'"])(.+)\g{2}#iU';
} else {
$pattern = '# ' . $attr . '=([\'"])(.+)\g{1}#iU';
}
preg_match_all($pattern, $this->content, $matches);
if (empty($matches[$v[0] ? 3 : 2])) {
continue;
}
foreach ($matches[$v[0] ? 3 : 2] as $k2 => $url) {
// Debug2::debug2( '[CDN] check ' . $url );
$postfix = '.' . pathinfo((string) parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
if (!array_key_exists($postfix, $this->_cfg_cdn_mapping)) {
// Debug2::debug2( '[CDN] non-existed postfix ' . $postfix );
continue;
}
Debug2::debug2('[CDN] matched file_type ' . $postfix . ' : ' . $url);
if (!($url2 = $this->rewrite($url, Base::CDN_MAPPING_FILETYPE, $postfix))) {
continue;
}
$attr = str_replace($url, $url2, $matches[0][$k2]);
$this->content = str_replace($matches[0][$k2], $attr, $this->content);
}
}
}
/**
* Parse all images
*
* @since 1.2.3
* @access private
*/
private function _replace_img()
{
preg_match_all('#<img([^>]+?)src=([\'"\\\]*)([^\'"\s\\\>]+)([\'"\\\]*)([^>]*)>#i', $this->content, $matches);
foreach ($matches[3] as $k => $url) {
// Check if is a DATA-URI
if (strpos($url, 'data:image') !== false) {
continue;
}
if (!($url2 = $this->rewrite($url, Base::CDN_MAPPING_INC_IMG))) {
continue;
}
$html_snippet = sprintf('<img %1$s src=%2$s %3$s>', $matches[1][$k], $matches[2][$k] . $url2 . $matches[4][$k], $matches[5][$k]);
$this->content = str_replace($matches[0][$k], $html_snippet, $this->content);
}
}
/**
* Parse and replace all inline styles containing url()
*
* @since 1.2.3
* @access private
*/
private function _replace_inline_css()
{
Debug2::debug2('[CDN] _replace_inline_css', $this->_cfg_cdn_mapping);
/**
* Excludes `\` from URL matching
* @see #959152 - WordPress LSCache CDN Mapping causing malformed URLS
* @see #685485
* @since 3.0
*/
preg_match_all('/url\((?![\'"]?data)[\'"]?(.+?)[\'"]?\)/i', $this->content, $matches);
foreach ($matches[1] as $k => $url) {
$url = str_replace(array(' ', '\t', '\n', '\r', '\0', '\x0B', '"', "'", '"', '''), '', $url);
// Parse file postfix
$parsed_url = parse_url($url, PHP_URL_PATH);
if (!$parsed_url) {
continue;
}
$postfix = '.' . pathinfo($parsed_url, PATHINFO_EXTENSION);
if (array_key_exists($postfix, $this->_cfg_cdn_mapping)) {
Debug2::debug2('[CDN] matched file_type ' . $postfix . ' : ' . $url);
if (!($url2 = $this->rewrite($url, Base::CDN_MAPPING_FILETYPE, $postfix))) {
continue;
}
} elseif (in_array($postfix, array('jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'avif'))) {
if (!($url2 = $this->rewrite($url, Base::CDN_MAPPING_INC_IMG))) {
continue;
}
} else {
continue;
}
$attr = str_replace($matches[1][$k], $url2, $matches[0][$k]);
$this->content = str_replace($matches[0][$k], $attr, $this->content);
}
Debug2::debug2('[CDN] _replace_inline_css done');
}
/**
* Hook to wp_get_attachment_image_src
*
* @since 1.2.3
* @since 1.7 Removed static from function
* @access public
* @param array $img The URL of the attachment image src, the width, the height
* @return array
*/
public function attach_img_src($img)
{
if ($img && ($url = $this->rewrite($img[0], Base::CDN_MAPPING_INC_IMG))) {
$img[0] = $url;
}
return $img;
}
/**
* Try to rewrite one URL with CDN
*
* @since 1.7
* @access public
*/
public function url_img($url)
{
if ($url && ($url2 = $this->rewrite($url, Base::CDN_MAPPING_INC_IMG))) {
$url = $url2;
}
return $url;
}
/**
* Try to rewrite one URL with CDN
*
* @since 1.7
* @access public
*/
public function url_css($url)
{
if ($url && ($url2 = $this->rewrite($url, Base::CDN_MAPPING_INC_CSS))) {
$url = $url2;
}
return $url;
}
/**
* Try to rewrite one URL with CDN
*
* @since 1.7
* @access public
*/
public function url_js($url)
{
if ($url && ($url2 = $this->rewrite($url, Base::CDN_MAPPING_INC_JS))) {
$url = $url2;
}
return $url;
}
/**
* Hook to replace WP responsive images
*
* @since 1.2.3
* @since 1.7 Removed static from function
* @access public
* @param array $srcs
* @return array
*/
public function srcset($srcs)
{
if ($srcs) {
foreach ($srcs as $w => $data) {
if (!($url = $this->rewrite($data['url'], Base::CDN_MAPPING_INC_IMG))) {
continue;
}
$srcs[$w]['url'] = $url;
}
}
return $srcs;
}
/**
* Replace URL to CDN URL
*
* @since 1.2.3
* @access public
* @param string $url
* @return string Replaced URL
*/
public function rewrite($url, $mapping_kind, $postfix = false)
{
Debug2::debug2('[CDN] rewrite ' . $url);
$url_parsed = parse_url($url);
if (empty($url_parsed['path'])) {
Debug2::debug2('[CDN] -rewrite bypassed: no path');
return false;
}
// Only images under wp-cotnent/wp-includes can be replaced
$is_internal_folder = Utility::str_hit_array($url_parsed['path'], $this->_cfg_ori_dir);
if (!$is_internal_folder) {
Debug2::debug2('[CDN] -rewrite failed: path not match: ' . LSCWP_CONTENT_FOLDER);
return false;
}
// Check if is external url
if (!empty($url_parsed['host'])) {
if (!Utility::internal($url_parsed['host']) && !$this->_is_ori_url($url)) {
Debug2::debug2('[CDN] -rewrite failed: host not internal');
return false;
}
}
$exclude = Utility::str_hit_array($url, $this->_cfg_cdn_exclude);
if ($exclude) {
Debug2::debug2('[CDN] -abort excludes ' . $exclude);
return false;
}
// Fill full url before replacement
if (empty($url_parsed['host'])) {
$url = Utility::uri2url($url);
Debug2::debug2('[CDN] -fill before rewritten: ' . $url);
$url_parsed = parse_url($url);
}
$scheme = !empty($url_parsed['scheme']) ? $url_parsed['scheme'] . ':' : '';
if ($scheme) {
// Debug2::debug2( '[CDN] -scheme from url: ' . $scheme );
}
// Find the mapping url to be replaced to
if (empty($this->_cfg_cdn_mapping[$mapping_kind])) {
return false;
}
if ($mapping_kind !== Base::CDN_MAPPING_FILETYPE) {
$final_url = $this->_cfg_cdn_mapping[$mapping_kind];
} else {
// select from file type
$final_url = $this->_cfg_cdn_mapping[$postfix];
}
// If filetype to url is one to many, need to random one
if (is_array($final_url)) {
$final_url = $final_url[array_rand($final_url)];
}
// Now lets replace CDN url
foreach ($this->_cfg_url_ori as $v) {
if (strpos($v, '*') !== false) {
$url = preg_replace('#' . $scheme . $v . '#iU', $final_url, $url);
} else {
$url = str_replace($scheme . $v, $final_url, $url);
}
}
Debug2::debug2('[CDN] -rewritten: ' . $url);
return $url;
}
/**
* Check if is original URL of CDN or not
*
* @since 2.1
* @access private
*/
private function _is_ori_url($url)
{
$url_parsed = parse_url($url);
$scheme = !empty($url_parsed['scheme']) ? $url_parsed['scheme'] . ':' : '';
foreach ($this->_cfg_url_ori as $v) {
$needle = $scheme . $v;
if (strpos($v, '*') !== false) {
if (preg_match('#' . $needle . '#iU', $url)) {
return true;
}
} else {
if (strpos($url, $needle) === 0) {
return true;
}
}
}
return false;
}
/**
* Check if the host is the CDN internal host
*
* @since 1.2.3
*
*/
public static function internal($host)
{
if (defined(self::BYPASS)) {
return false;
}
$instance = self::cls();
return in_array($host, $instance->cdn_mapping_hosts); // todo: can add $this->_is_ori_url() check in future
}
}
cloud.cls.php 0000644 00000150656 15153741266 0007172 0 ustar 00 <?php
/**
* Cloud service cls
*
* @since 3.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Cloud extends Base
{
const LOG_TAG = '❄️';
const CLOUD_SERVER = 'https://api.quic.cloud';
const CLOUD_IPS = 'https://quic.cloud/ips';
const CLOUD_SERVER_DASH = 'https://my.quic.cloud';
const CLOUD_SERVER_WP = 'https://wpapi.quic.cloud';
const SVC_D_ACTIVATE = 'd/activate';
const SVC_U_ACTIVATE = 'u/wp3/activate';
const SVC_D_ENABLE_CDN = 'd/enable_cdn';
const SVC_D_LINK = 'd/link';
const SVC_D_API = 'd/api';
const SVC_D_DASH = 'd/dash';
const SVC_D_V3UPGRADE = 'd/v3upgrade';
const SVC_U_LINK = 'u/wp3/link';
const SVC_U_ENABLE_CDN = 'u/wp3/enablecdn';
const SVC_D_STATUS_CDN_CLI = 'd/status/cdn_cli';
const SVC_D_NODES = 'd/nodes';
const SVC_D_SYNC_CONF = 'd/sync_conf';
const SVC_D_USAGE = 'd/usage';
const SVC_D_SETUP_TOKEN = 'd/get_token';
const SVC_D_DEL_CDN_DNS = 'd/del_cdn_dns';
const SVC_PAGE_OPTM = 'page_optm';
const SVC_CCSS = 'ccss';
const SVC_UCSS = 'ucss';
const SVC_VPI = 'vpi';
const SVC_LQIP = 'lqip';
const SVC_QUEUE = 'queue';
const SVC_IMG_OPTM = 'img_optm';
const SVC_HEALTH = 'health';
const SVC_CDN = 'cdn';
const IMG_OPTM_DEFAULT_GROUP = 200;
const IMGOPTM_TAKEN = 'img_optm-taken';
const TTL_NODE = 3; // Days before node expired
const EXPIRATION_REQ = 300; // Seconds of min interval between two unfinished requests
const TTL_IPS = 3; // Days for node ip list cache
const API_REPORT = 'wp/report';
const API_NEWS = 'news';
const API_VER = 'ver_check';
const API_BETA_TEST = 'beta_test';
const API_REST_ECHO = 'tool/wp_rest_echo';
const API_SERVER_KEY_SIGN = 'key_sign';
private static $CENTER_SVC_SET = array(
self::SVC_D_ACTIVATE,
self::SVC_U_ACTIVATE,
self::SVC_D_ENABLE_CDN,
self::SVC_D_LINK,
self::SVC_D_NODES,
self::SVC_D_SYNC_CONF,
self::SVC_D_USAGE,
self::SVC_D_API,
self::SVC_D_V3UPGRADE,
self::SVC_D_DASH,
self::SVC_D_STATUS_CDN_CLI,
// self::API_NEWS,
self::API_REPORT,
// self::API_VER,
// self::API_BETA_TEST,
self::SVC_D_SETUP_TOKEN,
self::SVC_D_DEL_CDN_DNS,
);
private static $WP_SVC_SET = array(self::API_NEWS, self::API_VER, self::API_BETA_TEST, self::API_REST_ECHO);
// No api key needed for these services
private static $_PUB_SVC_SET = array(self::API_NEWS, self::API_REPORT, self::API_VER, self::API_BETA_TEST, self::API_REST_ECHO, self::SVC_D_V3UPGRADE, self::SVC_D_DASH);
private static $_QUEUE_SVC_SET = array(self::SVC_CCSS, self::SVC_UCSS, self::SVC_VPI);
public static $SERVICES_LOAD_CHECK = array(
// self::SVC_CCSS,
// self::SVC_UCSS,
// self::SVC_VPI,
self::SVC_LQIP,
self::SVC_HEALTH,
);
public static $SERVICES = array(
self::SVC_IMG_OPTM,
self::SVC_PAGE_OPTM,
self::SVC_CCSS,
self::SVC_UCSS,
self::SVC_VPI,
self::SVC_LQIP,
self::SVC_CDN,
self::SVC_HEALTH,
// self::SVC_QUEUE,
);
const TYPE_CLEAR_PROMO = 'clear_promo';
const TYPE_REDETECT_CLOUD = 'redetect_cloud';
const TYPE_CLEAR_CLOUD = 'clear_cloud';
const TYPE_ACTIVATE = 'activate';
const TYPE_LINK = 'link';
const TYPE_ENABLE_CDN = 'enablecdn';
const TYPE_API = 'api';
const TYPE_SYNC_USAGE = 'sync_usage';
const TYPE_RESET = 'reset';
const TYPE_SYNC_STATUS = 'sync_status';
protected $_summary;
/**
* Init
*
* @since 3.0
*/
public function __construct()
{
$this->_summary = self::get_summary();
}
/**
* Init QC setup preparation
*
* @since 7.0
*/
public function init_qc_prepare()
{
if (empty($this->_summary['sk_b64'])) {
$keypair = sodium_crypto_sign_keypair();
$pk = base64_encode(sodium_crypto_sign_publickey($keypair));
$sk = base64_encode(sodium_crypto_sign_secretkey($keypair));
$this->_summary['pk_b64'] = $pk;
$this->_summary['sk_b64'] = $sk;
$this->save_summary();
// ATM `qc_activated` = null
return true;
}
return false;
}
/**
* Init QC setup
*
* @since 7.0
*/
public function init_qc()
{
$this->init_qc_prepare();
$ref = $this->_get_ref_url();
// WPAPI REST echo dryrun
$req_data = array(
'wp_pk_b64' => $this->_summary['pk_b64'],
);
$echobox = self::post(self::API_REST_ECHO, $req_data);
if ($echobox === false) {
self::debugErr('REST Echo Failed!');
$msg = __('Your WP REST API seems blocked our QUIC.cloud server calls.', 'litespeed-cache');
Admin_Display::error($msg);
wp_redirect($ref);
return;
}
self::debug('echo succeeded');
// Load separate thread echoed data from storage
if (empty($echobox['wpapi_ts']) || empty($echobox['wpapi_signature_b64'])) {
Admin_Display::error(__('Failed to get echo data from WPAPI', 'litespeed-cache'));
wp_redirect($ref);
return;
}
$data = array(
'wp_pk_b64' => $this->_summary['pk_b64'],
'wpapi_ts' => $echobox['wpapi_ts'],
'wpapi_signature_b64' => $echobox['wpapi_signature_b64'],
);
$server_ip = $this->conf(self::O_SERVER_IP);
if ($server_ip) {
$data['server_ip'] = $server_ip;
}
// Activation redirect
$param = array(
'site_url' => home_url(),
'ver' => Core::VER,
'data' => $data,
'ref' => $ref,
);
wp_redirect(self::CLOUD_SERVER_DASH . '/' . self::SVC_U_ACTIVATE . '?data=' . urlencode(Utility::arr2str($param)));
exit();
}
/**
* Decide the ref
*/
private function _get_ref_url($ref = false)
{
$link = 'admin.php?page=litespeed';
if ($ref == 'cdn') {
$link = 'admin.php?page=litespeed-cdn';
}
if ($ref == 'online') {
$link = 'admin.php?page=litespeed-general';
}
if (!empty($_GET['ref']) && $_GET['ref'] == 'cdn') {
$link = 'admin.php?page=litespeed-cdn';
}
if (!empty($_GET['ref']) && $_GET['ref'] == 'online') {
$link = 'admin.php?page=litespeed-general';
}
return get_admin_url(null, $link);
}
/**
* Init QC setup (CLI)
*
* @since 7.0
*/
public function init_qc_cli()
{
$this->init_qc_prepare();
$server_ip = $this->conf(self::O_SERVER_IP);
if (!$server_ip) {
self::debugErr('Server IP needs to be set first!');
$msg = sprintf(
__('You need to set the %1$s first. Please use the command %2$s to set.', 'litespeed-cache'),
'`' . __('Server IP', 'litespeed-cache') . '`',
'`wp litespeed-option set server_ip __your_ip_value__`'
);
Admin_Display::error($msg);
return;
}
// WPAPI REST echo dryrun
$req_data = array(
'wp_pk_b64' => $this->_summary['pk_b64'],
);
$echobox = self::post(self::API_REST_ECHO, $req_data);
if ($echobox === false) {
self::debugErr('REST Echo Failed!');
$msg = __('Your WP REST API seems blocked our QUIC.cloud server calls.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
self::debug('echo succeeded');
// Load separate thread echoed data from storage
if (empty($echobox['wpapi_ts']) || empty($echobox['wpapi_signature_b64'])) {
self::debug('Resp: ', $echobox);
Admin_Display::error(__('Failed to get echo data from WPAPI', 'litespeed-cache'));
return;
}
$data = array(
'wp_pk_b64' => $this->_summary['pk_b64'],
'wpapi_ts' => $echobox['wpapi_ts'],
'wpapi_signature_b64' => $echobox['wpapi_signature_b64'],
'server_ip' => $server_ip,
);
$res = $this->post(self::SVC_D_ACTIVATE, $data);
return $res;
}
/**
* Init QC CDN setup (CLI)
*
* @since 7.0
*/
public function init_qc_cdn_cli($method, $cert = false, $key = false, $cf_token = false)
{
if (!$this->activated()) {
Admin_Display::error(__('You need to activate QC first.', 'litespeed-cache'));
return;
}
$server_ip = $this->conf(self::O_SERVER_IP);
if (!$server_ip) {
self::debugErr('Server IP needs to be set first!');
$msg = sprintf(
__('You need to set the %1$s first. Please use the command %2$s to set.', 'litespeed-cache'),
'`' . __('Server IP', 'litespeed-cache') . '`',
'`wp litespeed-option set server_ip __your_ip_value__`'
);
Admin_Display::error($msg);
return;
}
if ($cert) {
if (!file_exists($cert) || !file_exists($key)) {
Admin_Display::error(__('Cert or key file does not exist.', 'litespeed-cache'));
return;
}
}
$data = array(
'method' => $method,
'server_ip' => $server_ip,
);
if ($cert) {
$data['cert'] = File::read($cert);
$data['key'] = File::read($key);
}
if ($cf_token) {
$data['cf_token'] = $cf_token;
}
$res = $this->post(self::SVC_D_ENABLE_CDN, $data);
return $res;
}
/**
* Link to QC setup
*
* @since 7.0
*/
public function link_qc()
{
if (!$this->activated()) {
Admin_Display::error(__('You need to activate QC first.', 'litespeed-cache'));
return;
}
$data = array(
'wp_ts' => time(),
);
$data['wp_signature_b64'] = $this->_sign_b64($data['wp_ts']);
// Activation redirect
$param = array(
'site_url' => home_url(),
'ver' => Core::VER,
'data' => $data,
'ref' => $this->_get_ref_url(),
);
wp_redirect(self::CLOUD_SERVER_DASH . '/' . self::SVC_U_LINK . '?data=' . urlencode(Utility::arr2str($param)));
exit();
}
/**
* Show QC Account CDN status
*
* @since 7.0
*/
public function cdn_status_cli()
{
if (!$this->activated()) {
Admin_Display::error(__('You need to activate QC first.', 'litespeed-cache'));
return;
}
$data = array();
$res = $this->post(self::SVC_D_STATUS_CDN_CLI, $data);
return $res;
}
/**
* Link to QC Account for CLI
*
* @since 7.0
*/
public function link_qc_cli($email, $key)
{
if (!$this->activated()) {
Admin_Display::error(__('You need to activate QC first.', 'litespeed-cache'));
return;
}
$data = array(
'qc_acct_email' => $email,
'qc_acct_apikey' => $key,
);
$res = $this->post(self::SVC_D_LINK, $data);
return $res;
}
/**
* API link parsed call to QC
*
* @since 7.0
*/
public function api_link_call($action2)
{
if (!$this->activated()) {
Admin_Display::error(__('You need to activate QC first.', 'litespeed-cache'));
return;
}
$data = array(
'action2' => $action2,
);
$res = $this->post(self::SVC_D_API, $data);
self::debug('API link call result: ', $res);
}
/**
* Enable QC CDN
*
* @since 7.0
*/
public function enable_cdn()
{
if (!$this->activated()) {
Admin_Display::error(__('You need to activate QC first.', 'litespeed-cache'));
return;
}
$data = array(
'wp_ts' => time(),
);
$data['wp_signature_b64'] = $this->_sign_b64($data['wp_ts']);
// Activation redirect
$param = array(
'site_url' => home_url(),
'ver' => Core::VER,
'data' => $data,
'ref' => $this->_get_ref_url(),
);
wp_redirect(self::CLOUD_SERVER_DASH . '/' . self::SVC_U_ENABLE_CDN . '?data=' . urlencode(Utility::arr2str($param)));
exit();
}
/**
* Encrypt data for cloud req
*
* @since 7.0
*/
private function _sign_b64($data)
{
if (empty($this->_summary['sk_b64'])) {
self::debugErr('No sk to sign.');
return false;
}
$sk = base64_decode($this->_summary['sk_b64']);
if (strlen($sk) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
self::debugErr('Invalid local sign sk length.');
// Reset local pk/sk
unset($this->_summary['pk_b64']);
unset($this->_summary['sk_b64']);
$this->save_summary();
self::debug('Clear local sign pk/sk pair.');
return false;
}
$signature = sodium_crypto_sign_detached((string) $data, $sk);
return base64_encode($signature);
}
/**
* Load server pk from cloud
*
* @since 7.0
*/
private function _load_server_pk($from_wpapi = false)
{
// Load cloud pk
$server_key_url = self::CLOUD_SERVER . '/' . self::API_SERVER_KEY_SIGN;
if ($from_wpapi) {
$server_key_url = self::CLOUD_SERVER_WP . '/' . self::API_SERVER_KEY_SIGN;
}
$resp = wp_safe_remote_get($server_key_url);
if (is_wp_error($resp)) {
self::debugErr('Failed to load key: ' . $resp->get_error_message());
return false;
}
$pk = trim($resp['body']);
self::debug('Loaded key from ' . $server_key_url . ': ' . $pk);
$cloud_pk = base64_decode($pk);
if (strlen($cloud_pk) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
self::debugErr('Invalid cloud public key length.');
return false;
}
$sk = base64_decode($this->_summary['sk_b64']);
if (strlen($sk) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
self::debugErr('Invalid local secret key length.');
// Reset local pk/sk
unset($this->_summary['pk_b64']);
unset($this->_summary['sk_b64']);
$this->save_summary();
self::debug('Unset local pk/sk pair.');
return false;
}
return $cloud_pk;
}
/**
* WPAPI echo back to notify the sealed databox
*
* @since 7.0
*/
public function wp_rest_echo()
{
self::debug('Parsing echo', $_POST);
if (empty($_POST['wpapi_ts']) || empty($_POST['wpapi_signature_b64'])) {
return self::err('No echo data');
}
$is_valid = $this->_validate_signature($_POST['wpapi_signature_b64'], $_POST['wpapi_ts'], true);
if (!$is_valid) {
return self::err('Data validation from WPAPI REST Echo failed');
}
$diff = time() - $_POST['wpapi_ts'];
if (abs($diff) > 86400) {
self::debugErr('WPAPI echo data timeout [diff] ' . $diff);
return self::err('Echo data expired');
}
$signature_b64 = $this->_sign_b64($_POST['wpapi_ts']);
self::debug('Response to echo [signature_b64] ' . $signature_b64);
return self::ok(array('signature_b64' => $signature_b64));
}
/**
* Validate cloud data
*
* @since 7.0
*/
private function _validate_signature($signature_b64, $data, $from_wpapi = false)
{
// Try validation
try {
$cloud_pk = $this->_load_server_pk($from_wpapi);
if (!$cloud_pk) {
return false;
}
$signature = base64_decode($signature_b64);
$is_valid = sodium_crypto_sign_verify_detached($signature, $data, $cloud_pk);
} catch (\SodiumException $e) {
self::debugErr('Decryption failed: ' . $e->getMessage());
return false;
}
self::debug('Signature validation result: ' . ($is_valid ? 'true' : 'false'));
return $is_valid;
}
/**
* Finish qc activation after redirection back from QC
*
* @since 7.0
*/
public function finish_qc_activation($ref = false)
{
if (empty($_GET['qc_activated']) || empty($_GET['qc_ts']) || empty($_GET['qc_signature_b64'])) {
return;
}
$data_to_validate_signature = array(
'wp_pk_b64' => $this->_summary['pk_b64'],
'qc_ts' => $_GET['qc_ts'],
);
$is_valid = $this->_validate_signature($_GET['qc_signature_b64'], implode('', $data_to_validate_signature));
if (!$is_valid) {
self::debugErr('Failed to validate qc activation data');
Admin_Display::error(sprintf(__('Failed to validate %s activation data.', 'litespeed-cache'), 'QUIC.cloud'));
return;
}
self::debug('QC activation status: ' . $_GET['qc_activated']);
if (!in_array($_GET['qc_activated'], array('anonymous', 'linked', 'cdn'))) {
self::debugErr('Failed to parse qc activation status');
Admin_Display::error(sprintf(__('Failed to parse %s activation status.', 'litespeed-cache'), 'QUIC.cloud'));
return;
}
$diff = time() - $_GET['qc_ts'];
if (abs($diff) > 86400) {
self::debugErr('QC activation data timeout [diff] ' . $diff);
Admin_Display::error(sprintf(__('%s activation data expired.', 'litespeed-cache'), 'QUIC.cloud'));
return;
}
$main_domain = !empty($_GET['main_domain']) ? $_GET['main_domain'] : false;
$this->update_qc_activation($_GET['qc_activated'], $main_domain);
wp_redirect($this->_get_ref_url($ref));
}
/**
* Finish qc activation process
*
* @since 7.0
*/
public function update_qc_activation($qc_activated, $main_domain = false, $quite = false)
{
$this->_summary['qc_activated'] = $qc_activated;
if ($main_domain) {
$this->_summary['main_domain'] = $main_domain;
}
$this->save_summary();
$msg = sprintf(__('Congratulations, %s successfully set this domain up for the anonymous online services.', 'litespeed-cache'), 'QUIC.cloud');
if ($qc_activated == 'linked') {
$msg = sprintf(__('Congratulations, %s successfully set this domain up for the online services.', 'litespeed-cache'), 'QUIC.cloud');
// Sync possible partner info
$this->sync_usage();
}
if ($qc_activated == 'cdn') {
$msg = sprintf(__('Congratulations, %s successfully set this domain up for the online services with CDN service.', 'litespeed-cache'), 'QUIC.cloud');
// Turn on CDN option
$this->cls('Conf')->update_confs(array(self::O_CDN_QUIC => true));
}
if (!$quite) {
Admin_Display::success('🎊 ' . $msg);
}
$this->_clear_reset_qc_reg_msg();
$this->clear_cloud();
}
/**
* Load QC status for dash usage
* Format to translate: `<a href="{#xxx#}" class="button button-primary">xxxx</a><a href="{#xxx#}">xxxx2</a>`
*
* @since 7.0
*/
public function load_qc_status_for_dash($type, $force = false)
{
return Str::translate_qc_apis($this->_load_qc_status_for_dash($type, $force));
}
private function _load_qc_status_for_dash($type, $force = false)
{
if (
!$force &&
!empty($this->_summary['mini_html']) &&
isset($this->_summary['mini_html'][$type]) &&
!empty($this->_summary['mini_html']['ttl.' . $type]) &&
$this->_summary['mini_html']['ttl.' . $type] > time()
) {
return Str::safe_html($this->_summary['mini_html'][$type]);
}
// Try to update dash content
$data = self::post(self::SVC_D_DASH, array('action2' => $type == 'cdn_dash_mini' ? 'cdn_dash' : $type));
if (!empty($data['qc_activated'])) {
// Sync conf as changed
if (empty($this->_summary['qc_activated']) || $this->_summary['qc_activated'] != $data['qc_activated']) {
$msg = sprintf(__('Congratulations, %s successfully set this domain up for the online services with CDN service.', 'litespeed-cache'), 'QUIC.cloud');
Admin_Display::success('🎊 ' . $msg);
$this->_clear_reset_qc_reg_msg();
// Turn on CDN option
$this->cls('Conf')->update_confs(array(self::O_CDN_QUIC => true));
$this->cls('CDN\Quic')->try_sync_conf(true);
}
$this->_summary['qc_activated'] = $data['qc_activated'];
$this->save_summary();
}
// Show the info
if (isset($this->_summary['mini_html'][$type])) {
return Str::safe_html($this->_summary['mini_html'][$type]);
}
return '';
}
/**
* Update QC status
*
* @since 7.0
*/
public function update_cdn_status()
{
if (empty($_POST['qc_activated']) || !in_array($_POST['qc_activated'], array('anonymous', 'linked', 'cdn', 'deleted'))) {
return self::err('lack_of_params');
}
self::debug('update_cdn_status request hash: ' . $_POST['qc_activated']);
if ($_POST['qc_activated'] == 'deleted') {
$this->_reset_qc_reg();
} else {
$this->_summary['qc_activated'] = $_POST['qc_activated'];
$this->save_summary();
}
if ($_POST['qc_activated'] == 'cdn') {
$msg = sprintf(__('Congratulations, %s successfully set this domain up for the online services with CDN service.', 'litespeed-cache'), 'QUIC.cloud');
Admin_Display::success('🎊 ' . $msg);
$this->_clear_reset_qc_reg_msg();
// Turn on CDN option
$this->cls('Conf')->update_confs(array(self::O_CDN_QUIC => true));
$this->cls('CDN\Quic')->try_sync_conf(true);
}
return self::ok(array('qc_activated' => $_POST['qc_activated']));
}
/**
* Reset QC setup
*
* @since 7.0
*/
public function reset_qc()
{
unset($this->_summary['pk_b64']);
unset($this->_summary['sk_b64']);
unset($this->_summary['qc_activated']);
if (!empty($this->_summary['partner'])) {
unset($this->_summary['partner']);
}
$this->save_summary();
self::debug('Clear local QC activation.');
$this->clear_cloud();
Admin_Display::success(sprintf(__('Reset %s activation successfully.', 'litespeed-cache'), 'QUIC.cloud'));
wp_redirect($this->_get_ref_url());
exit();
}
/**
* Show latest commit version always if is on dev
*
* @since 3.0
*/
public function check_dev_version()
{
if (!preg_match('/[^\d\.]/', Core::VER)) {
return;
}
$last_check = empty($this->_summary['last_request.' . self::API_VER]) ? 0 : $this->_summary['last_request.' . self::API_VER];
if (time() - $last_check > 86400) {
$auto_v = self::version_check('dev');
if (!empty($auto_v['dev'])) {
self::save_summary(array('version.dev' => $auto_v['dev']));
}
}
if (empty($this->_summary['version.dev'])) {
return;
}
self::debug('Latest dev version ' . $this->_summary['version.dev']);
if (version_compare($this->_summary['version.dev'], Core::VER, '<=')) {
return;
}
// Show the dev banner
require_once LSCWP_DIR . 'tpl/banner/new_version_dev.tpl.php';
}
/**
* Check latest version
*
* @since 2.9
* @access public
*/
public static function version_check($src = false)
{
$req_data = array(
'v' => defined('LSCWP_CUR_V') ? LSCWP_CUR_V : '',
'src' => $src,
'php' => phpversion(),
);
if (defined('LITESPEED_ERR')) {
$req_data['err'] = base64_encode(!is_string(LITESPEED_ERR) ? \json_encode(LITESPEED_ERR) : LITESPEED_ERR);
}
$data = self::post(self::API_VER, $req_data);
return $data;
}
/**
* Show latest news
*
* @since 3.0
*/
public function news()
{
$this->_update_news();
if (empty($this->_summary['news.new'])) {
return;
}
if (!empty($this->_summary['news.plugin']) && Activation::cls()->dash_notifier_is_plugin_active($this->_summary['news.plugin'])) {
return;
}
require_once LSCWP_DIR . 'tpl/banner/cloud_news.tpl.php';
}
/**
* Update latest news
*
* @since 2.9.9.1
*/
private function _update_news()
{
if (!empty($this->_summary['news.utime']) && time() - $this->_summary['news.utime'] < 86400 * 7) {
return;
}
self::save_summary(array('news.utime' => time()));
$data = self::get(self::API_NEWS);
if (empty($data['id'])) {
return;
}
// Save news
if (!empty($this->_summary['news.id']) && $this->_summary['news.id'] == $data['id']) {
return;
}
$this->_summary['news.id'] = $data['id'];
$this->_summary['news.plugin'] = !empty($data['plugin']) ? $data['plugin'] : '';
$this->_summary['news.title'] = !empty($data['title']) ? $data['title'] : '';
$this->_summary['news.content'] = !empty($data['content']) ? $data['content'] : '';
$this->_summary['news.zip'] = !empty($data['zip']) ? $data['zip'] : '';
$this->_summary['news.new'] = 1;
if ($this->_summary['news.plugin']) {
$plugin_info = Activation::cls()->dash_notifier_get_plugin_info($this->_summary['news.plugin']);
if ($plugin_info && !empty($plugin_info->name)) {
$this->_summary['news.plugin_name'] = $plugin_info->name;
}
}
self::save_summary();
}
/**
* Check if contains a package in a service or not
*
* @since 4.0
*/
public function has_pkg($service, $pkg)
{
if (!empty($this->_summary['usage.' . $service]['pkgs']) && $this->_summary['usage.' . $service]['pkgs'] & $pkg) {
return true;
}
return false;
}
/**
* Get allowance of current service
*
* @since 3.0
* @access private
*/
public function allowance($service, &$err = false)
{
// Only auto sync usage at most one time per day
if (empty($this->_summary['last_request.' . self::SVC_D_USAGE]) || time() - $this->_summary['last_request.' . self::SVC_D_USAGE] > 86400) {
$this->sync_usage();
}
if (in_array($service, array(self::SVC_CCSS, self::SVC_UCSS, self::SVC_VPI))) {
// @since 4.2
$service = self::SVC_PAGE_OPTM;
}
if (empty($this->_summary['usage.' . $service])) {
return 0;
}
$usage = $this->_summary['usage.' . $service];
// Image optm is always free
$allowance_max = 0;
if ($service == self::SVC_IMG_OPTM) {
$allowance_max = self::IMG_OPTM_DEFAULT_GROUP;
}
$allowance = $usage['quota'] - $usage['used'];
$err = 'out_of_quota';
if ($allowance > 0) {
if ($allowance_max && $allowance_max < $allowance) {
$allowance = $allowance_max;
}
// Daily limit @since 4.2
if (isset($usage['remaining_daily_quota']) && $usage['remaining_daily_quota'] >= 0 && $usage['remaining_daily_quota'] < $allowance) {
$allowance = $usage['remaining_daily_quota'];
if (!$allowance) {
$err = 'out_of_daily_quota';
}
}
return $allowance;
}
// Check Pay As You Go balance
if (empty($usage['pag_bal'])) {
return $allowance_max;
}
if ($allowance_max && $allowance_max < $usage['pag_bal']) {
return $allowance_max;
}
return $usage['pag_bal'];
}
/**
* Sync Cloud usage summary data
*
* @since 3.0
* @access public
*/
public function sync_usage()
{
$usage = $this->_post(self::SVC_D_USAGE);
if (!$usage) {
return;
}
self::debug('sync_usage ' . \json_encode($usage));
foreach (self::$SERVICES as $v) {
$this->_summary['usage.' . $v] = !empty($usage[$v]) ? $usage[$v] : false;
}
self::save_summary();
return $this->_summary;
}
/**
* Clear all existing cloud nodes for future reconnect
*
* @since 3.0
* @access public
*/
public function clear_cloud()
{
foreach (self::$SERVICES as $service) {
if (isset($this->_summary['server.' . $service])) {
unset($this->_summary['server.' . $service]);
}
if (isset($this->_summary['server_date.' . $service])) {
unset($this->_summary['server_date.' . $service]);
}
}
self::save_summary();
self::debug('Cleared all local service node caches');
}
/**
* ping clouds to find the fastest node
*
* @since 3.0
* @access public
*/
public function detect_cloud($service, $force = false)
{
if (in_array($service, self::$CENTER_SVC_SET)) {
return self::CLOUD_SERVER;
}
if (in_array($service, self::$WP_SVC_SET)) {
return self::CLOUD_SERVER_WP;
}
// Check if the stored server needs to be refreshed
if (!$force) {
if (
!empty($this->_summary['server.' . $service]) &&
!empty($this->_summary['server_date.' . $service]) &&
$this->_summary['server_date.' . $service] > time() - 86400 * self::TTL_NODE
) {
$server = $this->_summary['server.' . $service];
if (!strpos(self::CLOUD_SERVER, 'preview.') && !strpos($server, 'preview.')) {
return $server;
}
if (strpos(self::CLOUD_SERVER, 'preview.') && strpos($server, 'preview.')) {
return $server;
}
}
}
if (!$service || !in_array($service, self::$SERVICES)) {
$msg = __('Cloud Error', 'litespeed-cache') . ': ' . $service;
Admin_Display::error($msg);
return false;
}
// Send request to Quic Online Service
$json = $this->_post(self::SVC_D_NODES, array('svc' => $this->_maybe_queue($service)));
// Check if get list correctly
if (empty($json['list']) || !is_array($json['list'])) {
self::debug('request cloud list failed: ', $json);
if ($json) {
$msg = __('Cloud Error', 'litespeed-cache') . ": [Service] $service [Info] " . \json_encode($json);
Admin_Display::error($msg);
}
return false;
}
// Ping closest cloud
$valid_clouds = false;
if (!empty($json['list_preferred'])) {
$valid_clouds = $this->_get_closest_nodes($json['list_preferred'], $service);
}
if (!$valid_clouds) {
$valid_clouds = $this->_get_closest_nodes($json['list'], $service);
}
if (!$valid_clouds) {
return false;
}
// Check server load
if (in_array($service, self::$SERVICES_LOAD_CHECK)) {
// TODO
$valid_cloud_loads = array();
foreach ($valid_clouds as $k => $v) {
$response = wp_safe_remote_get($v, array('timeout' => 5));
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
self::debug('failed to do load checker: ' . $error_message);
continue;
}
$curr_load = \json_decode($response['body'], true);
if (!empty($curr_load['_res']) && $curr_load['_res'] == 'ok' && isset($curr_load['load'])) {
$valid_cloud_loads[$v] = $curr_load['load'];
}
}
if (!$valid_cloud_loads) {
$msg = __('Cloud Error', 'litespeed-cache') . ": [Service] $service [Info] " . __('No available Cloud Node after checked server load.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
self::debug('Closest nodes list after load check', $valid_cloud_loads);
$qualified_list = array_keys($valid_cloud_loads, min($valid_cloud_loads));
} else {
$qualified_list = $valid_clouds;
}
$closest = $qualified_list[array_rand($qualified_list)];
self::debug('Chose node: ' . $closest);
// store data into option locally
$this->_summary['server.' . $service] = $closest;
$this->_summary['server_date.' . $service] = time();
self::save_summary();
return $this->_summary['server.' . $service];
}
/**
* Ping to choose the closest nodes
* @since 7.0
*/
private function _get_closest_nodes($list, $service)
{
$speed_list = array();
foreach ($list as $v) {
// Exclude possible failed 503 nodes
if (!empty($this->_summary['disabled_node']) && !empty($this->_summary['disabled_node'][$v]) && time() - $this->_summary['disabled_node'][$v] < 86400) {
continue;
}
$speed_list[$v] = Utility::ping($v);
}
if (!$speed_list) {
self::debug('nodes are in 503 failed nodes');
return false;
}
$min = min($speed_list);
if ($min == 99999) {
self::debug('failed to ping all clouds');
return false;
}
// Random pick same time range ip (230ms 250ms)
$range_len = strlen($min);
$range_num = substr($min, 0, 1);
$valid_clouds = array();
foreach ($speed_list as $node => $speed) {
if (strlen($speed) == $range_len && substr($speed, 0, 1) == $range_num) {
$valid_clouds[] = $node;
}
// Append the lower speed ones
elseif ($speed < $min * 4) {
$valid_clouds[] = $node;
}
}
if (!$valid_clouds) {
$msg = __('Cloud Error', 'litespeed-cache') . ": [Service] $service [Info] " . __('No available Cloud Node.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
self::debug('Closest nodes list', $valid_clouds);
return $valid_clouds;
}
/**
* May need to convert to queue service
*/
private function _maybe_queue($service)
{
if (in_array($service, self::$_QUEUE_SVC_SET)) {
return self::SVC_QUEUE;
}
return $service;
}
/**
* Get data from QUIC cloud server
*
* @since 3.0
* @access public
*/
public static function get($service, $data = array())
{
$instance = self::cls();
return $instance->_get($service, $data);
}
/**
* Get data from QUIC cloud server
*
* @since 3.0
* @access private
*/
private function _get($service, $data = false)
{
$service_tag = $service;
if (!empty($data['action'])) {
$service_tag .= '-' . $data['action'];
}
$maybe_cloud = $this->_maybe_cloud($service_tag);
if (!$maybe_cloud || $maybe_cloud === 'svc_hot') {
return $maybe_cloud;
}
$server = $this->detect_cloud($service);
if (!$server) {
return;
}
$url = $server . '/' . $service;
$param = array(
'site_url' => home_url(),
'main_domain' => !empty($this->_summary['main_domain']) ? $this->_summary['main_domain'] : '',
'ver' => Core::VER,
);
if ($data) {
$param['data'] = $data;
}
$url .= '?' . http_build_query($param);
self::debug('getting from : ' . $url);
self::save_summary(array('curr_request.' . $service_tag => time()));
$response = wp_safe_remote_get($url, array(
'timeout' => 15,
'headers' => array('Accept' => 'application/json'),
));
return $this->_parse_response($response, $service, $service_tag, $server);
}
/**
* Check if is able to do cloud request or not
*
* @since 3.0
* @access private
*/
private function _maybe_cloud($service_tag)
{
$home_url = home_url();
if (!wp_http_validate_url($home_url)) {
self::debug('wp_http_validate_url failed: ' . $home_url);
return false;
}
// Deny if is IP
if (preg_match('#^(([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)\.){3}([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)$#', Utility::parse_url_safe($home_url, PHP_URL_HOST))) {
self::debug('IP home url is not allowed for cloud service.');
$msg = __('In order to use QC services, need a real domain name, cannot use an IP.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
/** @since 5.0 If in valid err_domains, bypass request */
if ($this->_is_err_domain($home_url)) {
self::debug('home url is in err_domains, bypass request: ' . $home_url);
return false;
}
// we don't want the `img_optm-taken` to fail at any given time
if ($service_tag == self::IMGOPTM_TAKEN) {
return true;
}
if ($service_tag == self::SVC_D_SYNC_CONF && !$this->activated()) {
self::debug('Skip sync conf as QC not activated yet.');
return false;
}
// Check TTL
if (!empty($this->_summary['ttl.' . $service_tag])) {
$ttl = $this->_summary['ttl.' . $service_tag] - time();
if ($ttl > 0) {
self::debug('❌ TTL limit. [srv] ' . $service_tag . ' [TTL cool down] ' . $ttl . ' seconds');
return 'svc_hot';
}
}
$expiration_req = self::EXPIRATION_REQ;
// Limit frequent unfinished request to 5min
$timestamp_tag = 'curr_request.';
if ($service_tag == self::SVC_IMG_OPTM . '-' . Img_Optm::TYPE_NEW_REQ) {
$timestamp_tag = 'last_request.';
} else {
// For all other requests, if is under debug mode, will always allow
if ($this->conf(self::O_DEBUG)) {
return true;
}
}
if (!empty($this->_summary[$timestamp_tag . $service_tag])) {
$expired = $this->_summary[$timestamp_tag . $service_tag] + $expiration_req - time();
if ($expired > 0) {
self::debug("❌ try [$service_tag] after $expired seconds");
if ($service_tag !== self::API_VER) {
$msg =
__('Cloud Error', 'litespeed-cache') .
': ' .
sprintf(__('Please try after %1$s for service %2$s.', 'litespeed-cache'), Utility::readable_time($expired, 0, true), '<code>' . $service_tag . '</code>');
Admin_Display::error(array('cloud_trylater' => $msg));
}
return false;
}
}
if (in_array($service_tag, self::$_PUB_SVC_SET)) {
return true;
}
if (!$this->activated() && $service_tag != self::SVC_D_ACTIVATE) {
Admin_Display::error(Error::msg('qc_setup_required'));
return false;
}
return true;
}
/**
* Check if a service tag ttl is valid or not
* @since 7.1
*/
public function service_hot($service_tag)
{
if (empty($this->_summary['ttl.' . $service_tag])) {
return false;
}
$ttl = $this->_summary['ttl.' . $service_tag] - time();
if ($ttl <= 0) {
return false;
}
return $ttl;
}
/**
* Check if activated QUIC.cloud service or not
*
* @since 7.0
* @access public
*/
public function activated()
{
return !empty($this->_summary['sk_b64']) && !empty($this->_summary['qc_activated']);
}
/**
* Show my.qc quick link to the domain page
*/
public function qc_link()
{
$data = array(
'site_url' => home_url(),
'ver' => LSCWP_V,
'ref' => $this->_get_ref_url(),
);
return self::CLOUD_SERVER_DASH . '/u/wp3/manage?data=' . urlencode(Utility::arr2str($data)); // . (!empty($this->_summary['is_linked']) ? '?wplogin=1' : '');
}
/**
* Post data to QUIC.cloud server
*
* @since 3.0
* @access public
*/
public static function post($service, $data = false, $time_out = false)
{
$instance = self::cls();
return $instance->_post($service, $data, $time_out);
}
/**
* Post data to cloud server
*
* @since 3.0
* @access private
*/
private function _post($service, $data = false, $time_out = false)
{
$service_tag = $service;
if (!empty($data['action'])) {
$service_tag .= '-' . $data['action'];
}
$maybe_cloud = $this->_maybe_cloud($service_tag);
if (!$maybe_cloud || $maybe_cloud === 'svc_hot') {
self::debug('Maybe cloud failed: ' . var_export($maybe_cloud, true));
return $maybe_cloud;
}
$server = $this->detect_cloud($service);
if (!$server) {
return;
}
$url = $server . '/' . $this->_maybe_queue($service);
self::debug('posting to : ' . $url);
if ($data) {
$data['service_type'] = $service; // For queue distribution usage
}
// Encrypt service as signature
// $signature_ts = time();
// $sign_data = array(
// 'service_tag' => $service_tag,
// 'ts' => $signature_ts,
// );
// $data['signature_b64'] = $this->_sign_b64(implode('', $sign_data));
// $data['signature_ts'] = $signature_ts;
self::debug('data', $data);
$param = array(
'site_url' => home_url(), // Need to use home_url() as WPML case may change it for diff langs, therefore we can do auto alias
'main_domain' => !empty($this->_summary['main_domain']) ? $this->_summary['main_domain'] : '',
'wp_pk_b64' => !empty($this->_summary['pk_b64']) ? $this->_summary['pk_b64'] : '',
'ver' => Core::VER,
'data' => $data,
);
self::save_summary(array('curr_request.' . $service_tag => time()));
$response = wp_safe_remote_post($url, array(
'body' => $param,
'timeout' => $time_out ?: 15,
'headers' => array('Accept' => 'application/json', 'Expect' => ''),
));
return $this->_parse_response($response, $service, $service_tag, $server);
}
/**
* Parse response JSON
* Mark the request successful if the response status is ok
*
* @since 3.0
*/
private function _parse_response($response, $service, $service_tag, $server)
{
// If show the error or not if failed
$visible_err = $service !== self::API_VER && $service !== self::API_NEWS && $service !== self::SVC_D_DASH;
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
self::debug('failed to request: ' . $error_message);
if ($visible_err) {
$msg = __('Failed to request via WordPress', 'litespeed-cache') . ': ' . $error_message . " [server] $server [service] $service";
Admin_Display::error($msg);
// Tmp disabled this node from reusing in 1 day
if (empty($this->_summary['disabled_node'])) {
$this->_summary['disabled_node'] = array();
}
$this->_summary['disabled_node'][$server] = time();
self::save_summary();
// Force redetect node
self::debug('Node error, redetecting node [svc] ' . $service);
$this->detect_cloud($service, true);
}
return false;
}
$json = \json_decode($response['body'], true);
if (!is_array($json)) {
self::debugErr('failed to decode response json: ' . $response['body']);
if ($visible_err) {
$msg = __('Failed to request via WordPress', 'litespeed-cache') . ': ' . $response['body'] . " [server] $server [service] $service";
Admin_Display::error($msg);
// Tmp disabled this node from reusing in 1 day
if (empty($this->_summary['disabled_node'])) {
$this->_summary['disabled_node'] = array();
}
$this->_summary['disabled_node'][$server] = time();
self::save_summary();
// Force redetect node
self::debugErr('Node error, redetecting node [svc] ' . $service);
$this->detect_cloud($service, true);
}
return false;
}
// Check and save TTL data
if (!empty($json['_ttl'])) {
$ttl = intval($json['_ttl']);
self::debug('Service TTL to save: ' . $ttl);
if ($ttl > 0 && $ttl < 86400) {
self::save_summary(array(
'ttl.' . $service_tag => $ttl + time(),
));
}
}
if (!empty($json['_code'])) {
self::debugErr('Hit err _code: ' . $json['_code']);
if ($json['_code'] == 'unpulled_images') {
$msg = __('Cloud server refused the current request due to unpulled images. Please pull the images first.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
if ($json['_code'] == 'blocklisted') {
$msg = __('Your domain_key has been temporarily blocklisted to prevent abuse. You may contact support at QUIC.cloud to learn more.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
if ($json['_code'] == 'rate_limit') {
self::debugErr('Cloud server rate limit exceeded.');
$msg = __('Cloud server refused the current request due to rate limiting. Please try again later.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
if ($json['_code'] == 'heavy_load' || $json['_code'] == 'redetect_node') {
// Force redetect node
self::debugErr('Node redetecting node [svc] ' . $service);
Admin_Display::info(__('Redetected node', 'litespeed-cache') . ': ' . Error::msg($json['_code']));
$this->detect_cloud($service, true);
}
}
if (!empty($json['_503'])) {
self::debugErr('service 503 unavailable temporarily. ' . $json['_503']);
$msg = __(
'We are working hard to improve your online service experience. The service will be unavailable while we work. We apologize for any inconvenience.',
'litespeed-cache'
);
$msg .= ' ' . $json['_503'] . " [server] $server [service] $service";
Admin_Display::error($msg);
// Force redetect node
self::debugErr('Node error, redetecting node [svc] ' . $service);
$this->detect_cloud($service, true);
return false;
}
list($json, $return) = $this->extract_msg($json, $service, $server);
if ($return) {
return false;
}
self::save_summary(array(
'last_request.' . $service_tag => $this->_summary['curr_request.' . $service_tag],
'curr_request.' . $service_tag => 0,
));
if ($json) {
self::debug2('response ok', $json);
} else {
self::debug2('response ok');
}
// Only successful request return Array
return $json;
}
/**
* Extract msg from json
* @since 5.0
*/
public function extract_msg($json, $service, $server = false, $is_callback = false)
{
if (!empty($json['_info'])) {
self::debug('_info: ' . $json['_info']);
$msg = __('Message from QUIC.cloud server', 'litespeed-cache') . ': ' . $json['_info'];
$msg .= $this->_parse_link($json);
Admin_Display::info($msg);
unset($json['_info']);
}
if (!empty($json['_note'])) {
self::debug('_note: ' . $json['_note']);
$msg = __('Message from QUIC.cloud server', 'litespeed-cache') . ': ' . $json['_note'];
$msg .= $this->_parse_link($json);
Admin_Display::note($msg);
unset($json['_note']);
}
if (!empty($json['_success'])) {
self::debug('_success: ' . $json['_success']);
$msg = __('Good news from QUIC.cloud server', 'litespeed-cache') . ': ' . $json['_success'];
$msg .= $this->_parse_link($json);
Admin_Display::success($msg);
unset($json['_success']);
}
// Upgrade is required
if (!empty($json['_err_req_v'])) {
self::debug('_err_req_v: ' . $json['_err_req_v']);
$msg =
sprintf(__('%1$s plugin version %2$s required for this action.', 'litespeed-cache'), Core::NAME, 'v' . $json['_err_req_v'] . '+') .
" [server] $server [service] $service";
// Append upgrade link
$msg2 = ' ' . GUI::plugin_upgrade_link(Core::NAME, Core::PLUGIN_NAME, $json['_err_req_v']);
$msg2 .= $this->_parse_link($json);
Admin_Display::error($msg . $msg2);
return array($json, true);
}
// Parse _carry_on info
if (!empty($json['_carry_on'])) {
self::debug('Carry_on usage', $json['_carry_on']);
// Store generic info
foreach (array('usage', 'promo', 'mini_html', 'partner', '_error', '_info', '_note', '_success') as $v) {
if (isset($json['_carry_on'][$v])) {
switch ($v) {
case 'usage':
$usage_svc_tag = in_array($service, array(self::SVC_CCSS, self::SVC_UCSS, self::SVC_VPI)) ? self::SVC_PAGE_OPTM : $service;
$this->_summary['usage.' . $usage_svc_tag] = $json['_carry_on'][$v];
break;
case 'promo':
if (empty($this->_summary[$v]) || !is_array($this->_summary[$v])) {
$this->_summary[$v] = array();
}
$this->_summary[$v][] = $json['_carry_on'][$v];
break;
case 'mini_html':
foreach ($json['_carry_on'][$v] as $k2 => $v2) {
if (strpos($k2, 'ttl.') === 0) {
$v2 += time();
}
$this->_summary[$v][$k2] = $v2;
}
break;
case 'partner':
$this->_summary[$v] = $json['_carry_on'][$v];
break;
case '_error':
case '_info':
case '_note':
case '_success':
$color_mode = substr($v, 1);
$msgs = $json['_carry_on'][$v];
Admin_Display::add_unique_notice($color_mode, $msgs, true);
break;
default:
break;
}
}
}
self::save_summary();
unset($json['_carry_on']);
}
// Parse general error msg
if (!$is_callback && (empty($json['_res']) || $json['_res'] !== 'ok')) {
$json_msg = !empty($json['_msg']) ? $json['_msg'] : 'unknown';
self::debug('❌ _err: ' . $json_msg, $json);
$str_translated = Error::msg($json_msg);
$msg = __('Failed to communicate with QUIC.cloud server', 'litespeed-cache') . ': ' . $str_translated . " [server] $server [service] $service";
$msg .= $this->_parse_link($json);
$visible_err = $service !== self::API_VER && $service !== self::API_NEWS && $service !== self::SVC_D_DASH;
if ($visible_err) {
Admin_Display::error($msg);
}
// QC may try auto alias
/** @since 5.0 Store the domain as `err_domains` only for QC auto alias feature */
if ($json_msg == 'err_alias') {
if (empty($this->_summary['err_domains'])) {
$this->_summary['err_domains'] = array();
}
$home_url = home_url();
if (!array_key_exists($home_url, $this->_summary['err_domains'])) {
$this->_summary['err_domains'][$home_url] = time();
}
self::save_summary();
}
// Site not on QC, delete invalid domain key
if ($json_msg == 'site_not_registered' || $json_msg == 'err_key') {
$this->_reset_qc_reg();
}
return array($json, true);
}
unset($json['_res']);
if (!empty($json['_msg'])) {
unset($json['_msg']);
}
return array($json, false);
}
/**
* Clear QC linked status
* @since 5.0
*/
private function _reset_qc_reg()
{
unset($this->_summary['qc_activated']);
if (!empty($this->_summary['partner'])) {
unset($this->_summary['partner']);
}
self::save_summary();
$msg = $this->_reset_qc_reg_content();
Admin_Display::error($msg, false, true);
}
private function _reset_qc_reg_content()
{
$msg = __('Site not recognized. QUIC.cloud deactivated automatically. Please reactivate your QUIC.cloud account.', 'litespeed-cache');
$msg .= Doc::learn_more(admin_url('admin.php?page=litespeed'), __('Click here to proceed.', 'litespeed-cache'), true, false, true);
$msg .= Doc::learn_more('https://docs.litespeedtech.com/lscache/lscwp/general/', false, false, false, true);
return $msg;
}
private function _clear_reset_qc_reg_msg()
{
self::debug('Removed pinned reset QC reg content msg');
$msg = $this->_reset_qc_reg_content();
Admin_Display::dismiss_pin_by_content($msg, Admin_Display::NOTICE_RED, true);
}
/**
* REST call: check if the error domain is valid call for auto alias purpose
* @since 5.0
*/
public function rest_err_domains()
{
if (empty($_POST['main_domain']) || empty($_POST['alias'])) {
return self::err('lack_of_param');
}
$this->extract_msg($_POST, 'Quic.cloud', false, true);
if ($this->_is_err_domain($_POST['alias'])) {
if ($_POST['alias'] == home_url()) {
$this->_remove_domain_from_err_list($_POST['alias']);
}
return self::ok();
}
return self::err('Not an alias req from here');
}
/**
* Remove a domain from err domain
* @since 5.0
*/
private function _remove_domain_from_err_list($url)
{
unset($this->_summary['err_domains'][$url]);
self::save_summary();
}
/**
* Check if is err domain
* @since 5.0
*/
private function _is_err_domain($home_url)
{
if (empty($this->_summary['err_domains'])) {
return false;
}
if (!array_key_exists($home_url, $this->_summary['err_domains'])) {
return false;
}
// Auto delete if too long ago
if (time() - $this->_summary['err_domains'][$home_url] > 86400 * 10) {
$this->_remove_domain_from_err_list($home_url);
return false;
}
if (time() - $this->_summary['err_domains'][$home_url] > 86400) {
return false;
}
return true;
}
/**
* Show promo from cloud
*
* @since 3.0
* @access public
*/
public function show_promo()
{
if (empty($this->_summary['promo'])) {
return;
}
require_once LSCWP_DIR . 'tpl/banner/cloud_promo.tpl.php';
}
/**
* Clear promo from cloud
*
* @since 3.0
* @access private
*/
private function _clear_promo()
{
if (count($this->_summary['promo']) > 1) {
array_shift($this->_summary['promo']);
} else {
$this->_summary['promo'] = array();
}
self::save_summary();
}
/**
* Parse _links from json
*
* @since 1.6.5
* @since 1.6.7 Self clean the parameter
* @access private
*/
private function _parse_link(&$json)
{
$msg = '';
if (!empty($json['_links'])) {
foreach ($json['_links'] as $v) {
$msg .= ' ' . sprintf('<a href="%s" class="%s" target="_blank">%s</a>', $v['link'], !empty($v['cls']) ? $v['cls'] : '', $v['title']);
}
unset($json['_links']);
}
return $msg;
}
/**
* Request callback validation from Cloud
*
* @since 3.0
* @access public
*/
public function ip_validate()
{
if (empty($_POST['hash'])) {
return self::err('lack_of_params');
}
if ($_POST['hash'] != md5(substr($this->_summary['pk_b64'], 0, 4))) {
self::debug('__callback IP request decryption failed');
return self::err('err_hash');
}
Control::set_nocache('Cloud IP hash validation');
$resp_hash = md5(substr($this->_summary['pk_b64'], 2, 4));
self::debug('__callback IP request hash: ' . $resp_hash);
return self::ok(array('hash' => $resp_hash));
}
/**
* Check if this visit is from cloud or not
*
* @since 3.0
*/
public function is_from_cloud()
{
// return true;
$check_point = time() - 86400 * self::TTL_IPS;
if (empty($this->_summary['ips']) || empty($this->_summary['ips_ts']) || $this->_summary['ips_ts'] < $check_point) {
self::debug('Force updating ip as ips_ts is older than ' . self::TTL_IPS . ' days');
$this->_update_ips();
}
$res = $this->cls('Router')->ip_access($this->_summary['ips']);
if (!$res) {
self::debug('❌ Not our cloud IP');
// Auto check ip list again but need an interval limit safety.
if (empty($this->_summary['ips_ts_runner']) || time() - $this->_summary['ips_ts_runner'] > 600) {
self::debug('Force updating ip as ips_ts_runner is older than 10mins');
// Refresh IP list for future detection
$this->_update_ips();
$res = $this->cls('Router')->ip_access($this->_summary['ips']);
if (!$res) {
self::debug('❌ 2nd time: Not our cloud IP');
} else {
self::debug('✅ Passed Cloud IP verification');
}
return $res;
}
} else {
self::debug('✅ Passed Cloud IP verification');
}
return $res;
}
/**
* Update Cloud IP list
*
* @since 4.2
*/
private function _update_ips()
{
self::debug('Load remote Cloud IP list from ' . self::CLOUD_IPS);
// Prevent multiple call in a short period
self::save_summary(array('ips_ts' => time(), 'ips_ts_runner' => time()));
$response = wp_safe_remote_get(self::CLOUD_IPS . '?json');
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
self::debug('failed to get ip whitelist: ' . $error_message);
throw new \Exception('Failed to fetch QUIC.cloud whitelist ' . $error_message);
}
$json = \json_decode($response['body'], true);
self::debug('Load ips', $json);
self::save_summary(array('ips' => $json));
}
/**
* Return succeeded response
*
* @since 3.0
*/
public static function ok($data = array())
{
$data['_res'] = 'ok';
return $data;
}
/**
* Return error
*
* @since 3.0
*/
public static function err($code)
{
self::debug("❌ Error response code: $code");
return array('_res' => 'err', '_msg' => $code);
}
/**
* Return pong for ping to check PHP function availability
* @since 6.5
*/
public function ping()
{
$resp = array(
'v_lscwp' => Core::VER,
'v_php' => PHP_VERSION,
'v_wp' => $GLOBALS['wp_version'],
'home_url' => home_url(),
);
if (!empty($_POST['funcs'])) {
foreach ($_POST['funcs'] as $v) {
$resp[$v] = function_exists($v) ? 'y' : 'n';
}
}
if (!empty($_POST['classes'])) {
foreach ($_POST['classes'] as $v) {
$resp[$v] = class_exists($v) ? 'y' : 'n';
}
}
if (!empty($_POST['consts'])) {
foreach ($_POST['consts'] as $v) {
$resp[$v] = defined($v) ? 'y' : 'n';
}
}
return self::ok($resp);
}
/**
* Display a banner for dev env if using preview QC node.
* @since 7.0
*/
public function maybe_preview_banner()
{
if (strpos(self::CLOUD_SERVER, 'preview.')) {
Admin_Display::note(__('Linked to QUIC.cloud preview environment, for testing purpose only.', 'litespeed-cache'), true, true, 'litespeed-warning-bg');
}
}
/**
* Handle all request actions from main cls
*
* @since 3.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_CLEAR_CLOUD:
$this->clear_cloud();
break;
case self::TYPE_REDETECT_CLOUD:
if (!empty($_GET['svc'])) {
$this->detect_cloud($_GET['svc'], true);
}
break;
case self::TYPE_CLEAR_PROMO:
$this->_clear_promo();
break;
case self::TYPE_RESET:
$this->reset_qc();
break;
case self::TYPE_ACTIVATE:
$this->init_qc();
break;
case self::TYPE_LINK:
$this->link_qc();
break;
case self::TYPE_ENABLE_CDN:
$this->enable_cdn();
break;
case self::TYPE_API:
if (!empty($_GET['action2'])) {
$this->api_link_call($_GET['action2']);
}
break;
case self::TYPE_SYNC_STATUS:
$this->load_qc_status_for_dash('cdn_dash', true);
$msg = __('Sync QUIC.cloud status successfully.', 'litespeed-cache');
Admin_Display::success($msg);
break;
case self::TYPE_SYNC_USAGE:
$this->sync_usage();
$msg = __('Sync credit allowance with Cloud Server successfully.', 'litespeed-cache');
Admin_Display::success($msg);
break;
default:
break;
}
Admin::redirect();
}
}
conf.cls.php 0000644 00000042613 15153741266 0007002 0 ustar 00 <?php
/**
* The core plugin config class.
*
* This maintains all the options and settings for this plugin.
*
* @since 1.0.0
* @since 1.5 Moved into /inc
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Conf extends Base
{
const TYPE_SET = 'set';
private $_updated_ids = array();
private $_is_primary = false;
/**
* Specify init logic to avoid infinite loop when calling conf.cls instance
*
* @since 3.0
* @access public
*/
public function init()
{
// Check if conf exists or not. If not, create them in DB (won't change version if is converting v2.9- data)
// Conf may be stale, upgrade later
$this->_conf_db_init();
/**
* Detect if has quic.cloud set
* @since 2.9.7
*/
if ($this->conf(self::O_CDN_QUIC)) {
!defined('LITESPEED_ALLOWED') && define('LITESPEED_ALLOWED', true);
}
add_action('litespeed_conf_append', array($this, 'option_append'), 10, 2);
add_action('litespeed_conf_force', array($this, 'force_option'), 10, 2);
$this->define_cache();
}
/**
* Init conf related data
*
* @since 3.0
* @access private
*/
private function _conf_db_init()
{
/**
* Try to load options first, network sites can override this later
*
* NOTE: Load before run `conf_upgrade()` to avoid infinite loop when getting conf in `conf_upgrade()`
*/
$this->load_options();
// Check if debug is on
// Init debug as early as possible
if ($this->conf(Base::O_DEBUG)) {
$this->cls('Debug2')->init();
}
$ver = $this->conf(self::_VER);
/**
* Version is less than v3.0, or, is a new installation
*/
$ver_check_tag = '';
if (!$ver) {
// Try upgrade first (network will upgrade inside too)
$ver_check_tag = Data::cls()->try_upgrade_conf_3_0();
} else {
defined('LSCWP_CUR_V') || define('LSCWP_CUR_V', $ver);
/**
* Upgrade conf
*/
if ($ver != Core::VER) {
// Plugin version will be set inside
// Site plugin upgrade & version change will do in load_site_conf
$ver_check_tag = Data::cls()->conf_upgrade($ver);
}
}
/**
* Sync latest new options
*/
if (!$ver || $ver != Core::VER) {
// Load default values
$this->load_default_vals();
if (!$ver) {
// New install
$this->set_conf(self::$_default_options);
$ver_check_tag .= ' activate' . (defined('LSCWP_REF') ? '_' . LSCWP_REF : '');
}
// Init new default/missing options
foreach (self::$_default_options as $k => $v) {
// If the option existed, bypass updating
// Bcos we may ask clients to deactivate for debug temporarily, we need to keep the current cfg in deactivation, hence we need to only try adding default cfg when activating.
self::add_option($k, $v);
}
// Force correct version in case a rare unexpected case that `_ver` exists but empty
self::update_option(Base::_VER, Core::VER);
if ($ver_check_tag) {
Cloud::version_check($ver_check_tag);
}
}
/**
* Network sites only
*
* Override conf if is network subsites and chose `Use Primary Config`
*/
$this->_try_load_site_options();
// Mark as conf loaded
defined('LITESPEED_CONF_LOADED') || define('LITESPEED_CONF_LOADED', true);
if (!$ver || $ver != Core::VER) {
// Only trigger once in upgrade progress, don't run always
$this->update_confs(); // Files only get corrected in activation or saving settings actions.
}
}
/**
* Load all latest options from DB
*
* @since 3.0
* @access public
*/
public function load_options($blog_id = null, $dry_run = false)
{
$options = array();
foreach (self::$_default_options as $k => $v) {
if (!is_null($blog_id)) {
$options[$k] = self::get_blog_option($blog_id, $k, $v);
} else {
$options[$k] = self::get_option($k, $v);
}
// Correct value type
$options[$k] = $this->type_casting($options[$k], $k);
}
if ($dry_run) {
return $options;
}
// Bypass site special settings
if ($blog_id !== null) {
// This is to load the primary settings ONLY
// These options are the ones that can be overwritten by primary
$options = array_diff_key($options, array_flip(self::$SINGLE_SITE_OPTIONS));
$this->set_primary_conf($options);
} else {
$this->set_conf($options);
}
// Append const options
if (defined('LITESPEED_CONF') && LITESPEED_CONF) {
foreach (self::$_default_options as $k => $v) {
$const = Base::conf_const($k);
if (defined($const)) {
$this->set_const_conf($k, $this->type_casting(constant($const), $k));
}
}
}
}
/**
* For multisite installations, the single site options need to be updated with the network wide options.
*
* @since 1.0.13
* @access private
*/
private function _try_load_site_options()
{
if (!$this->_if_need_site_options()) {
return;
}
$this->_conf_site_db_init();
$this->_is_primary = get_current_blog_id() == BLOG_ID_CURRENT_SITE;
// If network set to use primary setting
if ($this->network_conf(self::NETWORK_O_USE_PRIMARY) && !$this->_is_primary) {
// subsites or network admin
// Get the primary site settings
// If it's just upgraded, 2nd blog is being visited before primary blog, can just load default config (won't hurt as this could only happen shortly)
$this->load_options(BLOG_ID_CURRENT_SITE);
}
// Overwrite single blog options with site options
foreach (self::$_default_options as $k => $v) {
if (!$this->has_network_conf($k)) {
continue;
}
// $this->_options[ $k ] = $this->_network_options[ $k ];
// Special handler to `Enable Cache` option if the value is set to OFF
if ($k == self::O_CACHE) {
if ($this->_is_primary) {
if ($this->conf($k) != $this->network_conf($k)) {
if ($this->conf($k) != self::VAL_ON2) {
continue;
}
}
} else {
if ($this->network_conf(self::NETWORK_O_USE_PRIMARY)) {
if ($this->has_primary_conf($k) && $this->primary_conf($k) != self::VAL_ON2) {
// This case will use primary_options override always
continue;
}
} else {
if ($this->conf($k) != self::VAL_ON2) {
continue;
}
}
}
}
// primary_options will store primary settings + network settings, OR, store the network settings for subsites
$this->set_primary_conf($k, $this->network_conf($k));
}
// var_dump($this->_options);
}
/**
* Check if needs to load site_options for network sites
*
* @since 3.0
* @access private
*/
private function _if_need_site_options()
{
if (!is_multisite()) {
return false;
}
// Check if needs to use site_options or not
// todo: check if site settings are separate bcos it will affect .htaccess
/**
* In case this is called outside the admin page
* @see https://codex.wordpress.org/Function_Reference/is_plugin_active_for_network
* @since 2.0
*/
if (!function_exists('is_plugin_active_for_network')) {
require_once ABSPATH . '/wp-admin/includes/plugin.php';
}
// If is not activated on network, it will not have site options
if (!is_plugin_active_for_network(Core::PLUGIN_FILE)) {
if ((int) $this->conf(self::O_CACHE) == self::VAL_ON2) {
// Default to cache on
$this->set_conf(self::_CACHE, true);
}
return false;
}
return true;
}
/**
* Init site conf and upgrade if necessary
*
* @since 3.0
* @access private
*/
private function _conf_site_db_init()
{
$this->load_site_options();
$ver = $this->network_conf(self::_VER);
/**
* Don't upgrade or run new installations other than from backend visit
* In this case, just use default conf
*/
if (!$ver || $ver != Core::VER) {
if (!is_admin() && !defined('LITESPEED_CLI')) {
$this->set_network_conf($this->load_default_site_vals());
return;
}
}
/**
* Upgrade conf
*/
if ($ver && $ver != Core::VER) {
// Site plugin version will change inside
Data::cls()->conf_site_upgrade($ver);
}
/**
* Is a new installation
*/
if (!$ver || $ver != Core::VER) {
// Load default values
$this->load_default_site_vals();
// Init new default/missing options
foreach (self::$_default_site_options as $k => $v) {
// If the option existed, bypass updating
self::add_site_option($k, $v);
}
}
}
/**
* Get the plugin's site wide options.
*
* If the site wide options are not set yet, set it to default.
*
* @since 1.0.2
* @access public
*/
public function load_site_options()
{
if (!is_multisite()) {
return null;
}
// Load all site options
foreach (self::$_default_site_options as $k => $v) {
$val = self::get_site_option($k, $v);
$val = $this->type_casting($val, $k, true);
$this->set_network_conf($k, $val);
}
}
/**
* Append a 3rd party option to default options
*
* This will not be affected by network use primary site setting.
*
* NOTE: If it is a multi switch option, need to call `_conf_multi_switch()` first
*
* @since 3.0
* @access public
*/
public function option_append($name, $default)
{
self::$_default_options[$name] = $default;
$this->set_conf($name, self::get_option($name, $default));
$this->set_conf($name, $this->type_casting($this->conf($name), $name));
}
/**
* Force an option to a certain value
*
* @since 2.6
* @access public
*/
public function force_option($k, $v)
{
if (!$this->has_conf($k)) {
return;
}
$v = $this->type_casting($v, $k);
if ($this->conf($k) === $v) {
return;
}
Debug2::debug("[Conf] ** $k forced from " . var_export($this->conf($k), true) . ' to ' . var_export($v, true));
$this->set_conf($k, $v);
}
/**
* Define `_CACHE` const in options ( for both single and network )
*
* @since 3.0
* @access public
*/
public function define_cache()
{
// Init global const cache on setting
$this->set_conf(self::_CACHE, false);
if ((int) $this->conf(self::O_CACHE) == self::VAL_ON || $this->conf(self::O_CDN_QUIC)) {
$this->set_conf(self::_CACHE, true);
}
// Check network
if (!$this->_if_need_site_options()) {
// Set cache on
$this->_define_cache_on();
return;
}
// If use network setting
if ((int) $this->conf(self::O_CACHE) == self::VAL_ON2 && $this->network_conf(self::O_CACHE)) {
$this->set_conf(self::_CACHE, true);
}
$this->_define_cache_on();
}
/**
* Define `LITESPEED_ON`
*
* @since 2.1
* @access private
*/
private function _define_cache_on()
{
if (!$this->conf(self::_CACHE)) {
return;
}
defined('LITESPEED_ALLOWED') && !defined('LITESPEED_ON') && define('LITESPEED_ON', true);
}
/**
* Get an option value
*
* @since 3.0
* @access public
* @deprecated 4.0 Use $this->conf() instead
*/
public static function val($id, $ori = false)
{
error_log('Called deprecated function \LiteSpeed\Conf::val(). Please use API call instead.');
return self::cls()->conf($id, $ori);
}
/**
* Save option
*
* @since 3.0
* @access public
*/
public function update_confs($the_matrix = false)
{
if ($the_matrix) {
foreach ($the_matrix as $id => $val) {
$this->update($id, $val);
}
}
if ($this->_updated_ids) {
foreach ($this->_updated_ids as $id) {
// Check if need to do a purge all or not
if ($this->_conf_purge_all($id)) {
Purge::purge_all('conf changed [id] ' . $id);
}
// Check if need to purge a tag
if ($tag = $this->_conf_purge_tag($id)) {
Purge::add($tag);
}
// Update cron
if ($this->_conf_cron($id)) {
$this->cls('Task')->try_clean($id);
}
// Reset crawler bypassed list when any of the options WebP replace, guest mode, or cache mobile got changed
if ($id == self::O_IMG_OPTM_WEBP || $id == self::O_GUEST || $id == self::O_CACHE_MOBILE) {
$this->cls('Crawler')->clear_disabled_list();
}
}
}
do_action('litespeed_update_confs', $the_matrix);
// Update related tables
$this->cls('Data')->correct_tb_existence();
// Update related files
$this->cls('Activation')->update_files();
/**
* CDN related actions - Cloudflare
*/
$this->cls('CDN\Cloudflare')->try_refresh_zone();
/**
* CDN related actions - QUIC.cloud
* @since 2.3
*/
$this->cls('CDN\Quic')->try_sync_conf();
}
/**
* Save option
*
* Note: this is direct save, won't trigger corresponding file update or data sync. To save settings normally, always use `Conf->update_confs()`
*
* @since 3.0
* @access public
*/
public function update($id, $val)
{
// Bypassed this bcos $this->_options could be changed by force_option()
// if ( $this->_options[ $id ] === $val ) {
// return;
// }
if ($id == self::_VER) {
return;
}
if ($id == self::O_SERVER_IP) {
if ($val && !Utility::valid_ipv4($val)) {
$msg = sprintf(__('Saving option failed. IPv4 only for %s.', 'litespeed-cache'), Lang::title(Base::O_SERVER_IP));
Admin_Display::error($msg);
return;
}
}
if (!array_key_exists($id, self::$_default_options)) {
defined('LSCWP_LOG') && Debug2::debug('[Conf] Invalid option ID ' . $id);
return;
}
if ($val && $this->_conf_pswd($id) && !preg_match('/[^\*]/', $val)) {
return;
}
// Special handler for CDN Original URLs
if ($id == self::O_CDN_ORI && !$val) {
$home_url = home_url('/');
$parsed = parse_url($home_url);
$home_url = str_replace($parsed['scheme'] . ':', '', $home_url);
$val = $home_url;
}
// Validate type
$val = $this->type_casting($val, $id);
// Save data
self::update_option($id, $val);
// Handle purge if setting changed
if ($this->conf($id) != $val) {
$this->_updated_ids[] = $id;
// Check if need to fire a purge or not (Here has to stay inside `update()` bcos need comparing old value)
if ($this->_conf_purge($id)) {
$diff = array_diff($val, $this->conf($id));
$diff2 = array_diff($this->conf($id), $val);
$diff = array_merge($diff, $diff2);
// If has difference
foreach ($diff as $v) {
$v = ltrim($v, '^');
$v = rtrim($v, '$');
$this->cls('Purge')->purge_url($v);
}
}
}
// Update in-memory data
$this->set_conf($id, $val);
}
/**
* Save network option
*
* @since 3.0
* @access public
*/
public function network_update($id, $val)
{
if (!array_key_exists($id, self::$_default_site_options)) {
defined('LSCWP_LOG') && Debug2::debug('[Conf] Invalid network option ID ' . $id);
return;
}
if ($val && $this->_conf_pswd($id) && !preg_match('/[^\*]/', $val)) {
return;
}
// Validate type
if (is_bool(self::$_default_site_options[$id])) {
$max = $this->_conf_multi_switch($id);
if ($max && $val > 1) {
$val %= $max + 1;
} else {
$val = (bool) $val;
}
} elseif (is_array(self::$_default_site_options[$id])) {
// from textarea input
if (!is_array($val)) {
$val = Utility::sanitize_lines($val, $this->_conf_filter($id));
}
} elseif (!is_string(self::$_default_site_options[$id])) {
$val = (int) $val;
} else {
// Check if the string has a limit set
$val = $this->_conf_string_val($id, $val);
}
// Save data
self::update_site_option($id, $val);
// Handle purge if setting changed
if ($this->network_conf($id) != $val) {
// Check if need to do a purge all or not
if ($this->_conf_purge_all($id)) {
Purge::purge_all('[Conf] Network conf changed [id] ' . $id);
}
// Update in-memory data
$this->set_network_conf($id, $val);
}
// No need to update cron here, Cron will register in each init
if ($this->has_conf($id)) {
$this->set_conf($id, $val);
}
}
/**
* Check if one user role is in exclude optimization group settings
*
* @since 1.6
* @access public
* @param string $role The user role
* @return int The set value if already set
*/
public function in_optm_exc_roles($role = null)
{
// Get user role
if ($role === null) {
$role = Router::get_role();
}
if (!$role) {
return false;
}
$roles = explode(',', $role);
$found = array_intersect($roles, $this->conf(self::O_OPTM_EXC_ROLES));
return $found ? implode(',', $found) : false;
}
/**
* Set one config value directly
*
* @since 2.9
* @access private
*/
private function _set_conf()
{
/**
* NOTE: For URL Query String setting,
* 1. If append lines to an array setting e.g. `cache-force_uri`, use `set[cache-force_uri][]=the_url`.
* 2. If replace the array setting with one line, use `set[cache-force_uri]=the_url`.
* 3. If replace the array setting with multi lines value, use 2 then 1.
*/
if (empty($_GET[self::TYPE_SET]) || !is_array($_GET[self::TYPE_SET])) {
return;
}
$the_matrix = array();
foreach ($_GET[self::TYPE_SET] as $id => $v) {
if (!$this->has_conf($id)) {
continue;
}
// Append new item to array type settings
if (is_array($v) && is_array($this->conf($id))) {
$v = array_merge($this->conf($id), $v);
Debug2::debug('[Conf] Appended to settings [' . $id . ']: ' . var_export($v, true));
} else {
Debug2::debug('[Conf] Set setting [' . $id . ']: ' . var_export($v, true));
}
$the_matrix[$id] = $v;
}
if (!$the_matrix) {
return;
}
$this->update_confs($the_matrix);
$msg = __('Changed setting successfully.', 'litespeed-cache');
Admin_Display::success($msg);
// Redirect if changed frontend URL
if (!empty($_GET['redirect'])) {
wp_redirect($_GET['redirect']);
exit();
}
}
/**
* Handle all request actions from main cls
*
* @since 2.9
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_SET:
$this->_set_conf();
break;
default:
break;
}
Admin::redirect();
}
}
control.cls.php 0000644 00000053211 15153741266 0007531 0 ustar 00 <?php
/**
* The plugin cache-control class for X-Litespeed-Cache-Control
*
* @since 1.1.3
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Control extends Root
{
const LOG_TAG = '💵';
const BM_CACHEABLE = 1;
const BM_PRIVATE = 2;
const BM_SHARED = 4;
const BM_NO_VARY = 8;
const BM_FORCED_CACHEABLE = 32;
const BM_PUBLIC_FORCED = 64;
const BM_STALE = 128;
const BM_NOTCACHEABLE = 256;
const X_HEADER = 'X-LiteSpeed-Cache-Control';
protected static $_control = 0;
protected static $_custom_ttl = 0;
private $_response_header_ttls = array();
/**
* Init cache control
*
* @since 1.6.2
*/
public function init()
{
/**
* Add vary filter for Role Excludes
* @since 1.6.2
*/
add_filter('litespeed_vary', array($this, 'vary_add_role_exclude'));
// 301 redirect hook
add_filter('wp_redirect', array($this, 'check_redirect'), 10, 2);
// Load response header conf
$this->_response_header_ttls = $this->conf(Base::O_CACHE_TTL_STATUS);
foreach ($this->_response_header_ttls as $k => $v) {
$v = explode(' ', $v);
if (empty($v[0]) || empty($v[1])) {
continue;
}
$this->_response_header_ttls[$v[0]] = $v[1];
}
if ($this->conf(Base::O_PURGE_STALE)) {
$this->set_stale();
}
}
/**
* Exclude role from optimization filter
*
* @since 1.6.2
* @access public
*/
public function vary_add_role_exclude($vary)
{
if ($this->in_cache_exc_roles()) {
$vary['role_exclude_cache'] = 1;
}
return $vary;
}
/**
* Check if one user role is in exclude cache group settings
*
* @since 1.6.2
* @since 3.0 Moved here from conf.cls
* @access public
* @param string $role The user role
* @return int The set value if already set
*/
public function in_cache_exc_roles($role = null)
{
// Get user role
if ($role === null) {
$role = Router::get_role();
}
if (!$role) {
return false;
}
$roles = explode(',', $role);
$found = array_intersect($roles, $this->conf(Base::O_CACHE_EXC_ROLES));
return $found ? implode(',', $found) : false;
}
/**
* 1. Initialize cacheable status for `wp` hook
* 2. Hook error page tags for cacheable pages
*
* @since 1.1.3
* @access public
*/
public function init_cacheable()
{
// Hook `wp` to mark default cacheable status
// NOTE: Any process that does NOT run into `wp` hook will not get cacheable by default
add_action('wp', array($this, 'set_cacheable'), 5);
// Hook WP REST to be cacheable
if ($this->conf(Base::O_CACHE_REST)) {
add_action('rest_api_init', array($this, 'set_cacheable'), 5);
}
// Cache resources
// NOTE: If any strange resource doesn't use normal WP logic `wp_loaded` hook, rewrite rule can handle it
$cache_res = $this->conf(Base::O_CACHE_RES);
if ($cache_res) {
$uri = esc_url($_SERVER['REQUEST_URI']); // todo: check if need esc_url()
$pattern = '!' . LSCWP_CONTENT_FOLDER . Htaccess::RW_PATTERN_RES . '!';
if (preg_match($pattern, $uri)) {
add_action('wp_loaded', array($this, 'set_cacheable'), 5);
}
}
// AJAX cache
$ajax_cache = $this->conf(Base::O_CACHE_AJAX_TTL);
foreach ($ajax_cache as $v) {
$v = explode(' ', $v);
if (empty($v[0]) || empty($v[1])) {
continue;
}
// self::debug("Initializing cacheable status for wp_ajax_nopriv_" . $v[0]);
add_action(
'wp_ajax_nopriv_' . $v[0],
function () use ($v) {
self::set_custom_ttl($v[1]);
self::force_cacheable('ajax Cache setting for action ' . $v[0]);
},
4
);
}
// Check error page
add_filter('status_header', array($this, 'check_error_codes'), 10, 2);
}
/**
* Check if the page returns any error code.
*
* @since 1.0.13.1
* @access public
* @param $status_header
* @param $code
* @return $error_status
*/
public function check_error_codes($status_header, $code)
{
if (array_key_exists($code, $this->_response_header_ttls)) {
if (self::is_cacheable() && !$this->_response_header_ttls[$code]) {
self::set_nocache('[Ctrl] TTL is set to no cache [status_header] ' . $code);
}
// Set TTL
self::set_custom_ttl($this->_response_header_ttls[$code]);
} elseif (self::is_cacheable()) {
if (substr($code, 0, 1) == 4 || substr($code, 0, 1) == 5) {
self::set_nocache('[Ctrl] 4xx/5xx default to no cache [status_header] ' . $code);
}
}
// Set cache tag
if (in_array($code, Tag::$error_code_tags)) {
Tag::add(Tag::TYPE_HTTP . $code);
}
// Give the default status_header back
return $status_header;
}
/**
* Set no vary setting
*
* @access public
* @since 1.1.3
*/
public static function set_no_vary()
{
if (self::is_no_vary()) {
return;
}
self::$_control |= self::BM_NO_VARY;
self::debug('X Cache_control -> no-vary', 3);
}
/**
* Get no vary setting
*
* @access public
* @since 1.1.3
*/
public static function is_no_vary()
{
return self::$_control & self::BM_NO_VARY;
}
/**
* Set stale
*
* @access public
* @since 1.1.3
*/
public function set_stale()
{
if (self::is_stale()) {
return;
}
self::$_control |= self::BM_STALE;
self::debug('X Cache_control -> stale');
}
/**
* Get stale
*
* @access public
* @since 1.1.3
*/
public static function is_stale()
{
return self::$_control & self::BM_STALE;
}
/**
* Set cache control to shared private
*
* @access public
* @since 1.1.3
* @param string $reason The reason to no cache
*/
public static function set_shared($reason = false)
{
if (self::is_shared()) {
return;
}
self::$_control |= self::BM_SHARED;
self::set_private();
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = "( $reason )";
}
self::debug('X Cache_control -> shared ' . $reason);
}
/**
* Check if is shared private
*
* @access public
* @since 1.1.3
*/
public static function is_shared()
{
return self::$_control & self::BM_SHARED && self::is_private();
}
/**
* Set cache control to forced public
*
* @access public
* @since 1.7.1
*/
public static function set_public_forced($reason = false)
{
if (self::is_public_forced()) {
return;
}
self::$_control |= self::BM_PUBLIC_FORCED;
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = "( $reason )";
}
self::debug('X Cache_control -> public forced ' . $reason);
}
/**
* Check if is public forced
*
* @access public
* @since 1.7.1
*/
public static function is_public_forced()
{
return self::$_control & self::BM_PUBLIC_FORCED;
}
/**
* Set cache control to private
*
* @access public
* @since 1.1.3
* @param string $reason The reason to no cache
*/
public static function set_private($reason = false)
{
if (self::is_private()) {
return;
}
self::$_control |= self::BM_PRIVATE;
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = "( $reason )";
}
self::debug('X Cache_control -> private ' . $reason);
}
/**
* Check if is private
*
* @access public
* @since 1.1.3
*/
public static function is_private()
{
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
// return false;
}
return self::$_control & self::BM_PRIVATE && !self::is_public_forced();
}
/**
* Initialize cacheable status in `wp` hook, if not call this, by default it will be non-cacheable
*
* @access public
* @since 1.1.3
*/
public function set_cacheable($reason = false)
{
self::$_control |= self::BM_CACHEABLE;
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = ' [reason] ' . $reason;
}
self::debug('Cache_control init on' . $reason);
}
/**
* This will disable non-cacheable BM
*
* @access public
* @since 2.2
*/
public static function force_cacheable($reason = false)
{
self::$_control |= self::BM_FORCED_CACHEABLE;
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = ' [reason] ' . $reason;
}
self::debug('Forced cacheable' . $reason);
}
/**
* Switch to nocacheable status
*
* @access public
* @since 1.1.3
* @param string $reason The reason to no cache
*/
public static function set_nocache($reason = false)
{
self::$_control |= self::BM_NOTCACHEABLE;
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = "( $reason )";
}
self::debug('X Cache_control -> no Cache ' . $reason, 5);
}
/**
* Check current notcacheable bit set
*
* @access public
* @since 1.1.3
* @return bool True if notcacheable bit is set, otherwise false.
*/
public static function isset_notcacheable()
{
return self::$_control & self::BM_NOTCACHEABLE;
}
/**
* Check current force cacheable bit set
*
* @access public
* @since 2.2
*/
public static function is_forced_cacheable()
{
return self::$_control & self::BM_FORCED_CACHEABLE;
}
/**
* Check current cacheable status
*
* @access public
* @since 1.1.3
* @return bool True if is still cacheable, otherwise false.
*/
public static function is_cacheable()
{
if (defined('LSCACHE_NO_CACHE') && LSCACHE_NO_CACHE) {
self::debug('LSCACHE_NO_CACHE constant defined');
return false;
}
// Guest mode always cacheable
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
// return true;
}
// If its forced public cacheable
if (self::is_public_forced()) {
return true;
}
// If its forced cacheable
if (self::is_forced_cacheable()) {
return true;
}
return !self::isset_notcacheable() && self::$_control & self::BM_CACHEABLE;
}
/**
* Set a custom TTL to use with the request if needed.
*
* @access public
* @since 1.1.3
* @param mixed $ttl An integer or string to use as the TTL. Must be numeric.
*/
public static function set_custom_ttl($ttl, $reason = false)
{
if (is_numeric($ttl)) {
self::$_custom_ttl = $ttl;
self::debug('X Cache_control TTL -> ' . $ttl . ($reason ? ' [reason] ' . $ttl : ''));
}
}
/**
* Generate final TTL.
*
* @access public
* @since 1.1.3
*/
public function get_ttl()
{
if (self::$_custom_ttl != 0) {
return self::$_custom_ttl;
}
// Check if is in timed url list or not
$timed_urls = Utility::wildcard2regex($this->conf(Base::O_PURGE_TIMED_URLS));
$timed_urls_time = $this->conf(Base::O_PURGE_TIMED_URLS_TIME);
if ($timed_urls && $timed_urls_time) {
$current_url = Tag::build_uri_tag(true);
// Use time limit ttl
$scheduled_time = strtotime($timed_urls_time);
$ttl = $scheduled_time - time();
if ($ttl < 0) {
$ttl += 86400; // add one day
}
foreach ($timed_urls as $v) {
if (strpos($v, '*') !== false) {
if (preg_match('#' . $v . '#iU', $current_url)) {
self::debug('X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge regex ' . $v);
return $ttl;
}
} else {
if ($v == $current_url) {
self::debug('X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge rule ' . $v);
return $ttl;
}
}
}
}
// Private cache uses private ttl setting
if (self::is_private()) {
return $this->conf(Base::O_CACHE_TTL_PRIV);
}
if (is_front_page()) {
return $this->conf(Base::O_CACHE_TTL_FRONTPAGE);
}
$feed_ttl = $this->conf(Base::O_CACHE_TTL_FEED);
if (is_feed() && $feed_ttl > 0) {
return $feed_ttl;
}
if ($this->cls('REST')->is_rest() || $this->cls('REST')->is_internal_rest()) {
return $this->conf(Base::O_CACHE_TTL_REST);
}
return $this->conf(Base::O_CACHE_TTL_PUB);
}
/**
* Check if need to set no cache status for redirection or not
*
* @access public
* @since 1.1.3
*/
public function check_redirect($location, $status)
{
// TODO: some env don't have SCRIPT_URI but only REQUEST_URI, need to be compatible
if (!empty($_SERVER['SCRIPT_URI'])) {
// dont check $status == '301' anymore
self::debug('301 from ' . $_SERVER['SCRIPT_URI']);
self::debug("301 to $location");
$to_check = array(PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PATH, PHP_URL_QUERY);
$is_same_redirect = true;
foreach ($to_check as $v) {
$url_parsed = $v == PHP_URL_QUERY ? $_SERVER['QUERY_STRING'] : parse_url($_SERVER['SCRIPT_URI'], $v);
$target = parse_url($location, $v);
self::debug("Compare [from] $url_parsed [to] $target");
if ($v == PHP_URL_QUERY) {
$url_parsed = $url_parsed ? urldecode($url_parsed) : '';
$target = $target ? urldecode($target) : '';
if (substr($url_parsed, -1) == '&') {
$url_parsed = substr($url_parsed, 0, -1);
}
}
if ($url_parsed != $target) {
$is_same_redirect = false;
self::debug('301 different redirection');
break;
}
}
if ($is_same_redirect) {
self::set_nocache('301 to same url');
}
}
return $location;
}
/**
* Sets up the Cache Control header.
*
* @since 1.1.3
* @access public
* @return string empty string if empty, otherwise the cache control header.
*/
public function output()
{
$esi_hdr = '';
if (ESI::has_esi()) {
$esi_hdr = ',esi=on';
}
$hdr = self::X_HEADER . ': ';
if (defined('DONOTCACHEPAGE') && apply_filters('litespeed_const_DONOTCACHEPAGE', DONOTCACHEPAGE)) {
self::debug('❌ forced no cache [reason] DONOTCACHEPAGE const');
$hdr .= 'no-cache' . $esi_hdr;
return $hdr;
}
// Guest mode directly return cacheable result
// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
// // If is POST, no cache
// if ( defined( 'LSCACHE_NO_CACHE' ) && LSCACHE_NO_CACHE ) {
// self::debug( "[Ctrl] ❌ forced no cache [reason] LSCACHE_NO_CACHE const" );
// $hdr .= 'no-cache';
// }
// else if( $_SERVER[ 'REQUEST_METHOD' ] !== 'GET' ) {
// self::debug( "[Ctrl] ❌ forced no cache [reason] req not GET" );
// $hdr .= 'no-cache';
// }
// else {
// $hdr .= 'public';
// $hdr .= ',max-age=' . $this->get_ttl();
// }
// $hdr .= $esi_hdr;
// return $hdr;
// }
// Fix cli `uninstall --deactivate` fatal err
if (!self::is_cacheable()) {
$hdr .= 'no-cache' . $esi_hdr;
return $hdr;
}
if (self::is_shared()) {
$hdr .= 'shared,private';
} elseif (self::is_private()) {
$hdr .= 'private';
} else {
$hdr .= 'public';
}
if (self::is_no_vary()) {
$hdr .= ',no-vary';
}
$hdr .= ',max-age=' . $this->get_ttl() . $esi_hdr;
return $hdr;
}
/**
* Generate all `control` tags before output
*
* @access public
* @since 1.1.3
*/
public function finalize()
{
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
// return;
}
if (is_preview()) {
self::set_nocache('preview page');
return;
}
// Check if has metabox non-cacheable setting or not
if (file_exists(LSCWP_DIR . 'src/metabox.cls.php') && $this->cls('Metabox')->setting('litespeed_no_cache')) {
self::set_nocache('per post metabox setting');
return;
}
// Check if URI is forced public cache
$excludes = $this->conf(Base::O_CACHE_FORCE_PUB_URI);
$hit = Utility::str_hit_array($_SERVER['REQUEST_URI'], $excludes, true);
if ($hit) {
list($result, $this_ttl) = $hit;
self::set_public_forced('Setting: ' . $result);
self::debug('Forced public cacheable due to setting: ' . $result);
if ($this_ttl) {
self::set_custom_ttl($this_ttl);
}
}
if (self::is_public_forced()) {
return;
}
// Check if URI is forced cache
$excludes = $this->conf(Base::O_CACHE_FORCE_URI);
$hit = Utility::str_hit_array($_SERVER['REQUEST_URI'], $excludes, true);
if ($hit) {
list($result, $this_ttl) = $hit;
self::force_cacheable();
self::debug('Forced cacheable due to setting: ' . $result);
if ($this_ttl) {
self::set_custom_ttl($this_ttl);
}
}
// if is not cacheable, terminate check
// Even no need to run 3rd party hook
if (!self::is_cacheable()) {
self::debug('not cacheable before ctrl finalize');
return;
}
// Apply 3rd party filter
// NOTE: Hook always needs to run asap because some 3rd party set is_mobile in this hook
do_action('litespeed_control_finalize', defined('LSCACHE_IS_ESI') ? LSCACHE_IS_ESI : false); // Pass ESI block id
// if is not cacheable, terminate check
if (!self::is_cacheable()) {
self::debug('not cacheable after api_control');
return;
}
// Check litespeed setting to set cacheable status
if (!$this->_setting_cacheable()) {
self::set_nocache();
return;
}
// If user has password cookie, do not cache (moved from vary)
global $post;
if (!empty($post->post_password) && isset($_COOKIE['wp-postpass_' . COOKIEHASH])) {
// If user has password cookie, do not cache
self::set_nocache('pswd cookie');
return;
}
// The following check to the end is ONLY for mobile
$is_mobile = apply_filters('litespeed_is_mobile', false);
if (!$this->conf(Base::O_CACHE_MOBILE)) {
if ($is_mobile) {
self::set_nocache('mobile');
}
return;
}
$env_vary = isset($_SERVER['LSCACHE_VARY_VALUE']) ? $_SERVER['LSCACHE_VARY_VALUE'] : false;
if (!$env_vary) {
$env_vary = isset($_SERVER['HTTP_X_LSCACHE_VARY_VALUE']) ? $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] : false;
}
if ($env_vary && strpos($env_vary, 'ismobile') !== false) {
if (!wp_is_mobile() && !$is_mobile) {
self::set_nocache('is not mobile'); // todo: no need to uncache, it will correct vary value in vary finalize anyways
return;
}
} elseif (wp_is_mobile() || $is_mobile) {
self::set_nocache('is mobile');
return;
}
}
/**
* Check if is mobile for filter `litespeed_is_mobile` in API
*
* @since 3.0
* @access public
*/
public static function is_mobile()
{
return wp_is_mobile();
}
/**
* Get request method w/ compatibility to X-Http-Method-Override
*
* @since 6.2
*/
private function _get_req_method()
{
if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
self::debug('X-Http-Method-Override -> ' . $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
defined('LITESPEED_X_HTTP_METHOD_OVERRIDE') || define('LITESPEED_X_HTTP_METHOD_OVERRIDE', true);
return $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
}
if (isset($_SERVER['REQUEST_METHOD'])) {
return $_SERVER['REQUEST_METHOD'];
}
return 'unknown';
}
/**
* Check if a page is cacheable based on litespeed setting.
*
* @since 1.0.0
* @access private
* @return boolean True if cacheable, false otherwise.
*/
private function _setting_cacheable()
{
// logged_in users already excluded, no hook added
if (!empty($_REQUEST[Router::ACTION])) {
return $this->_no_cache_for('Query String Action');
}
$method = $this->_get_req_method();
if (defined('LITESPEED_X_HTTP_METHOD_OVERRIDE') && LITESPEED_X_HTTP_METHOD_OVERRIDE && $method == 'HEAD') {
return $this->_no_cache_for('HEAD method from override');
}
if ('GET' !== $method && 'HEAD' !== $method) {
return $this->_no_cache_for('Not GET method: ' . $method);
}
if (is_feed() && $this->conf(Base::O_CACHE_TTL_FEED) == 0) {
return $this->_no_cache_for('feed');
}
if (is_trackback()) {
return $this->_no_cache_for('trackback');
}
if (is_search()) {
return $this->_no_cache_for('search');
}
// if ( !defined('WP_USE_THEMES') || !WP_USE_THEMES ) {
// return $this->_no_cache_for('no theme used');
// }
// Check private cache URI setting
$excludes = $this->conf(Base::O_CACHE_PRIV_URI);
$result = Utility::str_hit_array($_SERVER['REQUEST_URI'], $excludes);
if ($result) {
self::set_private('Admin cfg Private Cached URI: ' . $result);
}
if (!self::is_forced_cacheable()) {
// Check if URI is excluded from cache
$excludes = $this->cls('Data')->load_cache_nocacheable($this->conf(Base::O_CACHE_EXC));
$result = Utility::str_hit_array($_SERVER['REQUEST_URI'], $excludes);
if ($result) {
return $this->_no_cache_for('Admin configured URI Do not cache: ' . $result);
}
// Check QS excluded setting
$excludes = $this->conf(Base::O_CACHE_EXC_QS);
if (!empty($excludes) && ($qs = $this->_is_qs_excluded($excludes))) {
return $this->_no_cache_for('Admin configured QS Do not cache: ' . $qs);
}
$excludes = $this->conf(Base::O_CACHE_EXC_CAT);
if (!empty($excludes) && has_category($excludes)) {
return $this->_no_cache_for('Admin configured Category Do not cache.');
}
$excludes = $this->conf(Base::O_CACHE_EXC_TAG);
if (!empty($excludes) && has_tag($excludes)) {
return $this->_no_cache_for('Admin configured Tag Do not cache.');
}
$excludes = $this->conf(Base::O_CACHE_EXC_COOKIES);
if (!empty($excludes) && !empty($_COOKIE)) {
$cookie_hit = array_intersect(array_keys($_COOKIE), $excludes);
if ($cookie_hit) {
return $this->_no_cache_for('Admin configured Cookie Do not cache.');
}
}
$excludes = $this->conf(Base::O_CACHE_EXC_USERAGENTS);
if (!empty($excludes) && isset($_SERVER['HTTP_USER_AGENT'])) {
$nummatches = preg_match(Utility::arr2regex($excludes), $_SERVER['HTTP_USER_AGENT']);
if ($nummatches) {
return $this->_no_cache_for('Admin configured User Agent Do not cache.');
}
}
// Check if is exclude roles ( Need to set Vary too )
if ($result = $this->in_cache_exc_roles()) {
return $this->_no_cache_for('Role Excludes setting ' . $result);
}
}
return true;
}
/**
* Write a debug message for if a page is not cacheable.
*
* @since 1.0.0
* @access private
* @param string $reason An explanation for why the page is not cacheable.
* @return boolean Return false.
*/
private function _no_cache_for($reason)
{
self::debug('X Cache_control off - ' . $reason);
return false;
}
/**
* Check if current request has qs excluded setting
*
* @since 1.3
* @access private
* @param array $excludes QS excludes setting
* @return boolean|string False if not excluded, otherwise the hit qs list
*/
private function _is_qs_excluded($excludes)
{
if (!empty($_GET) && ($intersect = array_intersect(array_keys($_GET), $excludes))) {
return implode(',', $intersect);
}
return false;
}
}
core.cls.php 0000644 00000047526 15153741266 0007015 0 ustar 00 <?php
/**
* The core plugin class.
*
* Note: Core doesn't allow $this->cls( 'Core' )
*
* @since 1.0.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Core extends Root
{
const NAME = 'LiteSpeed Cache';
const PLUGIN_NAME = 'litespeed-cache';
const PLUGIN_FILE = 'litespeed-cache/litespeed-cache.php';
const VER = LSCWP_V;
const ACTION_DISMISS = 'dismiss';
const ACTION_PURGE_BY = 'PURGE_BY';
const ACTION_PURGE_EMPTYCACHE = 'PURGE_EMPTYCACHE';
const ACTION_QS_PURGE = 'PURGE';
const ACTION_QS_PURGE_SINGLE = 'PURGESINGLE'; // This will be same as `ACTION_QS_PURGE` (purge single url only)
const ACTION_QS_SHOW_HEADERS = 'SHOWHEADERS';
const ACTION_QS_PURGE_ALL = 'purge_all';
const ACTION_QS_PURGE_EMPTYCACHE = 'empty_all';
const ACTION_QS_NOCACHE = 'NOCACHE';
const HEADER_DEBUG = 'X-LiteSpeed-Debug';
protected static $_debug_show_header = false;
private $_footer_comment = '';
/**
* Define the core functionality of the plugin.
*
* Set the plugin name and the plugin version that can be used throughout the plugin.
* Load the dependencies, define the locale, and set the hooks for the admin area and
* the public-facing side of the site.
*
* @since 1.0.0
*/
public function __construct()
{
!defined('LSCWP_TS_0') && define('LSCWP_TS_0', microtime(true));
$this->cls('Conf')->init();
/**
* Load API hooks
* @since 3.0
*/
$this->cls('API')->init();
if (defined('LITESPEED_ON')) {
// Load third party detection if lscache enabled.
include_once LSCWP_DIR . 'thirdparty/entry.inc.php';
}
if ($this->conf(Base::O_DEBUG_DISABLE_ALL)) {
!defined('LITESPEED_DISABLE_ALL') && define('LITESPEED_DISABLE_ALL', true);
}
/**
* Register plugin activate/deactivate/uninstall hooks
* NOTE: this can't be moved under after_setup_theme, otherwise activation will be bypassed somehow
* @since 2.7.1 Disabled admin&CLI check to make frontend able to enable cache too
*/
// if( is_admin() || defined( 'LITESPEED_CLI' ) ) {
$plugin_file = LSCWP_DIR . 'litespeed-cache.php';
register_activation_hook($plugin_file, array(__NAMESPACE__ . '\Activation', 'register_activation'));
register_deactivation_hook($plugin_file, array(__NAMESPACE__ . '\Activation', 'register_deactivation'));
register_uninstall_hook($plugin_file, __NAMESPACE__ . '\Activation::uninstall_litespeed_cache');
// }
if (defined('LITESPEED_ON')) {
// register purge_all actions
$purge_all_events = $this->conf(Base::O_PURGE_HOOK_ALL);
// purge all on upgrade
if ($this->conf(Base::O_PURGE_ON_UPGRADE)) {
$purge_all_events[] = 'automatic_updates_complete';
$purge_all_events[] = 'upgrader_process_complete';
$purge_all_events[] = 'admin_action_do-plugin-upgrade';
}
foreach ($purge_all_events as $event) {
// Don't allow hook to update_option bcos purge_all will cause infinite loop of update_option
if (in_array($event, array('update_option'))) {
continue;
}
add_action($event, __NAMESPACE__ . '\Purge::purge_all');
}
// add_filter( 'upgrader_pre_download', 'Purge::filter_with_purge_all' );
// Add headers to site health check for full page cache
// @since 5.4
add_filter('site_status_page_cache_supported_cache_headers', function ($cache_headers) {
$is_cache_hit = function ($header_value) {
return false !== strpos(strtolower($header_value), 'hit');
};
$cache_headers['x-litespeed-cache'] = $is_cache_hit;
$cache_headers['x-lsadc-cache'] = $is_cache_hit;
$cache_headers['x-qc-cache'] = $is_cache_hit;
return $cache_headers;
});
}
add_action('after_setup_theme', array($this, 'init'));
// Check if there is a purge request in queue
if (!defined('LITESPEED_CLI')) {
$purge_queue = Purge::get_option(Purge::DB_QUEUE);
if ($purge_queue && $purge_queue != -1) {
$this->_http_header($purge_queue);
Debug2::debug('[Core] Purge Queue found&sent: ' . $purge_queue);
}
if ($purge_queue != -1) {
Purge::update_option(Purge::DB_QUEUE, -1); // Use 0 to bypass purge while still enable db update as WP's update_option will check value===false to bypass update
}
$purge_queue = Purge::get_option(Purge::DB_QUEUE2);
if ($purge_queue && $purge_queue != -1) {
$this->_http_header($purge_queue);
Debug2::debug('[Core] Purge2 Queue found&sent: ' . $purge_queue);
}
if ($purge_queue != -1) {
Purge::update_option(Purge::DB_QUEUE2, -1);
}
}
/**
* Hook internal REST
* @since 2.9.4
*/
$this->cls('REST');
/**
* Hook wpnonce function
*
* Note: ESI nonce won't be available until hook after_setup_theme ESI init due to Guest Mode concern
* @since v4.1
*/
if ($this->cls('Router')->esi_enabled() && !function_exists('wp_create_nonce')) {
Debug2::debug('[ESI] Overwrite wp_create_nonce()');
litespeed_define_nonce_func();
}
}
/**
* The plugin initializer.
*
* This function checks if the cache is enabled and ready to use, then determines what actions need to be set up based on the type of user and page accessed. Output is buffered if the cache is enabled.
*
* NOTE: WP user doesn't init yet
*
* @since 1.0.0
* @access public
*/
public function init()
{
/**
* Added hook before init
* 3rd party preload hooks will be fired here too (e.g. Divi disable all in edit mode)
* @since 1.6.6
* @since 2.6 Added filter to all config values in Conf
*/
do_action('litespeed_init');
add_action('wp_ajax_async_litespeed', 'LiteSpeed\Task::async_litespeed_handler');
add_action('wp_ajax_nopriv_async_litespeed', 'LiteSpeed\Task::async_litespeed_handler');
// in `after_setup_theme`, before `init` hook
$this->cls('Activation')->auto_update();
if (is_admin() && !(defined('DOING_AJAX') && DOING_AJAX)) {
$this->cls('Admin');
}
if (defined('LITESPEED_DISABLE_ALL') && LITESPEED_DISABLE_ALL) {
Debug2::debug('[Core] Bypassed due to debug disable all setting');
return;
}
do_action('litespeed_initing');
ob_start(array($this, 'send_headers_force'));
add_action('shutdown', array($this, 'send_headers'), 0);
add_action('wp_footer', array($this, 'footer_hook'));
/**
* Check if is non optm simulator
* @since 2.9
*/
if (!empty($_GET[Router::ACTION]) && $_GET[Router::ACTION] == 'before_optm' && !apply_filters('litespeed_qs_forbidden', false)) {
Debug2::debug('[Core] ⛑️ bypass_optm due to QS CTRL');
!defined('LITESPEED_NO_OPTM') && define('LITESPEED_NO_OPTM', true);
}
/**
* Register vary filter
* @since 1.6.2
*/
$this->cls('Control')->init();
// 1. Init vary
// 2. Init cacheable status
// $this->cls('Vary')->init();
// Init Purge hooks
$this->cls('Purge')->init();
$this->cls('Tag')->init();
// Load hooks that may be related to users
add_action('init', array($this, 'after_user_init'), 5);
// Load 3rd party hooks
add_action('wp_loaded', array($this, 'load_thirdparty'), 2);
// test: Simulate a purge all
// if (defined( 'LITESPEED_CLI' )) Purge::add('test'.date('Ymd.His'));
}
/**
* Run hooks after user init
*
* @since 2.9.8
* @access public
*/
public function after_user_init()
{
$this->cls('Router')->is_role_simulation();
// Detect if is Guest mode or not also
$this->cls('Vary')->after_user_init();
/**
* Preload ESI functionality for ESI request uri recovery
* @since 1.8.1
* @since 4.0 ESI init needs to be after Guest mode detection to bypass ESI if is under Guest mode
*/
$this->cls('ESI')->init();
if (!is_admin() && !defined('LITESPEED_GUEST_OPTM') && ($result = $this->cls('Conf')->in_optm_exc_roles())) {
Debug2::debug('[Core] ⛑️ bypass_optm: hit Role Excludes setting: ' . $result);
!defined('LITESPEED_NO_OPTM') && define('LITESPEED_NO_OPTM', true);
}
// Heartbeat control
$this->cls('Tool')->heartbeat();
/**
* Backward compatibility for v4.2- @Ruikai
* TODO: Will change to hook in future versions to make it revertable
*/
if (defined('LITESPEED_BYPASS_OPTM') && !defined('LITESPEED_NO_OPTM')) {
define('LITESPEED_NO_OPTM', LITESPEED_BYPASS_OPTM);
}
if (!defined('LITESPEED_NO_OPTM') || !LITESPEED_NO_OPTM) {
// Check missing static files
$this->cls('Router')->serve_static();
$this->cls('Media')->init();
$this->cls('Placeholder')->init();
$this->cls('Router')->can_optm() && $this->cls('Optimize')->init();
$this->cls('Localization')->init();
// Hook cdn for attachments
$this->cls('CDN')->init();
// load cron tasks
$this->cls('Task')->init();
}
// load litespeed actions
if ($action = Router::get_action()) {
$this->proceed_action($action);
}
// Load frontend GUI
if (!is_admin()) {
$this->cls('GUI')->init();
}
}
/**
* Run frontend actions
*
* @since 1.1.0
* @access public
*/
public function proceed_action($action)
{
$msg = false;
// handle actions
switch ($action) {
case self::ACTION_QS_SHOW_HEADERS:
self::$_debug_show_header = true;
break;
case self::ACTION_QS_PURGE:
case self::ACTION_QS_PURGE_SINGLE:
Purge::set_purge_single();
break;
case self::ACTION_QS_PURGE_ALL:
Purge::purge_all();
break;
case self::ACTION_PURGE_EMPTYCACHE:
case self::ACTION_QS_PURGE_EMPTYCACHE:
define('LSWCP_EMPTYCACHE', true); // clear all sites caches
Purge::purge_all();
$msg = __('Notified LiteSpeed Web Server to purge everything.', 'litespeed-cache');
break;
case self::ACTION_PURGE_BY:
$this->cls('Purge')->purge_list();
$msg = __('Notified LiteSpeed Web Server to purge the list.', 'litespeed-cache');
break;
case self::ACTION_DISMISS: // Even its from ajax, we don't need to register wp ajax callback function but directly use our action
GUI::dismiss();
break;
default:
$msg = $this->cls('Router')->handler($action);
break;
}
if ($msg && !Router::is_ajax()) {
Admin_Display::add_notice(Admin_Display::NOTICE_GREEN, $msg);
Admin::redirect();
return;
}
if (Router::is_ajax()) {
exit();
}
}
/**
* Callback used to call the detect third party action.
*
* The detect action is used by third party plugin integration classes to determine if they should add the rest of their hooks.
*
* @since 1.0.5
* @access public
*/
public function load_thirdparty()
{
do_action('litespeed_load_thirdparty');
}
/**
* Mark wp_footer called
*
* @since 1.3
* @access public
*/
public function footer_hook()
{
Debug2::debug('[Core] Footer hook called');
if (!defined('LITESPEED_FOOTER_CALLED')) {
define('LITESPEED_FOOTER_CALLED', true);
}
}
/**
* Trigger comment info display hook
*
* @since 1.3
* @access private
*/
private function _check_is_html($buffer = null)
{
if (!defined('LITESPEED_FOOTER_CALLED')) {
Debug2::debug2('[Core] CHK html bypass: miss footer const');
return;
}
if (defined('DOING_AJAX')) {
Debug2::debug2('[Core] CHK html bypass: doing ajax');
return;
}
if (defined('DOING_CRON')) {
Debug2::debug2('[Core] CHK html bypass: doing cron');
return;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
Debug2::debug2('[Core] CHK html bypass: not get method ' . $_SERVER['REQUEST_METHOD']);
return;
}
if ($buffer === null) {
$buffer = ob_get_contents();
}
// double check to make sure it is a html file
if (strlen($buffer) > 300) {
$buffer = substr($buffer, 0, 300);
}
if (strstr($buffer, '<!--') !== false) {
$buffer = preg_replace('/<!--.*?-->/s', '', $buffer);
}
$buffer = trim($buffer);
$buffer = File::remove_zero_space($buffer);
$is_html = stripos($buffer, '<html') === 0 || stripos($buffer, '<!DOCTYPE') === 0;
if (!$is_html) {
Debug2::debug('[Core] Footer check failed: ' . ob_get_level() . '-' . substr($buffer, 0, 100));
return;
}
Debug2::debug('[Core] Footer check passed');
if (!defined('LITESPEED_IS_HTML')) {
define('LITESPEED_IS_HTML', true);
}
}
/**
* For compatibility with those plugins have 'Bad' logic that forced all buffer output even it is NOT their buffer :(
*
* Usually this is called after send_headers() if following original WP process
*
* @since 1.1.5
* @access public
* @param string $buffer
* @return string
*/
public function send_headers_force($buffer)
{
$this->_check_is_html($buffer);
// Hook to modify buffer before
$buffer = apply_filters('litespeed_buffer_before', $buffer);
/**
* Media: Image lazyload && WebP
* GUI: Clean wrapper mainly for esi block NOTE: this needs to be before optimizer to avoid wrapper being removed
* Optimize
* CDN
*/
if (!defined('LITESPEED_NO_OPTM') || !LITESPEED_NO_OPTM) {
Debug2::debug('[Core] run hook litespeed_buffer_finalize');
$buffer = apply_filters('litespeed_buffer_finalize', $buffer);
}
/**
* Replace ESI preserved list
* @since 3.3 Replace this in the end to avoid `Inline JS Defer` or other Page Optm features encoded ESI tags wrongly, which caused LSWS can't recognize ESI
*/
$buffer = $this->cls('ESI')->finalize($buffer);
$this->send_headers(true);
// Log ESI nonce buffer empty issue
if (defined('LSCACHE_IS_ESI') && strlen($buffer) == 0) {
// log ref for debug purpose
error_log('ESI buffer empty ' . $_SERVER['REQUEST_URI']);
}
// Init comment info
$running_info_showing = defined('LITESPEED_IS_HTML') || defined('LSCACHE_IS_ESI');
if (defined('LSCACHE_ESI_SILENCE')) {
$running_info_showing = false;
Debug2::debug('[Core] ESI silence');
}
/**
* Silence comment for json req
* @since 2.9.3
*/
if (REST::cls()->is_rest() || Router::is_ajax()) {
$running_info_showing = false;
Debug2::debug('[Core] Silence Comment due to REST/AJAX');
}
$running_info_showing = apply_filters('litespeed_comment', $running_info_showing);
if ($running_info_showing) {
if ($this->_footer_comment) {
$buffer .= $this->_footer_comment;
}
}
/**
* If ESI req is JSON, give the content JSON format
* @since 2.9.3
* @since 2.9.4 ESI req could be from internal REST call, so moved json_encode out of this cond
*/
if (defined('LSCACHE_IS_ESI')) {
Debug2::debug('[Core] ESI Start 👇');
if (strlen($buffer) > 500) {
Debug2::debug(trim(substr($buffer, 0, 500)) . '.....');
} else {
Debug2::debug($buffer);
}
Debug2::debug('[Core] ESI End 👆');
}
if (apply_filters('litespeed_is_json', false)) {
if (\json_decode($buffer, true) == null) {
Debug2::debug('[Core] Buffer converting to JSON');
$buffer = \json_encode($buffer);
$buffer = trim($buffer, '"');
} else {
Debug2::debug('[Core] JSON Buffer');
}
}
// Hook to modify buffer after
$buffer = apply_filters('litespeed_buffer_after', $buffer);
Debug2::ended();
return $buffer;
}
/**
* Sends the headers out at the end of processing the request.
*
* This will send out all LiteSpeed Cache related response headers needed for the post.
*
* @since 1.0.5
* @access public
* @param boolean $is_forced If the header is sent following our normal finalizing logic
*/
public function send_headers($is_forced = false)
{
// Make sure header output only run once
if (!defined('LITESPEED_DID_' . __FUNCTION__)) {
define('LITESPEED_DID_' . __FUNCTION__, true);
} else {
return;
}
// Avoid PHP warning for header sent out already
if (headers_sent()) {
self::debug('❌ !!! Err: Header sent out already');
return;
}
$this->_check_is_html();
// NOTE: cache ctrl output needs to be done first, as currently some varies are added in 3rd party hook `litespeed_api_control`.
$this->cls('Control')->finalize();
$vary_header = $this->cls('Vary')->finalize();
// If is not cacheable but Admin QS is `purge` or `purgesingle`, `tag` still needs to be generated
$tag_header = $this->cls('Tag')->output();
if (!$tag_header && Control::is_cacheable()) {
Control::set_nocache('empty tag header');
}
// NOTE: `purge` output needs to be after `tag` output as Admin QS may need to send `tag` header
$purge_header = Purge::output();
// generate `control` header in the end in case control status is changed by other headers.
$control_header = $this->cls('Control')->output();
// Give one more break to avoid ff crash
if (!defined('LSCACHE_IS_ESI')) {
$this->_footer_comment .= "\n";
}
$cache_support = 'supported';
if (defined('LITESPEED_ON')) {
$cache_support = Control::is_cacheable() ? 'cached' : 'uncached';
}
$this->_comment(
sprintf(
'%1$s %2$s by LiteSpeed Cache %4$s on %3$s',
defined('LSCACHE_IS_ESI') ? 'Block' : 'Page',
$cache_support,
date('Y-m-d H:i:s', time() + LITESPEED_TIME_OFFSET),
self::VER
)
);
// send Control header
if (defined('LITESPEED_ON') && $control_header) {
$this->_http_header($control_header);
if (!Control::is_cacheable()) {
$this->_http_header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0'); // @ref: https://wordpress.org/support/topic/apply_filterslitespeed_control_cacheable-returns-false-for-cacheable/
}
if (defined('LSCWP_LOG')) {
$this->_comment($control_header);
}
}
// send PURGE header (Always send regardless of cache setting disabled/enabled)
if (defined('LITESPEED_ON') && $purge_header) {
$this->_http_header($purge_header);
Debug2::log_purge($purge_header);
if (defined('LSCWP_LOG')) {
$this->_comment($purge_header);
}
}
// send Vary header
if (defined('LITESPEED_ON') && $vary_header) {
$this->_http_header($vary_header);
if (defined('LSCWP_LOG')) {
$this->_comment($vary_header);
}
}
if (defined('LITESPEED_ON') && defined('LSCWP_LOG')) {
$vary = $this->cls('Vary')->finalize_full_varies();
if ($vary) {
$this->_comment('Full varies: ' . $vary);
}
}
// Admin QS show header action
if (self::$_debug_show_header) {
$debug_header = self::HEADER_DEBUG . ': ';
if ($control_header) {
$debug_header .= $control_header . '; ';
}
if ($purge_header) {
$debug_header .= $purge_header . '; ';
}
if ($tag_header) {
$debug_header .= $tag_header . '; ';
}
if ($vary_header) {
$debug_header .= $vary_header . '; ';
}
$this->_http_header($debug_header);
} else {
// Control header
if (defined('LITESPEED_ON') && Control::is_cacheable() && $tag_header) {
$this->_http_header($tag_header);
if (defined('LSCWP_LOG')) {
$this->_comment($tag_header);
}
}
}
// Object cache _comment
if (defined('LSCWP_LOG') && defined('LSCWP_OBJECT_CACHE') && method_exists('WP_Object_Cache', 'debug')) {
$this->_comment('Object Cache ' . \WP_Object_Cache::get_instance()->debug());
}
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
$this->_comment('Guest Mode');
}
if (!empty($this->_footer_comment)) {
self::debug('[footer comment] ' . $this->_footer_comment);
}
if ($is_forced) {
Debug2::debug('--forced--');
}
/**
* If is CLI and contains Purge Header, then issue a HTTP req to Purge
* @since v5.3
*/
if (defined('LITESPEED_CLI')) {
$purge_queue = Purge::get_option(Purge::DB_QUEUE);
if (!$purge_queue || $purge_queue == -1) {
$purge_queue = Purge::get_option(Purge::DB_QUEUE2);
}
if ($purge_queue && $purge_queue != -1) {
self::debug('[Core] Purge Queue found, issue a HTTP req to purge: ' . $purge_queue);
// Kick off HTTP req
$url = admin_url('admin-ajax.php');
$resp = wp_safe_remote_get($url);
if (is_wp_error($resp)) {
$error_message = $resp->get_error_message();
self::debug('[URL]' . $url);
self::debug('failed to request: ' . $error_message);
} else {
self::debug('HTTP req res: ' . $resp['body']);
}
}
}
}
/**
* Append one HTML comment
* @since 5.5
*/
public static function comment($data)
{
self::cls()->_comment($data);
}
private function _comment($data)
{
$this->_footer_comment .= "\n<!-- " . $data . ' -->';
}
/**
* Send HTTP header
* @since 5.3
*/
private function _http_header($header)
{
if (defined('LITESPEED_CLI')) {
return;
}
@header($header);
if (!defined('LSCWP_LOG')) {
return;
}
Debug2::debug('💰 ' . $header);
}
}
crawler-map.cls.php 0000644 00000035245 15153741266 0010272 0 ustar 00 <?php
/**
* The Crawler Sitemap Class
*
* @since 1.1.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Crawler_Map extends Root
{
const LOG_TAG = '🐞🗺️';
const BM_MISS = 1;
const BM_HIT = 2;
const BM_BLACKLIST = 4;
private $_home_url; // Used to simplify urls
private $_tb;
private $_tb_blacklist;
private $__data;
private $_conf_map_timeout;
private $_urls = array();
/**
* Instantiate the class
*
* @since 1.1.0
*/
public function __construct()
{
$this->_home_url = get_home_url();
$this->__data = Data::cls();
$this->_tb = $this->__data->tb('crawler');
$this->_tb_blacklist = $this->__data->tb('crawler_blacklist');
$this->_conf_map_timeout = defined('LITESPEED_CRAWLER_MAP_TIMEOUT') ? LITESPEED_CRAWLER_MAP_TIMEOUT : 180; // Specify the timeout while parsing the sitemap
}
/**
* Save URLs crawl status into DB
*
* @since 3.0
* @access public
*/
public function save_map_status($list, $curr_crawler)
{
global $wpdb;
Utility::compatibility();
$total_crawler = count(Crawler::cls()->list_crawlers());
$total_crawler_pos = $total_crawler - 1;
// Replace current crawler's position
$curr_crawler = (int) $curr_crawler;
foreach ($list as $bit => $ids) {
// $ids = [ id => [ url, code ], ... ]
if (!$ids) {
continue;
}
self::debug("Update map [crawler] $curr_crawler [bit] $bit [count] " . count($ids));
// Update res first, then reason
$right_pos = $total_crawler_pos - $curr_crawler;
$sql_res = "CONCAT( LEFT( res, $curr_crawler ), '$bit', RIGHT( res, $right_pos ) )";
$id_all = implode(',', array_map('intval', array_keys($ids)));
$wpdb->query("UPDATE `$this->_tb` SET res = $sql_res WHERE id IN ( $id_all )");
// Add blacklist
if ($bit == Crawler::STATUS_BLACKLIST || $bit == Crawler::STATUS_NOCACHE) {
$q = "SELECT a.id, a.url FROM `$this->_tb_blacklist` a LEFT JOIN `$this->_tb` b ON b.url=a.url WHERE b.id IN ( $id_all )";
$existing = $wpdb->get_results($q, ARRAY_A);
// Update current crawler status tag in existing blacklist
if ($existing) {
$count = $wpdb->query("UPDATE `$this->_tb_blacklist` SET res = $sql_res WHERE id IN ( " . implode(',', array_column($existing, 'id')) . ' )');
self::debug('Update blacklist [count] ' . $count);
}
// Append new blacklist
if (count($ids) > count($existing)) {
$new_urls = array_diff(array_column($ids, 'url'), array_column($existing, 'url'));
self::debug('Insert into blacklist [count] ' . count($new_urls));
$q = "INSERT INTO `$this->_tb_blacklist` ( url, res, reason ) VALUES " . implode(',', array_fill(0, count($new_urls), '( %s, %s, %s )'));
$data = array();
$res = array_fill(0, $total_crawler, '-');
$res[$curr_crawler] = $bit;
$res = implode('', $res);
$default_reason = $total_crawler > 1 ? str_repeat(',', $total_crawler - 1) : ''; // Pre-populate default reason value first, update later
foreach ($new_urls as $url) {
$data[] = $url;
$data[] = $res;
$data[] = $default_reason;
}
$wpdb->query($wpdb->prepare($q, $data));
}
}
// Update sitemap reason w/ HTTP code
$reason_array = array();
foreach ($ids as $id => $v2) {
$code = (int) $v2['code'];
if (empty($reason_array[$code])) {
$reason_array[$code] = array();
}
$reason_array[$code][] = (int) $id;
}
foreach ($reason_array as $code => $v2) {
// Complement comma
if ($curr_crawler) {
$code = ',' . $code;
}
if ($curr_crawler < $total_crawler_pos) {
$code .= ',';
}
$count = $wpdb->query(
"UPDATE `$this->_tb` SET reason=CONCAT(SUBSTRING_INDEX(reason, ',', $curr_crawler), '$code', SUBSTRING_INDEX(reason, ',', -$right_pos)) WHERE id IN (" .
implode(',', $v2) .
')'
);
self::debug("Update map reason [code] $code [pos] left $curr_crawler right -$right_pos [count] $count");
// Update blacklist reason
if ($bit == Crawler::STATUS_BLACKLIST || $bit == Crawler::STATUS_NOCACHE) {
$count = $wpdb->query(
"UPDATE `$this->_tb_blacklist` a LEFT JOIN `$this->_tb` b ON b.url = a.url SET a.reason=CONCAT(SUBSTRING_INDEX(a.reason, ',', $curr_crawler), '$code', SUBSTRING_INDEX(a.reason, ',', -$right_pos)) WHERE b.id IN (" .
implode(',', $v2) .
')'
);
self::debug("Update blacklist [code] $code [pos] left $curr_crawler right -$right_pos [count] $count");
}
}
// Reset list
$list[$bit] = array();
}
return $list;
}
/**
* Add one record to blacklist
* NOTE: $id is sitemap table ID
*
* @since 3.0
* @access public
*/
public function blacklist_add($id)
{
global $wpdb;
$id = (int) $id;
// Build res&reason
$total_crawler = count(Crawler::cls()->list_crawlers());
$res = str_repeat(Crawler::STATUS_BLACKLIST, $total_crawler);
$reason = implode(',', array_fill(0, $total_crawler, 'Man'));
$row = $wpdb->get_row("SELECT a.url, b.id FROM `$this->_tb` a LEFT JOIN `$this->_tb_blacklist` b ON b.url = a.url WHERE a.id = '$id'", ARRAY_A);
if (!$row) {
self::debug('blacklist failed to add [id] ' . $id);
return;
}
self::debug('Add to blacklist [url] ' . $row['url']);
$q = "UPDATE `$this->_tb` SET res = %s, reason = %s WHERE id = %d";
$wpdb->query($wpdb->prepare($q, array($res, $reason, $id)));
if ($row['id']) {
$q = "UPDATE `$this->_tb_blacklist` SET res = %s, reason = %s WHERE id = %d";
$wpdb->query($wpdb->prepare($q, array($res, $reason, $row['id'])));
} else {
$q = "INSERT INTO `$this->_tb_blacklist` (url, res, reason) VALUES (%s, %s, %s)";
$wpdb->query($wpdb->prepare($q, array($row['url'], $res, $reason)));
}
}
/**
* Delete one record from blacklist
*
* @since 3.0
* @access public
*/
public function blacklist_del($id)
{
global $wpdb;
if (!$this->__data->tb_exist('crawler_blacklist')) {
return;
}
$id = (int) $id;
self::debug('blacklist delete [id] ' . $id);
$sql = sprintf(
"UPDATE `%s` SET res=REPLACE(REPLACE(res, '%s', '-'), '%s', '-') WHERE url=(SELECT url FROM `%s` WHERE id=%d)",
$this->_tb,
Crawler::STATUS_NOCACHE,
Crawler::STATUS_BLACKLIST,
$this->_tb_blacklist,
$id
);
$wpdb->query($sql);
$wpdb->query("DELETE FROM `$this->_tb_blacklist` WHERE id='$id'");
}
/**
* Empty blacklist
*
* @since 3.0
* @access public
*/
public function blacklist_empty()
{
global $wpdb;
if (!$this->__data->tb_exist('crawler_blacklist')) {
return;
}
self::debug('Truncate blacklist');
$sql = sprintf("UPDATE `%s` SET res=REPLACE(REPLACE(res, '%s', '-'), '%s', '-')", $this->_tb, Crawler::STATUS_NOCACHE, Crawler::STATUS_BLACKLIST);
$wpdb->query($sql);
$wpdb->query("TRUNCATE `$this->_tb_blacklist`");
}
/**
* List blacklist
*
* @since 3.0
* @access public
*/
public function list_blacklist($limit = false, $offset = false)
{
global $wpdb;
if (!$this->__data->tb_exist('crawler_blacklist')) {
return array();
}
$q = "SELECT * FROM `$this->_tb_blacklist` ORDER BY id DESC";
if ($limit !== false) {
if ($offset === false) {
$total = $this->count_blacklist();
$offset = Utility::pagination($total, $limit, true);
}
$q .= ' LIMIT %d, %d';
$q = $wpdb->prepare($q, $offset, $limit);
}
return $wpdb->get_results($q, ARRAY_A);
}
/**
* Count blacklist
*/
public function count_blacklist()
{
global $wpdb;
if (!$this->__data->tb_exist('crawler_blacklist')) {
return false;
}
$q = "SELECT COUNT(*) FROM `$this->_tb_blacklist`";
return $wpdb->get_var($q);
}
/**
* Empty sitemap
*
* @since 3.0
* @access public
*/
public function empty_map()
{
Data::cls()->tb_del('crawler');
$msg = __('Sitemap cleaned successfully', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* List generated sitemap
*
* @since 3.0
* @access public
*/
public function list_map($limit, $offset = false)
{
global $wpdb;
if (!$this->__data->tb_exist('crawler')) {
return array();
}
if ($offset === false) {
$total = $this->count_map();
$offset = Utility::pagination($total, $limit, true);
}
$type = Router::verify_type();
$where = '';
if (!empty($_POST['kw'])) {
$q = "SELECT * FROM `$this->_tb` WHERE url LIKE %s";
if ($type == 'hit') {
$q .= " AND res LIKE '%" . Crawler::STATUS_HIT . "%'";
}
if ($type == 'miss') {
$q .= " AND res LIKE '%" . Crawler::STATUS_MISS . "%'";
}
if ($type == 'blacklisted') {
$q .= " AND res LIKE '%" . Crawler::STATUS_BLACKLIST . "%'";
}
$q .= ' ORDER BY id LIMIT %d, %d';
$where = '%' . $wpdb->esc_like($_POST['kw']) . '%';
return $wpdb->get_results($wpdb->prepare($q, $where, $offset, $limit), ARRAY_A);
}
$q = "SELECT * FROM `$this->_tb`";
if ($type == 'hit') {
$q .= " WHERE res LIKE '%" . Crawler::STATUS_HIT . "%'";
}
if ($type == 'miss') {
$q .= " WHERE res LIKE '%" . Crawler::STATUS_MISS . "%'";
}
if ($type == 'blacklisted') {
$q .= " WHERE res LIKE '%" . Crawler::STATUS_BLACKLIST . "%'";
}
$q .= ' ORDER BY id LIMIT %d, %d';
// self::debug("q=$q offset=$offset, limit=$limit");
return $wpdb->get_results($wpdb->prepare($q, $offset, $limit), ARRAY_A);
}
/**
* Count sitemap
*/
public function count_map()
{
global $wpdb;
if (!$this->__data->tb_exist('crawler')) {
return false;
}
$q = "SELECT COUNT(*) FROM `$this->_tb`";
$type = Router::verify_type();
if ($type == 'hit') {
$q .= " WHERE res LIKE '%" . Crawler::STATUS_HIT . "%'";
}
if ($type == 'miss') {
$q .= " WHERE res LIKE '%" . Crawler::STATUS_MISS . "%'";
}
if ($type == 'blacklisted') {
$q .= " WHERE res LIKE '%" . Crawler::STATUS_BLACKLIST . "%'";
}
return $wpdb->get_var($q);
}
/**
* Generate sitemap
*
* @since 1.1.0
* @access public
*/
public function gen($manual = false)
{
$count = $this->_gen();
if (!$count) {
Admin_Display::error(__('No valid sitemap parsed for crawler.', 'litespeed-cache'));
return;
}
if (!defined('DOING_CRON') && $manual) {
$msg = sprintf(__('Sitemap created successfully: %d items', 'litespeed-cache'), $count);
Admin_Display::success($msg);
}
}
/**
* Generate the sitemap
*
* @since 1.1.0
* @access private
*/
private function _gen()
{
global $wpdb;
if (!$this->__data->tb_exist('crawler')) {
$this->__data->tb_create('crawler');
}
if (!$this->__data->tb_exist('crawler_blacklist')) {
$this->__data->tb_create('crawler_blacklist');
}
// use custom sitemap
if (!($sitemap = $this->conf(Base::O_CRAWLER_SITEMAP))) {
return false;
}
$offset = strlen($this->_home_url);
$sitemap = Utility::sanitize_lines($sitemap);
try {
foreach ($sitemap as $this_map) {
$this->_parse($this_map);
}
} catch (\Exception $e) {
self::debug('❌ failed to parse custom sitemap: ' . $e->getMessage());
}
if (is_array($this->_urls) && !empty($this->_urls)) {
if (defined('LITESPEED_CRAWLER_DROP_DOMAIN') && LITESPEED_CRAWLER_DROP_DOMAIN) {
foreach ($this->_urls as $k => $v) {
if (stripos($v, $this->_home_url) !== 0) {
unset($this->_urls[$k]);
continue;
}
$this->_urls[$k] = substr($v, $offset);
}
}
$this->_urls = array_unique($this->_urls);
}
self::debug('Truncate sitemap');
$wpdb->query("TRUNCATE `$this->_tb`");
self::debug('Generate sitemap');
// Filter URLs in blacklist
$blacklist = $this->list_blacklist();
$full_blacklisted = array();
$partial_blacklisted = array();
foreach ($blacklist as $v) {
if (strpos($v['res'], '-') === false) {
// Full blacklisted
$full_blacklisted[] = $v['url'];
} else {
// Replace existing reason
$v['reason'] = explode(',', $v['reason']);
$v['reason'] = array_map(function ($element) {
return $element ? 'Existed' : '';
}, $v['reason']);
$v['reason'] = implode(',', $v['reason']);
$partial_blacklisted[$v['url']] = array(
'res' => $v['res'],
'reason' => $v['reason'],
);
}
}
// Drop all blacklisted URLs
$this->_urls = array_diff($this->_urls, $full_blacklisted);
// Default res & reason
$crawler_count = count(Crawler::cls()->list_crawlers());
$default_res = str_repeat('-', $crawler_count);
$default_reason = $crawler_count > 1 ? str_repeat(',', $crawler_count - 1) : '';
$data = array();
foreach ($this->_urls as $url) {
$data[] = $url;
$data[] = array_key_exists($url, $partial_blacklisted) ? $partial_blacklisted[$url]['res'] : $default_res;
$data[] = array_key_exists($url, $partial_blacklisted) ? $partial_blacklisted[$url]['reason'] : $default_reason;
}
foreach (array_chunk($data, 300) as $data2) {
$this->_save($data2);
}
// Reset crawler
Crawler::cls()->reset_pos();
return count($this->_urls);
}
/**
* Save data to table
*
* @since 3.0
* @access private
*/
private function _save($data, $fields = 'url,res,reason')
{
global $wpdb;
if (empty($data)) {
return;
}
$q = "INSERT INTO `$this->_tb` ( $fields ) VALUES ";
// Add placeholder
$q .= Utility::chunk_placeholder($data, $fields);
// Store data
$wpdb->query($wpdb->prepare($q, $data));
}
/**
* Parse custom sitemap and return urls
*
* @since 1.1.1
* @access private
*/
private function _parse($sitemap)
{
/**
* Read via wp func to avoid allow_url_fopen = off
* @since 2.2.7
*/
$response = wp_safe_remote_get($sitemap, array('timeout' => $this->_conf_map_timeout, 'sslverify' => false));
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
self::debug('failed to read sitemap: ' . $error_message);
throw new \Exception('Failed to remote read ' . $sitemap);
}
$xml_object = simplexml_load_string($response['body'], null, LIBXML_NOCDATA);
if (!$xml_object) {
if ($this->_urls) {
return;
}
throw new \Exception('Failed to parse xml ' . $sitemap);
}
// start parsing
$xml_array = (array) $xml_object;
if (!empty($xml_array['sitemap'])) {
// parse sitemap set
if (is_object($xml_array['sitemap'])) {
$xml_array['sitemap'] = (array) $xml_array['sitemap'];
}
if (!empty($xml_array['sitemap']['loc'])) {
// is single sitemap
$this->_parse($xml_array['sitemap']['loc']);
} else {
// parse multiple sitemaps
foreach ($xml_array['sitemap'] as $val) {
$val = (array) $val;
if (!empty($val['loc'])) {
$this->_parse($val['loc']); // recursive parse sitemap
}
}
}
} elseif (!empty($xml_array['url'])) {
// parse url set
if (is_object($xml_array['url'])) {
$xml_array['url'] = (array) $xml_array['url'];
}
// if only 1 element
if (!empty($xml_array['url']['loc'])) {
$this->_urls[] = $xml_array['url']['loc'];
} else {
foreach ($xml_array['url'] as $val) {
$val = (array) $val;
if (!empty($val['loc'])) {
$this->_urls[] = $val['loc'];
}
}
}
}
}
}
crawler.cls.php 0000644 00000121213 15153741266 0007506 0 ustar 00 <?php
/**
* The crawler class
*
* @since 1.1.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Crawler extends Root
{
const LOG_TAG = '🕸️';
const TYPE_REFRESH_MAP = 'refresh_map';
const TYPE_EMPTY = 'empty';
const TYPE_BLACKLIST_EMPTY = 'blacklist_empty';
const TYPE_BLACKLIST_DEL = 'blacklist_del';
const TYPE_BLACKLIST_ADD = 'blacklist_add';
const TYPE_START = 'start';
const TYPE_RESET = 'reset';
const USER_AGENT = 'lscache_walker';
const FAST_USER_AGENT = 'lscache_runner';
const CHUNKS = 10000;
const STATUS_WAIT = 'W';
const STATUS_HIT = 'H';
const STATUS_MISS = 'M';
const STATUS_BLACKLIST = 'B';
const STATUS_NOCACHE = 'N';
private $_sitemeta = 'meta.data';
private $_resetfile;
private $_end_reason;
private $_ncpu = 1;
private $_server_ip;
private $_crawler_conf = array(
'cookies' => array(),
'headers' => array(),
'ua' => '',
);
private $_crawlers = array();
private $_cur_threads = -1;
private $_max_run_time;
private $_cur_thread_time;
private $_map_status_list = array(
'H' => array(),
'M' => array(),
'B' => array(),
'N' => array(),
);
protected $_summary;
/**
* Initialize crawler, assign sitemap path
*
* @since 1.1.0
*/
public function __construct()
{
if (is_multisite()) {
$this->_sitemeta = 'meta' . get_current_blog_id() . '.data';
}
$this->_resetfile = LITESPEED_STATIC_DIR . '/crawler/' . $this->_sitemeta . '.reset';
$this->_summary = self::get_summary();
$this->_ncpu = $this->_get_server_cpu();
$this->_server_ip = $this->conf(Base::O_SERVER_IP);
self::debug('Init w/ CPU cores=' . $this->_ncpu);
}
/**
* Try get server CPUs
* @since 5.2
*/
private function _get_server_cpu()
{
$cpuinfo_file = '/proc/cpuinfo';
$setting_open_dir = ini_get('open_basedir');
if ($setting_open_dir) {
return 1;
} // Server has limit
try {
if (!@is_file($cpuinfo_file)) {
return 1;
}
} catch (\Exception $e) {
return 1;
}
$cpuinfo = file_get_contents($cpuinfo_file);
preg_match_all('/^processor/m', $cpuinfo, $matches);
return count($matches[0]) ?: 1;
}
/**
* Check whether the current crawler is active/runable/useable/enabled/want it to work or not
*
* @since 4.3
*/
public function is_active($curr)
{
$bypass_list = self::get_option('bypass_list', array());
return !in_array($curr, $bypass_list);
}
/**
* Toggle the current crawler's activeness state, i.e., runable/useable/enabled/want it to work or not, and return the updated state
*
* @since 4.3
*/
public function toggle_activeness($curr)
{
// param type: int
$bypass_list = self::get_option('bypass_list', array());
if (in_array($curr, $bypass_list)) {
// when the ith opt was off / in the bypassed list, turn it on / remove it from the list
unset($bypass_list[array_search($curr, $bypass_list)]);
$bypass_list = array_values($bypass_list);
self::update_option('bypass_list', $bypass_list);
return true;
} else {
// when the ith opt was on / not in the bypassed list, turn it off / add it to the list
$bypass_list[] = (int) $curr;
self::update_option('bypass_list', $bypass_list);
return false;
}
}
/**
* Clear bypassed list
*
* @since 4.3
* @access public
*/
public function clear_disabled_list()
{
self::update_option('bypass_list', array());
$msg = __('Crawler disabled list is cleared! All crawlers are set to active! ', 'litespeed-cache');
Admin_Display::note($msg);
self::debug('All crawlers are set to active...... ');
}
/**
* Overwrite get_summary to init elements
*
* @since 3.0
* @access public
*/
public static function get_summary($field = false)
{
$_default = array(
'list_size' => 0,
'last_update_time' => 0,
'curr_crawler' => 0,
'curr_crawler_beginning_time' => 0,
'last_pos' => 0,
'last_count' => 0,
'last_crawled' => 0,
'last_start_time' => 0,
'last_status' => '',
'is_running' => 0,
'end_reason' => '',
'meta_save_time' => 0,
'pos_reset_check' => 0,
'done' => 0,
'this_full_beginning_time' => 0,
'last_full_time_cost' => 0,
'last_crawler_total_cost' => 0,
'crawler_stats' => array(), // this will store all crawlers hit/miss crawl status
);
wp_cache_delete('alloptions', 'options'); // ensure the summary is current
$summary = parent::get_summary();
$summary = array_merge($_default, $summary);
if (!$field) {
return $summary;
}
if (array_key_exists($field, $summary)) {
return $summary[$field];
}
return null;
}
/**
* Overwrite save_summary
*
* @since 3.0
* @access public
*/
public static function save_summary($data = false, $reload = false, $overwrite = false)
{
$instance = self::cls();
$instance->_summary['meta_save_time'] = time();
if (!$data) {
$data = $instance->_summary;
}
parent::save_summary($data, $reload, $overwrite);
File::save(LITESPEED_STATIC_DIR . '/crawler/' . $instance->_sitemeta, \json_encode($data), true);
}
/**
* Cron start async crawling
*
* @since 5.5
*/
public static function start_async_cron()
{
Task::async_call('crawler');
}
/**
* Manually start async crawling
*
* @since 5.5
*/
public static function start_async()
{
Task::async_call('crawler_force');
$msg = __('Started async crawling', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Ajax crawl handler
*
* @since 5.5
*/
public static function async_handler($manually_run = false)
{
self::debug('------------async-------------start_async_handler');
// check_ajax_referer('async_crawler', 'nonce');
self::start($manually_run);
}
/**
* Proceed crawling
*
* @since 1.1.0
* @access public
*/
public static function start($manually_run = false)
{
if (!Router::can_crawl()) {
self::debug('......crawler is NOT allowed by the server admin......');
return false;
}
if ($manually_run) {
self::debug('......crawler manually ran......');
}
self::cls()->_crawl_data($manually_run);
}
/**
* Crawling start
*
* @since 1.1.0
* @access private
*/
private function _crawl_data($manually_run)
{
if (!defined('LITESPEED_LANE_HASH')) {
define('LITESPEED_LANE_HASH', Str::rrand(8));
}
if ($this->_check_valid_lane()) {
$this->_take_over_lane();
} else {
self::debug('⚠️ lane in use');
return;
// if ($manually_run) {
// self::debug('......crawler started (manually_rund)......');
// // Log pid to prevent from multi running
// if (defined('LITESPEED_CLI')) {
// // Take over lane
// self::debug('⚠️⚠️⚠️ Forced take over lane (CLI)');
// $this->_take_over_lane();
// }
// }
}
self::debug('......crawler started......');
// for the first time running
if (!$this->_summary || !Data::cls()->tb_exist('crawler') || !Data::cls()->tb_exist('crawler_blacklist')) {
$this->cls('Crawler_Map')->gen();
}
// if finished last time, regenerate sitemap
if ($this->_summary['done'] === 'touchedEnd') {
// check whole crawling interval
$last_finished_at = $this->_summary['last_full_time_cost'] + $this->_summary['this_full_beginning_time'];
if (!$manually_run && time() - $last_finished_at < $this->conf(Base::O_CRAWLER_CRAWL_INTERVAL)) {
self::debug('Cron abort: cache warmed already.');
// if not reach whole crawling interval, exit
$this->Release_lane();
return;
}
self::debug('TouchedEnd. regenerate sitemap....');
$this->cls('Crawler_Map')->gen();
}
$this->list_crawlers();
// Skip the crawlers that in bypassed list
while (!$this->is_active($this->_summary['curr_crawler']) && $this->_summary['curr_crawler'] < count($this->_crawlers)) {
self::debug('Skipped the Crawler #' . $this->_summary['curr_crawler'] . ' ......');
$this->_summary['curr_crawler']++;
}
if ($this->_summary['curr_crawler'] >= count($this->_crawlers)) {
$this->_end_reason = 'end';
$this->_terminate_running();
$this->Release_lane();
return;
}
// In case crawlers are all done but not reload, reload it
if (empty($this->_summary['curr_crawler']) || empty($this->_crawlers[$this->_summary['curr_crawler']])) {
$this->_summary['curr_crawler'] = 0;
$this->_summary['crawler_stats'][$this->_summary['curr_crawler']] = array();
}
$res = $this->load_conf();
if (!$res) {
self::debug('Load conf failed');
$this->_terminate_running();
$this->Release_lane();
return;
}
try {
$this->_engine_start();
$this->Release_lane();
} catch (\Exception $e) {
self::debug('🛑 ' . $e->getMessage());
}
}
/**
* Load conf before running crawler
*
* @since 3.0
* @access private
*/
private function load_conf()
{
$this->_crawler_conf['base'] = home_url();
$current_crawler = $this->_crawlers[$this->_summary['curr_crawler']];
/**
* Check cookie crawler
* @since 2.8
*/
foreach ($current_crawler as $k => $v) {
if (strpos($k, 'cookie:') !== 0) {
continue;
}
if ($v == '_null') {
continue;
}
$this->_crawler_conf['cookies'][substr($k, 7)] = $v;
}
/**
* Set WebP simulation
* @since 1.9.1
*/
if (!empty($current_crawler['webp'])) {
$this->_crawler_conf['headers'][] = 'Accept: image/' . ($this->conf(Base::O_IMG_OPTM_WEBP) == 2 ? 'avif' : 'webp') . ',*/*';
}
/**
* Set mobile crawler
* @since 2.8
*/
if (!empty($current_crawler['mobile'])) {
$this->_crawler_conf['ua'] = 'Mobile iPhone';
}
/**
* Limit delay to use server setting
* @since 1.8.3
*/
$this->_crawler_conf['run_delay'] = 500; // microseconds
if (defined('LITESPEED_CRAWLER_USLEEP') && LITESPEED_CRAWLER_USLEEP > $this->_crawler_conf['run_delay']) {
$this->_crawler_conf['run_delay'] = LITESPEED_CRAWLER_USLEEP;
}
if (!empty($_SERVER[Base::ENV_CRAWLER_USLEEP]) && $_SERVER[Base::ENV_CRAWLER_USLEEP] > $this->_crawler_conf['run_delay']) {
$this->_crawler_conf['run_delay'] = $_SERVER[Base::ENV_CRAWLER_USLEEP];
}
$this->_crawler_conf['run_duration'] = $this->get_crawler_duration();
$this->_crawler_conf['load_limit'] = $this->conf(Base::O_CRAWLER_LOAD_LIMIT);
if (!empty($_SERVER[Base::ENV_CRAWLER_LOAD_LIMIT_ENFORCE])) {
$this->_crawler_conf['load_limit'] = $_SERVER[Base::ENV_CRAWLER_LOAD_LIMIT_ENFORCE];
} elseif (!empty($_SERVER[Base::ENV_CRAWLER_LOAD_LIMIT]) && $_SERVER[Base::ENV_CRAWLER_LOAD_LIMIT] < $this->_crawler_conf['load_limit']) {
$this->_crawler_conf['load_limit'] = $_SERVER[Base::ENV_CRAWLER_LOAD_LIMIT];
}
if ($this->_crawler_conf['load_limit'] == 0) {
self::debug('🛑 Terminated crawler due to load limit set to 0');
return false;
}
/**
* Set role simulation
* @since 1.9.1
*/
if (!empty($current_crawler['uid'])) {
if (!$this->_server_ip) {
self::debug('🛑 Terminated crawler due to Server IP not set');
return false;
}
// Get role simulation vary name
$vary_name = $this->cls('Vary')->get_vary_name();
$vary_val = $this->cls('Vary')->finalize_default_vary($current_crawler['uid']);
$this->_crawler_conf['cookies'][$vary_name] = $vary_val;
$this->_crawler_conf['cookies']['litespeed_hash'] = Router::cls()->get_hash($current_crawler['uid']);
}
return true;
}
/**
* Get crawler duration allowance
*
* @since 7.0
*/
public function get_crawler_duration()
{
$RUN_DURATION = defined('LITESPEED_CRAWLER_DURATION') ? LITESPEED_CRAWLER_DURATION : 900;
if ($RUN_DURATION > 900) {
$RUN_DURATION = 900; // reset to default value if defined in conf file is higher than 900 seconds for security enhancement
}
return $RUN_DURATION;
}
/**
* Start crawler
*
* @since 1.1.0
* @access private
*/
private function _engine_start()
{
// check if is running
// if ($this->_summary['is_running'] && time() - $this->_summary['is_running'] < $this->_crawler_conf['run_duration']) {
// $this->_end_reason = 'stopped';
// self::debug('The crawler is running.');
// return;
// }
// check current load
$this->_adjust_current_threads();
if ($this->_cur_threads == 0) {
$this->_end_reason = 'stopped_highload';
self::debug('Stopped due to heavy load.');
return;
}
// log started time
self::save_summary(array('last_start_time' => time()));
// set time limit
$maxTime = (int) ini_get('max_execution_time');
self::debug('ini_get max_execution_time=' . $maxTime);
if ($maxTime == 0) {
$maxTime = 300; // hardlimit
} else {
$maxTime -= 5;
}
if ($maxTime >= $this->_crawler_conf['run_duration']) {
$maxTime = $this->_crawler_conf['run_duration'];
self::debug('Use run_duration setting as max_execution_time=' . $maxTime);
} elseif (ini_set('max_execution_time', $this->_crawler_conf['run_duration'] + 15) !== false) {
$maxTime = $this->_crawler_conf['run_duration'];
self::debug('ini_set max_execution_time=' . $maxTime);
}
self::debug('final max_execution_time=' . $maxTime);
$this->_max_run_time = $maxTime + time();
// mark running
$this->_prepare_running();
// run crawler
$this->_do_running();
$this->_terminate_running();
}
/**
* Get server load
*
* @since 5.5
*/
public function get_server_load()
{
/**
* If server is windows, exit
* @see https://wordpress.org/support/topic/crawler-keeps-causing-crashes/
*/
if (!function_exists('sys_getloadavg')) {
return -1;
}
$curload = sys_getloadavg();
$curload = $curload[0];
self::debug('Server load: ' . $curload);
return $curload;
}
/**
* Adjust threads dynamically
*
* @since 1.1.0
* @access private
*/
private function _adjust_current_threads()
{
$curload = $this->get_server_load();
if ($curload == -1) {
self::debug('set threads=0 due to func sys_getloadavg not exist!');
$this->_cur_threads = 0;
return;
}
$curload /= $this->_ncpu;
// $curload = 1;
$CRAWLER_THREADS = defined('LITESPEED_CRAWLER_THREADS') ? LITESPEED_CRAWLER_THREADS : 3;
if ($this->_cur_threads == -1) {
// init
if ($curload > $this->_crawler_conf['load_limit']) {
$curthreads = 0;
} elseif ($curload >= $this->_crawler_conf['load_limit'] - 1) {
$curthreads = 1;
} else {
$curthreads = intval($this->_crawler_conf['load_limit'] - $curload);
if ($curthreads > $CRAWLER_THREADS) {
$curthreads = $CRAWLER_THREADS;
}
}
} else {
// adjust
$curthreads = $this->_cur_threads;
if ($curload >= $this->_crawler_conf['load_limit'] + 1) {
sleep(5); // sleep 5 secs
if ($curthreads >= 1) {
$curthreads--;
}
} elseif ($curload >= $this->_crawler_conf['load_limit']) {
// if ( $curthreads > 1 ) {// if already 1, keep
$curthreads--;
// }
} elseif ($curload + 1 < $this->_crawler_conf['load_limit']) {
if ($curthreads < $CRAWLER_THREADS) {
$curthreads++;
}
}
}
// $log = 'set current threads = ' . $curthreads . ' previous=' . $this->_cur_threads
// . ' max_allowed=' . $CRAWLER_THREADS . ' load_limit=' . $this->_crawler_conf[ 'load_limit' ] . ' current_load=' . $curload;
$this->_cur_threads = $curthreads;
$this->_cur_thread_time = time();
}
/**
* Mark running status
*
* @since 1.1.0
* @access private
*/
private function _prepare_running()
{
$this->_summary['is_running'] = time();
$this->_summary['done'] = 0; // reset done status
$this->_summary['last_status'] = 'prepare running';
$this->_summary['last_crawled'] = 0;
// Current crawler starttime mark
if ($this->_summary['last_pos'] == 0) {
$this->_summary['curr_crawler_beginning_time'] = time();
}
if ($this->_summary['curr_crawler'] == 0 && $this->_summary['last_pos'] == 0) {
$this->_summary['this_full_beginning_time'] = time();
$this->_summary['list_size'] = $this->cls('Crawler_Map')->count_map();
}
if ($this->_summary['end_reason'] == 'end' && $this->_summary['last_pos'] == 0) {
$this->_summary['crawler_stats'][$this->_summary['curr_crawler']] = array();
}
self::save_summary();
}
/**
* Take over lane
* @since 6.1
*/
private function _take_over_lane()
{
self::debug('Take over lane as lane is free: ' . $this->json_local_path() . '.pid');
file::save($this->json_local_path() . '.pid', LITESPEED_LANE_HASH);
}
/**
* Update lane file
* @since 6.1
*/
private function _touch_lane()
{
touch($this->json_local_path() . '.pid');
}
/**
* Release lane file
* @since 6.1
*/
public function Release_lane()
{
$lane_file = $this->json_local_path() . '.pid';
if (!file_exists($lane_file)) {
return;
}
self::debug('Release lane');
unlink($lane_file);
}
/**
* Check if lane is used by other crawlers
* @since 6.1
*/
private function _check_valid_lane($strict_mode = false)
{
// Check lane hash
$lane_file = $this->json_local_path() . '.pid';
if ($strict_mode) {
if (!file_exists($lane_file)) {
self::debug("lane file not existed, strict mode is false [file] $lane_file");
return false;
}
}
$pid = file::read($lane_file);
if ($pid && LITESPEED_LANE_HASH != $pid) {
// If lane file is older than 1h, ignore
if (time() - filemtime($lane_file) > 3600) {
self::debug('Lane file is older than 1h, releasing lane');
$this->Release_lane();
return true;
}
return false;
}
return true;
}
/**
* Test port for simulator
*
* @since 7.0
* @access private
* @return bool true if success and can continue crawling, false if failed and need to stop
*/
private function _test_port()
{
if (empty($this->_crawler_conf['cookies']) || empty($this->_crawler_conf['cookies']['litespeed_hash'])) {
return true;
}
if (!$this->_server_ip) {
self::debug('❌ Server IP not set');
return false;
}
if (defined('LITESPEED_CRAWLER_LOCAL_PORT')) {
self::debug('✅ LITESPEED_CRAWLER_LOCAL_PORT already defined');
return true;
}
// Don't repeat testing in 120s
if (!empty($this->_summary['test_port_tts']) && time() - $this->_summary['test_port_tts'] < 120) {
if (!empty($this->_summary['test_port'])) {
self::debug('✅ Use tested local port: ' . $this->_summary['test_port']);
define('LITESPEED_CRAWLER_LOCAL_PORT', $this->_summary['test_port']);
return true;
}
return false;
}
$this->_summary['test_port_tts'] = time();
self::save_summary();
$options = $this->_get_curl_options();
$home = home_url();
File::save(LITESPEED_STATIC_DIR . '/crawler/test_port.txt', $home, true);
$url = LITESPEED_STATIC_URL . '/crawler/test_port.txt';
$parsed_url = parse_url($url);
if (empty($parsed_url['host'])) {
self::debug('❌ Test port failed, invalid URL: ' . $url);
return false;
}
$resolved = $parsed_url['host'] . ':443:' . $this->_server_ip;
$options[CURLOPT_RESOLVE] = array($resolved);
$options[CURLOPT_DNS_USE_GLOBAL_CACHE] = false;
$options[CURLOPT_HEADER] = false;
self::debug('Test local 443 port for ' . $resolved);
$ch = curl_init();
curl_setopt_array($ch, $options);
curl_setopt($ch, CURLOPT_URL, $url);
$result = curl_exec($ch);
$test_result = false;
if (curl_errno($ch) || $result !== $home) {
if (curl_errno($ch)) {
self::debug('❌ Test port curl error: [errNo] ' . curl_errno($ch) . ' [err] ' . curl_error($ch));
} elseif ($result !== $home) {
self::debug('❌ Test port response is wrong: ' . $result);
}
self::debug('❌ Test local 443 port failed, try port 80');
// Try port 80
$resolved = $parsed_url['host'] . ':80:' . $this->_server_ip;
$options[CURLOPT_RESOLVE] = array($resolved);
$url = str_replace('https://', 'http://', $url);
if (!in_array('X-Forwarded-Proto: https', $options[CURLOPT_HTTPHEADER])) {
$options[CURLOPT_HTTPHEADER][] = 'X-Forwarded-Proto: https';
}
// $options[CURLOPT_HTTPHEADER][] = 'X-Forwarded-SSL: on';
$ch = curl_init();
curl_setopt_array($ch, $options);
curl_setopt($ch, CURLOPT_URL, $url);
$result = curl_exec($ch);
if (curl_errno($ch)) {
self::debug('❌ Test port curl error: [errNo] ' . curl_errno($ch) . ' [err] ' . curl_error($ch));
} elseif ($result !== $home) {
self::debug('❌ Test port response is wrong: ' . $result);
} else {
self::debug('✅ Test local 80 port successfully');
define('LITESPEED_CRAWLER_LOCAL_PORT', 80);
$this->_summary['test_port'] = 80;
$test_result = true;
}
// self::debug('Response data: ' . $result);
// $this->Release_lane();
// exit($result);
} else {
self::debug('✅ Tested local 443 port successfully');
define('LITESPEED_CRAWLER_LOCAL_PORT', 443);
$this->_summary['test_port'] = 443;
$test_result = true;
}
self::save_summary();
curl_close($ch);
return $test_result;
}
/**
* Run crawler
*
* @since 1.1.0
* @access private
*/
private function _do_running()
{
$options = $this->_get_curl_options(true);
// If is role simulator and not defined local port, check port once
$test_result = $this->_test_port();
if (!$test_result) {
$this->_end_reason = 'port_test_failed';
self::debug('❌ Test port failed, crawler stopped.');
return;
}
while ($urlChunks = $this->cls('Crawler_Map')->list_map(self::CHUNKS, $this->_summary['last_pos'])) {
// self::debug('$urlChunks=' . count($urlChunks) . ' $this->_cur_threads=' . $this->_cur_threads);
// start crawling
$urlChunks = array_chunk($urlChunks, $this->_cur_threads);
// self::debug('$urlChunks after array_chunk: ' . count($urlChunks));
foreach ($urlChunks as $rows) {
if (!$this->_check_valid_lane(true)) {
$this->_end_reason = 'lane_invalid';
self::debug('🛑 The crawler lane is used by newer crawler.');
throw new \Exception('invalid crawler lane');
}
// Update time
$this->_touch_lane();
// self::debug('chunk fetching count($rows)= ' . count($rows));
// multi curl
$rets = $this->_multi_request($rows, $options);
// check result headers
foreach ($rows as $row) {
// self::debug('chunk fetching 553');
if (empty($rets[$row['id']])) {
// If already in blacklist, no curl happened, no corresponding record
continue;
}
// self::debug('chunk fetching 557');
// check response
if ($rets[$row['id']]['code'] == 428) {
// HTTP/1.1 428 Precondition Required (need to test)
$this->_end_reason = 'crawler_disabled';
self::debug('crawler_disabled');
return;
}
$status = $this->_status_parse($rets[$row['id']]['header'], $rets[$row['id']]['code'], $row['url']); // B or H or M or N(nocache)
self::debug('[status] ' . $this->_status2title($status) . "\t\t [url] " . $row['url']);
$this->_map_status_list[$status][$row['id']] = array(
'url' => $row['url'],
'code' => $rets[$row['id']]['code'], // 201 or 200 or 404
);
if (empty($this->_summary['crawler_stats'][$this->_summary['curr_crawler']][$status])) {
$this->_summary['crawler_stats'][$this->_summary['curr_crawler']][$status] = 0;
}
$this->_summary['crawler_stats'][$this->_summary['curr_crawler']][$status]++;
}
// update offset position
$_time = time();
$this->_summary['last_count'] = count($rows);
$this->_summary['last_pos'] += $this->_summary['last_count'];
$this->_summary['last_crawled'] += $this->_summary['last_count'];
$this->_summary['last_update_time'] = $_time;
$this->_summary['last_status'] = 'updated position';
// self::debug("chunk fetching 604 last_pos:{$this->_summary['last_pos']} last_count:{$this->_summary['last_count']} last_crawled:{$this->_summary['last_crawled']}");
// check duration
if ($this->_summary['last_update_time'] > $this->_max_run_time) {
$this->_end_reason = 'stopped_maxtime';
self::debug('Terminated due to maxtime');
return;
// return __('Stopped due to exceeding defined Maximum Run Time', 'litespeed-cache');
}
// make sure at least each 10s save meta & map status once
if ($_time - $this->_summary['meta_save_time'] > 10) {
$this->_map_status_list = $this->cls('Crawler_Map')->save_map_status($this->_map_status_list, $this->_summary['curr_crawler']);
self::save_summary();
}
// self::debug('chunk fetching 597');
// check if need to reset pos each 5s
if ($_time > $this->_summary['pos_reset_check']) {
$this->_summary['pos_reset_check'] = $_time + 5;
if (file_exists($this->_resetfile) && unlink($this->_resetfile)) {
self::debug('Terminated due to reset file');
$this->_summary['last_pos'] = 0;
$this->_summary['curr_crawler'] = 0;
$this->_summary['crawler_stats'][$this->_summary['curr_crawler']] = array();
// reset done status
$this->_summary['done'] = 0;
$this->_summary['this_full_beginning_time'] = 0;
$this->_end_reason = 'stopped_reset';
return;
// return __('Stopped due to reset meta position', 'litespeed-cache');
}
}
// self::debug('chunk fetching 615');
// check loads
if ($this->_summary['last_update_time'] - $this->_cur_thread_time > 60) {
$this->_adjust_current_threads();
if ($this->_cur_threads == 0) {
$this->_end_reason = 'stopped_highload';
self::debug('🛑 Terminated due to highload');
return;
// return __('Stopped due to load over limit', 'litespeed-cache');
}
}
$this->_summary['last_status'] = 'sleeping ' . $this->_crawler_conf['run_delay'] . 'ms';
usleep($this->_crawler_conf['run_delay']);
}
// self::debug('chunk fetching done');
}
// All URLs are done for current crawler
$this->_end_reason = 'end';
$this->_summary['crawler_stats'][$this->_summary['curr_crawler']]['W'] = 0;
self::debug('Crawler #' . $this->_summary['curr_crawler'] . ' touched end');
}
/**
* Send multi curl requests
* If res=B, bypass request and won't return
*
* @since 1.1.0
* @access private
*/
private function _multi_request($rows, $options)
{
if (!function_exists('curl_multi_init')) {
exit('curl_multi_init disabled');
}
$mh = curl_multi_init();
$CRAWLER_DROP_DOMAIN = defined('LITESPEED_CRAWLER_DROP_DOMAIN') ? LITESPEED_CRAWLER_DROP_DOMAIN : false;
$curls = array();
foreach ($rows as $row) {
if (substr($row['res'], $this->_summary['curr_crawler'], 1) == self::STATUS_BLACKLIST) {
continue;
}
if (substr($row['res'], $this->_summary['curr_crawler'], 1) == self::STATUS_NOCACHE) {
continue;
}
if (!function_exists('curl_init')) {
exit('curl_init disabled');
}
$curls[$row['id']] = curl_init();
// Append URL
$url = $row['url'];
if ($CRAWLER_DROP_DOMAIN) {
$url = $this->_crawler_conf['base'] . $row['url'];
}
// IP resolve
if (!empty($this->_crawler_conf['cookies']) && !empty($this->_crawler_conf['cookies']['litespeed_hash'])) {
$parsed_url = parse_url($url);
// self::debug('Crawl role simulator, required to use localhost for resolve');
if (!empty($parsed_url['host'])) {
$dom = $parsed_url['host'];
$port = defined('LITESPEED_CRAWLER_LOCAL_PORT') ? LITESPEED_CRAWLER_LOCAL_PORT : '443';
$resolved = $dom . ':' . $port . ':' . $this->_server_ip;
$options[CURLOPT_RESOLVE] = array($resolved);
$options[CURLOPT_DNS_USE_GLOBAL_CACHE] = false;
// $options[CURLOPT_PORT] = $port;
if ($port == 80) {
$url = str_replace('https://', 'http://', $url);
if (!in_array('X-Forwarded-Proto: https', $options[CURLOPT_HTTPHEADER])) {
$options[CURLOPT_HTTPHEADER][] = 'X-Forwarded-Proto: https';
}
}
self::debug('Resolved DNS for ' . $resolved);
}
}
curl_setopt($curls[$row['id']], CURLOPT_URL, $url);
self::debug('Crawling [url] ' . $url . ($url == $row['url'] ? '' : ' [ori] ' . $row['url']));
curl_setopt_array($curls[$row['id']], $options);
curl_multi_add_handle($mh, $curls[$row['id']]);
}
// execute curl
if ($curls) {
do {
$status = curl_multi_exec($mh, $active);
if ($active) {
curl_multi_select($mh);
}
} while ($active && $status == CURLM_OK);
}
// curl done
$ret = array();
foreach ($rows as $row) {
if (substr($row['res'], $this->_summary['curr_crawler'], 1) == self::STATUS_BLACKLIST) {
continue;
}
if (substr($row['res'], $this->_summary['curr_crawler'], 1) == self::STATUS_NOCACHE) {
continue;
}
// self::debug('-----debug3');
$ch = $curls[$row['id']];
// Parse header
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$content = curl_multi_getcontent($ch);
$header = substr($content, 0, $header_size);
$ret[$row['id']] = array(
'header' => $header,
'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE),
);
// self::debug('-----debug4');
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
// self::debug('-----debug5');
curl_multi_close($mh);
// self::debug('-----debug6');
return $ret;
}
/**
* Translate the status to title
* @since 6.0
*/
private function _status2title($status)
{
if ($status == self::STATUS_HIT) {
return '✅ Hit';
}
if ($status == self::STATUS_MISS) {
return '😊 Miss';
}
if ($status == self::STATUS_BLACKLIST) {
return '😅 Blacklisted';
}
if ($status == self::STATUS_NOCACHE) {
return '😅 Blacklisted';
}
return '🛸 Unknown';
}
/**
* Check returned curl header to find if cached or not
*
* @since 2.0
* @access private
*/
private function _status_parse($header, $code, $url)
{
// self::debug('http status code: ' . $code . ' [headers]', $header);
if ($code == 201) {
return self::STATUS_HIT;
}
if (stripos($header, 'X-Litespeed-Cache-Control: no-cache') !== false) {
// If is from DIVI, taken as miss
if (defined('LITESPEED_CRAWLER_IGNORE_NONCACHEABLE') && LITESPEED_CRAWLER_IGNORE_NONCACHEABLE) {
return self::STATUS_MISS;
}
// If blacklist is disabled
if ((defined('LITESPEED_CRAWLER_DISABLE_BLOCKLIST') && LITESPEED_CRAWLER_DISABLE_BLOCKLIST) || apply_filters('litespeed_crawler_disable_blocklist', false, $url)) {
return self::STATUS_MISS;
}
return self::STATUS_NOCACHE; // Blacklist
}
$_cache_headers = array('x-qc-cache', 'x-lsadc-cache', 'x-litespeed-cache');
foreach ($_cache_headers as $_header) {
if (stripos($header, $_header) !== false) {
if (stripos($header, $_header . ': miss') !== false) {
return self::STATUS_MISS; // Miss
}
return self::STATUS_HIT; // Hit
}
}
// If blacklist is disabled
if ((defined('LITESPEED_CRAWLER_DISABLE_BLOCKLIST') && LITESPEED_CRAWLER_DISABLE_BLOCKLIST) || apply_filters('litespeed_crawler_disable_blocklist', false, $url)) {
return self::STATUS_MISS;
}
return self::STATUS_BLACKLIST; // Blacklist
}
/**
* Get curl_options
*
* @since 1.1.0
* @access private
*/
private function _get_curl_options($crawler_only = false)
{
$CRAWLER_TIMEOUT = defined('LITESPEED_CRAWLER_TIMEOUT') ? LITESPEED_CRAWLER_TIMEOUT : 30;
$options = array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_ENCODING => 'gzip',
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $CRAWLER_TIMEOUT, // Larger timeout to avoid incorrect blacklist addition #900171
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_NOBODY => false,
CURLOPT_HTTPHEADER => $this->_crawler_conf['headers'],
);
$options[CURLOPT_HTTPHEADER][] = 'Cache-Control: max-age=0';
/**
* Try to enable http2 connection (only available since PHP7+)
* @since 1.9.1
* @since 2.2.7 Commented due to cause no-cache issue
* @since 2.9.1+ Fixed wrongly usage of CURL_HTTP_VERSION_1_1 const
*/
$options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
// $options[ CURL_HTTP_VERSION_2 ] = 1;
// if is walker
// $options[ CURLOPT_FRESH_CONNECT ] = true;
// Referer
if (isset($_SERVER['HTTP_HOST']) && isset($_SERVER['REQUEST_URI'])) {
$options[CURLOPT_REFERER] = 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
}
// User Agent
if ($crawler_only) {
if (strpos($this->_crawler_conf['ua'], Crawler::FAST_USER_AGENT) !== 0) {
$this->_crawler_conf['ua'] = Crawler::FAST_USER_AGENT . ' ' . $this->_crawler_conf['ua'];
}
}
$options[CURLOPT_USERAGENT] = $this->_crawler_conf['ua'];
// Cookies
$cookies = array();
foreach ($this->_crawler_conf['cookies'] as $k => $v) {
if (!$v) {
continue;
}
$cookies[] = $k . '=' . urlencode($v);
}
if ($cookies) {
$options[CURLOPT_COOKIE] = implode('; ', $cookies);
}
return $options;
}
/**
* Self curl to get HTML content
*
* @since 3.3
*/
public function self_curl($url, $ua, $uid = false, $accept = false)
{
// $accept not in use yet
$this->_crawler_conf['base'] = home_url();
$this->_crawler_conf['ua'] = $ua;
if ($accept) {
$this->_crawler_conf['headers'] = array('Accept: ' . $accept);
}
$options = $this->_get_curl_options();
if ($uid) {
$this->_crawler_conf['cookies']['litespeed_flash_hash'] = Router::cls()->get_flash_hash($uid);
$parsed_url = parse_url($url);
if (!empty($parsed_url['host'])) {
$dom = $parsed_url['host'];
$port = defined('LITESPEED_CRAWLER_LOCAL_PORT') ? LITESPEED_CRAWLER_LOCAL_PORT : '443';
$resolved = $dom . ':' . $port . ':' . $this->_server_ip;
$options[CURLOPT_RESOLVE] = array($resolved);
$options[CURLOPT_DNS_USE_GLOBAL_CACHE] = false;
$options[CURLOPT_PORT] = $port;
self::debug('Resolved DNS for ' . $resolved);
}
}
$options[CURLOPT_HEADER] = false;
$options[CURLOPT_FOLLOWLOCATION] = true;
$ch = curl_init();
curl_setopt_array($ch, $options);
curl_setopt($ch, CURLOPT_URL, $url);
$result = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code != 200) {
self::debug('❌ Response code is not 200 in self_curl() [code] ' . var_export($code, true));
return false;
}
return $result;
}
/**
* Terminate crawling
*
* @since 1.1.0
* @access private
*/
private function _terminate_running()
{
$this->_map_status_list = $this->cls('Crawler_Map')->save_map_status($this->_map_status_list, $this->_summary['curr_crawler']);
if ($this->_end_reason == 'end') {
// Current crawler is fully done
// $end_reason = sprintf( __( 'Crawler %s reached end of sitemap file.', 'litespeed-cache' ), '#' . ( $this->_summary['curr_crawler'] + 1 ) );
$this->_summary['curr_crawler']++; // Jump to next crawler
// $this->_summary[ 'crawler_stats' ][ $this->_summary[ 'curr_crawler' ] ] = array(); // reset this at next crawl time
$this->_summary['last_pos'] = 0; // reset last position
$this->_summary['last_crawler_total_cost'] = time() - $this->_summary['curr_crawler_beginning_time'];
$count_crawlers = count($this->list_crawlers());
if ($this->_summary['curr_crawler'] >= $count_crawlers) {
self::debug('_terminate_running Touched end, whole crawled. Reload crawler!');
$this->_summary['curr_crawler'] = 0;
// $this->_summary[ 'crawler_stats' ][ $this->_summary[ 'curr_crawler' ] ] = array();
$this->_summary['done'] = 'touchedEnd'; // log done status
$this->_summary['last_full_time_cost'] = time() - $this->_summary['this_full_beginning_time'];
}
}
$this->_summary['last_status'] = 'stopped';
$this->_summary['is_running'] = 0;
$this->_summary['end_reason'] = $this->_end_reason;
self::save_summary();
}
/**
* List all crawlers ( tagA => [ valueA => titleA, ... ] ...)
*
* @since 1.9.1
* @access public
*/
public function list_crawlers()
{
if ($this->_crawlers) {
return $this->_crawlers;
}
$crawler_factors = array();
// Add default Guest crawler
$crawler_factors['uid'] = array(0 => __('Guest', 'litespeed-cache'));
// WebP on/off
if ($this->conf(Base::O_IMG_OPTM_WEBP)) {
$crawler_factors['webp'] = array(1 => $this->cls('Media')->next_gen_image_title());
if (apply_filters('litespeed_crawler_webp', false)) {
$crawler_factors['webp'][0] = '';
}
}
// Guest Mode on/off
if ($this->conf(Base::O_GUEST)) {
$vary_name = $this->cls('Vary')->get_vary_name();
$vary_val = 'guest_mode:1';
if (!defined('LSCWP_LOG')) {
$vary_val = md5($this->conf(Base::HASH) . $vary_val);
}
$crawler_factors['cookie:' . $vary_name] = array($vary_val => '', '_null' => '<font data-balloon-pos="up" aria-label="Guest Mode">👒</font>');
}
// Mobile crawler
if ($this->conf(Base::O_CACHE_MOBILE)) {
$crawler_factors['mobile'] = array(1 => '<font data-balloon-pos="up" aria-label="Mobile">📱</font>', 0 => '');
}
// Get roles set
// List all roles
foreach ($this->conf(Base::O_CRAWLER_ROLES) as $v) {
$role_title = '';
$udata = get_userdata($v);
if (isset($udata->roles) && is_array($udata->roles)) {
$tmp = array_values($udata->roles);
$role_title = array_shift($tmp);
}
if (!$role_title) {
continue;
}
$crawler_factors['uid'][$v] = ucfirst($role_title);
}
// Cookie crawler
foreach ($this->conf(Base::O_CRAWLER_COOKIES) as $v) {
if (empty($v['name'])) {
continue;
}
$this_cookie_key = 'cookie:' . $v['name'];
$crawler_factors[$this_cookie_key] = array();
foreach ($v['vals'] as $v2) {
$crawler_factors[$this_cookie_key][$v2] =
$v2 == '_null' ? '' : '<font data-balloon-pos="up" aria-label="Cookie">🍪</font>' . esc_html($v['name']) . '=' . esc_html($v2);
}
}
// Crossing generate the crawler list
$this->_crawlers = $this->_recursive_build_crawler($crawler_factors);
return $this->_crawlers;
}
/**
* Build a crawler list recursively
*
* @since 2.8
* @access private
*/
private function _recursive_build_crawler($crawler_factors, $group = array(), $i = 0)
{
$current_factor = array_keys($crawler_factors);
$current_factor = $current_factor[$i];
$if_touch_end = $i + 1 >= count($crawler_factors);
$final_list = array();
foreach ($crawler_factors[$current_factor] as $k => $v) {
// Don't alter $group bcos of loop usage
$item = $group;
$item['title'] = !empty($group['title']) ? $group['title'] : '';
if ($v) {
if ($item['title']) {
$item['title'] .= ' - ';
}
$item['title'] .= $v;
}
$item[$current_factor] = $k;
if ($if_touch_end) {
$final_list[] = $item;
} else {
// Inception: next layer
$final_list = array_merge($final_list, $this->_recursive_build_crawler($crawler_factors, $item, $i + 1));
}
}
return $final_list;
}
/**
* Return crawler meta file local path
*
* @since 6.1
* @access public
*/
public function json_local_path()
{
// if (!file_exists(LITESPEED_STATIC_DIR . '/crawler/' . $this->_sitemeta)) {
// return false;
// }
return LITESPEED_STATIC_DIR . '/crawler/' . $this->_sitemeta;
}
/**
* Return crawler meta file
*
* @since 1.1.0
* @access public
*/
public function json_path()
{
if (!file_exists(LITESPEED_STATIC_DIR . '/crawler/' . $this->_sitemeta)) {
return false;
}
return LITESPEED_STATIC_URL . '/crawler/' . $this->_sitemeta;
}
/**
* Create reset pos file
*
* @since 1.1.0
* @access public
*/
public function reset_pos()
{
File::save($this->_resetfile, time(), true);
self::save_summary(array('is_running' => 0));
}
/**
* Display status based by matching crawlers order
*
* @since 3.0
* @access public
*/
public function display_status($status_row, $reason_set)
{
if (!$status_row) {
return '';
}
$_status_list = array(
'-' => 'default',
self::STATUS_MISS => 'primary',
self::STATUS_HIT => 'success',
self::STATUS_BLACKLIST => 'danger',
self::STATUS_NOCACHE => 'warning',
);
$reason_set = explode(',', $reason_set);
$status = '';
foreach (str_split($status_row) as $k => $v) {
$reason = $reason_set[$k];
if ($reason == 'Man') {
$reason = __('Manually added to blocklist', 'litespeed-cache');
}
if ($reason == 'Existed') {
$reason = __('Previously existed in blocklist', 'litespeed-cache');
}
if ($reason) {
$reason = 'data-balloon-pos="up" aria-label="' . $reason . '"';
}
$status .= '<i class="litespeed-dot litespeed-bg-' . $_status_list[$v] . '" ' . $reason . '>' . ($k + 1) . '</i>';
}
return $status;
}
/**
* Output info and exit
*
* @since 1.1.0
* @access protected
* @param string $error Error info
*/
protected function output($msg)
{
if (defined('DOING_CRON')) {
echo $msg;
// exit();
} else {
echo "<script>alert('" . htmlspecialchars($msg) . "');</script>";
// exit;
}
}
/**
* Handle all request actions from main cls
*
* @since 3.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_REFRESH_MAP:
$this->cls('Crawler_Map')->gen(true);
break;
case self::TYPE_EMPTY:
$this->cls('Crawler_Map')->empty_map();
break;
case self::TYPE_BLACKLIST_EMPTY:
$this->cls('Crawler_Map')->blacklist_empty();
break;
case self::TYPE_BLACKLIST_DEL:
if (!empty($_GET['id'])) {
$this->cls('Crawler_Map')->blacklist_del($_GET['id']);
}
break;
case self::TYPE_BLACKLIST_ADD:
if (!empty($_GET['id'])) {
$this->cls('Crawler_Map')->blacklist_add($_GET['id']);
}
break;
case self::TYPE_START: // Handle the ajax request to proceed crawler manually by admin
self::start_async();
break;
case self::TYPE_RESET:
$this->reset_pos();
break;
default:
break;
}
Admin::redirect();
}
}
css.cls.php 0000644 00000036215 15153741266 0006646 0 ustar 00 <?php
/**
* The optimize css class.
*
* @since 2.3
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class CSS extends Base
{
const LOG_TAG = '[CSS]';
const TYPE_GEN_CCSS = 'gen_ccss';
const TYPE_CLEAR_Q_CCSS = 'clear_q_ccss';
protected $_summary;
private $_ccss_whitelist;
private $_queue;
/**
* Init
*
* @since 3.0
*/
public function __construct()
{
$this->_summary = self::get_summary();
add_filter('litespeed_ccss_whitelist', array($this->cls('Data'), 'load_ccss_whitelist'));
}
/**
* HTML lazyload CSS
* @since 4.0
*/
public function prepare_html_lazy()
{
return '<style>' . implode(',', $this->conf(self::O_OPTM_HTML_LAZY)) . '{content-visibility:auto;contain-intrinsic-size:1px 1000px;}</style>';
}
/**
* Output critical css
*
* @since 1.3
* @access public
*/
public function prepare_ccss()
{
// Get critical css for current page
// Note: need to consider mobile
$rules = $this->_ccss();
if (!$rules) {
return null;
}
$error_tag = '';
if (substr($rules, 0, 2) == '/*' && substr($rules, -2) == '*/') {
Core::comment('QUIC.cloud CCSS bypassed due to generation error ❌');
$error_tag = ' data-error="failed to generate"';
}
// Append default critical css
$rules .= $this->conf(self::O_OPTM_CCSS_CON);
return '<style id="litespeed-ccss"' . $error_tag . '>' . $rules . '</style>';
}
/**
* Generate CCSS url tag
*
* @since 4.0
*/
private function _gen_ccss_file_tag($request_url)
{
if (is_404()) {
return '404';
}
if ($this->conf(self::O_OPTM_CCSS_PER_URL)) {
return $request_url;
}
$sep_uri = $this->conf(self::O_OPTM_CCSS_SEP_URI);
if ($sep_uri && ($hit = Utility::str_hit_array($request_url, $sep_uri))) {
Debug2::debug('[CCSS] Separate CCSS due to separate URI setting: ' . $hit);
return $request_url;
}
$pt = Utility::page_type();
$sep_pt = $this->conf(self::O_OPTM_CCSS_SEP_POSTTYPE);
if (in_array($pt, $sep_pt)) {
Debug2::debug('[CCSS] Separate CCSS due to posttype setting: ' . $pt);
return $request_url;
}
// Per posttype
return $pt;
}
/**
* The critical css content of the current page
*
* @since 2.3
*/
private function _ccss()
{
global $wp;
$request_url = get_permalink();
// Backup, in case get_permalink() fails.
if (!$request_url) {
$request_url = home_url($wp->request);
}
$filepath_prefix = $this->_build_filepath_prefix('ccss');
$url_tag = $this->_gen_ccss_file_tag($request_url);
$vary = $this->cls('Vary')->finalize_full_varies();
$filename = $this->cls('Data')->load_url_file($url_tag, $vary, 'ccss');
if ($filename) {
$static_file = LITESPEED_STATIC_DIR . $filepath_prefix . $filename . '.css';
if (file_exists($static_file)) {
Debug2::debug2('[CSS] existing ccss ' . $static_file);
Core::comment('QUIC.cloud CCSS loaded ✅ ' . $filepath_prefix . $filename . '.css');
return File::read($static_file);
}
}
$uid = get_current_user_id();
$ua = !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
// Store it to prepare for cron
Core::comment('QUIC.cloud CCSS in queue');
$this->_queue = $this->load_queue('ccss');
if (count($this->_queue) > 500) {
self::debug('CCSS Queue is full - 500');
return null;
}
$queue_k = (strlen($vary) > 32 ? md5($vary) : $vary) . ' ' . $url_tag;
$this->_queue[$queue_k] = array(
'url' => apply_filters('litespeed_ccss_url', $request_url),
'user_agent' => substr($ua, 0, 200),
'is_mobile' => $this->_separate_mobile(),
'is_webp' => $this->cls('Media')->webp_support() ? 1 : 0,
'uid' => $uid,
'vary' => $vary,
'url_tag' => $url_tag,
); // Current UA will be used to request
$this->save_queue('ccss', $this->_queue);
self::debug('Added queue_ccss [url_tag] ' . $url_tag . ' [UA] ' . $ua . ' [vary] ' . $vary . ' [uid] ' . $uid);
// Prepare cache tag for later purge
Tag::add('CCSS.' . md5($queue_k));
// For v4.1- clean up
if (isset($this->_summary['ccss_type_history']) || isset($this->_summary['ccss_history']) || isset($this->_summary['queue_ccss'])) {
if (isset($this->_summary['ccss_type_history'])) {
unset($this->_summary['ccss_type_history']);
}
if (isset($this->_summary['ccss_history'])) {
unset($this->_summary['ccss_history']);
}
if (isset($this->_summary['queue_ccss'])) {
unset($this->_summary['queue_ccss']);
}
self::save_summary();
}
return null;
}
/**
* Cron ccss generation
*
* @since 2.3
* @access private
*/
public static function cron_ccss($continue = false)
{
$_instance = self::cls();
return $_instance->_cron_handler('ccss', $continue);
}
/**
* Handle UCSS/CCSS cron
*
* @since 4.2
*/
private function _cron_handler($type, $continue)
{
$this->_queue = $this->load_queue($type);
if (empty($this->_queue)) {
return;
}
$type_tag = strtoupper($type);
// For cron, need to check request interval too
if (!$continue) {
if (!empty($this->_summary['curr_request_' . $type]) && time() - $this->_summary['curr_request_' . $type] < 300 && !$this->conf(self::O_DEBUG)) {
Debug2::debug('[' . $type_tag . '] Last request not done');
return;
}
}
$i = 0;
foreach ($this->_queue as $k => $v) {
if (!empty($v['_status'])) {
continue;
}
Debug2::debug('[' . $type_tag . '] cron job [tag] ' . $k . ' [url] ' . $v['url'] . ($v['is_mobile'] ? ' 📱 ' : '') . ' [UA] ' . $v['user_agent']);
if ($type == 'ccss' && empty($v['url_tag'])) {
unset($this->_queue[$k]);
$this->save_queue($type, $this->_queue);
Debug2::debug('[CCSS] wrong queue_ccss format');
continue;
}
if (!isset($v['is_webp'])) {
$v['is_webp'] = false;
}
$i++;
$res = $this->_send_req($v['url'], $k, $v['uid'], $v['user_agent'], $v['vary'], $v['url_tag'], $type, $v['is_mobile'], $v['is_webp']);
if (!$res) {
// Status is wrong, drop this this->_queue
unset($this->_queue[$k]);
$this->save_queue($type, $this->_queue);
if (!$continue) {
return;
}
if ($i > 3) {
GUI::print_loading(count($this->_queue), $type_tag);
return Router::self_redirect(Router::ACTION_CSS, CSS::TYPE_GEN_CCSS);
}
continue;
}
// Exit queue if out of quota or service is hot
if ($res === 'out_of_quota' || $res === 'svc_hot') {
return;
}
$this->_queue[$k]['_status'] = 'requested';
$this->save_queue($type, $this->_queue);
// only request first one
if (!$continue) {
return;
}
if ($i > 3) {
GUI::print_loading(count($this->_queue), $type_tag);
return Router::self_redirect(Router::ACTION_CSS, CSS::TYPE_GEN_CCSS);
}
}
}
/**
* Send to QC API to generate CCSS/UCSS
*
* @since 2.3
* @access private
*/
private function _send_req($request_url, $queue_k, $uid, $user_agent, $vary, $url_tag, $type, $is_mobile, $is_webp)
{
// Check if has credit to push or not
$err = false;
$allowance = $this->cls('Cloud')->allowance(Cloud::SVC_CCSS, $err);
if (!$allowance) {
Debug2::debug('[CCSS] ❌ No credit: ' . $err);
$err && Admin_Display::error(Error::msg($err));
return 'out_of_quota';
}
set_time_limit(120);
// Update css request status
$this->_summary['curr_request_' . $type] = time();
self::save_summary();
// Gather guest HTML to send
$html = $this->prepare_html($request_url, $user_agent, $uid);
if (!$html) {
return false;
}
// Parse HTML to gather all CSS content before requesting
list($css, $html) = $this->prepare_css($html, $is_webp);
if (!$css) {
$type_tag = strtoupper($type);
Debug2::debug('[' . $type_tag . '] ❌ No combined css');
return false;
}
// Generate critical css
$data = array(
'url' => $request_url,
'queue_k' => $queue_k,
'user_agent' => $user_agent,
'is_mobile' => $is_mobile ? 1 : 0, // todo:compatible w/ tablet
'is_webp' => $is_webp ? 1 : 0,
'html' => $html,
'css' => $css,
);
if (!isset($this->_ccss_whitelist)) {
$this->_ccss_whitelist = $this->_filter_whitelist();
}
$data['whitelist'] = $this->_ccss_whitelist;
self::debug('Generating: ', $data);
$json = Cloud::post(Cloud::SVC_CCSS, $data, 30);
if (!is_array($json)) {
return $json;
}
// Old version compatibility
if (empty($json['status'])) {
if (!empty($json[$type])) {
$this->_save_con($type, $json[$type], $queue_k, $is_mobile, $is_webp);
}
// Delete the row
return false;
}
// Unknown status, remove this line
if ($json['status'] != 'queued') {
return false;
}
// Save summary data
$this->_summary['last_spent_' . $type] = time() - $this->_summary['curr_request_' . $type];
$this->_summary['last_request_' . $type] = $this->_summary['curr_request_' . $type];
$this->_summary['curr_request_' . $type] = 0;
self::save_summary();
return true;
}
/**
* Save CCSS/UCSS content
*
* @since 4.2
*/
private function _save_con($type, $css, $queue_k, $mobile, $webp)
{
// Add filters
$css = apply_filters('litespeed_' . $type, $css, $queue_k);
Debug2::debug2('[CSS] con: ' . $css);
if (substr($css, 0, 2) == '/*' && substr($css, -2) == '*/') {
self::debug('❌ empty ' . $type . ' [content] ' . $css);
// continue; // Save the error info too
}
// Write to file
$filecon_md5 = md5($css);
$filepath_prefix = $this->_build_filepath_prefix($type);
$static_file = LITESPEED_STATIC_DIR . $filepath_prefix . $filecon_md5 . '.css';
File::save($static_file, $css, true);
$url_tag = $this->_queue[$queue_k]['url_tag'];
$vary = $this->_queue[$queue_k]['vary'];
Debug2::debug2("[CSS] Save URL to file [file] $static_file [vary] $vary");
$this->cls('Data')->save_url($url_tag, $vary, $type, $filecon_md5, dirname($static_file), $mobile, $webp);
Purge::add(strtoupper($type) . '.' . md5($queue_k));
}
/**
* Play for fun
*
* @since 3.4.3
*/
public function test_url($request_url)
{
$user_agent = $_SERVER['HTTP_USER_AGENT'];
$html = $this->prepare_html($request_url, $user_agent);
list($css, $html) = $this->prepare_css($html, true, true);
// var_dump( $css );
// $html = <<<EOT
// EOT;
// $css = <<<EOT
// EOT;
$data = array(
'url' => $request_url,
'ccss_type' => 'test',
'user_agent' => $user_agent,
'is_mobile' => 0,
'html' => $html,
'css' => $css,
'type' => 'CCSS',
);
// self::debug( 'Generating: ', $data );
$json = Cloud::post(Cloud::SVC_CCSS, $data, 180);
var_dump($json);
}
/**
* Prepare HTML from URL
*
* @since 3.4.3
*/
public function prepare_html($request_url, $user_agent, $uid = false)
{
$html = $this->cls('Crawler')->self_curl(add_query_arg('LSCWP_CTRL', 'before_optm', $request_url), $user_agent, $uid);
Debug2::debug2('[CSS] self_curl result....', $html);
if (!$html) {
return false;
}
$html = $this->cls('Optimizer')->html_min($html, true);
// Drop <noscript>xxx</noscript>
$html = preg_replace('#<noscript>.*</noscript>#isU', '', $html);
return $html;
}
/**
* Prepare CSS from HTML for CCSS generation only. UCSS will used combined CSS directly.
* Prepare refined HTML for both CCSS and UCSS.
*
* @since 3.4.3
*/
public function prepare_css($html, $is_webp = false, $dryrun = false)
{
$css = '';
preg_match_all('#<link ([^>]+)/?>|<style([^>]*)>([^<]+)</style>#isU', $html, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$debug_info = '';
if (strpos($match[0], '<link') === 0) {
$attrs = Utility::parse_attr($match[1]);
if (empty($attrs['rel'])) {
continue;
}
if ($attrs['rel'] != 'stylesheet') {
if ($attrs['rel'] != 'preload' || empty($attrs['as']) || $attrs['as'] != 'style') {
continue;
}
}
if (!empty($attrs['media']) && strpos($attrs['media'], 'print') !== false) {
continue;
}
if (empty($attrs['href'])) {
continue;
}
// Check Google fonts hit
if (strpos($attrs['href'], 'fonts.googleapis.com') !== false) {
$html = str_replace($match[0], '', $html);
continue;
}
$debug_info = $attrs['href'];
// Load CSS content
if (!$dryrun) {
// Dryrun will not load CSS but just drop them
$con = $this->cls('Optimizer')->load_file($attrs['href']);
if (!$con) {
continue;
}
} else {
$con = '';
}
} else {
// Inline style
$attrs = Utility::parse_attr($match[2]);
if (!empty($attrs['media']) && strpos($attrs['media'], 'print') !== false) {
continue;
}
Debug2::debug2('[CSS] Load inline CSS ' . substr($match[3], 0, 100) . '...', $attrs);
$con = $match[3];
$debug_info = '__INLINE__';
}
$con = Optimizer::minify_css($con);
if ($is_webp && $this->cls('Media')->webp_support()) {
$con = $this->cls('Media')->replace_background_webp($con);
}
if (!empty($attrs['media']) && $attrs['media'] !== 'all') {
$con = '@media ' . $attrs['media'] . '{' . $con . "}\n";
} else {
$con = $con . "\n";
}
$con = '/* ' . $debug_info . ' */' . $con;
$css .= $con;
$html = str_replace($match[0], '', $html);
}
return array($css, $html);
}
/**
* Filter the comment content, add quotes to selector from whitelist. Return the json
*
* @since 7.1
*/
private function _filter_whitelist()
{
$whitelist = array();
$list = apply_filters('litespeed_ccss_whitelist', $this->conf(self::O_OPTM_CCSS_SELECTOR_WHITELIST));
foreach ($list as $v) {
if (substr($v, 0, 2) === '//') {
continue;
}
$whitelist[] = $v;
}
return $whitelist;
}
/**
* Notify finished from server
* @since 7.1
*/
public function notify()
{
$post_data = \json_decode(file_get_contents('php://input'), true);
if (is_null($post_data)) {
$post_data = $_POST;
}
self::debug('notify() data', $post_data);
$this->_queue = $this->load_queue('ccss');
list($post_data) = $this->cls('Cloud')->extract_msg($post_data, 'ccss');
$notified_data = $post_data['data'];
if (empty($notified_data) || !is_array($notified_data)) {
self::debug('❌ notify exit: no notified data');
return Cloud::err('no notified data');
}
// Check if its in queue or not
$valid_i = 0;
foreach ($notified_data as $v) {
if (empty($v['request_url'])) {
self::debug('❌ notify bypass: no request_url', $v);
continue;
}
if (empty($v['queue_k'])) {
self::debug('❌ notify bypass: no queue_k', $v);
continue;
}
if (empty($this->_queue[$v['queue_k']])) {
self::debug('❌ notify bypass: no this queue [q_k]' . $v['queue_k']);
continue;
}
// Save data
if (!empty($v['data_ccss'])) {
$is_mobile = $this->_queue[$v['queue_k']]['is_mobile'];
$is_webp = $this->_queue[$v['queue_k']]['is_webp'];
$this->_save_con('ccss', $v['data_ccss'], $v['queue_k'], $is_mobile, $is_webp);
$valid_i++;
}
unset($this->_queue[$v['queue_k']]);
self::debug('notify data handled, unset queue [q_k] ' . $v['queue_k']);
}
$this->save_queue('ccss', $this->_queue);
self::debug('notified');
return Cloud::ok(array('count' => $valid_i));
}
/**
* Handle all request actions from main cls
*
* @since 2.3
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_GEN_CCSS:
self::cron_ccss(true);
break;
case self::TYPE_CLEAR_Q_CCSS:
$this->clear_q('ccss');
break;
default:
break;
}
Admin::redirect();
}
}
data.cls.php 0000644 00000043164 15153741266 0006770 0 ustar 00 <?php
/**
* The class to store and manage litespeed db data.
*
* @since 1.3.1
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Data extends Root
{
const LOG_TAG = '🚀';
private $_db_updater = array(
'3.5.0.3' => array('litespeed_update_3_5'),
'4.0' => array('litespeed_update_4'),
'4.1' => array('litespeed_update_4_1'),
'4.3' => array('litespeed_update_4_3'),
'4.4.4-b1' => array('litespeed_update_4_4_4'),
'5.3-a5' => array('litespeed_update_5_3'),
'7.0-b26' => array('litespeed_update_7'),
'7.0.1-b1' => array('litespeed_update_7_0_1'),
);
private $_db_site_updater = array(
// Example
// '2.0' => array(
// 'litespeed_update_site_2_0',
// ),
);
private $_url_file_types = array(
'css' => 1,
'js' => 2,
'ccss' => 3,
'ucss' => 4,
);
const TB_IMG_OPTM = 'litespeed_img_optm';
const TB_IMG_OPTMING = 'litespeed_img_optming'; // working table
const TB_AVATAR = 'litespeed_avatar';
const TB_CRAWLER = 'litespeed_crawler';
const TB_CRAWLER_BLACKLIST = 'litespeed_crawler_blacklist';
const TB_URL = 'litespeed_url';
const TB_URL_FILE = 'litespeed_url_file';
/**
* Init
*
* @since 1.3.1
*/
public function __construct()
{
}
/**
* Correct table existence
*
* Call when activate -> update_confs()
* Call when update_confs()
*
* @since 3.0
* @access public
*/
public function correct_tb_existence()
{
// Gravatar
if ($this->conf(Base::O_DISCUSS_AVATAR_CACHE)) {
$this->tb_create('avatar');
}
// Crawler
if ($this->conf(Base::O_CRAWLER)) {
$this->tb_create('crawler');
$this->tb_create('crawler_blacklist');
}
// URL mapping
$this->tb_create('url');
$this->tb_create('url_file');
// Image optm is a bit different. Only trigger creation when sending requests. Drop when destroying.
}
/**
* Upgrade conf to latest format version from previous versions
*
* NOTE: Only for v3.0+
*
* @since 3.0
* @access public
*/
public function conf_upgrade($ver)
{
// Skip count check if `Use Primary Site Configurations` is on
// Deprecated since v3.0 as network primary site didn't override the subsites conf yet
// if ( ! is_main_site() && ! empty ( $this->_site_options[ self::NETWORK_O_USE_PRIMARY ] ) ) {
// return;
// }
if ($this->_get_upgrade_lock()) {
return;
}
$this->_set_upgrade_lock(true);
require_once LSCWP_DIR . 'src/data.upgrade.func.php';
// Init log manually
if ($this->conf(Base::O_DEBUG)) {
$this->cls('Debug2')->init();
}
foreach ($this->_db_updater as $k => $v) {
if (version_compare($ver, $k, '<')) {
// run each callback
foreach ($v as $v2) {
self::debug("Updating [ori_v] $ver \t[to] $k \t[func] $v2");
call_user_func($v2);
}
}
}
// Reload options
$this->cls('Conf')->load_options();
$this->correct_tb_existence();
// Update related files
$this->cls('Activation')->update_files();
// Update version to latest
Conf::delete_option(Base::_VER);
Conf::add_option(Base::_VER, Core::VER);
self::debug('Updated version to ' . Core::VER);
$this->_set_upgrade_lock(false);
!defined('LSWCP_EMPTYCACHE') && define('LSWCP_EMPTYCACHE', true); // clear all sites caches
Purge::purge_all();
return 'upgrade';
}
/**
* Upgrade site conf to latest format version from previous versions
*
* NOTE: Only for v3.0+
*
* @since 3.0
* @access public
*/
public function conf_site_upgrade($ver)
{
if ($this->_get_upgrade_lock()) {
return;
}
$this->_set_upgrade_lock(true);
require_once LSCWP_DIR . 'src/data.upgrade.func.php';
foreach ($this->_db_site_updater as $k => $v) {
if (version_compare($ver, $k, '<')) {
// run each callback
foreach ($v as $v2) {
self::debug("Updating site [ori_v] $ver \t[to] $k \t[func] $v2");
call_user_func($v2);
}
}
}
// Reload options
$this->cls('Conf')->load_site_options();
Conf::delete_site_option(Base::_VER);
Conf::add_site_option(Base::_VER, Core::VER);
self::debug('Updated site_version to ' . Core::VER);
$this->_set_upgrade_lock(false);
!defined('LSWCP_EMPTYCACHE') && define('LSWCP_EMPTYCACHE', true); // clear all sites caches
Purge::purge_all();
}
/**
* Check if upgrade script is running or not
*
* @since 3.0.1
*/
private function _get_upgrade_lock()
{
$is_upgrading = get_option('litespeed.data.upgrading');
if (!$is_upgrading) {
$this->_set_upgrade_lock(false); // set option value to existed to avoid repeated db query next time
}
if ($is_upgrading && time() - $is_upgrading < 3600) {
return $is_upgrading;
}
return false;
}
/**
* Show the upgrading banner if upgrade script is running
*
* @since 3.0.1
*/
public function check_upgrading_msg()
{
$is_upgrading = $this->_get_upgrade_lock();
if (!$is_upgrading) {
return;
}
Admin_Display::info(
sprintf(
__('The database has been upgrading in the background since %s. This message will disappear once upgrade is complete.', 'litespeed-cache'),
'<code>' . Utility::readable_time($is_upgrading) . '</code>'
) . ' [LiteSpeed]',
true
);
}
/**
* Set lock for upgrade process
*
* @since 3.0.1
*/
private function _set_upgrade_lock($lock)
{
if (!$lock) {
update_option('litespeed.data.upgrading', -1);
} else {
update_option('litespeed.data.upgrading', time());
}
}
/**
* Upgrade the conf to v3.0 from previous v3.0- data
*
* NOTE: Only for v3.0-
*
* @since 3.0
* @access public
*/
public function try_upgrade_conf_3_0()
{
$previous_options = get_option('litespeed-cache-conf');
if (!$previous_options) {
return 'new';
}
$ver = $previous_options['version'];
!defined('LSCWP_CUR_V') && define('LSCWP_CUR_V', $ver);
// Init log manually
if ($this->conf(Base::O_DEBUG)) {
$this->cls('Debug2')->init();
}
self::debug('Upgrading previous settings [from] ' . $ver . ' [to] v3.0');
if ($this->_get_upgrade_lock()) {
return;
}
$this->_set_upgrade_lock(true);
require_once LSCWP_DIR . 'src/data.upgrade.func.php';
// Here inside will update the version to v3.0
litespeed_update_3_0($ver);
$this->_set_upgrade_lock(false);
self::debug('Upgraded to v3.0');
// Upgrade from 3.0 to latest version
$ver = '3.0';
if (Core::VER != $ver) {
return $this->conf_upgrade($ver);
} else {
// Reload options
$this->cls('Conf')->load_options();
$this->correct_tb_existence();
!defined('LSWCP_EMPTYCACHE') && define('LSWCP_EMPTYCACHE', true); // clear all sites caches
Purge::purge_all();
return 'upgrade';
}
}
/**
* Get the table name
*
* @since 3.0
* @access public
*/
public function tb($tb)
{
global $wpdb;
switch ($tb) {
case 'img_optm':
return $wpdb->prefix . self::TB_IMG_OPTM;
break;
case 'img_optming':
return $wpdb->prefix . self::TB_IMG_OPTMING;
break;
case 'avatar':
return $wpdb->prefix . self::TB_AVATAR;
break;
case 'crawler':
return $wpdb->prefix . self::TB_CRAWLER;
break;
case 'crawler_blacklist':
return $wpdb->prefix . self::TB_CRAWLER_BLACKLIST;
break;
case 'url':
return $wpdb->prefix . self::TB_URL;
break;
case 'url_file':
return $wpdb->prefix . self::TB_URL_FILE;
break;
default:
break;
}
}
/**
* Check if one table exists or not
*
* @since 3.0
* @access public
*/
public function tb_exist($tb)
{
global $wpdb;
return $wpdb->get_var("SHOW TABLES LIKE '" . $this->tb($tb) . "'");
}
/**
* Get data structure of one table
*
* @since 2.0
* @access private
*/
private function _tb_structure($tb)
{
return File::read(LSCWP_DIR . 'src/data_structure/' . $tb . '.sql');
}
/**
* Create img optm table and sync data from wp_postmeta
*
* @since 3.0
* @access public
*/
public function tb_create($tb)
{
global $wpdb;
self::debug2('[Data] Checking table ' . $tb);
// Check if table exists first
if ($this->tb_exist($tb)) {
self::debug2('[Data] Existed');
return;
}
self::debug('Creating ' . $tb);
$sql = sprintf(
'CREATE TABLE IF NOT EXISTS `%1$s` (' . $this->_tb_structure($tb) . ') %2$s;',
$this->tb($tb),
$wpdb->get_charset_collate() // 'DEFAULT CHARSET=utf8'
);
$res = $wpdb->query($sql);
if ($res !== true) {
self::debug('Warning! Creating table failed!', $sql);
Admin_Display::error(Error::msg('failed_tb_creation', array('<code>' . $tb . '</code>', '<code>' . $sql . '</code>')));
}
}
/**
* Drop table
*
* @since 3.0
* @access public
*/
public function tb_del($tb)
{
global $wpdb;
if (!$this->tb_exist($tb)) {
return;
}
self::debug('Deleting table ' . $tb);
$q = 'DROP TABLE IF EXISTS ' . $this->tb($tb);
$wpdb->query($q);
}
/**
* Drop generated tables
*
* @since 3.0
* @access public
*/
public function tables_del()
{
$this->tb_del('avatar');
$this->tb_del('crawler');
$this->tb_del('crawler_blacklist');
$this->tb_del('url');
$this->tb_del('url_file');
// Deleting img_optm only can be done when destroy all optm images
}
/**
* Keep table but clear all data
*
* @since 4.0
*/
public function table_truncate($tb)
{
global $wpdb;
$q = 'TRUNCATE TABLE ' . $this->tb($tb);
$wpdb->query($q);
}
/**
* Clean certain type of url_file
*
* @since 4.0
*/
public function url_file_clean($file_type)
{
global $wpdb;
if (!$this->tb_exist('url_file')) {
return;
}
$type = $this->_url_file_types[$file_type];
$q = 'DELETE FROM ' . $this->tb('url_file') . ' WHERE `type` = %d';
$wpdb->query($wpdb->prepare($q, $type));
// Added to cleanup url table. See issue: https://wordpress.org/support/topic/wp_litespeed_url-1-1-gb-in-db-huge-big/
$wpdb->query(
'DELETE d
FROM `' .
$this->tb('url') .
'` AS d
LEFT JOIN `' .
$this->tb('url_file') .
'` AS f ON d.`id` = f.`url_id`
WHERE f.`url_id` IS NULL'
);
}
/**
* Generate filename based on URL, if content md5 existed, reuse existing file.
* @since 4.0
*/
public function save_url($request_url, $vary, $file_type, $filecon_md5, $path, $mobile = false, $webp = false)
{
global $wpdb;
if (strlen($vary) > 32) {
$vary = md5($vary);
}
$type = $this->_url_file_types[$file_type];
$tb_url = $this->tb('url');
$tb_url_file = $this->tb('url_file');
$q = "SELECT * FROM `$tb_url` WHERE url=%s";
$url_row = $wpdb->get_row($wpdb->prepare($q, $request_url), ARRAY_A);
if (!$url_row) {
$q = "INSERT INTO `$tb_url` SET url=%s";
$wpdb->query($wpdb->prepare($q, $request_url));
$url_id = $wpdb->insert_id;
} else {
$url_id = $url_row['id'];
}
$q = "SELECT * FROM `$tb_url_file` WHERE url_id=%d AND vary=%s AND type=%d AND expired=0";
$file_row = $wpdb->get_row($wpdb->prepare($q, array($url_id, $vary, $type)), ARRAY_A);
// Check if has previous file or not
if ($file_row && $file_row['filename'] == $filecon_md5) {
return;
}
// If the new $filecon_md5 is marked as expired by previous records, clear those records
$q = "DELETE FROM `$tb_url_file` WHERE filename = %s AND expired > 0";
$wpdb->query($wpdb->prepare($q, $filecon_md5));
// Check if there is any other record used the same filename or not
$q = "SELECT id FROM `$tb_url_file` WHERE filename = %s AND expired = 0 AND id != %d LIMIT 1";
if ($file_row && $wpdb->get_var($wpdb->prepare($q, array($file_row['filename'], $file_row['id'])))) {
$q = "UPDATE `$tb_url_file` SET filename=%s WHERE id=%d";
$wpdb->query($wpdb->prepare($q, array($filecon_md5, $file_row['id'])));
return;
}
// New record needed
$q = "INSERT INTO `$tb_url_file` SET url_id=%d, vary=%s, filename=%s, type=%d, mobile=%d, webp=%d, expired=0";
$wpdb->query($wpdb->prepare($q, array($url_id, $vary, $filecon_md5, $type, $mobile ? 1 : 0, $webp ? 1 : 0)));
// Mark existing rows as expired
if ($file_row) {
$q = "UPDATE `$tb_url_file` SET expired=%d WHERE id=%d";
$expired = time() + 86400 * apply_filters('litespeed_url_file_expired_days', 20);
$wpdb->query($wpdb->prepare($q, array($expired, $file_row['id'])));
// Also check if has other files expired already to be deleted
$q = "SELECT * FROM `$tb_url_file` WHERE url_id = %d AND expired BETWEEN 1 AND %d";
$q = $wpdb->prepare($q, array($url_id, time()));
$list = $wpdb->get_results($q, ARRAY_A);
if ($list) {
foreach ($list as $v) {
$file_to_del = $path . '/' . $v['filename'] . '.' . ($file_type == 'js' ? 'js' : 'css');
if (file_exists($file_to_del)) {
// Safe to delete
self::debug('Delete expired unused file: ' . $file_to_del);
// Clear related lscache first to avoid cache copy of same URL w/ diff QS
// Purge::add( Tag::TYPE_MIN . '.' . $file_row[ 'filename' ] . '.' . $file_type );
unlink($file_to_del);
}
}
$q = "DELETE FROM `$tb_url_file` WHERE url_id = %d AND expired BETWEEN 1 AND %d";
$wpdb->query($wpdb->prepare($q, array($url_id, time())));
}
}
// Purge this URL to avoid cache copy of same URL w/ diff QS
// $this->cls( 'Purge' )->purge_url( Utility::make_relative( $request_url ) ?: '/', true, true );
}
/**
* Load CCSS related file
* @since 4.0
*/
public function load_url_file($request_url, $vary, $file_type)
{
global $wpdb;
if (strlen($vary) > 32) {
$vary = md5($vary);
}
$type = $this->_url_file_types[$file_type];
self::debug2('load url file: ' . $request_url);
$tb_url = $this->tb('url');
$q = "SELECT * FROM `$tb_url` WHERE url=%s";
$url_row = $wpdb->get_row($wpdb->prepare($q, $request_url), ARRAY_A);
if (!$url_row) {
return false;
}
$url_id = $url_row['id'];
$tb_url_file = $this->tb('url_file');
$q = "SELECT * FROM `$tb_url_file` WHERE url_id=%d AND vary=%s AND type=%d AND expired=0";
$file_row = $wpdb->get_row($wpdb->prepare($q, array($url_id, $vary, $type)), ARRAY_A);
if (!$file_row) {
return false;
}
return $file_row['filename'];
}
/**
* Mark all entries of one URL to expired
* @since 4.5
*/
public function mark_as_expired($request_url, $auto_q = false)
{
global $wpdb;
$tb_url = $this->tb('url');
self::debug('Try to mark as expired: ' . $request_url);
$q = "SELECT * FROM `$tb_url` WHERE url=%s";
$url_row = $wpdb->get_row($wpdb->prepare($q, $request_url), ARRAY_A);
if (!$url_row) {
return;
}
self::debug('Mark url_id=' . $url_row['id'] . ' as expired');
$tb_url_file = $this->tb('url_file');
$existing_url_files = array();
if ($auto_q) {
$q = "SELECT a.*, b.url FROM `$tb_url_file` a LEFT JOIN `$tb_url` b ON b.id=a.url_id WHERE a.url_id=%d AND a.type=4 AND a.expired=0";
$q = $wpdb->prepare($q, $url_row['id']);
$existing_url_files = $wpdb->get_results($q, ARRAY_A);
}
$q = "UPDATE `$tb_url_file` SET expired=%d WHERE url_id=%d AND type=4 AND expired=0";
$expired = time() + 86400 * apply_filters('litespeed_url_file_expired_days', 20);
$wpdb->query($wpdb->prepare($q, array($expired, $url_row['id'])));
return $existing_url_files;
}
/**
* Get list from `data/css_excludes.txt`
*
* @since 3.6
*/
public function load_css_exc($list)
{
$data = $this->_load_per_line('css_excludes.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/ccss_whitelist.txt`
*
* @since 7.1
*/
public function load_ccss_whitelist($list)
{
$data = $this->_load_per_line('ccss_whitelist.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/ucss_whitelist.txt`
*
* @since 4.0
*/
public function load_ucss_whitelist($list)
{
$data = $this->_load_per_line('ucss_whitelist.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/js_excludes.txt`
*
* @since 3.5
*/
public function load_js_exc($list)
{
$data = $this->_load_per_line('js_excludes.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/js_defer_excludes.txt`
*
* @since 3.6
*/
public function load_js_defer_exc($list)
{
$data = $this->_load_per_line('js_defer_excludes.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/optm_uri_exc.txt`
*
* @since 5.4
*/
public function load_optm_uri_exc($list)
{
$data = $this->_load_per_line('optm_uri_exc.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/esi.nonces.txt`
*
* @since 3.5
*/
public function load_esi_nonces($list)
{
$data = $this->_load_per_line('esi.nonces.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Get list from `data/cache_nocacheable.txt`
*
* @since 6.3.0.1
*/
public function load_cache_nocacheable($list)
{
$data = $this->_load_per_line('cache_nocacheable.txt');
if ($data) {
$list = array_unique(array_filter(array_merge($list, $data)));
}
return $list;
}
/**
* Load file per line
*
* Support two kinds of comments:
* 1. `# this is comment`
* 2. `##this is comment`
*
* @since 3.5
*/
private function _load_per_line($file)
{
$data = File::read(LSCWP_DIR . 'data/' . $file);
$data = explode(PHP_EOL, $data);
$list = array();
foreach ($data as $v) {
// Drop two kinds of comments
if (strpos($v, '##') !== false) {
$v = trim(substr($v, 0, strpos($v, '##')));
}
if (strpos($v, '# ') !== false) {
$v = trim(substr($v, 0, strpos($v, '# ')));
}
if (!$v) {
continue;
}
$list[] = $v;
}
return $list;
}
}
data.upgrade.func.php 0000644 00000056156 15153741266 0010575 0 ustar 00 <?php
/**
* Database upgrade funcs
*
* NOTE: whenever called this file, always call Data::get_upgrade_lock and Data::_set_upgrade_lock first.
*
* @since 3.0
*/
defined('WPINC') || exit();
use LiteSpeed\Debug2;
use LiteSpeed\Conf;
use LiteSpeed\Admin_Display;
use LiteSpeed\File;
use LiteSpeed\Cloud;
/**
* Migrate v7.0- url_files URL from no trailing slash to trailing slash
* @since 7.0.1
*/
function litespeed_update_7_0_1()
{
global $wpdb;
Debug2::debug('[Data] v7.0.1 upgrade started');
$tb_url = $wpdb->prefix . 'litespeed_url';
$tb_exists = $wpdb->get_var("SHOW TABLES LIKE '" . $tb_url . "'");
if (!$tb_exists) {
Debug2::debug('[Data] Table `litespeed_url` not found, bypassed migration');
return;
}
$q = "SELECT * FROM `$tb_url` WHERE url LIKE 'https://%/'";
$q = $wpdb->prepare($q);
$list = $wpdb->get_results($q, ARRAY_A);
$existing_urls = array();
if ($list) {
foreach ($list as $v) {
$existing_urls[] = $v['url'];
}
}
$q = "SELECT * FROM `$tb_url` WHERE url LIKE 'https://%'";
$q = $wpdb->prepare($q);
$list = $wpdb->get_results($q, ARRAY_A);
if (!$list) {
return;
}
foreach ($list as $v) {
if (substr($v['url'], -1) == '/') {
continue;
}
$new_url = $v['url'] . '/';
if (in_array($new_url, $existing_urls)) {
continue;
}
$q = "UPDATE `$tb_url` SET url = %s WHERE id = %d";
$q = $wpdb->prepare($q, $new_url, $v['id']);
$wpdb->query($q);
}
}
/**
* Migrate from domain key to pk/sk for QC
* @since 7.0
*/
function litespeed_update_7()
{
Debug2::debug('[Data] v7 upgrade started');
$__cloud = Cloud::cls();
$domain_key = $__cloud->conf('api_key');
if (!$domain_key) {
Debug2::debug('[Data] No domain key, bypassed migration');
return;
}
$new_prepared = $__cloud->init_qc_prepare();
if (!$new_prepared && $__cloud->activated()) {
Debug2::debug('[Data] QC previously activated in v7, bypassed migration');
return;
}
$data = array(
'domain_key' => $domain_key,
);
$resp = $__cloud->post(Cloud::SVC_D_V3UPGRADE, $data);
if (!empty($resp['qc_activated'])) {
if ($resp['qc_activated'] != 'deleted') {
$cloud_summary_updates = array('qc_activated' => $resp['qc_activated']);
if (!empty($resp['main_domain'])) {
$cloud_summary_updates['main_domain'] = $resp['main_domain'];
}
Cloud::save_summary($cloud_summary_updates);
Debug2::debug('[Data] Updated QC activated status to ' . $resp['qc_activated']);
}
}
}
/**
* Append webp/mobile to url_file
* @since 5.3
*/
function litespeed_update_5_3()
{
global $wpdb;
Debug2::debug('[Data] Upgrade url_file table');
$tb_exists = $wpdb->get_var('SHOW TABLES LIKE "' . $wpdb->prefix . 'litespeed_url_file"');
if ($tb_exists) {
$q =
'ALTER TABLE `' .
$wpdb->prefix .
'litespeed_url_file`
ADD COLUMN `mobile` tinyint(4) NOT NULL COMMENT "mobile=1",
ADD COLUMN `webp` tinyint(4) NOT NULL COMMENT "webp=1"
';
$wpdb->query($q);
}
}
/**
* Add expired to url_file table
* @since 4.4.4
*/
function litespeed_update_4_4_4()
{
global $wpdb;
Debug2::debug('[Data] Upgrade url_file table');
$tb_exists = $wpdb->get_var('SHOW TABLES LIKE "' . $wpdb->prefix . 'litespeed_url_file"');
if ($tb_exists) {
$q =
'ALTER TABLE `' .
$wpdb->prefix .
'litespeed_url_file`
ADD COLUMN `expired` int(11) NOT NULL DEFAULT 0,
ADD KEY `filename_2` (`filename`,`expired`),
ADD KEY `url_id` (`url_id`,`expired`)
';
$wpdb->query($q);
}
}
/**
* Drop cssjs table and rm cssjs folder
* @since 4.3
*/
function litespeed_update_4_3()
{
if (file_exists(LITESPEED_STATIC_DIR . '/ccsjs')) {
File::rrmdir(LITESPEED_STATIC_DIR . '/ccsjs');
}
}
/**
* Drop object cache data file
* @since 4.1
*/
function litespeed_update_4_1()
{
if (file_exists(WP_CONTENT_DIR . '/.object-cache.ini')) {
unlink(WP_CONTENT_DIR . '/.object-cache.ini');
}
}
/**
* Drop cssjs table and rm cssjs folder
* @since 4.0
*/
function litespeed_update_4()
{
global $wpdb;
$tb = $wpdb->prefix . 'litespeed_cssjs';
$existed = $wpdb->get_var("SHOW TABLES LIKE '$tb'");
if (!$existed) {
return;
}
$q = 'DROP TABLE IF EXISTS ' . $tb;
$wpdb->query($q);
if (file_exists(LITESPEED_STATIC_DIR . '/ccsjs')) {
File::rrmdir(LITESPEED_STATIC_DIR . '/ccsjs');
}
}
/**
* Append jQuery to JS optm exclude list for max compatibility
* Turn off JS Combine and Defer
*
* @since 3.5.1
*/
function litespeed_update_3_5()
{
$__conf = Conf::cls();
// Excludes jQuery
foreach (array('optm-js_exc', 'optm-js_defer_exc') as $v) {
$curr_setting = $__conf->conf($v);
$curr_setting[] = 'jquery.js';
$curr_setting[] = 'jquery.min.js';
$__conf->update($v, $curr_setting);
}
// Turn off JS Combine and defer
$show_msg = false;
foreach (array('optm-js_comb', 'optm-js_defer', 'optm-js_inline_defer') as $v) {
$curr_setting = $__conf->conf($v);
if (!$curr_setting) {
continue;
}
$show_msg = true;
$__conf->update($v, false);
}
if ($show_msg) {
$msg = sprintf(
__(
'LiteSpeed Cache upgraded successfully. NOTE: Due to changes in this version, the settings %1$s and %2$s have been turned OFF. Please turn them back on manually and verify that your site layout is correct, and you have no JS errors.',
'litespeed-cache'
),
'<code>' . __('JS Combine', 'litespeed-cache') . '</code>',
'<code>' . __('JS Defer', 'litespeed-cache') . '</code>'
);
$msg .= sprintf(' <a href="admin.php?page=litespeed-page_optm#settings_js">%s</a>.', __('Click here to settings', 'litespeed-cache'));
Admin_Display::info($msg, false, true);
}
}
/**
* For version under v2.0 to v2.0+
*
* @since 3.0
*/
function litespeed_update_2_0($ver)
{
global $wpdb;
// Table version only exists after all old data migrated
// Last modified is v2.4.2
if (version_compare($ver, '2.4.2', '<')) {
/**
* Convert old data from postmeta to img_optm table
* @since 2.0
*/
// Migrate data from `wp_postmeta` to `wp_litespeed_img_optm`
$mids_to_del = array();
$q = "SELECT * FROM $wpdb->postmeta WHERE meta_key = %s ORDER BY meta_id";
$meta_value_list = $wpdb->get_results($wpdb->prepare($q, 'litespeed-optimize-data'));
if ($meta_value_list) {
$max_k = count($meta_value_list) - 1;
foreach ($meta_value_list as $k => $v) {
$mids_to_del[] = $v->meta_id;
// Delete from postmeta
if (count($mids_to_del) > 100 || $k == $max_k) {
$q = "DELETE FROM $wpdb->postmeta WHERE meta_id IN ( " . implode(',', array_fill(0, count($mids_to_del), '%s')) . ' ) ';
$wpdb->query($wpdb->prepare($q, $mids_to_del));
$mids_to_del = array();
}
}
Debug2::debug('[Data] img_optm inserted records: ' . $k);
}
$q = "DELETE FROM $wpdb->postmeta WHERE meta_key = %s";
$rows = $wpdb->query($wpdb->prepare($q, 'litespeed-optimize-status'));
Debug2::debug('[Data] img_optm delete optm_status records: ' . $rows);
}
/**
* Add target_md5 field to table
* @since 2.4.2
*/
if (version_compare($ver, '2.4.2', '<') && version_compare($ver, '2.0', '>=')) {
// NOTE: For new users, need to bypass this section
$sql = sprintf('ALTER TABLE `%1$s` ADD `server_info` text NOT NULL, DROP COLUMN `server`', $wpdb->prefix . 'litespeed_img_optm');
$res = $wpdb->query($sql);
if ($res !== true) {
Debug2::debug('[Data] Warning: Alter table img_optm failed!', $sql);
} else {
Debug2::debug('[Data] Successfully upgraded table img_optm.');
}
}
// Delete img optm tb version
delete_option($wpdb->prefix . 'litespeed_img_optm');
// Delete possible HTML optm data from wp_options
delete_option('litespeed-cache-optimized');
// Delete HTML optm tb version
delete_option($wpdb->prefix . 'litespeed_optimizer');
}
/**
* Move all options in litespeed-cache-conf from v3.0- to separate records
*
* @since 3.0
*/
function litespeed_update_3_0($ver)
{
global $wpdb;
// Upgrade v2.0- to v2.0 first
if (version_compare($ver, '2.0', '<')) {
litespeed_update_2_0($ver);
}
set_time_limit(86400);
// conv items to litespeed.conf.*
Debug2::debug('[Data] Conv items to litespeed.conf.*');
$data = array(
'litespeed-cache-exclude-cache-roles' => 'cache-exc_roles',
'litespeed-cache-drop_qs' => 'cache-drop_qs',
'litespeed-forced_cache_uri' => 'cache-force_uri',
'litespeed-cache_uri_priv' => 'cache-priv_uri',
'litespeed-excludes_uri' => 'cache-exc',
'litespeed-cache-vary-group' => 'cache-vary_group',
'litespeed-adv-purge_all_hooks' => 'purge-hook_all',
'litespeed-object_global_groups' => 'object-global_groups',
'litespeed-object_non_persistent_groups' => 'object-non_persistent_groups',
'litespeed-media-lazy-img-excludes' => 'media-lazy_exc',
'litespeed-media-lazy-img-cls-excludes' => 'media-lazy_cls_exc',
'litespeed-media-webp_attribute' => 'img_optm-webp_attr',
'litespeed-optm-css' => 'optm-ccss_con',
'litespeed-optm_excludes' => 'optm-exc',
'litespeed-optm-ccss-separate_posttype' => 'optm-ccss_sep_posttype',
'litespeed-optm-css-separate_uri' => 'optm-ccss_sep_uri',
'litespeed-optm-js-defer-excludes' => 'optm-js_defer_exc',
'litespeed-cache-dns_prefetch' => 'optm-dns_prefetch',
'litespeed-cache-exclude-optimization-roles' => 'optm-exc_roles',
'litespeed-log_ignore_filters' => 'debug-log_no_filters', // depreciated
'litespeed-log_ignore_part_filters' => 'debug-log_no_part_filters', // depreciated
'litespeed-cdn-ori_dir' => 'cdn-ori_dir',
'litespeed-cache-cdn_mapping' => 'cdn-mapping',
'litespeed-crawler-as-uids' => 'crawler-roles',
'litespeed-crawler-cookies' => 'crawler-cookies',
);
foreach ($data as $k => $v) {
$old_data = get_option($k);
if ($old_data) {
Debug2::debug("[Data] Convert $k");
// They must be an array
if (!is_array($old_data) && $v != 'optm-ccss_con') {
$old_data = explode("\n", $old_data);
}
if ($v == 'crawler-cookies') {
$tmp = array();
$i = 0;
foreach ($old_data as $k2 => $v2) {
$tmp[$i]['name'] = $k2;
$tmp[$i]['vals'] = explode("\n", $v2);
$i++;
}
$old_data = $tmp;
}
add_option('litespeed.conf.' . $v, $old_data);
}
Debug2::debug("[Data] Delete $k");
delete_option($k);
}
// conv other items
$data = array(
'litespeed-setting-mode' => 'litespeed.setting.mode',
'litespeed-media-need-pull' => 'litespeed.img_optm.need_pull',
'litespeed-env-ref' => 'litespeed.env.ref',
'litespeed-cache-cloudflare_status' => 'litespeed.cdn.cloudflare.status',
);
foreach ($data as $k => $v) {
$old_data = get_option($k);
if ($old_data) {
add_option($v, $old_data);
}
delete_option($k);
}
// Conv conf from litespeed-cache-conf child to litespeed.conf.*
Debug2::debug('[Data] Conv conf from litespeed-cache-conf child to litespeed.conf.*');
$previous_options = get_option('litespeed-cache-conf');
$data = array(
'radio_select' => 'cache',
'hash' => 'hash',
'auto_upgrade' => 'auto_upgrade',
'news' => 'news',
'crawler_domain_ip' => 'server_ip',
'esi_enabled' => 'esi',
'esi_cached_admbar' => 'esi-cache_admbar',
'esi_cached_commform' => 'esi-cache_commform',
'heartbeat' => 'misc-heartbeat_front',
'cache_browser' => 'cache-browser',
'cache_browser_ttl' => 'cache-ttl_browser',
'instant_click' => 'util-instant_click',
'use_http_for_https_vary' => 'util-no_https_vary',
'purge_upgrade' => 'purge-upgrade',
'timed_urls' => 'purge-timed_urls',
'timed_urls_time' => 'purge-timed_urls_time',
'cache_priv' => 'cache-priv',
'cache_commenter' => 'cache-commenter',
'cache_rest' => 'cache-rest',
'cache_page_login' => 'cache-page_login',
'cache_favicon' => 'cache-favicon',
'cache_resources' => 'cache-resources',
'mobileview_enabled' => 'cache-mobile',
'mobileview_rules' => 'cache-mobile_rules',
'nocache_useragents' => 'cache-exc_useragents',
'nocache_cookies' => 'cache-exc_cookies',
'excludes_qs' => 'cache-exc_qs',
'excludes_cat' => 'cache-exc_cat',
'excludes_tag' => 'cache-exc_tag',
'public_ttl' => 'cache-ttl_pub',
'private_ttl' => 'cache-ttl_priv',
'front_page_ttl' => 'cache-ttl_frontpage',
'feed_ttl' => 'cache-ttl_feed',
'login_cookie' => 'cache-login_cookie',
'debug_disable_all' => 'debug-disable_all',
'debug' => 'debug',
'admin_ips' => 'debug-ips',
'debug_level' => 'debug-level',
'log_file_size' => 'debug-filesize',
'debug_cookie' => 'debug-cookie',
'collapse_qs' => 'debug-collapse_qs',
// 'log_filters' => 'debug-log_filters',
'crawler_cron_active' => 'crawler',
// 'crawler_include_posts' => 'crawler-inc_posts',
// 'crawler_include_pages' => 'crawler-inc_pages',
// 'crawler_include_cats' => 'crawler-inc_cats',
// 'crawler_include_tags' => 'crawler-inc_tags',
// 'crawler_excludes_cpt' => 'crawler-exc_cpt',
// 'crawler_order_links' => 'crawler-order_links',
'crawler_usleep' => 'crawler-usleep',
'crawler_run_duration' => 'crawler-run_duration',
'crawler_run_interval' => 'crawler-run_interval',
'crawler_crawl_interval' => 'crawler-crawl_interval',
'crawler_threads' => 'crawler-threads',
'crawler_load_limit' => 'crawler-load_limit',
'crawler_custom_sitemap' => 'crawler-sitemap',
'cache_object' => 'object',
'cache_object_kind' => 'object-kind',
'cache_object_host' => 'object-host',
'cache_object_port' => 'object-port',
'cache_object_life' => 'object-life',
'cache_object_persistent' => 'object-persistent',
'cache_object_admin' => 'object-admin',
'cache_object_transients' => 'object-transients',
'cache_object_db_id' => 'object-db_id',
'cache_object_user' => 'object-user',
'cache_object_pswd' => 'object-psw',
'cdn' => 'cdn',
'cdn_ori' => 'cdn-ori',
'cdn_exclude' => 'cdn-exc',
// 'cdn_remote_jquery' => 'cdn-remote_jq',
'cdn_quic' => 'cdn-quic',
'cdn_cloudflare' => 'cdn-cloudflare',
'cdn_cloudflare_email' => 'cdn-cloudflare_email',
'cdn_cloudflare_key' => 'cdn-cloudflare_key',
'cdn_cloudflare_name' => 'cdn-cloudflare_name',
'cdn_cloudflare_zone' => 'cdn-cloudflare_zone',
'media_img_lazy' => 'media-lazy',
'media_img_lazy_placeholder' => 'media-lazy_placeholder',
'media_placeholder_resp' => 'media-placeholder_resp',
'media_placeholder_resp_color' => 'media-placeholder_resp_color',
'media_placeholder_resp_async' => 'media-placeholder_resp_async',
'media_iframe_lazy' => 'media-iframe_lazy',
// 'media_img_lazyjs_inline' => 'media-lazyjs_inline',
'media_optm_auto' => 'img_optm-auto',
'media_optm_cron' => 'img_optm-cron',
'media_optm_ori' => 'img_optm-ori',
'media_rm_ori_bkup' => 'img_optm-rm_bkup',
// 'media_optm_webp' => 'img_optm-webp',
'media_webp_replace' => 'img_optm-webp',
'media_optm_lossless' => 'img_optm-lossless',
'media_optm_exif' => 'img_optm-exif',
'media_webp_replace_srcset' => 'img_optm-webp_replace_srcset',
'css_minify' => 'optm-css_min',
// 'css_inline_minify' => 'optm-css_inline_min',
'css_combine' => 'optm-css_comb',
// 'css_combined_priority' => 'optm-css_comb_priority',
// 'css_http2' => 'optm-css_http2',
'css_exclude' => 'optm-css_exc',
'js_minify' => 'optm-js_min',
// 'js_inline_minify' => 'optm-js_inline_min',
'js_combine' => 'optm-js_comb',
// 'js_combined_priority' => 'optm-js_comb_priority',
// 'js_http2' => 'optm-js_http2',
'js_exclude' => 'optm-js_exc',
// 'optimize_ttl' => 'optm-ttl',
'html_minify' => 'optm-html_min',
'optm_qs_rm' => 'optm-qs_rm',
'optm_ggfonts_rm' => 'optm-ggfonts_rm',
'optm_css_async' => 'optm-css_async',
// 'optm_ccss_gen' => 'optm-ccss_gen',
// 'optm_ccss_async' => 'optm-ccss_async',
'optm_css_async_inline' => 'optm-css_async_inline',
'optm_js_defer' => 'optm-js_defer',
'optm_emoji_rm' => 'optm-emoji_rm',
// 'optm_exclude_jquery' => 'optm-exc_jq',
'optm_ggfonts_async' => 'optm-ggfonts_async',
// 'optm_max_size' => 'optm-max_size',
// 'optm_rm_comment' => 'optm-rm_comment',
);
foreach ($data as $k => $v) {
if (!isset($previous_options[$k])) {
continue;
}
// The following values must be array
if (!is_array($previous_options[$k])) {
if (in_array($v, array('cdn-ori', 'cache-exc_cat', 'cache-exc_tag'))) {
$previous_options[$k] = explode(',', $previous_options[$k]);
$previous_options[$k] = array_filter($previous_options[$k]);
} elseif (in_array($v, array('cache-mobile_rules', 'cache-exc_useragents', 'cache-exc_cookies'))) {
$previous_options[$k] = explode('|', str_replace('\\ ', ' ', $previous_options[$k]));
$previous_options[$k] = array_filter($previous_options[$k]);
} elseif (
in_array($v, array(
'purge-timed_urls',
'cache-exc_qs',
'debug-ips',
// 'crawler-exc_cpt',
'cdn-exc',
'optm-css_exc',
'optm-js_exc',
))
) {
$previous_options[$k] = explode("\n", $previous_options[$k]);
$previous_options[$k] = array_filter($previous_options[$k]);
}
}
// Special handler for heartbeat
if ($v == 'misc-heartbeat_front') {
if (!$previous_options[$k]) {
add_option('litespeed.conf.misc-heartbeat_front', true);
add_option('litespeed.conf.misc-heartbeat_back', true);
add_option('litespeed.conf.misc-heartbeat_editor', true);
add_option('litespeed.conf.misc-heartbeat_front_ttl', 0);
add_option('litespeed.conf.misc-heartbeat_back_ttl', 0);
add_option('litespeed.conf.misc-heartbeat_editor_ttl', 0);
}
continue;
}
add_option('litespeed.conf.' . $v, $previous_options[$k]);
}
// Conv purge_by_post
$data = array(
'-' => 'purge-post_all',
'F' => 'purge-post_f',
'H' => 'purge-post_h',
'PGS' => 'purge-post_p',
'PGSRP' => 'purge-post_pwrp',
'A' => 'purge-post_a',
'Y' => 'purge-post_y',
'M' => 'purge-post_m',
'D' => 'purge-post_d',
'T' => 'purge-post_t',
'PT' => 'purge-post_pt',
);
if (isset($previous_options['purge_by_post'])) {
$purge_by_post = explode('.', $previous_options['purge_by_post']);
foreach ($data as $k => $v) {
add_option('litespeed.conf.' . $v, in_array($k, $purge_by_post));
}
}
// Conv 404/403/500 TTL
$ttl_status = array();
if (isset($previous_options['403_ttl'])) {
$ttl_status[] = '403 ' . $previous_options['403_ttl'];
}
if (isset($previous_options['404_ttl'])) {
$ttl_status[] = '404 ' . $previous_options['404_ttl'];
}
if (isset($previous_options['500_ttl'])) {
$ttl_status[] = '500 ' . $previous_options['500_ttl'];
}
add_option('litespeed.conf.cache-ttl_status', $ttl_status);
/**
* Resave cdn cfg from lscfg to separate cfg when upgrade to v1.7
*
* NOTE: this can be left here as `add_option` bcos it is after the item `litespeed-cache-cdn_mapping` is converted
*
* @since 1.7
*/
if (isset($previous_options['cdn_url'])) {
$cdn_mapping = array(
'url' => $previous_options['cdn_url'],
'inc_img' => $previous_options['cdn_inc_img'],
'inc_css' => $previous_options['cdn_inc_css'],
'inc_js' => $previous_options['cdn_inc_js'],
'filetype' => $previous_options['cdn_filetype'],
);
add_option('litespeed.conf.cdn-mapping', array($cdn_mapping));
Debug2::debug('[Data] plugin_upgrade option adding CDN map');
}
/**
* Move Exclude settings to separate item
*
* NOTE: this can be left here as `add_option` bcos it is after the relevant items are converted
*
* @since 2.3
*/
if (isset($previous_options['forced_cache_uri'])) {
add_option('litespeed.conf.cache-force_uri', $previous_options['forced_cache_uri']);
}
if (isset($previous_options['cache_uri_priv'])) {
add_option('litespeed.conf.cache-priv_uri', $previous_options['cache_uri_priv']);
}
if (isset($previous_options['optm_excludes'])) {
add_option('litespeed.conf.optm-exc', $previous_options['optm_excludes']);
}
if (isset($previous_options['excludes_uri'])) {
add_option('litespeed.conf.cache-exc', $previous_options['excludes_uri']);
}
// Backup stale conf
Debug2::debug('[Data] Backup stale conf');
delete_option('litespeed-cache-conf');
add_option('litespeed-cache-conf.bk', $previous_options);
// Upgrade site_options if is network
if (is_multisite()) {
$ver = get_site_option('litespeed.conf._version');
if (!$ver) {
Debug2::debug('[Data] Conv multisite');
$previous_site_options = get_site_option('litespeed-cache-conf');
$data = array(
'network_enabled' => 'cache',
'use_primary_settings' => 'use_primary_settings',
'auto_upgrade' => 'auto_upgrade',
'purge_upgrade' => 'purge-upgrade',
'cache_favicon' => 'cache-favicon',
'cache_resources' => 'cache-resources',
'mobileview_enabled' => 'cache-mobile',
'mobileview_rules' => 'cache-mobile_rules',
'login_cookie' => 'cache-login_cookie',
'nocache_cookies' => 'cache-exc_cookies',
'nocache_useragents' => 'cache-exc_useragents',
'cache_object' => 'object',
'cache_object_kind' => 'object-kind',
'cache_object_host' => 'object-host',
'cache_object_port' => 'object-port',
'cache_object_life' => 'object-life',
'cache_object_persistent' => 'object-persistent',
'cache_object_admin' => 'object-admin',
'cache_object_transients' => 'object-transients',
'cache_object_db_id' => 'object-db_id',
'cache_object_user' => 'object-user',
'cache_object_pswd' => 'object-psw',
'cache_browser' => 'cache-browser',
'cache_browser_ttl' => 'cache-ttl_browser',
'media_webp_replace' => 'img_optm-webp',
);
foreach ($data as $k => $v) {
if (!isset($previous_site_options[$k])) {
continue;
}
// The following values must be array
if (!is_array($previous_site_options[$k])) {
if (in_array($v, array('cache-mobile_rules', 'cache-exc_useragents', 'cache-exc_cookies'))) {
$previous_site_options[$k] = explode('|', str_replace('\\ ', ' ', $previous_site_options[$k]));
$previous_site_options[$k] = array_filter($previous_site_options[$k]);
}
}
add_site_option('litespeed.conf.' . $v, $previous_site_options[$k]);
}
// These are already converted to single record in single site
$data = array('object-global_groups', 'object-non_persistent_groups');
foreach ($data as $v) {
$old_data = get_option($v);
if ($old_data) {
add_site_option('litespeed.conf.' . $v, $old_data);
}
}
delete_site_option('litespeed-cache-conf');
add_site_option('litespeed.conf._version', '3.0');
}
}
// delete tables
Debug2::debug('[Data] Drop litespeed_optimizer');
$q = 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'litespeed_optimizer';
$wpdb->query($q);
// Update image optm table
Debug2::debug('[Data] Upgrade img_optm table');
$tb_exists = $wpdb->get_var('SHOW TABLES LIKE "' . $wpdb->prefix . 'litespeed_img_optm"');
if ($tb_exists) {
$status_mapping = array(
'requested' => 3,
'notified' => 6,
'pulled' => 9,
'failed' => -1,
'miss' => -3,
'err' => -9,
'err_fetch' => -5,
'err_optm' => -7,
'xmeta' => -8,
);
foreach ($status_mapping as $k => $v) {
$q = 'UPDATE `' . $wpdb->prefix . "litespeed_img_optm` SET optm_status='$v' WHERE optm_status='$k'";
$wpdb->query($q);
}
$q =
'ALTER TABLE `' .
$wpdb->prefix .
'litespeed_img_optm`
DROP INDEX `post_id_2`,
DROP INDEX `root_id`,
DROP INDEX `src_md5`,
DROP INDEX `srcpath_md5`,
DROP COLUMN `srcpath_md5`,
DROP COLUMN `src_md5`,
DROP COLUMN `root_id`,
DROP COLUMN `target_saved`,
DROP COLUMN `webp_saved`,
DROP COLUMN `server_info`,
MODIFY COLUMN `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
MODIFY COLUMN `optm_status` tinyint(4) NOT NULL DEFAULT 0,
MODIFY COLUMN `src` text COLLATE utf8mb4_unicode_ci NOT NULL
';
$wpdb->query($q);
}
delete_option('litespeed-recommended');
Debug2::debug('[Data] litespeed_update_3_0 done!');
add_option('litespeed.conf._version', '3.0');
}
data_structure/avatar.sql 0000644 00000000404 15153741266 0011604 0 ustar 00 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`url` varchar(1000) NOT NULL DEFAULT '',
`md5` varchar(128) NOT NULL DEFAULT '',
`dateline` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `md5` (`md5`),
KEY `dateline` (`dateline`)
data_structure/crawler.sql 0000644 00000000632 15153741266 0011770 0 ustar 00 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`url` varchar(1000) NOT NULL DEFAULT '',
`res` varchar(255) NOT NULL DEFAULT '' COMMENT '-=not crawl, H=hit, M=miss, B=blacklist',
`reason` text NOT NULL COMMENT 'response code, comma separated',
`mtime` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `url` (`url`(191)),
KEY `res` (`res`)
data_structure/crawler_blacklist.sql 0000644 00000000626 15153741266 0014023 0 ustar 00 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`url` varchar(1000) NOT NULL DEFAULT '',
`res` varchar(255) NOT NULL DEFAULT '' COMMENT '-=Not Blacklist, B=blacklist',
`reason` text NOT NULL COMMENT 'Reason for blacklist, comma separated',
`mtime` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `url` (`url`(191)),
KEY `res` (`res`)
data_structure/img_optm.sql 0000644 00000000632 15153741266 0012144 0 ustar 00 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT '0',
`optm_status` tinyint(4) NOT NULL DEFAULT '0',
`src` text NOT NULL,
`src_filesize` int(11) NOT NULL DEFAULT '0',
`target_filesize` int(11) NOT NULL DEFAULT '0',
`webp_filesize` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `optm_status` (`optm_status`)
data_structure/img_optming.sql 0000644 00000000526 15153741266 0012644 0 ustar 00 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT '0',
`optm_status` tinyint(4) NOT NULL DEFAULT '0',
`src` varchar(1000) NOT NULL DEFAULT '',
`server_info` text NOT NULL,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `optm_status` (`optm_status`),
KEY `src` (`src`(191))
data_structure/url.sql 0000644 00000000315 15153741266 0011131 0 ustar 00 `id` bigint(20) NOT NULL AUTO_INCREMENT,
`url` varchar(500) NOT NULL,
`cache_tags` varchar(1000) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `url` (`url`(191)),
KEY `cache_tags` (`cache_tags`(191)) data_structure/url_file.sql 0000644 00000001210 15153741266 0012123 0 ustar 00 `id` bigint(20) NOT NULL AUTO_INCREMENT,
`url_id` bigint(20) NOT NULL,
`vary` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT 'md5 of final vary',
`filename` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT 'md5 of file content',
`type` tinyint(4) NOT NULL COMMENT 'css=1,js=2,ccss=3,ucss=4',
`mobile` tinyint(4) NOT NULL COMMENT 'mobile=1',
`webp` tinyint(4) NOT NULL COMMENT 'webp=1',
`expired` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `filename` (`filename`),
KEY `type` (`type`),
KEY `url_id_2` (`url_id`,`vary`,`type`),
KEY `filename_2` (`filename`,`expired`),
KEY `url_id` (`url_id`,`expired`)
db-optm.cls.php 0000644 00000023513 15153741266 0007415 0 ustar 00 <?php
/**
* The admin optimize tool
*
*
* @since 1.2.1
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class DB_Optm extends Root
{
private static $_hide_more = false;
private static $TYPES = array(
'revision',
'orphaned_post_meta',
'auto_draft',
'trash_post',
'spam_comment',
'trash_comment',
'trackback-pingback',
'expired_transient',
'all_transients',
'optimize_tables',
);
const TYPE_CONV_TB = 'conv_innodb';
/**
* Show if there are more sites in hidden
*
* @since 3.0
*/
public static function hide_more()
{
return self::$_hide_more;
}
/**
* Clean/Optimize WP tables
*
* @since 1.2.1
* @access public
* @param string $type The type to clean
* @param bool $ignore_multisite If ignore multisite check
* @return int The rows that will be affected
*/
public function db_count($type, $ignore_multisite = false)
{
if ($type === 'all') {
$num = 0;
foreach (self::$TYPES as $v) {
$num += $this->db_count($v);
}
return $num;
}
if (!$ignore_multisite) {
if (is_multisite() && is_network_admin()) {
$num = 0;
$blogs = Activation::get_network_ids();
foreach ($blogs as $k => $blog_id) {
if ($k > 3) {
self::$_hide_more = true;
break;
}
switch_to_blog($blog_id);
$num += $this->db_count($type, true);
restore_current_blog();
}
return $num;
}
}
global $wpdb;
switch ($type) {
case 'revision':
$rev_max = (int) $this->conf(Base::O_DB_OPTM_REVISIONS_MAX);
$rev_age = (int) $this->conf(Base::O_DB_OPTM_REVISIONS_AGE);
$sql_add = '';
if ($rev_age) {
$sql_add = " and post_modified < DATE_SUB( NOW(), INTERVAL $rev_age DAY ) ";
}
$sql = "SELECT COUNT(*) FROM `$wpdb->posts` WHERE post_type = 'revision' $sql_add";
if (!$rev_max) {
return $wpdb->get_var($sql);
}
// Has count limit
$sql = "SELECT COUNT(*)-$rev_max FROM `$wpdb->posts` WHERE post_type = 'revision' $sql_add GROUP BY post_parent HAVING count(*)>$rev_max";
$res = $wpdb->get_results($sql, ARRAY_N);
Utility::compatibility();
return array_sum(array_column($res, 0));
case 'orphaned_post_meta':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->postmeta` a LEFT JOIN `$wpdb->posts` b ON b.ID=a.post_id WHERE b.ID IS NULL");
case 'auto_draft':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->posts` WHERE post_status = 'auto-draft'");
case 'trash_post':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->posts` WHERE post_status = 'trash'");
case 'spam_comment':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->comments` WHERE comment_approved = 'spam'");
case 'trash_comment':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->comments` WHERE comment_approved = 'trash'");
case 'trackback-pingback':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->comments` WHERE comment_type = 'trackback' OR comment_type = 'pingback'");
case 'expired_transient':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->options` WHERE option_name LIKE '_transient_timeout%' AND option_value < " . time());
case 'all_transients':
return $wpdb->get_var("SELECT COUNT(*) FROM `$wpdb->options` WHERE option_name LIKE '%_transient_%'");
case 'optimize_tables':
return $wpdb->get_var("SELECT COUNT(*) FROM information_schema.tables WHERE TABLE_SCHEMA = '" . DB_NAME . "' and ENGINE <> 'InnoDB' and DATA_FREE > 0");
}
return '-';
}
/**
* Clean/Optimize WP tables
*
* @since 1.2.1
* @since 3.0 changed to private
* @access private
*/
private function _db_clean($type)
{
if ($type === 'all') {
foreach (self::$TYPES as $v) {
$this->_db_clean($v);
}
return __('Clean all successfully.', 'litespeed-cache');
}
global $wpdb;
switch ($type) {
case 'revision':
$rev_max = (int) $this->conf(Base::O_DB_OPTM_REVISIONS_MAX);
$rev_age = (int) $this->conf(Base::O_DB_OPTM_REVISIONS_AGE);
$postmeta = "`$wpdb->postmeta`";
$posts = "`$wpdb->posts`";
$sql_postmeta_join = function ($table) use ($postmeta, $posts) {
return "
$postmeta
CROSS JOIN $table
ON $posts.ID = $postmeta.post_id
";
};
$sql_where = "WHERE $posts.post_type = 'revision'";
$sql_add = $rev_age ? "AND $posts.post_modified < DATE_SUB( NOW(), INTERVAL $rev_age DAY )" : '';
if (!$rev_max) {
$sql_where = "$sql_where $sql_add";
$sql_postmeta = $sql_postmeta_join($posts);
$wpdb->query("DELETE $postmeta FROM $sql_postmeta $sql_where");
$wpdb->query("DELETE FROM $posts $sql_where");
} else {
// Has count limit
$sql = "
SELECT COUNT(*) - $rev_max
AS del_max, post_parent
FROM $posts
WHERE post_type = 'revision'
$sql_add
GROUP BY post_parent
HAVING COUNT(*) > $rev_max
";
$res = $wpdb->get_results($sql);
$sql_where = "
$sql_where
AND post_parent = %d
ORDER BY ID
LIMIT %d
";
$sql_postmeta = $sql_postmeta_join("(SELECT ID FROM $posts $sql_where) AS $posts");
foreach ($res as $v) {
$args = array($v->post_parent, $v->del_max);
$sql = $wpdb->prepare("DELETE $postmeta FROM $sql_postmeta", $args);
$wpdb->query($sql);
$sql = $wpdb->prepare("DELETE FROM $posts $sql_where", $args);
$wpdb->query($sql);
}
}
return __('Clean post revisions successfully.', 'litespeed-cache');
case 'orphaned_post_meta':
$wpdb->query("DELETE a FROM `$wpdb->postmeta` a LEFT JOIN `$wpdb->posts` b ON b.ID=a.post_id WHERE b.ID IS NULL");
return __('Clean orphaned post meta successfully.', 'litespeed-cache');
case 'auto_draft':
$wpdb->query("DELETE FROM `$wpdb->posts` WHERE post_status = 'auto-draft'");
return __('Clean auto drafts successfully.', 'litespeed-cache');
case 'trash_post':
$wpdb->query("DELETE FROM `$wpdb->posts` WHERE post_status = 'trash'");
return __('Clean trashed posts and pages successfully.', 'litespeed-cache');
case 'spam_comment':
$wpdb->query("DELETE FROM `$wpdb->comments` WHERE comment_approved = 'spam'");
return __('Clean spam comments successfully.', 'litespeed-cache');
case 'trash_comment':
$wpdb->query("DELETE FROM `$wpdb->comments` WHERE comment_approved = 'trash'");
return __('Clean trashed comments successfully.', 'litespeed-cache');
case 'trackback-pingback':
$wpdb->query("DELETE FROM `$wpdb->comments` WHERE comment_type = 'trackback' OR comment_type = 'pingback'");
return __('Clean trackbacks and pingbacks successfully.', 'litespeed-cache');
case 'expired_transient':
$wpdb->query("DELETE FROM `$wpdb->options` WHERE option_name LIKE '_transient_timeout%' AND option_value < " . time());
return __('Clean expired transients successfully.', 'litespeed-cache');
case 'all_transients':
$wpdb->query("DELETE FROM `$wpdb->options` WHERE option_name LIKE '%\\_transient\\_%'");
return __('Clean all transients successfully.', 'litespeed-cache');
case 'optimize_tables':
$sql = "SELECT table_name, DATA_FREE FROM information_schema.tables WHERE TABLE_SCHEMA = '" . DB_NAME . "' and ENGINE <> 'InnoDB' and DATA_FREE > 0";
$result = $wpdb->get_results($sql);
if ($result) {
foreach ($result as $row) {
$wpdb->query('OPTIMIZE TABLE ' . $row->table_name);
}
}
return __('Optimized all tables.', 'litespeed-cache');
}
}
/**
* Get all myisam tables
*
* @since 3.0
* @access public
*/
public function list_myisam()
{
global $wpdb;
$q = "SELECT * FROM information_schema.tables WHERE TABLE_SCHEMA = '" . DB_NAME . "' and ENGINE = 'myisam' AND TABLE_NAME LIKE '{$wpdb->prefix}%'";
return $wpdb->get_results($q);
}
/**
* Convert tables to InnoDB
*
* @since 3.0
* @access private
*/
private function _conv_innodb()
{
global $wpdb;
if (empty($_GET['tb'])) {
Admin_Display::error('No table to convert');
return;
}
$tb = false;
$list = $this->list_myisam();
foreach ($list as $v) {
if ($v->TABLE_NAME == $_GET['tb']) {
$tb = $v->TABLE_NAME;
break;
}
}
if (!$tb) {
Admin_Display::error('No existing table');
return;
}
$q = 'ALTER TABLE ' . DB_NAME . '.' . $tb . ' ENGINE = InnoDB';
$wpdb->query($q);
Debug2::debug("[DB] Converted $tb to InnoDB");
$msg = __('Converted to InnoDB successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Count all autoload size
*
* @since 3.0
* @access public
*/
public function autoload_summary()
{
global $wpdb;
$autoloads = function_exists('wp_autoload_values_to_autoload') ? wp_autoload_values_to_autoload() : array('yes', 'on', 'auto-on', 'auto');
$autoloads = '("' . implode('","', $autoloads) . '")';
$summary = $wpdb->get_row("SELECT SUM(LENGTH(option_value)) AS autoload_size,COUNT(*) AS autload_entries FROM `$wpdb->options` WHERE autoload IN " . $autoloads);
$summary->autoload_toplist = $wpdb->get_results(
"SELECT option_name, LENGTH(option_value) AS option_value_length, autoload FROM `$wpdb->options` WHERE autoload IN " .
$autoloads .
' ORDER BY option_value_length DESC LIMIT 20'
);
return $summary;
}
/**
* Handle all request actions from main cls
*
* @since 3.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case 'all':
case in_array($type, self::$TYPES):
if (is_multisite() && is_network_admin()) {
$blogs = Activation::get_network_ids();
foreach ($blogs as $blog_id) {
switch_to_blog($blog_id);
$msg = $this->_db_clean($type);
restore_current_blog();
}
} else {
$msg = $this->_db_clean($type);
}
Admin_Display::success($msg);
break;
case self::TYPE_CONV_TB:
$this->_conv_innodb();
break;
default:
break;
}
Admin::redirect();
}
}
debug2.cls.php 0000644 00000032126 15153741266 0007223 0 ustar 00 <?php
/**
* The plugin logging class.
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Debug2 extends Root
{
private static $log_path;
private static $log_path_prefix;
private static $_prefix;
const TYPE_CLEAR_LOG = 'clear_log';
const TYPE_BETA_TEST = 'beta_test';
const BETA_TEST_URL = 'beta_test_url';
const BETA_TEST_URL_WP = 'https://downloads.wordpress.org/plugin/litespeed-cache.zip';
/**
* Log class Confructor
*
* NOTE: in this process, until last step ( define const LSCWP_LOG = true ), any usage to WP filter will not be logged to prevent infinite loop with log_filters()
*
* @since 1.1.2
* @access public
*/
public function __construct()
{
self::$log_path_prefix = LITESPEED_STATIC_DIR . '/debug/';
// Maybe move legacy log files
$this->_maybe_init_folder();
self::$log_path = $this->path('debug');
if (!empty($_SERVER['HTTP_USER_AGENT']) && strpos($_SERVER['HTTP_USER_AGENT'], 'lscache_') === 0) {
self::$log_path = $this->path('crawler');
}
!defined('LSCWP_LOG_TAG') && define('LSCWP_LOG_TAG', get_current_blog_id());
if ($this->conf(Base::O_DEBUG_LEVEL)) {
!defined('LSCWP_LOG_MORE') && define('LSCWP_LOG_MORE', true);
}
defined('LSCWP_DEBUG_EXC_STRINGS') || define('LSCWP_DEBUG_EXC_STRINGS', $this->conf(Base::O_DEBUG_EXC_STRINGS));
}
/**
* Try moving legacy logs into /litespeed/debug/ folder
*
* @since 6.5
*/
private function _maybe_init_folder()
{
if (file_exists(self::$log_path_prefix . 'index.php')) {
return;
}
file::save(self::$log_path_prefix . 'index.php', '<?php // Silence is golden.', true);
$logs = array('debug', 'debug.purge', 'crawler');
foreach ($logs as $log) {
if (file_exists(LSCWP_CONTENT_DIR . '/' . $log . '.log') && !file_exists($this->path($log))) {
rename(LSCWP_CONTENT_DIR . '/' . $log . '.log', $this->path($log));
}
}
}
/**
* Generate log file path
*
* @since 6.5
*/
public function path($type)
{
return self::$log_path_prefix . self::FilePath($type);
}
/**
* Generate the fixed log filename
*
* @since 6.5
*/
public static function FilePath($type)
{
if ($type == 'debug.purge') {
$type = 'purge';
}
$key = defined('AUTH_KEY') ? AUTH_KEY : md5(__FILE__);
$rand = substr(md5(substr($key, -16)), -16);
return $type . $rand . '.log';
}
/**
* End call of one request process
* @since 4.7
* @access public
*/
public static function ended()
{
$headers = headers_list();
foreach ($headers as $key => $header) {
if (stripos($header, 'Set-Cookie') === 0) {
unset($headers[$key]);
}
}
self::debug('Response headers', $headers);
$elapsed_time = number_format((microtime(true) - LSCWP_TS_0) * 1000, 2);
self::debug("End response\n--------------------------------------------------Duration: " . $elapsed_time . " ms------------------------------\n");
}
/**
* Beta test upgrade
*
* @since 2.9.5
* @access public
*/
public function beta_test($zip = false)
{
if (!$zip) {
if (empty($_REQUEST[self::BETA_TEST_URL])) {
return;
}
$zip = $_REQUEST[self::BETA_TEST_URL];
if ($zip !== Debug2::BETA_TEST_URL_WP) {
if ($zip === 'latest') {
$zip = Debug2::BETA_TEST_URL_WP;
} else {
// Generate zip url
$zip = $this->_package_zip($zip);
}
}
}
if (!$zip) {
Debug2::debug('[Debug2] ❌ No ZIP file');
return;
}
Debug2::debug('[Debug2] ZIP file ' . $zip);
$update_plugins = get_site_transient('update_plugins');
if (!is_object($update_plugins)) {
$update_plugins = new \stdClass();
}
$plugin_info = new \stdClass();
$plugin_info->new_version = Core::VER;
$plugin_info->slug = Core::PLUGIN_NAME;
$plugin_info->plugin = Core::PLUGIN_FILE;
$plugin_info->package = $zip;
$plugin_info->url = 'https://wordpress.org/plugins/litespeed-cache/';
$update_plugins->response[Core::PLUGIN_FILE] = $plugin_info;
set_site_transient('update_plugins', $update_plugins);
// Run upgrade
Activation::cls()->upgrade();
}
/**
* Git package refresh
*
* @since 2.9.5
* @access private
*/
private function _package_zip($commit)
{
$data = array(
'commit' => $commit,
);
$res = Cloud::get(Cloud::API_BETA_TEST, $data);
if (empty($res['zip'])) {
return false;
}
return $res['zip'];
}
/**
* Log Purge headers separately
*
* @since 2.7
* @access public
*/
public static function log_purge($purge_header)
{
// Check if debug is ON
if (!defined('LSCWP_LOG') && !defined('LSCWP_LOG_BYPASS_NOTADMIN')) {
return;
}
$purge_file = self::cls()->path('purge');
self::cls()->_init_request($purge_file);
$msg = $purge_header . self::_backtrace_info(6);
File::append($purge_file, self::format_message($msg));
}
/**
* Enable debug log
*
* @since 1.1.0
* @access public
*/
public function init()
{
$debug = $this->conf(Base::O_DEBUG);
if ($debug == Base::VAL_ON2) {
if (!$this->cls('Router')->is_admin_ip()) {
defined('LSCWP_LOG_BYPASS_NOTADMIN') || define('LSCWP_LOG_BYPASS_NOTADMIN', true);
return;
}
}
/**
* Check if hit URI includes/excludes
* This is after LSCWP_LOG_BYPASS_NOTADMIN to make `log_purge()` still work
* @since 3.0
*/
$list = $this->conf(Base::O_DEBUG_INC);
if ($list) {
$result = Utility::str_hit_array($_SERVER['REQUEST_URI'], $list);
if (!$result) {
return;
}
}
$list = $this->conf(Base::O_DEBUG_EXC);
if ($list) {
$result = Utility::str_hit_array($_SERVER['REQUEST_URI'], $list);
if ($result) {
return;
}
}
if (!defined('LSCWP_LOG')) {
// If not initialized, do it now
$this->_init_request();
define('LSCWP_LOG', true);
}
}
/**
* Create the initial log messages with the request parameters.
*
* @since 1.0.12
* @access private
*/
private function _init_request($log_file = null)
{
if (!$log_file) {
$log_file = self::$log_path;
}
// Check log file size
$log_file_size = $this->conf(Base::O_DEBUG_FILESIZE);
if (file_exists($log_file) && filesize($log_file) > $log_file_size * 1000000) {
File::save($log_file, '');
}
// For more than 2s's requests, add more break
if (file_exists($log_file) && time() - filemtime($log_file) > 2) {
File::append($log_file, "\n\n\n\n");
}
if (PHP_SAPI == 'cli') {
return;
}
$servervars = array(
'Query String' => '',
'HTTP_ACCEPT' => '',
'HTTP_USER_AGENT' => '',
'HTTP_ACCEPT_ENCODING' => '',
'HTTP_COOKIE' => '',
'REQUEST_METHOD' => '',
'SERVER_PROTOCOL' => '',
'X-LSCACHE' => '',
'LSCACHE_VARY_COOKIE' => '',
'LSCACHE_VARY_VALUE' => '',
'ESI_CONTENT_TYPE' => '',
);
$server = array_merge($servervars, $_SERVER);
$params = array();
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') {
$server['SERVER_PROTOCOL'] .= ' (HTTPS) ';
}
$param = sprintf('💓 ------%s %s %s', $server['REQUEST_METHOD'], $server['SERVER_PROTOCOL'], strtok($server['REQUEST_URI'], '?'));
$qs = !empty($server['QUERY_STRING']) ? $server['QUERY_STRING'] : '';
if ($this->conf(Base::O_DEBUG_COLLAPSE_QS)) {
$qs = $this->_omit_long_message($qs);
if ($qs) {
$param .= ' ? ' . $qs;
}
$params[] = $param;
} else {
$params[] = $param;
$params[] = 'Query String: ' . $qs;
}
if (!empty($_SERVER['HTTP_REFERER'])) {
$params[] = 'HTTP_REFERER: ' . $this->_omit_long_message($server['HTTP_REFERER']);
}
if (defined('LSCWP_LOG_MORE')) {
$params[] = 'User Agent: ' . $this->_omit_long_message($server['HTTP_USER_AGENT']);
$params[] = 'Accept: ' . $server['HTTP_ACCEPT'];
$params[] = 'Accept Encoding: ' . $server['HTTP_ACCEPT_ENCODING'];
}
// $params[] = 'Cookie: ' . $server['HTTP_COOKIE'];
if (isset($_COOKIE['_lscache_vary'])) {
$params[] = 'Cookie _lscache_vary: ' . $_COOKIE['_lscache_vary'];
}
if (defined('LSCWP_LOG_MORE')) {
$params[] = 'X-LSCACHE: ' . (!empty($server['X-LSCACHE']) ? 'true' : 'false');
}
if ($server['LSCACHE_VARY_COOKIE']) {
$params[] = 'LSCACHE_VARY_COOKIE: ' . $server['LSCACHE_VARY_COOKIE'];
}
if ($server['LSCACHE_VARY_VALUE']) {
$params[] = 'LSCACHE_VARY_VALUE: ' . $server['LSCACHE_VARY_VALUE'];
}
if ($server['ESI_CONTENT_TYPE']) {
$params[] = 'ESI_CONTENT_TYPE: ' . $server['ESI_CONTENT_TYPE'];
}
$request = array_map(__CLASS__ . '::format_message', $params);
File::append($log_file, $request);
}
/**
* Trim long msg to keep log neat
* @since 6.3
*/
private function _omit_long_message($msg)
{
if (strlen($msg) > 53) {
$msg = substr($msg, 0, 53) . '...';
}
return $msg;
}
/**
* Formats the log message with a consistent prefix.
*
* @since 1.0.12
* @access private
* @param string $msg The log message to write.
* @return string The formatted log message.
*/
private static function format_message($msg)
{
// If call here without calling get_enabled() first, improve compatibility
if (!defined('LSCWP_LOG_TAG')) {
return $msg . "\n";
}
if (!isset(self::$_prefix)) {
// address
if (PHP_SAPI == 'cli') {
$addr = '=CLI=';
if (isset($_SERVER['USER'])) {
$addr .= $_SERVER['USER'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$addr .= $_SERVER['HTTP_X_FORWARDED_FOR'];
}
} else {
$addr = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
$port = isset($_SERVER['REMOTE_PORT']) ? $_SERVER['REMOTE_PORT'] : '';
$addr = "$addr:$port";
}
// Generate a unique string per request
self::$_prefix = sprintf(' [%s %s %s] ', $addr, LSCWP_LOG_TAG, Str::rrand(3));
}
list($usec, $sec) = explode(' ', microtime());
return date('m/d/y H:i:s', $sec + LITESPEED_TIME_OFFSET) . substr($usec, 1, 4) . self::$_prefix . $msg . "\n";
}
/**
* Direct call to log a debug message.
*
* @since 1.1.3
* @access public
*/
public static function debug($msg, $backtrace_limit = false)
{
if (!defined('LSCWP_LOG')) {
return;
}
if (defined('LSCWP_DEBUG_EXC_STRINGS') && Utility::str_hit_array($msg, LSCWP_DEBUG_EXC_STRINGS)) {
return;
}
if ($backtrace_limit !== false) {
if (!is_numeric($backtrace_limit)) {
$backtrace_limit = self::trim_longtext($backtrace_limit);
if (is_array($backtrace_limit) && count($backtrace_limit) == 1 && !empty($backtrace_limit[0])) {
$msg .= ' --- ' . $backtrace_limit[0];
} else {
$msg .= ' --- ' . var_export($backtrace_limit, true);
}
self::push($msg);
return;
}
self::push($msg, $backtrace_limit + 1);
return;
}
self::push($msg);
}
/**
* Trim long string before array dump
* @since 3.3
*/
public static function trim_longtext($backtrace_limit)
{
if (is_array($backtrace_limit)) {
$backtrace_limit = array_map(__CLASS__ . '::trim_longtext', $backtrace_limit);
}
if (is_string($backtrace_limit) && strlen($backtrace_limit) > 500) {
$backtrace_limit = substr($backtrace_limit, 0, 1000) . '...';
}
return $backtrace_limit;
}
/**
* Direct call to log an advanced debug message.
*
* @since 1.2.0
* @access public
*/
public static function debug2($msg, $backtrace_limit = false)
{
if (!defined('LSCWP_LOG_MORE')) {
return;
}
self::debug($msg, $backtrace_limit);
}
/**
* Logs a debug message.
*
* @since 1.1.0
* @access private
* @param string $msg The debug message.
* @param int $backtrace_limit Backtrace depth.
*/
private static function push($msg, $backtrace_limit = false)
{
// backtrace handler
if (defined('LSCWP_LOG_MORE') && $backtrace_limit !== false) {
$msg .= self::_backtrace_info($backtrace_limit);
}
File::append(self::$log_path, self::format_message($msg));
}
/**
* Backtrace info
*
* @since 2.7
*/
private static function _backtrace_info($backtrace_limit)
{
$msg = '';
$trace = version_compare(PHP_VERSION, '5.4.0', '<') ? debug_backtrace() : debug_backtrace(false, $backtrace_limit + 3);
for ($i = 2; $i <= $backtrace_limit + 2; $i++) {
// 0st => _backtrace_info(), 1st => push()
if (empty($trace[$i]['class'])) {
if (empty($trace[$i]['file'])) {
break;
}
$log = "\n" . $trace[$i]['file'];
} else {
if ($trace[$i]['class'] == __CLASS__) {
continue;
}
$args = '';
if (!empty($trace[$i]['args'])) {
foreach ($trace[$i]['args'] as $v) {
if (is_array($v)) {
$v = 'ARRAY';
}
if (is_string($v) || is_numeric($v)) {
$args .= $v . ',';
}
}
$args = substr($args, 0, strlen($args) > 100 ? 100 : -1);
}
$log = str_replace('Core', 'LSC', $trace[$i]['class']) . $trace[$i]['type'] . $trace[$i]['function'] . '(' . $args . ')';
}
if (!empty($trace[$i - 1]['line'])) {
$log .= '@' . $trace[$i - 1]['line'];
}
$msg .= " => $log";
}
return $msg;
}
/**
* Clear log file
*
* @since 1.6.6
* @access private
*/
private function _clear_log()
{
$logs = array('debug', 'purge', 'crawler');
foreach ($logs as $log) {
File::save($this->path($log), '');
}
}
/**
* Handle all request actions from main cls
*
* @since 1.6.6
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_CLEAR_LOG:
$this->_clear_log();
break;
case self::TYPE_BETA_TEST:
$this->beta_test();
break;
default:
break;
}
Admin::redirect();
}
}
doc.cls.php 0000644 00000011353 15153741266 0006617 0 ustar 00 <?php
/**
* The Doc class.
*
* @since 2.2.7
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Doc
{
// protected static $_instance;
/**
* Show option is actually ON by GM
*
* @since 5.5
* @access public
*/
public static function maybe_on_by_gm($id)
{
if (apply_filters('litespeed_conf', $id)) {
return;
}
if (!apply_filters('litespeed_conf', Base::O_GUEST)) {
return;
}
if (!apply_filters('litespeed_conf', Base::O_GUEST_OPTM)) {
return;
}
echo '<font class="litespeed-warning">';
echo '⚠️ ' .
sprintf(
__('This setting is %1$s for certain qualifying requests due to %2$s!', 'litespeed-cache'),
'<code>' . __('ON', 'litespeed-cache') . '</code>',
Lang::title(Base::O_GUEST_OPTM)
);
self::learn_more('https://docs.litespeedtech.com/lscache/lscwp/general/#guest-optimization');
echo '</font>';
}
/**
* Changes affect crawler list warning
*
* @since 4.3
* @access public
*/
public static function crawler_affected()
{
echo '<font class="litespeed-primary">';
echo '⚠️ ' . __('This setting will regenerate crawler list and clear the disabled list!', 'litespeed-cache');
echo '</font>';
}
/**
* Privacy policy
*
* @since 2.2.7
* @access public
*/
public static function privacy_policy()
{
return __(
'This site utilizes caching in order to facilitate a faster response time and better user experience. Caching potentially stores a duplicate copy of every web page that is on display on this site. All cache files are temporary, and are never accessed by any third party, except as necessary to obtain technical support from the cache plugin vendor. Cache files expire on a schedule set by the site administrator, but may easily be purged by the admin before their natural expiration, if necessary. We may use QUIC.cloud services to process & cache your data temporarily.',
'litespeed-cache'
) .
sprintf(
__('Please see %s for more details.', 'litespeed-cache'),
'<a href="https://quic.cloud/privacy-policy/" target="_blank">https://quic.cloud/privacy-policy/</a>'
);
}
/**
* Learn more link
*
* @since 2.4.2
* @access public
*/
public static function learn_more($url, $title = false, $self = false, $class = false, $return = false)
{
if (!$class) {
$class = 'litespeed-learn-more';
}
if (!$title) {
$title = __('Learn More', 'litespeed-cache');
}
$self = $self ? '' : "target='_blank'";
$txt = " <a href='$url' $self class='$class'>$title</a>";
if ($return) {
return $txt;
}
echo $txt;
}
/**
* One per line
*
* @since 3.0
* @access public
*/
public static function one_per_line($return = false)
{
$str = __('One per line.', 'litespeed-cache');
if ($return) {
return $str;
}
echo $str;
}
/**
* One per line
*
* @since 3.4
* @access public
*/
public static function full_or_partial_url($string_only = false)
{
if ($string_only) {
echo __('Both full and partial strings can be used.', 'litespeed-cache');
} else {
echo __('Both full URLs and partial strings can be used.', 'litespeed-cache');
}
}
/**
* Notice to edit .htaccess
*
* @since 3.0
* @access public
*/
public static function notice_htaccess()
{
echo '<font class="litespeed-primary">';
echo '⚠️ ' . __('This setting will edit the .htaccess file.', 'litespeed-cache');
echo ' <a href="https://docs.litespeedtech.com/lscache/lscwp/toolbox/#edit-htaccess-tab" target="_blank" class="litespeed-learn-more">' .
__('Learn More', 'litespeed-cache') .
'</a>';
echo '</font>';
}
/**
* Notice for whitelist IPs
*
* @since 3.0
* @access public
*/
public static function notice_ips()
{
echo '<div class="litespeed-primary">';
echo '⚠️ ' . sprintf(__('For online services to work correctly, you must allowlist all %s server IPs.', 'litespeed-cache'), 'QUIC.cloud') . '<br/>';
echo ' ' . __('Before generating key, please verify all IPs on this list are allowlisted', 'litespeed-cache') . ': ';
echo '<a href="' . Cloud::CLOUD_IPS . '" target="_blank">' . __('Current Online Server IPs', 'litespeed-cache') . '</a>';
echo '</div>';
}
/**
* Gentle reminder that web services run asynchronously
*
* @since 5.3.1
* @access public
*/
public static function queue_issues($return = false)
{
$str =
'<div class="litespeed-desc">' .
__('The queue is processed asynchronously. It may take time.', 'litespeed-cache') .
self::learn_more('https://docs.litespeedtech.com/lscache/lscwp/troubleshoot/#quiccloud-queue-issues', false, false, false, true) .
'</div>';
if ($return) {
return $str;
}
echo $str;
}
}
error.cls.php 0000644 00000015620 15153741266 0007204 0 ustar 00 <?php
/**
* The error class.
*
* @since 3.0
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Error
{
private static $CODE_SET = array(
'HTA_LOGIN_COOKIE_INVALID' => 4300, // .htaccess did not find.
'HTA_DNF' => 4500, // .htaccess did not find.
'HTA_BK' => 9010, // backup
'HTA_R' => 9041, // read htaccess
'HTA_W' => 9042, // write
'HTA_GET' => 9030, // failed to get
);
/**
* Throw an error with msg
*
* @since 3.0
*/
public static function t($code, $args = null)
{
throw new \Exception(self::msg($code, $args));
}
/**
* Translate an error to description
*
* @since 3.0
*/
public static function msg($code, $args = null)
{
switch ($code) {
case 'disabled_all':
$msg =
sprintf(__('The setting %s is currently enabled.', 'litespeed-cache'), '<strong>' . Lang::title(Base::O_DEBUG_DISABLE_ALL) . '</strong>') .
Doc::learn_more(
is_network_admin() ? network_admin_url('admin.php?page=litespeed-toolbox') : admin_url('admin.php?page=litespeed-toolbox'),
__('Click here to change.', 'litespeed-cache'),
true,
false,
true
);
break;
case 'qc_setup_required':
$msg =
sprintf(__('You will need to finish %s setup to use the online services.', 'litespeed-cache'), '<strong>QUIC.cloud</strong>') .
Doc::learn_more(admin_url('admin.php?page=litespeed-general'), __('Click here to set.', 'litespeed-cache'), true, false, true);
break;
case 'out_of_daily_quota':
$msg = __('You have used all of your daily quota for today.', 'litespeed-cache');
$msg .=
' ' .
Doc::learn_more(
'https://docs.quic.cloud/billing/services/#daily-limits-on-free-quota-usage',
__('Learn more or purchase additional quota.', 'litespeed-cache'),
false,
false,
true
);
break;
case 'out_of_quota':
$msg = __('You have used all of your quota left for current service this month.', 'litespeed-cache');
$msg .=
' ' .
Doc::learn_more(
'https://docs.quic.cloud/billing/services/#daily-limits-on-free-quota-usage',
__('Learn more or purchase additional quota.', 'litespeed-cache'),
false,
false,
true
);
break;
case 'too_many_requested':
$msg = __('You have too many requested images, please try again in a few minutes.', 'litespeed-cache');
break;
case 'too_many_notified':
$msg = __('You have images waiting to be pulled. Please wait for the automatic pull to complete, or pull them down manually now.', 'litespeed-cache');
break;
case 'empty_list':
$msg = __('The image list is empty.', 'litespeed-cache');
break;
case 'lack_of_param':
$msg = __('Not enough parameters. Please check if the domain key is set correctly', 'litespeed-cache');
break;
case 'unfinished_queue':
$msg = __('There is proceeding queue not pulled yet.', 'litespeed-cache');
break;
case strpos($code, 'unfinished_queue ') === 0:
$msg = sprintf(
__('There is proceeding queue not pulled yet. Queue info: %s.', 'litespeed-cache'),
'<code>' . substr($code, strlen('unfinished_queue ')) . '</code>'
);
break;
case 'err_alias':
$msg = __('The site is not a valid alias on QUIC.cloud.', 'litespeed-cache');
break;
case 'site_not_registered':
$msg = __('The site is not registered on QUIC.cloud.', 'litespeed-cache');
break;
case 'err_key':
$msg = __('The domain key is not correct. Please try to sync your domain key again.', 'litespeed-cache');
break;
case 'heavy_load':
$msg = __('The current server is under heavy load.', 'litespeed-cache');
break;
case 'redetect_node':
$msg = __('Online node needs to be redetected.', 'litespeed-cache');
break;
case 'err_overdraw':
$msg = __('Credits are not enough to proceed the current request.', 'litespeed-cache');
break;
case 'W':
$msg = __('%s file not writable.', 'litespeed-cache');
break;
case 'HTA_DNF':
if (!is_array($args)) {
$args = array('<code>' . $args . '</code>');
}
$args[] = '.htaccess';
$msg = __('Could not find %1$s in %2$s.', 'litespeed-cache');
break;
case 'HTA_LOGIN_COOKIE_INVALID':
$msg = sprintf(__('Invalid login cookie. Please check the %s file.', 'litespeed-cache'), '.htaccess');
break;
case 'HTA_BK':
$msg = sprintf(__('Failed to back up %s file, aborted changes.', 'litespeed-cache'), '.htaccess');
break;
case 'HTA_R':
$msg = sprintf(__('%s file not readable.', 'litespeed-cache'), '.htaccess');
break;
case 'HTA_W':
$msg = sprintf(__('%s file not writable.', 'litespeed-cache'), '.htaccess');
break;
case 'HTA_GET':
$msg = sprintf(__('Failed to get %s file contents.', 'litespeed-cache'), '.htaccess');
break;
case 'failed_tb_creation':
$msg = __('Failed to create table %s! SQL: %s.', 'litespeed-cache');
break;
case 'crawler_disabled':
$msg = __('Crawler disabled by the server admin.', 'litespeed-cache');
break;
case 'try_later': // QC error code
$msg = __('Previous request too recent. Please try again later.', 'litespeed-cache');
break;
case strpos($code, 'try_later ') === 0:
$msg = sprintf(
__('Previous request too recent. Please try again after %s.', 'litespeed-cache'),
'<code>' . Utility::readable_time(substr($code, strlen('try_later ')), 3600, true) . '</code>'
);
break;
case 'waiting_for_approval':
$msg = __('Your application is waiting for approval.', 'litespeed-cache');
break;
case 'callback_fail_hash':
$msg = __('The callback validation to your domain failed due to hash mismatch.', 'litespeed-cache');
break;
case 'callback_fail':
$msg = __('The callback validation to your domain failed. Please make sure there is no firewall blocking our servers.', 'litespeed-cache');
break;
case substr($code, 0, 14) === 'callback_fail ':
$msg =
__('The callback validation to your domain failed. Please make sure there is no firewall blocking our servers. Response code: ', 'litespeed-cache') .
substr($code, 14);
break;
case 'forbidden':
$msg = __('Your domain has been forbidden from using our services due to a previous policy violation.', 'litespeed-cache');
break;
case 'err_dns_active':
$msg = __(
'You cannot remove this DNS zone, because it is still in use. Please update the domain\'s nameservers, then try to delete this zone again, otherwise your site will become inaccessible.',
'litespeed-cache'
);
break;
default:
$msg = __('Unknown error', 'litespeed-cache') . ': ' . $code;
break;
}
if ($args !== null) {
$msg = is_array($args) ? vsprintf($msg, $args) : sprintf($msg, $args);
}
if (isset(self::$CODE_SET[$code])) {
$msg = 'ERROR ' . self::$CODE_SET[$code] . ': ' . $msg;
}
return $msg;
}
}
esi.cls.php 0000644 00000065701 15153741266 0006640 0 ustar 00 <?php
/**
* The ESI class.
*
* This is used to define all esi related functions.
*
* @since 1.1.3
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class ESI extends Root
{
const LOG_TAG = '⏺';
private static $has_esi = false;
private static $_combine_ids = array();
private $esi_args = null;
private $_esi_preserve_list = array();
private $_nonce_actions = array(-1 => ''); // val is cache control
const QS_ACTION = 'lsesi';
const QS_PARAMS = 'esi';
const COMBO = '__combo'; // ESI include combine='main' handler
const PARAM_ARGS = 'args';
const PARAM_ID = 'id';
const PARAM_INSTANCE = 'instance';
const PARAM_NAME = 'name';
const WIDGET_O_ESIENABLE = 'widget_esi_enable';
const WIDGET_O_TTL = 'widget_ttl';
/**
* Confructor of ESI
*
* @since 1.2.0
* @since 4.0 Change to be after Vary init in hook 'after_setup_theme'
*/
public function init()
{
/**
* Bypass ESI related funcs if disabled ESI to fix potential DIVI compatibility issue
* @since 2.9.7.2
*/
if (Router::is_ajax() || !$this->cls('Router')->esi_enabled()) {
return;
}
// Guest mode, don't need to use ESI
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
return;
}
if (defined('LITESPEED_ESI_OFF')) {
return;
}
// If page is not cacheable
if (defined('DONOTCACHEPAGE') && apply_filters('litespeed_const_DONOTCACHEPAGE', DONOTCACHEPAGE)) {
return;
}
// Init ESI in `after_setup_theme` hook after detected if LITESPEED_DISABLE_ALL is ON or not
$this->_hooks();
/**
* Overwrite wp_create_nonce func
* @since 2.9.5
*/
$this->_transform_nonce();
!defined('LITESPEED_ESI_INITED') && define('LITESPEED_ESI_INITED', true);
}
/**
* Init ESI related hooks
*
* Load delayed by hook to give the ability to bypass by LITESPEED_DISABLE_ALL const
*
* @since 2.9.7.2
* @since 4.0 Changed to private from public
* @access private
*/
private function _hooks()
{
add_filter('template_include', array($this, 'esi_template'), 99999);
add_action('load-widgets.php', __NAMESPACE__ . '\Purge::purge_widget');
add_action('wp_update_comment_count', __NAMESPACE__ . '\Purge::purge_comment_widget');
/**
* Recover REQUEST_URI
* @since 1.8.1
*/
if (!empty($_GET[self::QS_ACTION])) {
self::debug('ESI req');
$this->_register_esi_actions();
}
/**
* Shortcode ESI
*
* To use it, just change the original shortcode as below:
* old: [someshortcode aa='bb']
* new: [esi someshortcode aa='bb' cache='private,no-vary' ttl='600']
*
* 1. `cache` attribute is optional, default to 'public,no-vary'.
* 2. `ttl` attribute is optional, default is your public TTL setting.
* 3. `_ls_silence` attribute is optional, default is false.
*
* @since 2.8
* @since 2.8.1 Check is_admin for Elementor compatibility #726013
*/
if (!is_admin()) {
add_shortcode('esi', array($this, 'shortcode'));
}
}
/**
* Take over all nonce calls and transform to ESI
*
* @since 2.9.5
*/
private function _transform_nonce()
{
if (is_admin()) {
return;
}
// Load ESI nonces in conf
$nonces = $this->conf(Base::O_ESI_NONCE);
add_filter('litespeed_esi_nonces', array($this->cls('Data'), 'load_esi_nonces'));
if ($nonces = apply_filters('litespeed_esi_nonces', $nonces)) {
foreach ($nonces as $action) {
$this->nonce_action($action);
}
}
add_action('litespeed_nonce', array($this, 'nonce_action'));
}
/**
* Register a new nonce action to convert it to ESI
*
* @since 2.9.5
*/
public function nonce_action($action)
{
// Split the Cache Control
$action = explode(' ', $action);
$control = !empty($action[1]) ? $action[1] : '';
$action = $action[0];
// Wildcard supported
$action = Utility::wildcard2regex($action);
if (array_key_exists($action, $this->_nonce_actions)) {
return;
}
$this->_nonce_actions[$action] = $control;
// Debug2::debug('[ESI] Appended nonce action to nonce list [action] ' . $action);
}
/**
* Check if an action is registered to replace ESI
*
* @since 2.9.5
*/
public function is_nonce_action($action)
{
// If GM not run yet, then ESI not init yet, then ESI nonce will not be allowed even nonce func replaced.
if (!defined('LITESPEED_ESI_INITED')) {
return null;
}
if (is_admin()) {
return null;
}
if (defined('LITESPEED_ESI_OFF')) {
return null;
}
foreach ($this->_nonce_actions as $k => $v) {
if (strpos($k, '*') !== false) {
if (preg_match('#' . $k . '#iU', $action)) {
return $v;
}
} else {
if ($k == $action) {
return $v;
}
}
}
return null;
}
/**
* Shortcode ESI
*
* @since 2.8
* @access public
*/
public function shortcode($atts)
{
if (empty($atts[0])) {
Debug2::debug('[ESI] ===shortcode wrong format', $atts);
return 'Wrong shortcode esi format';
}
$cache = 'public,no-vary';
if (!empty($atts['cache'])) {
$cache = $atts['cache'];
unset($atts['cache']);
}
$silence = false;
if (!empty($atts['_ls_silence'])) {
$silence = true;
}
do_action('litespeed_esi_shortcode-' . $atts[0]);
// Show ESI link
return $this->sub_esi_block('esi', 'esi-shortcode', $atts, $cache, $silence);
}
/**
* Check if the requested page has esi elements. If so, return esi on
* header.
*
* @since 1.1.3
* @access public
* @return string Esi On header if request has esi, empty string otherwise.
*/
public static function has_esi()
{
return self::$has_esi;
}
/**
* Sets that the requested page has esi elements.
*
* @since 1.1.3
* @access public
*/
public static function set_has_esi()
{
self::$has_esi = true;
}
/**
* Register all of the hooks related to the esi logic of the plugin.
* Specifically when the page IS an esi page.
*
* @since 1.1.3
* @access private
*/
private function _register_esi_actions()
{
/**
* This hook is in `init`
* For any plugin need to check if page is ESI, use `LSCACHE_IS_ESI` check after `init` hook
*/
!defined('LSCACHE_IS_ESI') && define('LSCACHE_IS_ESI', $_GET[self::QS_ACTION]); // Reused this to ESI block ID
!empty($_SERVER['ESI_REFERER']) && defined('LSCWP_LOG') && Debug2::debug('[ESI] ESI_REFERER: ' . $_SERVER['ESI_REFERER']);
/**
* Only when ESI's parent is not REST, replace REQUEST_URI to avoid breaking WP5 editor REST call
* @since 2.9.3
*/
if (!empty($_SERVER['ESI_REFERER']) && !$this->cls('REST')->is_rest($_SERVER['ESI_REFERER'])) {
self::debug('overwrite REQUEST_URI to ESI_REFERER [from] ' . $_SERVER['REQUEST_URI'] . ' [to] ' . $_SERVER['ESI_REFERER']);
if (!empty($_SERVER['ESI_REFERER'])) {
$_SERVER['REQUEST_URI'] = $_SERVER['ESI_REFERER'];
if (substr(get_option('permalink_structure'), -1) === '/' && strpos($_SERVER['ESI_REFERER'], '?') === false) {
$_SERVER['REQUEST_URI'] = trailingslashit($_SERVER['ESI_REFERER']);
}
}
# Prevent from 301 redirecting
if (!empty($_SERVER['SCRIPT_URI'])) {
$SCRIPT_URI = parse_url($_SERVER['SCRIPT_URI']);
$SCRIPT_URI['path'] = $_SERVER['REQUEST_URI'];
Utility::compatibility();
$_SERVER['SCRIPT_URI'] = http_build_url($SCRIPT_URI);
}
}
if (!empty($_SERVER['ESI_CONTENT_TYPE']) && strpos($_SERVER['ESI_CONTENT_TYPE'], 'application/json') === 0) {
add_filter('litespeed_is_json', '__return_true');
}
/**
* Make REST call be able to parse ESI
* NOTE: Not effective due to ESI req are all to `/` yet
* @since 2.9.4
*/
add_action('rest_api_init', array($this, 'load_esi_block'), 101);
// Register ESI blocks
add_action('litespeed_esi_load-widget', array($this, 'load_widget_block'));
add_action('litespeed_esi_load-admin-bar', array($this, 'load_admin_bar_block'));
add_action('litespeed_esi_load-comment-form', array($this, 'load_comment_form_block'));
add_action('litespeed_esi_load-nonce', array($this, 'load_nonce_block'));
add_action('litespeed_esi_load-esi', array($this, 'load_esi_shortcode'));
add_action('litespeed_esi_load-' . self::COMBO, array($this, 'load_combo'));
}
/**
* Hooked to the template_include action.
* Selects the esi template file when the post type is a LiteSpeed ESI page.
*
* @since 1.1.3
* @access public
* @param string $template The template path filtered.
* @return string The new template path.
*/
public function esi_template($template)
{
// Check if is an ESI request
if (defined('LSCACHE_IS_ESI')) {
self::debug('calling ESI template');
return LSCWP_DIR . 'tpl/esi.tpl.php';
}
self::debug('calling default template');
$this->_register_not_esi_actions();
return $template;
}
/**
* Register all of the hooks related to the esi logic of the plugin.
* Specifically when the page is NOT an esi page.
*
* @since 1.1.3
* @access private
*/
private function _register_not_esi_actions()
{
do_action('litespeed_tpl_normal');
if (!Control::is_cacheable()) {
return;
}
if (Router::is_ajax()) {
return;
}
add_filter('widget_display_callback', array($this, 'sub_widget_block'), 0, 3);
// Add admin_bar esi
if (Router::is_logged_in()) {
remove_action('wp_body_open', 'wp_admin_bar_render', 0); // Remove default Admin bar. Fix https://github.com/elementor/elementor/issues/25198
remove_action('wp_footer', 'wp_admin_bar_render', 1000);
add_action('wp_footer', array($this, 'sub_admin_bar_block'), 1000);
}
// Add comment forum esi for logged-in user or commenter
if (!Router::is_ajax() && Vary::has_vary()) {
add_filter('comment_form_defaults', array($this, 'register_comment_form_actions'));
}
}
/**
* Set an ESI to be combine='sub'
*
* @since 3.4.2
*/
public static function combine($block_id)
{
if (!isset($_SERVER['X-LSCACHE']) || strpos($_SERVER['X-LSCACHE'], 'combine') === false) {
return;
}
if (in_array($block_id, self::$_combine_ids)) {
return;
}
self::$_combine_ids[] = $block_id;
}
/**
* Load combined ESI
*
* @since 3.4.2
*/
public function load_combo()
{
Control::set_nocache('ESI combine request');
if (empty($_POST['esi_include'])) {
return;
}
self::set_has_esi();
Debug2::debug('[ESI] 🍔 Load combo', $_POST['esi_include']);
$output = '';
foreach ($_POST['esi_include'] as $url) {
$qs = parse_url(htmlspecialchars_decode($url), PHP_URL_QUERY);
parse_str($qs, $qs);
if (empty($qs[self::QS_ACTION])) {
continue;
}
$esi_id = $qs[self::QS_ACTION];
$esi_param = !empty($qs[self::QS_PARAMS]) ? $this->_parse_esi_param($qs[self::QS_PARAMS]) : false;
$inline_param = apply_filters('litespeed_esi_inline-' . $esi_id, array(), $esi_param); // Returned array need to be [ val, control, tag ]
if ($inline_param) {
$output .= self::_build_inline($url, $inline_param);
}
}
echo $output;
}
/**
* Build a whole inline segment
*
* @since 3.4.2
*/
private static function _build_inline($url, $inline_param)
{
if (!$url || empty($inline_param['val']) || empty($inline_param['control']) || empty($inline_param['tag'])) {
return '';
}
$url = esc_attr($url);
$control = esc_attr($inline_param['control']);
$tag = esc_attr($inline_param['tag']);
return "<esi:inline name='$url' cache-control='" . $control . "' cache-tag='" . $tag . "'>" . $inline_param['val'] . '</esi:inline>';
}
/**
* Build the esi url. This method will build the html comment wrapper as well as serialize and encode the parameter array.
*
* The block_id parameter should contain alphanumeric and '-_' only.
*
* @since 1.1.3
* @access private
* @param string $block_id The id to use to display the correct esi block.
* @param string $wrapper The wrapper for the esi comments.
* @param array $params The esi parameters.
* @param string $control The cache control attribute if any.
* @param bool $silence If generate wrapper comment or not
* @param bool $preserved If this ESI block is used in any filter, need to temporarily convert it to a string to avoid the HTML tag being removed/filtered.
* @param bool $svar If store the value in memory or not, in memory will be faster
* @param array $inline_val If show the current value for current request( this can avoid multiple esi requests in first time cache generating process )
*/
public function sub_esi_block(
$block_id,
$wrapper,
$params = array(),
$control = 'private,no-vary',
$silence = false,
$preserved = false,
$svar = false,
$inline_param = array()
) {
if (empty($block_id) || !is_array($params) || preg_match('/[^\w-]/', $block_id)) {
return false;
}
if (defined('LITESPEED_ESI_OFF')) {
Debug2::debug('[ESI] ESI OFF so force loading [block_id] ' . $block_id);
do_action('litespeed_esi_load-' . $block_id, $params);
return;
}
if ($silence) {
// Don't add comment to esi block ( original for nonce used in tag property data-nonce='esi_block' )
$params['_ls_silence'] = true;
}
if ($this->cls('REST')->is_rest() || $this->cls('REST')->is_internal_rest()) {
$params['is_json'] = 1;
}
$params = apply_filters('litespeed_esi_params', $params, $block_id);
$control = apply_filters('litespeed_esi_control', $control, $block_id);
if (!is_array($params) || !is_string($control)) {
defined('LSCWP_LOG') && Debug2::debug("[ESI] 🛑 Sub hooks returned Params: \n" . var_export($params, true) . "\ncache control: \n" . var_export($control, true));
return false;
}
// Build params for URL
$appended_params = array(
self::QS_ACTION => $block_id,
);
if (!empty($control)) {
$appended_params['_control'] = $control;
}
if ($params) {
$appended_params[self::QS_PARAMS] = base64_encode(\json_encode($params));
Debug2::debug2('[ESI] param ', $params);
}
// Append hash
$appended_params['_hash'] = $this->_gen_esi_md5($appended_params);
/**
* Escape potential chars
* @since 2.9.4
*/
$appended_params = array_map('urlencode', $appended_params);
// Generate ESI URL
$url = add_query_arg($appended_params, trailingslashit(wp_make_link_relative(home_url())));
$output = '';
if ($inline_param) {
$output .= self::_build_inline($url, $inline_param);
}
$output .= "<esi:include src='$url'";
if (!empty($control)) {
$control = esc_attr($control);
$output .= " cache-control='$control'";
}
if ($svar) {
$output .= " as-var='1'";
}
if (in_array($block_id, self::$_combine_ids)) {
$output .= " combine='sub'";
}
if ($block_id == self::COMBO && isset($_SERVER['X-LSCACHE']) && strpos($_SERVER['X-LSCACHE'], 'combine') !== false) {
$output .= " combine='main'";
}
$output .= ' />';
if (!$silence) {
$output = "<!-- lscwp $wrapper -->$output<!-- lscwp $wrapper esi end -->";
}
self::debug("💕 [BLock_ID] $block_id \t[wrapper] $wrapper \t\t[Control] $control");
self::debug2($output);
self::set_has_esi();
// Convert to string to avoid html chars filter when using
// Will reverse the buffer when output in self::finalize()
if ($preserved) {
$hash = md5($output);
$this->_esi_preserve_list[$hash] = $output;
self::debug("Preserved to $hash");
return $hash;
}
return $output;
}
/**
* Generate ESI hash md5
*
* @since 2.9.6
* @access private
*/
private function _gen_esi_md5($params)
{
$keys = array(self::QS_ACTION, '_control', self::QS_PARAMS);
$str = '';
foreach ($keys as $v) {
if (isset($params[$v]) && is_string($params[$v])) {
$str .= $params[$v];
}
}
Debug2::debug2('[ESI] md5_string=' . $str);
return md5($this->conf(Base::HASH) . $str);
}
/**
* Parses the request parameters on an ESI request
*
* @since 1.1.3
* @access private
*/
private function _parse_esi_param($qs_params = false)
{
$req_params = false;
if ($qs_params) {
$req_params = $qs_params;
} elseif (isset($_REQUEST[self::QS_PARAMS])) {
$req_params = $_REQUEST[self::QS_PARAMS];
}
if (!$req_params) {
return false;
}
$unencrypted = base64_decode($req_params);
if ($unencrypted === false) {
return false;
}
Debug2::debug2('[ESI] params', $unencrypted);
// $unencoded = urldecode($unencrypted); no need to do this as $_GET is already parsed
$params = \json_decode($unencrypted, true);
return $params;
}
/**
* Select the correct esi output based on the parameters in an ESI request.
*
* @since 1.1.3
* @access public
*/
public function load_esi_block()
{
/**
* Validate if is a legal ESI req
* @since 2.9.6
*/
if (empty($_GET['_hash']) || $this->_gen_esi_md5($_GET) != $_GET['_hash']) {
Debug2::debug('[ESI] ❌ Failed to validate _hash');
return;
}
$params = $this->_parse_esi_param();
if (defined('LSCWP_LOG')) {
$logInfo = '[ESI] ⭕ ';
if (!empty($params[self::PARAM_NAME])) {
$logInfo .= ' Name: ' . $params[self::PARAM_NAME] . ' ----- ';
}
$logInfo .= ' [ID] ' . LSCACHE_IS_ESI;
Debug2::debug($logInfo);
}
if (!empty($params['_ls_silence'])) {
!defined('LSCACHE_ESI_SILENCE') && define('LSCACHE_ESI_SILENCE', true);
}
/**
* Buffer needs to be JSON format
* @since 2.9.4
*/
if (!empty($params['is_json'])) {
add_filter('litespeed_is_json', '__return_true');
}
Tag::add(rtrim(Tag::TYPE_ESI, '.'));
Tag::add(Tag::TYPE_ESI . LSCACHE_IS_ESI);
// Debug2::debug(var_export($params, true ));
/**
* Handle default cache control 'private,no-vary' for sub_esi_block() @ticket #923505
*
* @since 2.2.3
*/
if (!empty($_GET['_control'])) {
$control = explode(',', $_GET['_control']);
if (in_array('private', $control)) {
Control::set_private();
}
if (in_array('no-vary', $control)) {
Control::set_no_vary();
}
}
do_action('litespeed_esi_load-' . LSCACHE_IS_ESI, $params);
}
// The *_sub_* functions are helpers for the sub_* functions.
// The *_load_* functions are helpers for the load_* functions.
/**
* Loads the default options for default WordPress widgets.
*
* @since 1.1.3
* @access public
*/
public static function widget_default_options($options, $widget)
{
if (!is_array($options)) {
return $options;
}
$widget_name = get_class($widget);
switch ($widget_name) {
case 'WP_Widget_Recent_Posts':
case 'WP_Widget_Recent_Comments':
$options[self::WIDGET_O_ESIENABLE] = Base::VAL_OFF;
$options[self::WIDGET_O_TTL] = 86400;
break;
default:
break;
}
return $options;
}
/**
* Hooked to the widget_display_callback filter.
* If the admin configured the widget to display via esi, this function
* will set up the esi request and cancel the widget display.
*
* @since 1.1.3
* @access public
* @param array $instance Parameter used to build the widget.
* @param WP_Widget $widget The widget to build.
* @param array $args Parameter used to build the widget.
* @return mixed Return false if display through esi, instance otherwise.
*/
public function sub_widget_block($instance, $widget, $args)
{
// #210407
if (!is_array($instance)) {
return $instance;
}
$name = get_class($widget);
if (!isset($instance[Base::OPTION_NAME])) {
return $instance;
}
$options = $instance[Base::OPTION_NAME];
if (!isset($options) || !$options[self::WIDGET_O_ESIENABLE]) {
defined('LSCWP_LOG') && Debug2::debug('ESI 0 ' . $name . ': ' . (!isset($options) ? 'not set' : 'set off'));
return $instance;
}
$esi_private = $options[self::WIDGET_O_ESIENABLE] == Base::VAL_ON2 ? 'private,' : '';
$params = array(
self::PARAM_NAME => $name,
self::PARAM_ID => $widget->id,
self::PARAM_INSTANCE => $instance,
self::PARAM_ARGS => $args,
);
echo $this->sub_esi_block('widget', 'widget ' . $name, $params, $esi_private . 'no-vary');
return false;
}
/**
* Hooked to the wp_footer action.
* Sets up the ESI request for the admin bar.
*
* @access public
* @since 1.1.3
* @global type $wp_admin_bar
*/
public function sub_admin_bar_block()
{
global $wp_admin_bar;
if (!is_admin_bar_showing() || !is_object($wp_admin_bar)) {
return;
}
// To make each admin bar ESI request different for `Edit` button different link
$params = array(
'ref' => $_SERVER['REQUEST_URI'],
);
echo $this->sub_esi_block('admin-bar', 'adminbar', $params);
}
/**
* Parses the esi input parameters and generates the widget for esi display.
*
* @access public
* @since 1.1.3
* @global $wp_widget_factory
* @param array $params Input parameters needed to correctly display widget
*/
public function load_widget_block($params)
{
// global $wp_widget_factory;
// $widget = $wp_widget_factory->widgets[ $params[ self::PARAM_NAME ] ];
$option = $params[self::PARAM_INSTANCE];
$option = $option[Base::OPTION_NAME];
// Since we only reach here via esi, safe to assume setting exists.
$ttl = $option[self::WIDGET_O_TTL];
defined('LSCWP_LOG') && Debug2::debug('ESI widget render: name ' . $params[self::PARAM_NAME] . ', id ' . $params[self::PARAM_ID] . ', ttl ' . $ttl);
if ($ttl == 0) {
Control::set_nocache('ESI Widget time to live set to 0');
} else {
Control::set_custom_ttl($ttl);
if ($option[self::WIDGET_O_ESIENABLE] == Base::VAL_ON2) {
Control::set_private();
}
Control::set_no_vary();
Tag::add(Tag::TYPE_WIDGET . $params[self::PARAM_ID]);
}
the_widget($params[self::PARAM_NAME], $params[self::PARAM_INSTANCE], $params[self::PARAM_ARGS]);
}
/**
* Generates the admin bar for esi display.
*
* @access public
* @since 1.1.3
*/
public function load_admin_bar_block($params)
{
if (!empty($params['ref'])) {
$ref_qs = parse_url($params['ref'], PHP_URL_QUERY);
if (!empty($ref_qs)) {
parse_str($ref_qs, $ref_qs_arr);
if (!empty($ref_qs_arr)) {
foreach ($ref_qs_arr as $k => $v) {
$_GET[$k] = $v;
}
}
}
}
// Needed when permalink structure is "Plain"
wp();
wp_admin_bar_render();
if (!$this->conf(Base::O_ESI_CACHE_ADMBAR)) {
Control::set_nocache('build-in set to not cacheable');
} else {
Control::set_private();
Control::set_no_vary();
}
defined('LSCWP_LOG') && Debug2::debug('ESI: adminbar ref: ' . $_SERVER['REQUEST_URI']);
}
/**
* Parses the esi input parameters and generates the comment form for esi display.
*
* @access public
* @since 1.1.3
* @param array $params Input parameters needed to correctly display comment form
*/
public function load_comment_form_block($params)
{
comment_form($params[self::PARAM_ARGS], $params[self::PARAM_ID]);
if (!$this->conf(Base::O_ESI_CACHE_COMMFORM)) {
Control::set_nocache('build-in set to not cacheable');
} else {
// by default comment form is public
if (Vary::has_vary()) {
Control::set_private();
Control::set_no_vary();
}
}
}
/**
* Generate nonce for certain action
*
* @access public
* @since 2.6
*/
public function load_nonce_block($params)
{
$action = $params['action'];
Debug2::debug('[ESI] load_nonce_block [action] ' . $action);
// set nonce TTL to half day
Control::set_custom_ttl(43200);
if (Router::is_logged_in()) {
Control::set_private();
}
if (function_exists('wp_create_nonce_litespeed_esi')) {
echo wp_create_nonce_litespeed_esi($action);
} else {
echo wp_create_nonce($action);
}
}
/**
* Show original shortcode
*
* @access public
* @since 2.8
*/
public function load_esi_shortcode($params)
{
if (isset($params['ttl'])) {
if (!$params['ttl']) {
Control::set_nocache('ESI shortcode att ttl=0');
} else {
Control::set_custom_ttl($params['ttl']);
}
unset($params['ttl']);
}
// Replace to original shortcode
$shortcode = $params[0];
$atts_ori = array();
foreach ($params as $k => $v) {
if ($k === 0) {
continue;
}
$atts_ori[] = is_string($k) ? "$k='" . addslashes($v) . "'" : $v;
}
Tag::add(Tag::TYPE_ESI . "esi.$shortcode");
// Output original shortcode final content
echo do_shortcode("[$shortcode " . implode(' ', $atts_ori) . ' ]');
}
/**
* Hooked to the comment_form_defaults filter.
* Stores the default comment form settings.
* If sub_comment_form_block is triggered, the output buffer is cleared and an esi block is added. The remaining comment form is also buffered and cleared.
* Else there is no need to make the comment form ESI.
*
* @since 1.1.3
* @access public
*/
public function register_comment_form_actions($defaults)
{
$this->esi_args = $defaults;
echo GUI::clean_wrapper_begin();
add_filter('comment_form_submit_button', array($this, 'sub_comment_form_btn'), 1000, 2); // To save the params passed in
add_action('comment_form', array($this, 'sub_comment_form_block'), 1000);
return $defaults;
}
/**
* Store the args passed in comment_form for the ESI comment param usage in `$this->sub_comment_form_block()`
*
* @since 3.4
* @access public
*/
public function sub_comment_form_btn($unused, $args)
{
if (empty($args) || empty($this->esi_args)) {
Debug2::debug('comment form args empty?');
return $unused;
}
$esi_args = array();
// compare current args with default ones
foreach ($args as $k => $v) {
if (!isset($this->esi_args[$k])) {
$esi_args[$k] = $v;
} elseif (is_array($v)) {
$diff = array_diff_assoc($v, $this->esi_args[$k]);
if (!empty($diff)) {
$esi_args[$k] = $diff;
}
} elseif ($v !== $this->esi_args[$k]) {
$esi_args[$k] = $v;
}
}
$this->esi_args = $esi_args;
return $unused;
}
/**
* Hooked to the comment_form_submit_button filter.
*
* This method will compare the used comment form args against the default args. The difference will be passed to the esi request.
*
* @access public
* @since 1.1.3
*/
public function sub_comment_form_block($post_id)
{
echo GUI::clean_wrapper_end();
$params = array(
self::PARAM_ID => $post_id,
self::PARAM_ARGS => $this->esi_args,
);
echo $this->sub_esi_block('comment-form', 'comment form', $params);
echo GUI::clean_wrapper_begin();
add_action('comment_form_after', array($this, 'comment_form_sub_clean'));
}
/**
* Hooked to the comment_form_after action.
* Cleans up the remaining comment form output.
*
* @since 1.1.3
* @access public
*/
public function comment_form_sub_clean()
{
echo GUI::clean_wrapper_end();
}
/**
* Replace preserved blocks
*
* @since 2.6
* @access public
*/
public function finalize($buffer)
{
// Prepend combo esi block
if (self::$_combine_ids) {
Debug2::debug('[ESI] 🍔 Enabled combo');
$esi_block = $this->sub_esi_block(self::COMBO, '__COMBINE_MAIN__', array(), 'no-cache', true);
$buffer = $esi_block . $buffer;
}
// Bypass if no preserved list to be replaced
if (!$this->_esi_preserve_list) {
return $buffer;
}
$keys = array_keys($this->_esi_preserve_list);
Debug2::debug('[ESI] replacing preserved blocks', $keys);
$buffer = str_replace($keys, $this->_esi_preserve_list, $buffer);
return $buffer;
}
/**
* Check if the content contains preserved list or not
*
* @since 3.3
*/
public function contain_preserve_esi($content)
{
$hit_list = array();
foreach ($this->_esi_preserve_list as $k => $v) {
if (strpos($content, '"' . $k . '"') !== false) {
$hit_list[] = '"' . $k . '"';
}
if (strpos($content, "'" . $k . "'") !== false) {
$hit_list[] = "'" . $k . "'";
}
}
return $hit_list;
}
}
file.cls.php 0000644 00000024734 15153741266 0007000 0 ustar 00 <?php
/**
* LiteSpeed File Operator Library Class
* Append/Replace content to a file
*
* @since 1.1.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class File
{
const MARKER = 'LiteSpeed Operator';
/**
* Detect if an URL is 404
*
* @since 3.3
*/
public static function is_404($url)
{
$response = wp_safe_remote_get($url);
$code = wp_remote_retrieve_response_code($response);
if ($code == 404) {
return true;
}
return false;
}
/**
* Delete folder
*
* @since 2.1
*/
public static function rrmdir($dir)
{
$files = array_diff(scandir($dir), array('.', '..'));
foreach ($files as $file) {
is_dir("$dir/$file") ? self::rrmdir("$dir/$file") : unlink("$dir/$file");
}
return rmdir($dir);
}
public static function count_lines($filename)
{
if (!file_exists($filename)) {
return 0;
}
$file = new \SplFileObject($filename);
$file->seek(PHP_INT_MAX);
return $file->key() + 1;
}
/**
* Read data from file
*
* @since 1.1.0
* @param string $filename
* @param int $start_line
* @param int $lines
*/
public static function read($filename, $start_line = null, $lines = null)
{
if (!file_exists($filename)) {
return '';
}
if (!is_readable($filename)) {
return false;
}
if ($start_line !== null) {
$res = array();
$file = new \SplFileObject($filename);
$file->seek($start_line);
if ($lines === null) {
while (!$file->eof()) {
$res[] = rtrim($file->current(), "\n");
$file->next();
}
} else {
for ($i = 0; $i < $lines; $i++) {
if ($file->eof()) {
break;
}
$res[] = rtrim($file->current(), "\n");
$file->next();
}
}
unset($file);
return $res;
}
$content = file_get_contents($filename);
$content = self::remove_zero_space($content);
return $content;
}
/**
* Append data to file
*
* @since 1.1.5
* @access public
* @param string $filename
* @param string $data
* @param boolean $mkdir
* @param boolean $silence Used to avoid WP's functions are used
*/
public static function append($filename, $data, $mkdir = false, $silence = true)
{
return self::save($filename, $data, $mkdir, true, $silence);
}
/**
* Save data to file
*
* @since 1.1.0
* @param string $filename
* @param string $data
* @param boolean $mkdir
* @param boolean $append If the content needs to be appended
* @param boolean $silence Used to avoid WP's functions are used
*/
public static function save($filename, $data, $mkdir = false, $append = false, $silence = true)
{
if (is_null($filename)) {
return $silence ? false : __('Filename is empty!', 'litespeed-cache');
}
$error = false;
$folder = dirname($filename);
// mkdir if folder does not exist
if (!file_exists($folder)) {
if (!$mkdir) {
return $silence ? false : sprintf(__('Folder does not exist: %s', 'litespeed-cache'), $folder);
}
set_error_handler('litespeed_exception_handler');
try {
mkdir($folder, 0755, true);
// Create robots.txt file to forbid search engine indexes
if (!file_exists(LITESPEED_STATIC_DIR . '/robots.txt')) {
file_put_contents(LITESPEED_STATIC_DIR . '/robots.txt', "User-agent: *\nDisallow: /\n");
}
} catch (\ErrorException $ex) {
return $silence ? false : sprintf(__('Can not create folder: %1$s. Error: %2$s', 'litespeed-cache'), $folder, $ex->getMessage());
}
restore_error_handler();
}
if (!file_exists($filename)) {
if (!is_writable($folder)) {
return $silence ? false : sprintf(__('Folder is not writable: %s.', 'litespeed-cache'), $folder);
}
set_error_handler('litespeed_exception_handler');
try {
touch($filename);
} catch (\ErrorException $ex) {
return $silence ? false : sprintf(__('File %s is not writable.', 'litespeed-cache'), $filename);
}
restore_error_handler();
} elseif (!is_writable($filename)) {
return $silence ? false : sprintf(__('File %s is not writable.', 'litespeed-cache'), $filename);
}
$data = self::remove_zero_space($data);
$ret = file_put_contents($filename, $data, $append ? FILE_APPEND : LOCK_EX);
if ($ret === false) {
return $silence ? false : sprintf(__('Failed to write to %s.', 'litespeed-cache'), $filename);
}
return true;
}
/**
* Remove Unicode zero-width space <200b><200c>
*
* @since 2.1.2
* @since 2.9 changed to public
*/
public static function remove_zero_space($content)
{
if (is_array($content)) {
$content = array_map(__CLASS__ . '::remove_zero_space', $content);
return $content;
}
// Remove UTF-8 BOM if present
if (substr($content, 0, 3) === "\xEF\xBB\xBF") {
$content = substr($content, 3);
}
$content = str_replace("\xe2\x80\x8b", '', $content);
$content = str_replace("\xe2\x80\x8c", '', $content);
$content = str_replace("\xe2\x80\x8d", '', $content);
return $content;
}
/**
* Appends an array of strings into a file (.htaccess ), placing it between
* BEGIN and END markers.
*
* Replaces existing marked info. Retains surrounding
* data. Creates file if none exists.
*
* @param string $filename Filename to alter.
* @param string $marker The marker to alter.
* @param array|string $insertion The new content to insert.
* @param bool $prepend Prepend insertion if not exist.
* @return bool True on write success, false on failure.
*/
public static function insert_with_markers($filename, $insertion = false, $marker = false, $prepend = false)
{
if (!$marker) {
$marker = self::MARKER;
}
if (!$insertion) {
$insertion = array();
}
return self::_insert_with_markers($filename, $marker, $insertion, $prepend); //todo: capture exceptions
}
/**
* Return wrapped block data with marker
*
* @param string $insertion
* @param string $marker
* @return string The block data
*/
public static function wrap_marker_data($insertion, $marker = false)
{
if (!$marker) {
$marker = self::MARKER;
}
$start_marker = "# BEGIN {$marker}";
$end_marker = "# END {$marker}";
$new_data = implode("\n", array_merge(array($start_marker), $insertion, array($end_marker)));
return $new_data;
}
/**
* Touch block data from file, return with marker
*
* @param string $filename
* @param string $marker
* @return string The current block data
*/
public static function touch_marker_data($filename, $marker = false)
{
if (!$marker) {
$marker = self::MARKER;
}
$result = self::_extract_from_markers($filename, $marker);
if (!$result) {
return false;
}
$start_marker = "# BEGIN {$marker}";
$end_marker = "# END {$marker}";
$new_data = implode("\n", array_merge(array($start_marker), $result, array($end_marker)));
return $new_data;
}
/**
* Extracts strings from between the BEGIN and END markers in the .htaccess file.
*
* @param string $filename
* @param string $marker
* @return array An array of strings from a file (.htaccess ) from between BEGIN and END markers.
*/
public static function extract_from_markers($filename, $marker = false)
{
if (!$marker) {
$marker = self::MARKER;
}
return self::_extract_from_markers($filename, $marker);
}
/**
* Extracts strings from between the BEGIN and END markers in the .htaccess file.
*
* @param string $filename
* @param string $marker
* @return array An array of strings from a file (.htaccess ) from between BEGIN and END markers.
*/
private static function _extract_from_markers($filename, $marker)
{
$result = array();
if (!file_exists($filename)) {
return $result;
}
if ($markerdata = explode("\n", implode('', file($filename)))) {
$state = false;
foreach ($markerdata as $markerline) {
if (strpos($markerline, '# END ' . $marker) !== false) {
$state = false;
}
if ($state) {
$result[] = $markerline;
}
if (strpos($markerline, '# BEGIN ' . $marker) !== false) {
$state = true;
}
}
}
return array_map('trim', $result);
}
/**
* Inserts an array of strings into a file (.htaccess ), placing it between BEGIN and END markers.
*
* Replaces existing marked info. Retains surrounding data. Creates file if none exists.
*
* NOTE: will throw error if failed
*
* @since 3.0-
* @since 3.0 Throw errors if failed
* @access private
*/
private static function _insert_with_markers($filename, $marker, $insertion, $prepend = false)
{
if (!file_exists($filename)) {
if (!is_writable(dirname($filename))) {
Error::t('W', dirname($filename));
}
set_error_handler('litespeed_exception_handler');
try {
touch($filename);
} catch (\ErrorException $ex) {
Error::t('W', $filename);
}
restore_error_handler();
} elseif (!is_writable($filename)) {
Error::t('W', $filename);
}
if (!is_array($insertion)) {
$insertion = explode("\n", $insertion);
}
$start_marker = "# BEGIN {$marker}";
$end_marker = "# END {$marker}";
$fp = fopen($filename, 'r+');
if (!$fp) {
Error::t('W', $filename);
}
// Attempt to get a lock. If the filesystem supports locking, this will block until the lock is acquired.
flock($fp, LOCK_EX);
$lines = array();
while (!feof($fp)) {
$lines[] = rtrim(fgets($fp), "\r\n");
}
// Split out the existing file into the preceding lines, and those that appear after the marker
$pre_lines = $post_lines = $existing_lines = array();
$found_marker = $found_end_marker = false;
foreach ($lines as $line) {
if (!$found_marker && false !== strpos($line, $start_marker)) {
$found_marker = true;
continue;
} elseif (!$found_end_marker && false !== strpos($line, $end_marker)) {
$found_end_marker = true;
continue;
}
if (!$found_marker) {
$pre_lines[] = $line;
} elseif ($found_marker && $found_end_marker) {
$post_lines[] = $line;
} else {
$existing_lines[] = $line;
}
}
// Check to see if there was a change
if ($existing_lines === $insertion) {
flock($fp, LOCK_UN);
fclose($fp);
return true;
}
// Check if need to prepend data if not exist
if ($prepend && !$post_lines) {
// Generate the new file data
$new_file_data = implode("\n", array_merge(array($start_marker), $insertion, array($end_marker), $pre_lines));
} else {
// Generate the new file data
$new_file_data = implode("\n", array_merge($pre_lines, array($start_marker), $insertion, array($end_marker), $post_lines));
}
// Write to the start of the file, and truncate it to that length
fseek($fp, 0);
$bytes = fwrite($fp, $new_file_data);
if ($bytes) {
ftruncate($fp, ftell($fp));
}
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return (bool) $bytes;
}
}
gui.cls.php 0000644 00000066745 15153741266 0006655 0 ustar 00 <?php
/**
* The frontend GUI class.
*
* @since 1.3
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class GUI extends Base
{
private static $_clean_counter = 0;
private $_promo_true;
// [ file_tag => [ days, litespeed_only ], ... ]
private $_promo_list = array(
'new_version' => array(7, false),
'score' => array(14, false),
// 'slack' => array( 3, false ),
);
const LIB_GUEST_JS = 'assets/js/guest.min.js';
const LIB_GUEST_DOCREF_JS = 'assets/js/guest.docref.min.js';
const PHP_GUEST = 'guest.vary.php';
const TYPE_DISMISS_WHM = 'whm';
const TYPE_DISMISS_EXPIRESDEFAULT = 'ExpiresDefault';
const TYPE_DISMISS_PROMO = 'promo';
const TYPE_DISMISS_PIN = 'pin';
const WHM_MSG = 'lscwp_whm_install';
const WHM_MSG_VAL = 'whm_install';
protected $_summary;
/**
* Instance
*
* @since 1.3
*/
public function __construct()
{
$this->_summary = self::get_summary();
}
/**
* Frontend Init
*
* @since 3.0
*/
public function init()
{
Debug2::debug2('[GUI] init');
if (is_admin_bar_showing() && current_user_can('manage_options')) {
add_action('wp_enqueue_scripts', array($this, 'frontend_enqueue_style'));
add_action('admin_bar_menu', array($this, 'frontend_shortcut'), 95);
}
/**
* Turn on instant click
* @since 1.8.2
*/
if ($this->conf(self::O_UTIL_INSTANT_CLICK)) {
add_action('wp_enqueue_scripts', array($this, 'frontend_enqueue_style_public'));
}
// NOTE: this needs to be before optimizer to avoid wrapper being removed
add_filter('litespeed_buffer_finalize', array($this, 'finalize'), 8);
}
/**
* Print a loading message when redirecting CCSS/UCSS page to avoid whiteboard confusion
*/
public static function print_loading($counter, $type)
{
echo '<div style="font-size: 25px; text-align: center; padding-top: 150px; width: 100%; position: absolute;">';
echo "<img width='35' src='" . LSWCP_PLUGIN_URL . "assets/img/Litespeed.icon.svg' /> ";
echo sprintf(__('%1$s %2$s files left in queue', 'litespeed-cache'), $counter, $type);
echo '<p><a href="' . admin_url('admin.php?page=litespeed-page_optm') . '">' . __('Cancel', 'litespeed-cache') . '</a></p>';
echo '</div>';
}
/**
* Display a pie
*
* @since 1.6.6
*/
public static function pie($percent, $width = 50, $finished_tick = false, $without_percentage = false, $append_cls = false)
{
$percentage = '<text x="50%" y="50%">' . $percent . ($without_percentage ? '' : '%') . '</text>';
if ($percent == 100 && $finished_tick) {
$percentage = '<text x="50%" y="50%" class="litespeed-pie-done">✓</text>';
}
return "
<svg class='litespeed-pie $append_cls' viewbox='0 0 33.83098862 33.83098862' width='$width' height='$width' xmlns='http://www.w3.org/2000/svg'>
<circle class='litespeed-pie_bg' cx='16.91549431' cy='16.91549431' r='15.91549431' />
<circle class='litespeed-pie_circle' cx='16.91549431' cy='16.91549431' r='15.91549431' stroke-dasharray='$percent,100' />
<g class='litespeed-pie_info'>$percentage</g>
</svg>
";
}
/**
* Display a tiny pie with a tooltip
*
* @since 3.0
*/
public static function pie_tiny($percent, $width = 50, $tooltip = '', $tooltip_pos = 'up', $append_cls = false)
{
// formula C = 2πR
$dasharray = 2 * 3.1416 * 9 * ($percent / 100);
return "
<button type='button' data-balloon-break data-balloon-pos='$tooltip_pos' aria-label='$tooltip' class='litespeed-btn-pie'>
<svg class='litespeed-pie litespeed-pie-tiny $append_cls' viewbox='0 0 30 30' width='$width' height='$width' xmlns='http://www.w3.org/2000/svg'>
<circle class='litespeed-pie_bg' cx='15' cy='15' r='9' />
<circle class='litespeed-pie_circle' cx='15' cy='15' r='9' stroke-dasharray='$dasharray,100' />
<g class='litespeed-pie_info'><text x='50%' y='50%'>i</text></g>
</svg>
</button>
";
}
/**
* Get classname of PageSpeed Score
*
* Scale:
* 90-100 (fast)
* 50-89 (average)
* 0-49 (slow)
*
* @since 2.9
* @access public
*/
public function get_cls_of_pagescore($score)
{
if ($score >= 90) {
return 'success';
}
if ($score >= 50) {
return 'warning';
}
return 'danger';
}
/**
* Dismiss banner
*
* @since 1.0
* @access public
*/
public static function dismiss()
{
$_instance = self::cls();
switch (Router::verify_type()) {
case self::TYPE_DISMISS_WHM:
self::dismiss_whm();
break;
case self::TYPE_DISMISS_EXPIRESDEFAULT:
self::update_option(Admin_Display::DB_DISMISS_MSG, Admin_Display::RULECONFLICT_DISMISSED);
break;
case self::TYPE_DISMISS_PIN:
admin_display::dismiss_pin();
break;
case self::TYPE_DISMISS_PROMO:
if (empty($_GET['promo_tag'])) {
break;
}
$promo_tag = sanitize_key($_GET['promo_tag']);
if (empty($_instance->_promo_list[$promo_tag])) {
break;
}
defined('LSCWP_LOG') && Debug2::debug('[GUI] Dismiss promo ' . $promo_tag);
// Forever dismiss
if (!empty($_GET['done'])) {
$_instance->_summary[$promo_tag] = 'done';
} elseif (!empty($_GET['later'])) {
// Delay the banner to half year later
$_instance->_summary[$promo_tag] = time() + 86400 * 180;
} else {
// Update welcome banner to 30 days after
$_instance->_summary[$promo_tag] = time() + 86400 * 30;
}
self::save_summary();
break;
default:
break;
}
if (Router::is_ajax()) {
// All dismiss actions are considered as ajax call, so just exit
exit(\json_encode(array('success' => 1)));
}
// Plain click link, redirect to referral url
Admin::redirect();
}
/**
* Check if has rule conflict notice
*
* @since 1.1.5
* @access public
* @return boolean
*/
public static function has_msg_ruleconflict()
{
$db_dismiss_msg = self::get_option(Admin_Display::DB_DISMISS_MSG);
if (!$db_dismiss_msg) {
self::update_option(Admin_Display::DB_DISMISS_MSG, -1);
}
return $db_dismiss_msg == Admin_Display::RULECONFLICT_ON;
}
/**
* Check if has whm notice
*
* @since 1.1.1
* @access public
* @return boolean
*/
public static function has_whm_msg()
{
$val = self::get_option(self::WHM_MSG);
if (!$val) {
self::dismiss_whm();
return false;
}
return $val == self::WHM_MSG_VAL;
}
/**
* Delete whm msg tag
*
* @since 1.1.1
* @access public
*/
public static function dismiss_whm()
{
self::update_option(self::WHM_MSG, -1);
}
/**
* Set current page a litespeed page
*
* @since 2.9
*/
private function _is_litespeed_page()
{
if (
!empty($_GET['page']) &&
in_array($_GET['page'], array(
'litespeed-settings',
'litespeed-dash',
Admin::PAGE_EDIT_HTACCESS,
'litespeed-optimization',
'litespeed-crawler',
'litespeed-import',
'litespeed-report',
))
) {
return true;
}
return false;
}
/**
* Display promo banner
*
* @since 2.1
* @access public
*/
public function show_promo($check_only = false)
{
$is_litespeed_page = $this->_is_litespeed_page();
// Bypass showing info banner if disabled all in debug
if (defined('LITESPEED_DISABLE_ALL') && LITESPEED_DISABLE_ALL) {
if ($is_litespeed_page && !$check_only) {
include_once LSCWP_DIR . 'tpl/inc/disabled_all.php';
}
return false;
}
if (file_exists(ABSPATH . '.litespeed_no_banner')) {
defined('LSCWP_LOG') && Debug2::debug('[GUI] Bypass banners due to silence file');
return false;
}
foreach ($this->_promo_list as $promo_tag => $v) {
list($delay_days, $litespeed_page_only) = $v;
if ($litespeed_page_only && !$is_litespeed_page) {
continue;
}
// first time check
if (empty($this->_summary[$promo_tag])) {
$this->_summary[$promo_tag] = time() + 86400 * $delay_days;
self::save_summary();
continue;
}
$promo_timestamp = $this->_summary[$promo_tag];
// was ticked as done
if ($promo_timestamp == 'done') {
continue;
}
// Not reach the dateline yet
if (time() < $promo_timestamp) {
continue;
}
// try to load, if can pass, will set $this->_promo_true = true
$this->_promo_true = false;
include LSCWP_DIR . "tpl/banner/$promo_tag.php";
// If not defined, means it didn't pass the display workflow in tpl.
if (!$this->_promo_true) {
continue;
}
if ($check_only) {
return $promo_tag;
}
defined('LSCWP_LOG') && Debug2::debug('[GUI] Show promo ' . $promo_tag);
// Only contain one
break;
}
return false;
}
/**
* Load frontend public script
*
* @since 1.8.2
* @access public
*/
public function frontend_enqueue_style_public()
{
wp_enqueue_script(Core::PLUGIN_NAME, LSWCP_PLUGIN_URL . 'assets/js/instant_click.min.js', array(), Core::VER, true);
}
/**
* Load frontend menu shortcut
*
* @since 1.3
* @access public
*/
public function frontend_enqueue_style()
{
wp_enqueue_style(Core::PLUGIN_NAME, LSWCP_PLUGIN_URL . 'assets/css/litespeed.css', array(), Core::VER, 'all');
}
/**
* Load frontend menu shortcut
*
* @since 1.3
* @access public
*/
public function frontend_shortcut()
{
global $wp_admin_bar;
$wp_admin_bar->add_menu(array(
'id' => 'litespeed-menu',
'title' => '<span class="ab-icon"></span>',
'href' => get_admin_url(null, 'admin.php?page=litespeed'),
'meta' => array('tabindex' => 0, 'class' => 'litespeed-top-toolbar'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-single',
'title' => __('Purge this page', 'litespeed-cache') . ' - LSCache',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_FRONT, false, true),
'meta' => array('tabindex' => '0'),
));
if ($this->has_cache_folder('ucss')) {
$possible_url_tag = UCSS::get_url_tag();
$append_arr = array();
if ($possible_url_tag) {
$append_arr['url_tag'] = $possible_url_tag;
}
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-single-ucss',
'title' => __('Purge this page', 'litespeed-cache') . ' - UCSS',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_UCSS, false, true, $append_arr),
'meta' => array('tabindex' => '0'),
));
}
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-single-action',
'title' => __('Mark this page as ', 'litespeed-cache'),
'meta' => array('tabindex' => '0'),
));
if (!empty($_SERVER['REQUEST_URI'])) {
$append_arr = array(
Conf::TYPE_SET . '[' . self::O_CACHE_FORCE_URI . '][]' => $_SERVER['REQUEST_URI'] . '$',
'redirect' => $_SERVER['REQUEST_URI'],
);
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-single-action',
'id' => 'litespeed-single-forced_cache',
'title' => __('Forced cacheable', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_CONF, Conf::TYPE_SET, false, true, $append_arr),
));
$append_arr = array(
Conf::TYPE_SET . '[' . self::O_CACHE_EXC . '][]' => $_SERVER['REQUEST_URI'] . '$',
'redirect' => $_SERVER['REQUEST_URI'],
);
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-single-action',
'id' => 'litespeed-single-noncache',
'title' => __('Non cacheable', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_CONF, Conf::TYPE_SET, false, true, $append_arr),
));
$append_arr = array(
Conf::TYPE_SET . '[' . self::O_CACHE_PRIV_URI . '][]' => $_SERVER['REQUEST_URI'] . '$',
'redirect' => $_SERVER['REQUEST_URI'],
);
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-single-action',
'id' => 'litespeed-single-private',
'title' => __('Private cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_CONF, Conf::TYPE_SET, false, true, $append_arr),
));
$append_arr = array(
Conf::TYPE_SET . '[' . self::O_OPTM_EXC . '][]' => $_SERVER['REQUEST_URI'] . '$',
'redirect' => $_SERVER['REQUEST_URI'],
);
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-single-action',
'id' => 'litespeed-single-nonoptimize',
'title' => __('No optimization', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_CONF, Conf::TYPE_SET, false, true, $append_arr),
));
}
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-single-action',
'id' => 'litespeed-single-more',
'title' => __('More settings', 'litespeed-cache'),
'href' => get_admin_url(null, 'admin.php?page=litespeed-cache'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-all',
'title' => __('Purge All', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-all-lscache',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('LSCache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LSCACHE, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-cssjs',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('CSS/JS Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_CSSJS, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
if ($this->conf(self::O_CDN_CLOUDFLARE)) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-cloudflare',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Cloudflare', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_CDN_CLOUDFLARE, CDN\Cloudflare::TYPE_PURGE_ALL),
'meta' => array('tabindex' => '0'),
));
}
if (defined('LSCWP_OBJECT_CACHE')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-object',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Object Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_OBJECT, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
}
if (Router::opcache_enabled()) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-opcache',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Opcode Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_OPCACHE, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('ccss')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-ccss',
'title' => __('Purge All', 'litespeed-cache') . ' - CCSS',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_CCSS, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('ucss')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-ucss',
'title' => __('Purge All', 'litespeed-cache') . ' - UCSS',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_UCSS, false, '_ori'),
));
}
if ($this->has_cache_folder('localres')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-localres',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Localized Resources', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LOCALRES, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('lqip')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-placeholder',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('LQIP Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LQIP, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('avatar')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-avatar',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Gravatar Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_AVATAR, false, '_ori'),
'meta' => array('tabindex' => '0'),
));
}
do_action('litespeed_frontend_shortcut');
}
/**
* Hooked to wp_before_admin_bar_render.
* Adds a link to the admin bar so users can quickly purge all.
*
* @access public
* @global WP_Admin_Bar $wp_admin_bar
* @since 1.7.2 Moved from admin_display.cls to gui.cls; Renamed from `add_quick_purge` to `backend_shortcut`
*/
public function backend_shortcut()
{
global $wp_admin_bar;
// if ( defined( 'LITESPEED_ON' ) ) {
$wp_admin_bar->add_menu(array(
'id' => 'litespeed-menu',
'title' => '<span class="ab-icon" title="' . __('LiteSpeed Cache Purge All', 'litespeed-cache') . ' - ' . __('LSCache', 'litespeed-cache') . '"></span>',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LSCACHE),
'meta' => array('tabindex' => 0, 'class' => 'litespeed-top-toolbar'),
));
// }
// else {
// $wp_admin_bar->add_menu( array(
// 'id' => 'litespeed-menu',
// 'title' => '<span class="ab-icon" title="' . __( 'LiteSpeed Cache', 'litespeed-cache' ) . '"></span>',
// 'meta' => array( 'tabindex' => 0, 'class' => 'litespeed-top-toolbar' ),
// ) );
// }
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-bar-manage',
'title' => __('Manage', 'litespeed-cache'),
'href' => 'admin.php?page=litespeed',
'meta' => array('tabindex' => '0'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-bar-setting',
'title' => __('Settings', 'litespeed-cache'),
'href' => 'admin.php?page=litespeed-cache',
'meta' => array('tabindex' => '0'),
));
if (!is_network_admin()) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-bar-imgoptm',
'title' => __('Image Optimization', 'litespeed-cache'),
'href' => 'admin.php?page=litespeed-img_optm',
'meta' => array('tabindex' => '0'),
));
}
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-all',
'title' => __('Purge All', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL),
'meta' => array('tabindex' => '0'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-all-lscache',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('LSCache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LSCACHE),
'meta' => array('tabindex' => '0'),
));
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-cssjs',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('CSS/JS Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_CSSJS),
'meta' => array('tabindex' => '0'),
));
if ($this->conf(self::O_CDN_CLOUDFLARE)) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-cloudflare',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Cloudflare', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_CDN_CLOUDFLARE, CDN\Cloudflare::TYPE_PURGE_ALL),
'meta' => array('tabindex' => '0'),
));
}
if (defined('LSCWP_OBJECT_CACHE')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-object',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Object Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_OBJECT),
'meta' => array('tabindex' => '0'),
));
}
if (Router::opcache_enabled()) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-opcache',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Opcode Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_OPCACHE),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('ccss')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-ccss',
'title' => __('Purge All', 'litespeed-cache') . ' - CCSS',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_CCSS),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('ucss')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-ucss',
'title' => __('Purge All', 'litespeed-cache') . ' - UCSS',
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_UCSS),
));
}
if ($this->has_cache_folder('localres')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-localres',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Localized Resources', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LOCALRES),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('lqip')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-placeholder',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('LQIP Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_LQIP),
'meta' => array('tabindex' => '0'),
));
}
if ($this->has_cache_folder('avatar')) {
$wp_admin_bar->add_menu(array(
'parent' => 'litespeed-menu',
'id' => 'litespeed-purge-avatar',
'title' => __('Purge All', 'litespeed-cache') . ' - ' . __('Gravatar Cache', 'litespeed-cache'),
'href' => Utility::build_url(Router::ACTION_PURGE, Purge::TYPE_PURGE_ALL_AVATAR),
'meta' => array('tabindex' => '0'),
));
}
do_action('litespeed_backend_shortcut');
}
/**
* Clear unfinished data
*
* @since 2.4.2
* @access public
*/
public static function img_optm_clean_up($unfinished_num)
{
return sprintf(
'<a href="%1$s" class="button litespeed-btn-warning" data-balloon-pos="up" aria-label="%2$s"><span class="dashicons dashicons-editor-removeformatting"></span> %3$s</a>',
Utility::build_url(Router::ACTION_IMG_OPTM, Img_Optm::TYPE_CLEAN),
__('Remove all previous unfinished image optimization requests.', 'litespeed-cache'),
__('Clean Up Unfinished Data', 'litespeed-cache') . ($unfinished_num ? ': ' . Admin_Display::print_plural($unfinished_num, 'image') : '')
);
}
/**
* Generate install link
*
* @since 2.4.2
* @access public
*/
public static function plugin_install_link($title, $name, $v)
{
$url = wp_nonce_url(self_admin_url('update.php?action=install-plugin&plugin=' . $name), 'install-plugin_' . $name);
$action = sprintf(
'<a href="%1$s" class="install-now" data-slug="%2$s" data-name="%3$s" aria-label="%4$s">%5$s</a>',
esc_url($url),
esc_attr($name),
esc_attr($title),
esc_attr(sprintf(__('Install %s', 'litespeed-cache'), $title)),
__('Install Now', 'litespeed-cache')
);
return $action;
// $msg .= " <a href='$upgrade_link' class='litespeed-btn-success' target='_blank'>" . __( 'Click here to upgrade', 'litespeed-cache' ) . '</a>';
}
/**
* Generate upgrade link
*
* @since 2.4.2
* @access public
*/
public static function plugin_upgrade_link($title, $name, $v)
{
$details_url = self_admin_url('plugin-install.php?tab=plugin-information&plugin=' . $name . '§ion=changelog&TB_iframe=true&width=600&height=800');
$file = $name . '/' . $name . '.php';
$msg = sprintf(
__('<a href="%1$s" %2$s>View version %3$s details</a> or <a href="%4$s" %5$s target="_blank">update now</a>.', 'litespeed-cache'),
esc_url($details_url),
sprintf('class="thickbox open-plugin-details-modal" aria-label="%s"', esc_attr(sprintf(__('View %1$s version %2$s details', 'litespeed-cache'), $title, $v))),
$v,
wp_nonce_url(self_admin_url('update.php?action=upgrade-plugin&plugin=') . $file, 'upgrade-plugin_' . $file),
sprintf('class="update-link" aria-label="%s"', esc_attr(sprintf(__('Update %s now', 'litespeed-cache'), $title)))
);
return $msg;
}
/**
* Finalize buffer by GUI class
*
* @since 1.6
* @access public
*/
public function finalize($buffer)
{
$buffer = $this->_clean_wrapper($buffer);
// Maybe restore doc.ref
if ($this->conf(Base::O_GUEST) && strpos($buffer, '<head>') !== false && defined('LITESPEED_IS_HTML')) {
$buffer = $this->_enqueue_guest_docref_js($buffer);
}
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST && strpos($buffer, '</body>') !== false && defined('LITESPEED_IS_HTML')) {
$buffer = $this->_enqueue_guest_js($buffer);
}
return $buffer;
}
/**
* Append guest restore doc.ref JS for organic traffic count
*
* @since 4.4.6
*/
private function _enqueue_guest_docref_js($buffer)
{
$js_con = File::read(LSCWP_DIR . self::LIB_GUEST_DOCREF_JS);
$buffer = preg_replace('/<head>/', '<head><script data-no-optimize="1">' . $js_con . '</script>', $buffer, 1);
return $buffer;
}
/**
* Append guest JS to update vary
*
* @since 4.0
*/
private function _enqueue_guest_js($buffer)
{
$js_con = File::read(LSCWP_DIR . self::LIB_GUEST_JS);
// $guest_update_url = add_query_arg( 'litespeed_guest', 1, home_url( '/' ) );
$guest_update_url = parse_url(LSWCP_PLUGIN_URL . self::PHP_GUEST, PHP_URL_PATH);
$js_con = str_replace('litespeed_url', esc_url($guest_update_url), $js_con);
$buffer = preg_replace('/<\/body>/', '<script data-no-optimize="1">' . $js_con . '</script></body>', $buffer, 1);
return $buffer;
}
/**
* Clean wrapper from buffer
*
* @since 1.4
* @since 1.6 converted to private with adding prefix _
* @access private
*/
private function _clean_wrapper($buffer)
{
if (self::$_clean_counter < 1) {
Debug2::debug2('GUI bypassed by no counter');
return $buffer;
}
Debug2::debug2('GUI start cleaning counter ' . self::$_clean_counter);
for ($i = 1; $i <= self::$_clean_counter; $i++) {
// If miss beginning
$start = strpos($buffer, self::clean_wrapper_begin($i));
if ($start === false) {
$buffer = str_replace(self::clean_wrapper_end($i), '', $buffer);
Debug2::debug2("GUI lost beginning wrapper $i");
continue;
}
// If miss end
$end_wrapper = self::clean_wrapper_end($i);
$end = strpos($buffer, $end_wrapper);
if ($end === false) {
$buffer = str_replace(self::clean_wrapper_begin($i), '', $buffer);
Debug2::debug2("GUI lost ending wrapper $i");
continue;
}
// Now replace wrapped content
$buffer = substr_replace($buffer, '', $start, $end - $start + strlen($end_wrapper));
Debug2::debug2("GUI cleaned wrapper $i");
}
return $buffer;
}
/**
* Display a to-be-removed html wrapper
*
* @since 1.4
* @access public
*/
public static function clean_wrapper_begin($counter = false)
{
if ($counter === false) {
self::$_clean_counter++;
$counter = self::$_clean_counter;
Debug2::debug("GUI clean wrapper $counter begin");
}
return '<!-- LiteSpeed To Be Removed begin ' . $counter . ' -->';
}
/**
* Display a to-be-removed html wrapper
*
* @since 1.4
* @access public
*/
public static function clean_wrapper_end($counter = false)
{
if ($counter === false) {
$counter = self::$_clean_counter;
Debug2::debug("GUI clean wrapper $counter end");
}
return '<!-- LiteSpeed To Be Removed end ' . $counter . ' -->';
}
}
health.cls.php 0000644 00000005622 15153741266 0007321 0 ustar 00 <?php
/**
* The page health
*
*
* @since 3.0
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Health extends Base
{
const TYPE_SPEED = 'speed';
const TYPE_SCORE = 'score';
protected $_summary;
/**
* Init
*
* @since 3.0
*/
public function __construct()
{
$this->_summary = self::get_summary();
}
/**
* Test latest speed
*
* @since 3.0
*/
private function _ping($type)
{
$data = array('action' => $type);
$json = Cloud::post(Cloud::SVC_HEALTH, $data, 600);
if (empty($json['data']['before']) || empty($json['data']['after'])) {
Debug2::debug('[Health] ❌ no data');
return false;
}
$this->_summary[$type . '.before'] = $json['data']['before'];
$this->_summary[$type . '.after'] = $json['data']['after'];
self::save_summary();
Debug2::debug('[Health] saved result');
}
/**
* Generate scores
*
* @since 3.0
*/
public function scores()
{
$speed_before = $speed_after = $speed_improved = 0;
if (!empty($this->_summary['speed.before']) && !empty($this->_summary['speed.after'])) {
// Format loading time
$speed_before = $this->_summary['speed.before'] / 1000;
if ($speed_before < 0.01) {
$speed_before = 0.01;
}
$speed_before = number_format($speed_before, 2);
$speed_after = $this->_summary['speed.after'] / 1000;
if ($speed_after < 0.01) {
$speed_after = number_format($speed_after, 3);
} else {
$speed_after = number_format($speed_after, 2);
}
$speed_improved = (($this->_summary['speed.before'] - $this->_summary['speed.after']) * 100) / $this->_summary['speed.before'];
if ($speed_improved > 99) {
$speed_improved = number_format($speed_improved, 2);
} else {
$speed_improved = number_format($speed_improved);
}
}
$score_before = $score_after = $score_improved = 0;
if (!empty($this->_summary['score.before']) && !empty($this->_summary['score.after'])) {
$score_before = $this->_summary['score.before'];
$score_after = $this->_summary['score.after'];
// Format Score
$score_improved = (($score_after - $score_before) * 100) / $score_after;
if ($score_improved > 99) {
$score_improved = number_format($score_improved, 2);
} else {
$score_improved = number_format($score_improved);
}
}
return array(
'speed_before' => $speed_before,
'speed_after' => $speed_after,
'speed_improved' => $speed_improved,
'score_before' => $score_before,
'score_after' => $score_after,
'score_improved' => $score_improved,
);
}
/**
* Handle all request actions from main cls
*
* @since 3.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_SPEED:
case self::TYPE_SCORE:
$this->_ping($type);
break;
default:
break;
}
Admin::redirect();
}
}
htaccess.cls.php 0000644 00000060241 15153741266 0007647 0 ustar 00 <?php
/**
* The htaccess rewrite rule operation class
*
*
* @since 1.0.0
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Htaccess extends Root
{
private $frontend_htaccess = null;
private $_default_frontend_htaccess = null;
private $backend_htaccess = null;
private $_default_backend_htaccess = null;
private $theme_htaccess = null; // Not used yet
private $frontend_htaccess_readable = false;
private $frontend_htaccess_writable = false;
private $backend_htaccess_readable = false;
private $backend_htaccess_writable = false;
private $theme_htaccess_readable = false;
private $theme_htaccess_writable = false;
private $__rewrite_on;
const LS_MODULE_START = '<IfModule LiteSpeed>';
const EXPIRES_MODULE_START = '<IfModule mod_expires.c>';
const LS_MODULE_END = '</IfModule>';
const LS_MODULE_REWRITE_START = '<IfModule mod_rewrite.c>';
const REWRITE_ON = 'RewriteEngine on';
const LS_MODULE_DONOTEDIT = '## LITESPEED WP CACHE PLUGIN - Do not edit the contents of this block! ##';
const MARKER = 'LSCACHE';
const MARKER_NONLS = 'NON_LSCACHE';
const MARKER_LOGIN_COOKIE = '### marker LOGIN COOKIE';
const MARKER_ASYNC = '### marker ASYNC';
const MARKER_CRAWLER = '### marker CRAWLER';
const MARKER_MOBILE = '### marker MOBILE';
const MARKER_NOCACHE_COOKIES = '### marker NOCACHE COOKIES';
const MARKER_NOCACHE_USER_AGENTS = '### marker NOCACHE USER AGENTS';
const MARKER_CACHE_RESOURCE = '### marker CACHE RESOURCE';
const MARKER_BROWSER_CACHE = '### marker BROWSER CACHE';
const MARKER_MINIFY = '### marker MINIFY';
const MARKER_CORS = '### marker CORS';
const MARKER_WEBP = '### marker WEBP';
const MARKER_DROPQS = '### marker DROPQS';
const MARKER_START = ' start ###';
const MARKER_END = ' end ###';
const RW_PATTERN_RES = '/.*/[^/]*(responsive|css|js|dynamic|loader|fonts)\.php';
/**
* Initialize the class and set its properties.
*
* @since 1.0.7
*/
public function __construct()
{
$this->_path_set();
$this->_default_frontend_htaccess = $this->frontend_htaccess;
$this->_default_backend_htaccess = $this->backend_htaccess;
$frontend_htaccess = defined('LITESPEED_CFG_HTACCESS') ? LITESPEED_CFG_HTACCESS : false;
if ($frontend_htaccess && substr($frontend_htaccess, -10) === '/.htaccess') {
$this->frontend_htaccess = $frontend_htaccess;
}
$backend_htaccess = defined('LITESPEED_CFG_HTACCESS_BACKEND') ? LITESPEED_CFG_HTACCESS_BACKEND : false;
if ($backend_htaccess && substr($backend_htaccess, -10) === '/.htaccess') {
$this->backend_htaccess = $backend_htaccess;
}
// Filter for frontend&backend htaccess path
$this->frontend_htaccess = apply_filters('litespeed_frontend_htaccess', $this->frontend_htaccess);
$this->backend_htaccess = apply_filters('litespeed_backend_htaccess', $this->backend_htaccess);
clearstatcache();
// frontend .htaccess privilege
$test_permissions = file_exists($this->frontend_htaccess) ? $this->frontend_htaccess : dirname($this->frontend_htaccess);
if (is_readable($test_permissions)) {
$this->frontend_htaccess_readable = true;
}
if (is_writable($test_permissions)) {
$this->frontend_htaccess_writable = true;
}
$this->__rewrite_on = array(
self::REWRITE_ON,
'CacheLookup on',
'RewriteRule .* - [E=Cache-Control:no-autoflush]',
'RewriteRule ' . preg_quote(LITESPEED_DATA_FOLDER) . '/debug/.*\.log$ - [F,L]',
'RewriteRule ' . preg_quote(self::CONF_FILE) . ' - [F,L]',
);
// backend .htaccess privilege
if ($this->frontend_htaccess === $this->backend_htaccess) {
$this->backend_htaccess_readable = $this->frontend_htaccess_readable;
$this->backend_htaccess_writable = $this->frontend_htaccess_writable;
} else {
$test_permissions = file_exists($this->backend_htaccess) ? $this->backend_htaccess : dirname($this->backend_htaccess);
if (is_readable($test_permissions)) {
$this->backend_htaccess_readable = true;
}
if (is_writable($test_permissions)) {
$this->backend_htaccess_writable = true;
}
}
}
/**
* Get if htaccess file is readable
*
* @since 1.1.0
* @return string
*/
private function _readable($kind = 'frontend')
{
if ($kind === 'frontend') {
return $this->frontend_htaccess_readable;
}
if ($kind === 'backend') {
return $this->backend_htaccess_readable;
}
}
/**
* Get if htaccess file is writable
*
* @since 1.1.0
* @return string
*/
public function writable($kind = 'frontend')
{
if ($kind === 'frontend') {
return $this->frontend_htaccess_writable;
}
if ($kind === 'backend') {
return $this->backend_htaccess_writable;
}
}
/**
* Get frontend htaccess path
*
* @since 1.1.0
* @return string
*/
public static function get_frontend_htaccess($show_default = false)
{
if ($show_default) {
return self::cls()->_default_frontend_htaccess;
}
return self::cls()->frontend_htaccess;
}
/**
* Get backend htaccess path
*
* @since 1.1.0
* @return string
*/
public static function get_backend_htaccess($show_default = false)
{
if ($show_default) {
return self::cls()->_default_backend_htaccess;
}
return self::cls()->backend_htaccess;
}
/**
* Check to see if .htaccess exists starting at $start_path and going up directories until it hits DOCUMENT_ROOT.
*
* As dirname() strips the ending '/', paths passed in must exclude the final '/'
*
* @since 1.0.11
* @access private
*/
private function _htaccess_search($start_path)
{
while (!file_exists($start_path . '/.htaccess')) {
if ($start_path === '/' || !$start_path) {
return false;
}
if (!empty($_SERVER['DOCUMENT_ROOT']) && wp_normalize_path($start_path) === wp_normalize_path($_SERVER['DOCUMENT_ROOT'])) {
return false;
}
if (dirname($start_path) === $start_path) {
return false;
}
$start_path = dirname($start_path);
}
return $start_path;
}
/**
* Set the path class variables.
*
* @since 1.0.11
* @access private
*/
private function _path_set()
{
$frontend = Router::frontend_path();
$frontend_htaccess_search = $this->_htaccess_search($frontend); // The existing .htaccess path to be used for frontend .htaccess
$this->frontend_htaccess = ($frontend_htaccess_search ?: $frontend) . '/.htaccess';
$backend = realpath(ABSPATH); // /home/user/public_html/backend/
if ($frontend == $backend) {
$this->backend_htaccess = $this->frontend_htaccess;
return;
}
// Backend is a different path
$backend_htaccess_search = $this->_htaccess_search($backend);
// Found affected .htaccess
if ($backend_htaccess_search) {
$this->backend_htaccess = $backend_htaccess_search . '/.htaccess';
return;
}
// Frontend path is the parent of backend path
if (stripos($backend, $frontend . '/') === 0) {
// backend use frontend htaccess
$this->backend_htaccess = $this->frontend_htaccess;
return;
}
$this->backend_htaccess = $backend . '/.htaccess';
}
/**
* Get corresponding htaccess path
*
* @since 1.1.0
* @param string $kind Frontend or backend
* @return string Path
*/
public function htaccess_path($kind = 'frontend')
{
switch ($kind) {
case 'backend':
$path = $this->backend_htaccess;
break;
case 'frontend':
default:
$path = $this->frontend_htaccess;
break;
}
return $path;
}
/**
* Get the content of the rules file.
*
* NOTE: will throw error if failed
*
* @since 1.0.4
* @since 2.9 Used exception for failed reading
* @access public
*/
public function htaccess_read($kind = 'frontend')
{
$path = $this->htaccess_path($kind);
if (!$path || !file_exists($path)) {
return "\n";
}
if (!$this->_readable($kind)) {
Error::t('HTA_R');
}
$content = File::read($path);
if ($content === false) {
Error::t('HTA_GET');
}
// Remove ^M characters.
$content = str_ireplace("\x0D", '', $content);
return $content;
}
/**
* Try to backup the .htaccess file if we didn't save one before.
*
* NOTE: will throw error if failed
*
* @since 1.0.10
* @access private
*/
private function _htaccess_backup($kind = 'frontend')
{
$path = $this->htaccess_path($kind);
if (!file_exists($path)) {
return;
}
if (file_exists($path . '.bk')) {
return;
}
$res = copy($path, $path . '.bk');
// Failed to backup, abort
if (!$res) {
Error::t('HTA_BK');
}
}
/**
* Get mobile view rule from htaccess file
*
* NOTE: will throw error if failed
*
* @since 1.1.0
*/
public function current_mobile_agents()
{
$rules = $this->_get_rule_by(self::MARKER_MOBILE);
if (!isset($rules[0])) {
Error::t('HTA_DNF', self::MARKER_MOBILE);
}
$rule = trim($rules[0]);
// 'RewriteCond %{HTTP_USER_AGENT} ' . Utility::arr2regex( $cfg[ $id ], true ) . ' [NC]';
$match = substr($rule, strlen('RewriteCond %{HTTP_USER_AGENT} '), -strlen(' [NC]'));
if (!$match) {
Error::t('HTA_DNF', __('Mobile Agent Rules', 'litespeed-cache'));
}
return $match;
}
/**
* Parse rewrites rule from the .htaccess file.
*
* NOTE: will throw error if failed
*
* @since 1.1.0
* @access public
*/
public function current_login_cookie($kind = 'frontend')
{
$rule = $this->_get_rule_by(self::MARKER_LOGIN_COOKIE, $kind);
if (!$rule) {
Error::t('HTA_DNF', self::MARKER_LOGIN_COOKIE);
}
if (strpos($rule, 'RewriteRule .? - [E=') !== 0) {
Error::t('HTA_LOGIN_COOKIE_INVALID');
}
$rule_cookie = substr($rule, strlen('RewriteRule .? - [E='), -1);
if (LITESPEED_SERVER_TYPE === 'LITESPEED_SERVER_OLS') {
$rule_cookie = trim($rule_cookie, '"');
}
// Drop `Cache-Vary:`
$rule_cookie = substr($rule_cookie, strlen('Cache-Vary:'));
return $rule_cookie;
}
/**
* Get rewrite rules based on the marker
*
* @since 2.0
* @access private
*/
private function _get_rule_by($cond, $kind = 'frontend')
{
clearstatcache();
$path = $this->htaccess_path($kind);
if (!$this->_readable($kind)) {
return false;
}
$rules = File::extract_from_markers($path, self::MARKER);
if (!in_array($cond . self::MARKER_START, $rules) || !in_array($cond . self::MARKER_END, $rules)) {
return false;
}
$key_start = array_search($cond . self::MARKER_START, $rules);
$key_end = array_search($cond . self::MARKER_END, $rules);
if ($key_start === false || $key_end === false) {
return false;
}
$results = array_slice($rules, $key_start + 1, $key_end - $key_start - 1);
if (!$results) {
return false;
}
if (count($results) == 1) {
return trim($results[0]);
}
return array_filter($results);
}
/**
* Generate browser cache rules
*
* @since 1.3
* @access private
* @return array Rules set
*/
private function _browser_cache_rules($cfg)
{
/**
* Add ttl setting
* @since 1.6.3
*/
$id = Base::O_CACHE_TTL_BROWSER;
$ttl = $cfg[$id];
$rules = array(
self::EXPIRES_MODULE_START,
// '<FilesMatch "\.(pdf|ico|svg|xml|jpg|jpeg|png|gif|webp|ogg|mp4|webm|js|css|woff|woff2|ttf|eot)(\.gz)?$">',
'ExpiresActive on',
'ExpiresByType application/pdf A' . $ttl,
'ExpiresByType image/x-icon A' . $ttl,
'ExpiresByType image/vnd.microsoft.icon A' . $ttl,
'ExpiresByType image/svg+xml A' . $ttl,
'',
'ExpiresByType image/jpg A' . $ttl,
'ExpiresByType image/jpeg A' . $ttl,
'ExpiresByType image/png A' . $ttl,
'ExpiresByType image/gif A' . $ttl,
'ExpiresByType image/webp A' . $ttl,
'ExpiresByType image/avif A' . $ttl,
'',
'ExpiresByType video/ogg A' . $ttl,
'ExpiresByType audio/ogg A' . $ttl,
'ExpiresByType video/mp4 A' . $ttl,
'ExpiresByType video/webm A' . $ttl,
'',
'ExpiresByType text/css A' . $ttl,
'ExpiresByType text/javascript A' . $ttl,
'ExpiresByType application/javascript A' . $ttl,
'ExpiresByType application/x-javascript A' . $ttl,
'',
'ExpiresByType application/x-font-ttf A' . $ttl,
'ExpiresByType application/x-font-woff A' . $ttl,
'ExpiresByType application/font-woff A' . $ttl,
'ExpiresByType application/font-woff2 A' . $ttl,
'ExpiresByType application/vnd.ms-fontobject A' . $ttl,
'ExpiresByType font/ttf A' . $ttl,
'ExpiresByType font/otf A' . $ttl,
'ExpiresByType font/woff A' . $ttl,
'ExpiresByType font/woff2 A' . $ttl,
'',
// '</FilesMatch>',
self::LS_MODULE_END,
);
return $rules;
}
/**
* Generate CORS rules for fonts
*
* @since 1.5
* @access private
* @return array Rules set
*/
private function _cors_rules()
{
return array(
'<FilesMatch "\.(ttf|ttc|otf|eot|woff|woff2|font\.css)$">',
'<IfModule mod_headers.c>',
'Header set Access-Control-Allow-Origin "*"',
'</IfModule>',
'</FilesMatch>',
);
}
/**
* Generate rewrite rules based on settings
*
* @since 1.3
* @access private
* @param array $cfg The settings to be used for rewrite rule
* @return array Rules array
*/
private function _generate_rules($cfg)
{
$new_rules = array();
$new_rules_nonls = array();
$new_rules_backend = array();
$new_rules_backend_nonls = array();
# continual crawler
// $id = Base::O_CRAWLER;
// if (!empty($cfg[$id])) {
$new_rules[] = self::MARKER_ASYNC . self::MARKER_START;
$new_rules[] = 'RewriteCond %{REQUEST_URI} /wp-admin/admin-ajax\.php';
$new_rules[] = 'RewriteCond %{QUERY_STRING} action=async_litespeed';
$new_rules[] = 'RewriteRule .* - [E=noabort:1]';
$new_rules[] = self::MARKER_ASYNC . self::MARKER_END;
$new_rules[] = '';
// }
// mobile agents
$id = Base::O_CACHE_MOBILE_RULES;
if ((!empty($cfg[Base::O_CACHE_MOBILE]) || !empty($cfg[Base::O_GUEST])) && !empty($cfg[$id])) {
$new_rules[] = self::MARKER_MOBILE . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} ' . Utility::arr2regex($cfg[$id], true) . ' [NC]';
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+ismobile]';
$new_rules[] = self::MARKER_MOBILE . self::MARKER_END;
$new_rules[] = '';
}
// nocache cookie
$id = Base::O_CACHE_EXC_COOKIES;
if (!empty($cfg[$id])) {
$new_rules[] = self::MARKER_NOCACHE_COOKIES . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_COOKIE} ' . Utility::arr2regex($cfg[$id], true);
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:no-cache]';
$new_rules[] = self::MARKER_NOCACHE_COOKIES . self::MARKER_END;
$new_rules[] = '';
}
// nocache user agents
$id = Base::O_CACHE_EXC_USERAGENTS;
if (!empty($cfg[$id])) {
$new_rules[] = self::MARKER_NOCACHE_USER_AGENTS . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} ' . Utility::arr2regex($cfg[$id], true) . ' [NC]';
$new_rules[] = 'RewriteRule .* - [E=Cache-Control:no-cache]';
$new_rules[] = self::MARKER_NOCACHE_USER_AGENTS . self::MARKER_END;
$new_rules[] = '';
}
// caching php resource TODO: consider drop
$id = Base::O_CACHE_RES;
if (!empty($cfg[$id])) {
$new_rules[] = $new_rules_backend[] = self::MARKER_CACHE_RESOURCE . self::MARKER_START;
$new_rules[] = $new_rules_backend[] = 'RewriteRule ' . LSCWP_CONTENT_FOLDER . self::RW_PATTERN_RES . ' - [E=cache-control:max-age=3600]';
$new_rules[] = $new_rules_backend[] = self::MARKER_CACHE_RESOURCE . self::MARKER_END;
$new_rules[] = $new_rules_backend[] = '';
}
// check login cookie
$vary_cookies = $cfg[Base::O_CACHE_VARY_COOKIES];
$id = Base::O_CACHE_LOGIN_COOKIE;
if (!empty($cfg[$id])) {
$vary_cookies[] = $cfg[$id];
}
if (LITESPEED_SERVER_TYPE === 'LITESPEED_SERVER_OLS') {
// Need to keep this due to different behavior of OLS when handling response vary header @Sep/22/2018
if (defined('COOKIEHASH')) {
$vary_cookies[] = ',wp-postpass_' . COOKIEHASH;
}
}
$vary_cookies = apply_filters('litespeed_vary_cookies', $vary_cookies); // todo: test if response vary header can work in latest OLS, drop the above two lines
// frontend and backend
if ($vary_cookies) {
$env = 'Cache-Vary:' . implode(',', $vary_cookies);
// if (LITESPEED_SERVER_TYPE === 'LITESPEED_SERVER_OLS') {
// }
$env = '"' . $env . '"';
$new_rules[] = $new_rules_backend[] = self::MARKER_LOGIN_COOKIE . self::MARKER_START;
$new_rules[] = $new_rules_backend[] = 'RewriteRule .? - [E=' . $env . ']';
$new_rules[] = $new_rules_backend[] = self::MARKER_LOGIN_COOKIE . self::MARKER_END;
$new_rules[] = '';
}
// CORS font rules
$id = Base::O_CDN;
if (!empty($cfg[$id])) {
$new_rules[] = self::MARKER_CORS . self::MARKER_START;
$new_rules = array_merge($new_rules, $this->_cors_rules()); //todo: network
$new_rules[] = self::MARKER_CORS . self::MARKER_END;
$new_rules[] = '';
}
// webp support
$id = Base::O_IMG_OPTM_WEBP;
if (!empty($cfg[$id])) {
$webP_rule = 'RewriteRule .* - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+webp]';
$next_gen_format = 'webp';
if ($cfg[$id] == 2) {
$next_gen_format = 'avif';
}
$new_rules[] = self::MARKER_WEBP . self::MARKER_START;
$new_rules[] = 'RewriteCond %{HTTP_ACCEPT} "image/' . $next_gen_format . '"';
$new_rules[] = $webP_rule;
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} iPhone.*Version/(\d{2}).*Safari';
$new_rules[] = 'RewriteCond %1 >13';
$new_rules[] = $webP_rule;
$new_rules[] = 'RewriteCond %{HTTP_USER_AGENT} Firefox/([0-9]+)';
$new_rules[] = 'RewriteCond %1 >=65';
$new_rules[] = $webP_rule;
$new_rules[] = self::MARKER_WEBP . self::MARKER_END;
$new_rules[] = '';
}
// drop qs support
$id = Base::O_CACHE_DROP_QS;
if (!empty($cfg[$id])) {
$new_rules[] = self::MARKER_DROPQS . self::MARKER_START;
foreach ($cfg[$id] as $v) {
$new_rules[] = 'CacheKeyModify -qs:' . $v;
}
$new_rules[] = self::MARKER_DROPQS . self::MARKER_END;
$new_rules[] = '';
}
// Browser cache
$id = Base::O_CACHE_BROWSER;
if (!empty($cfg[$id])) {
$new_rules_nonls[] = $new_rules_backend_nonls[] = self::MARKER_BROWSER_CACHE . self::MARKER_START;
$new_rules_nonls = array_merge($new_rules_nonls, $this->_browser_cache_rules($cfg));
$new_rules_backend_nonls = array_merge($new_rules_backend_nonls, $this->_browser_cache_rules($cfg));
$new_rules_nonls[] = $new_rules_backend_nonls[] = self::MARKER_BROWSER_CACHE . self::MARKER_END;
$new_rules_nonls[] = '';
}
// Add module wrapper for LiteSpeed rules
if ($new_rules) {
$new_rules = $this->_wrap_ls_module($new_rules);
}
if ($new_rules_backend) {
$new_rules_backend = $this->_wrap_ls_module($new_rules_backend);
}
return array($new_rules, $new_rules_backend, $new_rules_nonls, $new_rules_backend_nonls);
}
/**
* Add LitSpeed module wrapper with rewrite on
*
* @since 2.1.1
* @access private
*/
private function _wrap_ls_module($rules = array())
{
return array_merge(array(self::LS_MODULE_START), $this->__rewrite_on, array(''), $rules, array(self::LS_MODULE_END));
}
/**
* Insert LitSpeed module wrapper with rewrite on
*
* @since 2.1.1
* @access public
*/
public function insert_ls_wrapper()
{
$rules = $this->_wrap_ls_module();
$this->_insert_wrapper($rules);
}
/**
* wrap rules with module on info
*
* @since 1.1.5
* @param array $rules
* @return array wrapped rules with module info
*/
private function _wrap_do_no_edit($rules)
{
// When to clear rules, don't need DONOTEDIT msg
if ($rules === false || !is_array($rules)) {
return $rules;
}
$rules = array_merge(array(self::LS_MODULE_DONOTEDIT), $rules, array(self::LS_MODULE_DONOTEDIT));
return $rules;
}
/**
* Write to htaccess with rules
*
* NOTE: will throw error if failed
*
* @since 1.1.0
* @access private
*/
private function _insert_wrapper($rules = array(), $kind = false, $marker = false)
{
if ($kind != 'backend') {
$kind = 'frontend';
}
// Default marker is LiteSpeed marker `LSCACHE`
if ($marker === false) {
$marker = self::MARKER;
}
$this->_htaccess_backup($kind);
File::insert_with_markers($this->htaccess_path($kind), $this->_wrap_do_no_edit($rules), $marker, true);
}
/**
* Update rewrite rules based on setting
*
* NOTE: will throw error if failed
*
* @since 1.3
* @access public
*/
public function update($cfg)
{
list($frontend_rules, $backend_rules, $frontend_rules_nonls, $backend_rules_nonls) = $this->_generate_rules($cfg);
// Check frontend content
list($rules, $rules_nonls) = $this->_extract_rules();
// Check Non-LiteSpeed rules
if ($this->_wrap_do_no_edit($frontend_rules_nonls) != $rules_nonls) {
Debug2::debug('[Rules] Update non-ls frontend rules');
// Need to update frontend htaccess
try {
$this->_insert_wrapper($frontend_rules_nonls, false, self::MARKER_NONLS);
} catch (\Exception $e) {
$manual_guide_codes = $this->_rewrite_codes_msg($this->frontend_htaccess, $frontend_rules_nonls, self::MARKER_NONLS);
Debug2::debug('[Rules] Update Failed');
throw new \Exception($manual_guide_codes);
}
}
// Check LiteSpeed rules
if ($this->_wrap_do_no_edit($frontend_rules) != $rules) {
Debug2::debug('[Rules] Update frontend rules');
// Need to update frontend htaccess
try {
$this->_insert_wrapper($frontend_rules);
} catch (\Exception $e) {
Debug2::debug('[Rules] Update Failed');
$manual_guide_codes = $this->_rewrite_codes_msg($this->frontend_htaccess, $frontend_rules);
throw new \Exception($manual_guide_codes);
}
}
if ($this->frontend_htaccess !== $this->backend_htaccess) {
list($rules, $rules_nonls) = $this->_extract_rules('backend');
// Check Non-LiteSpeed rules for backend
if ($this->_wrap_do_no_edit($backend_rules_nonls) != $rules_nonls) {
Debug2::debug('[Rules] Update non-ls backend rules');
// Need to update frontend htaccess
try {
$this->_insert_wrapper($backend_rules_nonls, 'backend', self::MARKER_NONLS);
} catch (\Exception $e) {
Debug2::debug('[Rules] Update Failed');
$manual_guide_codes = $this->_rewrite_codes_msg($this->backend_htaccess, $backend_rules_nonls, self::MARKER_NONLS);
throw new \Exception($manual_guide_codes);
}
}
// Check backend content
if ($this->_wrap_do_no_edit($backend_rules) != $rules) {
Debug2::debug('[Rules] Update backend rules');
// Need to update backend htaccess
try {
$this->_insert_wrapper($backend_rules, 'backend');
} catch (\Exception $e) {
Debug2::debug('[Rules] Update Failed');
$manual_guide_codes = $this->_rewrite_codes_msg($this->backend_htaccess, $backend_rules);
throw new \Exception($manual_guide_codes);
}
}
}
return true;
}
/**
* Get existing rewrite rules
*
* NOTE: will throw error if failed
*
* @since 1.3
* @access private
* @param string $kind Frontend or backend .htaccess file
*/
private function _extract_rules($kind = 'frontend')
{
clearstatcache();
$path = $this->htaccess_path($kind);
if (!$this->_readable($kind)) {
Error::t('E_HTA_R');
}
$rules = File::extract_from_markers($path, self::MARKER);
$rules_nonls = File::extract_from_markers($path, self::MARKER_NONLS);
return array($rules, $rules_nonls);
}
/**
* Output the msg with rules plain data for manual insert
*
* @since 1.1.5
* @param string $file
* @param array $rules
* @return string final msg to output
*/
private function _rewrite_codes_msg($file, $rules, $marker = false)
{
return sprintf(
__('<p>Please add/replace the following codes into the beginning of %1$s:</p> %2$s', 'litespeed-cache'),
$file,
'<textarea style="width:100%;" rows="10" readonly>' . htmlspecialchars($this->_wrap_rules_with_marker($rules, $marker)) . '</textarea>'
);
}
/**
* Generate rules plain data for manual insert
*
* @since 1.1.5
*/
private function _wrap_rules_with_marker($rules, $marker = false)
{
// Default marker is LiteSpeed marker `LSCACHE`
if ($marker === false) {
$marker = self::MARKER;
}
$start_marker = "# BEGIN {$marker}";
$end_marker = "# END {$marker}";
$new_file_data = implode("\n", array_merge(array($start_marker), $this->_wrap_do_no_edit($rules), array($end_marker)));
return $new_file_data;
}
/**
* Clear the rules file of any changes added by the plugin specifically.
*
* @since 1.0.4
* @access public
*/
public function clear_rules()
{
$this->_insert_wrapper(false); // Use false to avoid do-not-edit msg
// Clear non ls rules
$this->_insert_wrapper(false, false, self::MARKER_NONLS);
if ($this->frontend_htaccess !== $this->backend_htaccess) {
$this->_insert_wrapper(false, 'backend');
$this->_insert_wrapper(false, 'backend', self::MARKER_NONLS);
}
}
}
img-optm.cls.php 0000644 00000200144 15153741266 0007601 0 ustar 00 <?php
/**
* The class to optimize image.
*
* @since 2.0
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
use WpOrg\Requests\Autoload;
use WpOrg\Requests\Requests;
defined('WPINC') || exit();
class Img_Optm extends Base
{
const LOG_TAG = '🗜️';
const CLOUD_ACTION_NEW_REQ = 'new_req';
const CLOUD_ACTION_TAKEN = 'taken';
const CLOUD_ACTION_REQUEST_DESTROY = 'imgoptm_destroy';
const CLOUD_ACTION_CLEAN = 'clean';
const TYPE_NEW_REQ = 'new_req';
const TYPE_RESCAN = 'rescan';
const TYPE_DESTROY = 'destroy';
const TYPE_RESET_COUNTER = 'reset_counter';
const TYPE_CLEAN = 'clean';
const TYPE_PULL = 'pull';
const TYPE_BATCH_SWITCH_ORI = 'batch_switch_ori';
const TYPE_BATCH_SWITCH_OPTM = 'batch_switch_optm';
const TYPE_CALC_BKUP = 'calc_bkup';
const TYPE_RESET_ROW = 'reset_row';
const TYPE_RM_BKUP = 'rm_bkup';
const STATUS_NEW = 0; // 'new';
const STATUS_RAW = 1; // 'raw';
const STATUS_REQUESTED = 3; // 'requested';
const STATUS_NOTIFIED = 6; // 'notified';
const STATUS_DUPLICATED = 8; // 'duplicated';
const STATUS_PULLED = 9; // 'pulled';
const STATUS_FAILED = -1; //'failed';
const STATUS_MISS = -3; // 'miss';
const STATUS_ERR_FETCH = -5; // 'err_fetch';
const STATUS_ERR_404 = -6; // 'err_404';
const STATUS_ERR_OPTM = -7; // 'err_optm';
const STATUS_XMETA = -8; // 'xmeta';
const STATUS_ERR = -9; // 'err';
const DB_SIZE = 'litespeed-optimize-size';
const DB_SET = 'litespeed-optimize-set';
const DB_NEED_PULL = 'need_pull';
private $wp_upload_dir;
private $tmp_pid;
private $tmp_type;
private $tmp_path;
private $_img_in_queue = array();
private $_existed_src_list = array();
private $_pids_set = array();
private $_thumbnail_set = '';
private $_table_img_optm;
private $_table_img_optming;
private $_cron_ran = false;
private $__media;
private $__data;
protected $_summary;
private $_format = '';
/**
* Init
*
* @since 2.0
*/
public function __construct()
{
Debug2::debug2('[ImgOptm] init');
$this->wp_upload_dir = wp_upload_dir();
$this->__media = $this->cls('Media');
$this->__data = $this->cls('Data');
$this->_table_img_optm = $this->__data->tb('img_optm');
$this->_table_img_optming = $this->__data->tb('img_optming');
$this->_summary = self::get_summary();
if (empty($this->_summary['next_post_id'])) {
$this->_summary['next_post_id'] = 0;
}
if ($this->conf(Base::O_IMG_OPTM_WEBP)) {
$this->_format = 'webp';
if ($this->conf(Base::O_IMG_OPTM_WEBP) == 2) {
$this->_format = 'avif';
}
}
}
/**
* Gather images auto when update attachment meta
* This is to optimize new uploaded images first. Stored in img_optm table.
* Later normal process will auto remove these records when trying to optimize these images again
*
* @since 4.0
*/
public function wp_update_attachment_metadata($meta_value, $post_id)
{
global $wpdb;
self::debug2('🖌️ Auto update attachment meta [id] ' . $post_id);
if (empty($meta_value['file'])) {
return;
}
// Load gathered images
if (!$this->_existed_src_list) {
// To aavoid extra query when recalling this function
self::debug('SELECT src from img_optm table');
if ($this->__data->tb_exist('img_optm')) {
$q = "SELECT src FROM `$this->_table_img_optm` WHERE post_id = %d";
$list = $wpdb->get_results($wpdb->prepare($q, $post_id));
foreach ($list as $v) {
$this->_existed_src_list[] = $post_id . '.' . $v->src;
}
}
if ($this->__data->tb_exist('img_optming')) {
$q = "SELECT src FROM `$this->_table_img_optming` WHERE post_id = %d";
$list = $wpdb->get_results($wpdb->prepare($q, $post_id));
foreach ($list as $v) {
$this->_existed_src_list[] = $post_id . '.' . $v->src;
}
} else {
$this->__data->tb_create('img_optming');
}
}
// Prepare images
$this->tmp_pid = $post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_append_img_queue($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_append_img_queue'), $meta_value['sizes']);
}
if (!$this->_img_in_queue) {
self::debug('auto update attachment meta 2 bypass: empty _img_in_queue');
return;
}
// Save to DB
$this->_save_raw();
// $this->_send_request();
}
/**
* Auto send optm request
*
* @since 2.4.1
* @access public
*/
public static function cron_auto_request()
{
if (!defined('DOING_CRON')) {
return false;
}
$instance = self::cls();
$instance->new_req();
}
/**
* Calculate wet run allowance
*
* @since 3.0
*/
public function wet_limit()
{
$wet_limit = 1;
if (!empty($this->_summary['img_taken'])) {
$wet_limit = pow($this->_summary['img_taken'], 2);
}
if ($wet_limit == 1 && !empty($this->_summary['img_status.' . self::STATUS_ERR_OPTM])) {
$wet_limit = pow($this->_summary['img_status.' . self::STATUS_ERR_OPTM], 2);
}
if ($wet_limit < Cloud::IMG_OPTM_DEFAULT_GROUP) {
return $wet_limit;
}
// No limit
return false;
}
/**
* Push raw img to image optm server
*
* @since 1.6
* @access public
*/
public function new_req()
{
global $wpdb;
// check if is running
if (!empty($this->_summary['is_running']) && time() - $this->_summary['is_running'] < apply_filters('litespeed_imgoptm_new_req_interval', 3600)) {
self::debug('The previous req was in 3600s.');
return;
}
$this->_summary['is_running'] = time();
self::save_summary();
// Check if has credit to push
$err = false;
$allowance = Cloud::cls()->allowance(Cloud::SVC_IMG_OPTM, $err);
$wet_limit = $this->wet_limit();
self::debug("allowance_max $allowance wet_limit $wet_limit");
if ($wet_limit && $wet_limit < $allowance) {
$allowance = $wet_limit;
}
if (!$allowance) {
self::debug('❌ No credit');
Admin_Display::error(Error::msg($err));
$this->_finished_running();
return;
}
self::debug('preparing images to push');
$this->__data->tb_create('img_optming');
$q = "SELECT COUNT(1) FROM `$this->_table_img_optming` WHERE optm_status = %d";
$q = $wpdb->prepare($q, array(self::STATUS_REQUESTED));
$total_requested = $wpdb->get_var($q);
$max_requested = $allowance * 1;
if ($total_requested > $max_requested) {
self::debug('❌ Too many queued images (' . $total_requested . ' > ' . $max_requested . ')');
Admin_Display::error(Error::msg('too_many_requested'));
$this->_finished_running();
return;
}
$allowance -= $total_requested;
if ($allowance < 1) {
self::debug('❌ Too many requested images ' . $total_requested);
Admin_Display::error(Error::msg('too_many_requested'));
$this->_finished_running();
return;
}
// Limit maximum number of items waiting to be pulled
$q = "SELECT COUNT(1) FROM `$this->_table_img_optming` WHERE optm_status = %d";
$q = $wpdb->prepare($q, array(self::STATUS_NOTIFIED));
$total_notified = $wpdb->get_var($q);
if ($total_notified > 0) {
self::debug('❌ Too many notified images (' . $total_notified . ')');
Admin_Display::error(Error::msg('too_many_notified'));
$this->_finished_running();
return;
}
$q = "SELECT COUNT(1) FROM `$this->_table_img_optming` WHERE optm_status IN (%d, %d)";
$q = $wpdb->prepare($q, array(self::STATUS_NEW, self::STATUS_RAW));
$total_new = $wpdb->get_var($q);
// $allowance -= $total_new;
// May need to get more images
$list = array();
$more = $allowance - $total_new;
if ($more > 0) {
$q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.ID>%d
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
ORDER BY a.ID
LIMIT %d
";
$q = $wpdb->prepare($q, array($this->_summary['next_post_id'], $more));
$list = $wpdb->get_results($q);
foreach ($list as $v) {
if (!$v->post_id) {
continue;
}
$this->_summary['next_post_id'] = $v->post_id;
$meta_value = $this->_parse_wp_meta_value($v);
if (!$meta_value) {
continue;
}
$meta_value['file'] = wp_normalize_path($meta_value['file']);
$basedir = $this->wp_upload_dir['basedir'] . '/';
if (strpos($meta_value['file'], $basedir) === 0) {
$meta_value['file'] = substr($meta_value['file'], strlen($basedir));
}
$this->tmp_pid = $v->post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_append_img_queue($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_append_img_queue'), $meta_value['sizes']);
}
}
self::save_summary();
$num_a = count($this->_img_in_queue);
self::debug('Images found: ' . $num_a);
$this->_filter_duplicated_src();
self::debug('Images after duplicated: ' . count($this->_img_in_queue));
$this->_filter_invalid_src();
self::debug('Images after invalid: ' . count($this->_img_in_queue));
// Check w/ legacy imgoptm table, bypass finished images
$this->_filter_legacy_src();
$num_b = count($this->_img_in_queue);
if ($num_b != $num_a) {
self::debug('Images after filtered duplicated/invalid/legacy src: ' . $num_b);
}
// Save to DB
$this->_save_raw();
}
// Push to Cloud server
$accepted_imgs = $this->_send_request($allowance);
$this->_finished_running();
if (!$accepted_imgs) {
return;
}
$placeholder1 = Admin_Display::print_plural($accepted_imgs[0], 'image');
$placeholder2 = Admin_Display::print_plural($accepted_imgs[1], 'image');
$msg = sprintf(__('Pushed %1$s to Cloud server, accepted %2$s.', 'litespeed-cache'), $placeholder1, $placeholder2);
Admin_Display::success($msg);
}
/**
* Set running to done
*/
private function _finished_running()
{
$this->_summary['is_running'] = 0;
self::save_summary();
}
/**
* Add a new img to queue which will be pushed to request
*
* @since 1.6
* @access private
*/
private function _append_img_queue($meta_value, $is_ori_file = false)
{
if (empty($meta_value['file']) || empty($meta_value['width']) || empty($meta_value['height'])) {
self::debug2('bypass image due to lack of file/w/h: pid ' . $this->tmp_pid, $meta_value);
return;
}
$short_file_path = $meta_value['file'];
if (!$is_ori_file) {
$short_file_path = $this->tmp_path . $short_file_path;
}
// Check if src is gathered already or not
if (in_array($this->tmp_pid . '.' . $short_file_path, $this->_existed_src_list)) {
// Debug2::debug2( '[Img_Optm] bypass image due to gathered: pid ' . $this->tmp_pid . ' ' . $short_file_path );
return;
} else {
// Append handled images
$this->_existed_src_list[] = $this->tmp_pid . '.' . $short_file_path;
}
// check file exists or not
$_img_info = $this->__media->info($short_file_path, $this->tmp_pid);
$extension = pathinfo($short_file_path, PATHINFO_EXTENSION);
if (!$_img_info || !in_array($extension, array('jpg', 'jpeg', 'png', 'gif'))) {
self::debug2('bypass image due to file not exist: pid ' . $this->tmp_pid . ' ' . $short_file_path);
return;
}
// Check if optimized file exists or not
$target_needed = false;
if ($this->_format) {
$target_file_path = $short_file_path . '.' . $this->_format;
if (!$this->__media->info($target_file_path, $this->tmp_pid)) {
$target_needed = true;
}
}
if ($this->conf(self::O_IMG_OPTM_ORI)) {
$target_file_path = substr($short_file_path, 0, -strlen($extension)) . 'bk.' . $extension;
if (!$this->__media->info($target_file_path, $this->tmp_pid)) {
$target_needed = true;
}
}
if (!$target_needed) {
self::debug2('bypass image due to optimized file exists: pid ' . $this->tmp_pid . ' ' . $short_file_path);
return;
}
// Debug2::debug2( '[Img_Optm] adding image: pid ' . $this->tmp_pid );
$this->_img_in_queue[] = array(
'pid' => $this->tmp_pid,
'md5' => $_img_info['md5'],
'url' => $_img_info['url'],
'src' => $short_file_path, // not needed in LiteSpeed IAPI, just leave for local storage after post
'mime_type' => !empty($meta_value['mime-type']) ? $meta_value['mime-type'] : '',
);
}
/**
* Save gathered image raw data
*
* @since 3.0
*/
private function _save_raw()
{
if (empty($this->_img_in_queue)) {
return;
}
$data = array();
$pid_list = array();
foreach ($this->_img_in_queue as $k => $v) {
$_img_info = $this->__media->info($v['src'], $v['pid']);
// attachment doesn't exist, delete the record
if (empty($_img_info['url']) || empty($_img_info['md5'])) {
unset($this->_img_in_queue[$k]);
continue;
}
$pid_list[] = (int) $v['pid'];
$data[] = $v['pid'];
$data[] = self::STATUS_RAW;
$data[] = $v['src'];
}
global $wpdb;
$fields = 'post_id, optm_status, src';
$q = "INSERT INTO `$this->_table_img_optming` ( $fields ) VALUES ";
// Add placeholder
$q .= Utility::chunk_placeholder($data, $fields);
// Store data
$wpdb->query($wpdb->prepare($q, $data));
$count = count($this->_img_in_queue);
self::debug('Added raw images [total] ' . $count);
$this->_img_in_queue = array();
// Save thumbnail groups for future rescan index
$this->_gen_thumbnail_set();
$pid_list = array_unique($pid_list);
self::debug('pid list to append to postmeta', $pid_list);
$pid_list = array_diff($pid_list, $this->_pids_set);
$this->_pids_set = array_merge($this->_pids_set, $pid_list);
$existed_meta = $wpdb->get_results("SELECT * FROM `$wpdb->postmeta` WHERE post_id IN ('" . implode("','", $pid_list) . "') AND meta_key='" . self::DB_SET . "'");
$existed_pid = array();
if ($existed_meta) {
foreach ($existed_meta as $v) {
$existed_pid[] = $v->post_id;
}
self::debug('pid list to update postmeta', $existed_pid);
$wpdb->query(
$wpdb->prepare("UPDATE `$wpdb->postmeta` SET meta_value=%s WHERE post_id IN ('" . implode("','", $existed_pid) . "') AND meta_key=%s", array(
$this->_thumbnail_set,
self::DB_SET,
))
);
}
# Add new meta
$new_pids = $existed_pid ? array_diff($pid_list, $existed_pid) : $pid_list;
if ($new_pids) {
self::debug('pid list to update postmeta', $new_pids);
foreach ($new_pids as $v) {
self::debug('New group set info [pid] ' . $v);
$q = "INSERT INTO `$wpdb->postmeta` (post_id, meta_key, meta_value) VALUES (%d, %s, %s)";
$wpdb->query($wpdb->prepare($q, array($v, self::DB_SET, $this->_thumbnail_set)));
}
}
}
/**
* Generate thumbnail sets of current image group
*
* @since 5.4
*/
private function _gen_thumbnail_set()
{
if ($this->_thumbnail_set) {
return;
}
$set = array();
foreach (Media::cls()->get_image_sizes() as $size) {
$curr_size = $size['width'] . 'x' . $size['height'];
if (in_array($curr_size, $set)) {
continue;
}
$set[] = $curr_size;
}
$this->_thumbnail_set = implode(PHP_EOL, $set);
}
/**
* Filter duplicated src in work table and $this->_img_in_queue, then mark them as duplicated
*
* @since 2.0
* @access private
*/
private function _filter_duplicated_src()
{
global $wpdb;
$srcpath_list = array();
$list = $wpdb->get_results("SELECT src FROM `$this->_table_img_optming`");
foreach ($list as $v) {
$srcpath_list[] = $v->src;
}
foreach ($this->_img_in_queue as $k => $v) {
if (in_array($v['src'], $srcpath_list)) {
unset($this->_img_in_queue[$k]);
continue;
}
$srcpath_list[] = $v['src'];
}
}
/**
* Filter legacy finished ones
*
* @since 5.4
*/
private function _filter_legacy_src()
{
global $wpdb;
if (!$this->__data->tb_exist('img_optm')) {
return;
}
if (!$this->_img_in_queue) {
return;
}
$finished_ids = array();
Utility::compatibility();
$post_ids = array_unique(array_column($this->_img_in_queue, 'pid'));
$list = $wpdb->get_results("SELECT post_id FROM `$this->_table_img_optm` WHERE post_id in (" . implode(',', $post_ids) . ') GROUP BY post_id');
foreach ($list as $v) {
$finished_ids[] = $v->post_id;
}
foreach ($this->_img_in_queue as $k => $v) {
if (in_array($v['pid'], $finished_ids)) {
self::debug('Legacy image optimized [pid] ' . $v['pid']);
unset($this->_img_in_queue[$k]);
continue;
}
}
// Drop all existing legacy records
$wpdb->query("DELETE FROM `$this->_table_img_optm` WHERE post_id in (" . implode(',', $post_ids) . ')');
}
/**
* Filter the invalid src before sending
*
* @since 3.0.8.3
* @access private
*/
private function _filter_invalid_src()
{
$img_in_queue_invalid = array();
foreach ($this->_img_in_queue as $k => $v) {
if ($v['src']) {
$extension = pathinfo($v['src'], PATHINFO_EXTENSION);
}
if (!$v['src'] || empty($extension) || !in_array($extension, array('jpg', 'jpeg', 'png', 'gif'))) {
$img_in_queue_invalid[] = $v['id'];
unset($this->_img_in_queue[$k]);
continue;
}
}
if (!$img_in_queue_invalid) {
return;
}
$count = count($img_in_queue_invalid);
$msg = sprintf(__('Cleared %1$s invalid images.', 'litespeed-cache'), $count);
Admin_Display::success($msg);
self::debug('Found invalid src [total] ' . $count);
}
/**
* Push img request to Cloud server
*
* @since 1.6.7
* @access private
*/
private function _send_request($allowance)
{
global $wpdb;
$q = "SELECT id, src, post_id FROM `$this->_table_img_optming` WHERE optm_status=%d LIMIT %d";
$q = $wpdb->prepare($q, array(self::STATUS_RAW, $allowance));
$_img_in_queue = $wpdb->get_results($q);
if (!$_img_in_queue) {
return;
}
self::debug('Load img in queue [total] ' . count($_img_in_queue));
$list = array();
foreach ($_img_in_queue as $v) {
$_img_info = $this->__media->info($v->src, $v->post_id);
# If record is invalid, remove from img_optming table
if (empty($_img_info['url']) || empty($_img_info['md5'])) {
$wpdb->query($wpdb->prepare("DELETE FROM `$this->_table_img_optming` WHERE id=%d", $v->id));
continue;
}
$img = array(
'id' => $v->id,
'url' => $_img_info['url'],
'md5' => $_img_info['md5'],
);
// Build the needed image types for request as we now support soft reset counter
if ($this->_format) {
$target_file_path = $v->src . '.' . $this->_format;
if ($this->__media->info($target_file_path, $v->post_id)) {
$img['optm_' . $this->_format] = 0;
}
}
if ($this->conf(self::O_IMG_OPTM_ORI)) {
$extension = pathinfo($v->src, PATHINFO_EXTENSION);
$target_file_path = substr($v->src, 0, -strlen($extension)) . 'bk.' . $extension;
if ($this->__media->info($target_file_path, $v->post_id)) {
$img['optm_ori'] = 0;
}
}
$list[] = $img;
}
if (!$list) {
$msg = __('No valid image found in the current request.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
$data = array(
'action' => self::CLOUD_ACTION_NEW_REQ,
'list' => \json_encode($list),
'optm_ori' => $this->conf(self::O_IMG_OPTM_ORI) ? 1 : 0,
'optm_lossless' => $this->conf(self::O_IMG_OPTM_LOSSLESS) ? 1 : 0,
'keep_exif' => $this->conf(self::O_IMG_OPTM_EXIF) ? 1 : 0,
);
if ($this->_format) {
$data['optm_' . $this->_format] = 1;
}
// Push to Cloud server
$json = Cloud::post(Cloud::SVC_IMG_OPTM, $data);
if (!$json) {
return;
}
// Check data format
if (empty($json['ids'])) {
self::debug('Failed to parse response data from Cloud server ', $json);
$msg = __('No valid image found by Cloud server in the current request.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
self::debug('Returned data from Cloud server count: ' . count($json['ids']));
$ids = implode(',', array_map('intval', $json['ids']));
// Update img table
$q = "UPDATE `$this->_table_img_optming` SET optm_status = '" . self::STATUS_REQUESTED . "' WHERE id IN ( $ids )";
$wpdb->query($q);
$this->_summary['last_requested'] = time();
self::save_summary();
return array(count($list), count($json['ids']));
}
/**
* Cloud server notify Client img status changed
*
* @access public
*/
public function notify_img()
{
// Interval validation to avoid hacking domain_key
if (!empty($this->_summary['notify_ts_err']) && time() - $this->_summary['notify_ts_err'] < 3) {
return Cloud::err('too_often');
}
$post_data = \json_decode(file_get_contents('php://input'), true);
if (is_null($post_data)) {
$post_data = $_POST;
}
global $wpdb;
$notified_data = $post_data['data'];
if (empty($notified_data) || !is_array($notified_data)) {
self::debug('❌ notify exit: no notified data');
return Cloud::err('no notified data');
}
if (empty($post_data['server']) || (substr($post_data['server'], -11) !== '.quic.cloud' && substr($post_data['server'], -15) !== '.quicserver.com')) {
self::debug('notify exit: no/wrong server');
return Cloud::err('no/wrong server');
}
if (empty($post_data['status'])) {
self::debug('notify missing status');
return Cloud::err('no status');
}
$status = $post_data['status'];
self::debug('notified status=' . $status);
$last_log_pid = 0;
if (empty($this->_summary['reduced'])) {
$this->_summary['reduced'] = 0;
}
if ($status == self::STATUS_NOTIFIED) {
// Notified data format: [ img_optm_id => [ id=>, src_size=>, ori=>, ori_md5=>, ori_reduced=>, webp=>, webp_md5=>, webp_reduced=> ] ]
$q =
"SELECT a.*, b.meta_id as b_meta_id, b.meta_value AS b_optm_info
FROM `$this->_table_img_optming` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.post_id AND b.meta_key = %s
WHERE a.id IN ( " .
implode(',', array_fill(0, count($notified_data), '%d')) .
' )';
$list = $wpdb->get_results($wpdb->prepare($q, array_merge(array(self::DB_SIZE), array_keys($notified_data))));
$ls_optm_size_row_exists_postids = array();
foreach ($list as $v) {
$json = $notified_data[$v->id];
// self::debug('Notified data for [id] ' . $v->id, $json);
$server = !empty($json['server']) ? $json['server'] : $post_data['server'];
$server_info = array(
'server' => $server,
);
// Save server side ID to send taken notification after pulled
$server_info['id'] = $json['id'];
if (!empty($json['file_id'])) {
$server_info['file_id'] = $json['file_id'];
}
// Optm info array
$postmeta_info = array(
'ori_total' => 0,
'ori_saved' => 0,
'webp_total' => 0,
'webp_saved' => 0,
'avif_total' => 0,
'avif_saved' => 0,
);
// Init postmeta_info for the first one
if (!empty($v->b_meta_id)) {
foreach (maybe_unserialize($v->b_optm_info) as $k2 => $v2) {
$postmeta_info[$k2] += $v2;
}
}
if (!empty($json['ori'])) {
$server_info['ori_md5'] = $json['ori_md5'];
$server_info['ori'] = $json['ori'];
// Append meta info
$postmeta_info['ori_total'] += $json['src_size'];
$postmeta_info['ori_saved'] += $json['ori_reduced']; // optimized image size info in img_optm tb will be updated when pull
$this->_summary['reduced'] += $json['ori_reduced'];
}
if (!empty($json['webp'])) {
$server_info['webp_md5'] = $json['webp_md5'];
$server_info['webp'] = $json['webp'];
// Append meta info
$postmeta_info['webp_total'] += $json['src_size'];
$postmeta_info['webp_saved'] += $json['webp_reduced'];
$this->_summary['reduced'] += $json['webp_reduced'];
}
if (!empty($json['avif'])) {
$server_info['avif_md5'] = $json['avif_md5'];
$server_info['avif'] = $json['avif'];
// Append meta info
$postmeta_info['avif_total'] += $json['src_size'];
$postmeta_info['avif_saved'] += $json['avif_reduced'];
$this->_summary['reduced'] += $json['avif_reduced'];
}
// Update status and data in working table
$q = "UPDATE `$this->_table_img_optming` SET optm_status = %d, server_info = %s WHERE id = %d ";
$wpdb->query($wpdb->prepare($q, array($status, \json_encode($server_info), $v->id)));
// Update postmeta for optm summary
$postmeta_info = serialize($postmeta_info);
if (empty($v->b_meta_id) && !in_array($v->post_id, $ls_optm_size_row_exists_postids)) {
self::debug('New size info [pid] ' . $v->post_id);
$q = "INSERT INTO `$wpdb->postmeta` ( post_id, meta_key, meta_value ) VALUES ( %d, %s, %s )";
$wpdb->query($wpdb->prepare($q, array($v->post_id, self::DB_SIZE, $postmeta_info)));
$ls_optm_size_row_exists_postids[] = $v->post_id;
} else {
$q = "UPDATE `$wpdb->postmeta` SET meta_value = %s WHERE meta_id = %d ";
$wpdb->query($wpdb->prepare($q, array($postmeta_info, $v->b_meta_id)));
}
// write log
$pid_log = $last_log_pid == $v->post_id ? '.' : $v->post_id;
self::debug('notify_img [status] ' . $status . " \t\t[pid] " . $pid_log . " \t\t[id] " . $v->id);
$last_log_pid = $v->post_id;
}
self::save_summary();
// Mark need_pull tag for cron
self::update_option(self::DB_NEED_PULL, self::STATUS_NOTIFIED);
} else {
// Other errors will directly remove the working records
// Delete from working table
$q = "DELETE FROM `$this->_table_img_optming` WHERE id IN ( " . implode(',', array_fill(0, count($notified_data), '%d')) . ' ) ';
$wpdb->query($wpdb->prepare($q, $notified_data));
}
return Cloud::ok(array('count' => count($notified_data)));
}
/**
* Cron start async req
*
* @since 5.5
*/
public static function start_async_cron()
{
Task::async_call('imgoptm');
}
/**
* Manually start async req
*
* @since 5.5
*/
public static function start_async()
{
Task::async_call('imgoptm_force');
$msg = __('Started async image optimization request', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Ajax req handler
*
* @since 5.5
*/
public static function async_handler($force = false)
{
self::debug('------------async-------------start_async_handler');
$tag = self::get_option(self::DB_NEED_PULL);
if (!$tag || $tag != self::STATUS_NOTIFIED) {
self::debug('❌ no need pull [tag] ' . $tag);
return;
}
if (defined('LITESPEED_IMG_OPTM_PULL_CRON') && !LITESPEED_IMG_OPTM_PULL_CRON) {
self::debug('Cron disabled [define] LITESPEED_IMG_OPTM_PULL_CRON');
return;
}
self::cls()->pull($force);
}
/**
* Calculate pull threads
*
* @since 5.8
* @access private
*/
private function _calc_pull_threads()
{
global $wpdb;
if (defined('LITESPEED_IMG_OPTM_PULL_THREADS')) {
return LITESPEED_IMG_OPTM_PULL_THREADS;
}
// Tune number of images per request based on number of images waiting and cloud packages
$imgs_per_req = 1; // base 1, ramp up to ~50 max
// Ramp up the request rate based on how many images are waiting
$c = "SELECT count(id) FROM `$this->_table_img_optming` WHERE optm_status = %d";
$_c = $wpdb->prepare($c, array(self::STATUS_NOTIFIED));
$images_waiting = $wpdb->get_var($_c);
if ($images_waiting && $images_waiting > 0) {
$imgs_per_req = ceil($images_waiting / 1000); //ie. download 5/request if 5000 images are waiting
}
// Cap the request rate at 50 images per request
$imgs_per_req = min(50, $imgs_per_req);
self::debug('Pulling images at rate: ' . $imgs_per_req . ' Images per request.');
return $imgs_per_req;
}
/**
* Pull optimized img
*
* @since 1.6
* @access public
*/
public function pull($manual = false)
{
global $wpdb;
$timeoutLimit = ini_get('max_execution_time');
$endts = time() + $timeoutLimit;
self::debug('' . ($manual ? 'Manually' : 'Cron') . ' pull started [timeout: ' . $timeoutLimit . 's]');
if ($this->cron_running()) {
self::debug('Pull cron is running');
$msg = __('Pull Cron is running', 'litespeed-cache');
Admin_Display::note($msg);
return;
}
$this->_summary['last_pulled'] = time();
$this->_summary['last_pulled_by_cron'] = !$manual;
self::save_summary();
$imgs_per_req = $this->_calc_pull_threads();
$q = "SELECT * FROM `$this->_table_img_optming` WHERE optm_status = %d ORDER BY id LIMIT %d";
$_q = $wpdb->prepare($q, array(self::STATUS_NOTIFIED, $imgs_per_req));
$rm_ori_bkup = $this->conf(self::O_IMG_OPTM_RM_BKUP);
$total_pulled_ori = 0;
$total_pulled_webp = 0;
$total_pulled_avif = 0;
$server_list = array();
try {
while ($img_rows = $wpdb->get_results($_q)) {
self::debug('timeout left: ' . ($endts - time()) . 's');
if (function_exists('set_time_limit')) {
$endts += 600;
self::debug('Endtime extended to ' . date('Ymd H:i:s', $endts));
set_time_limit(600); // This will be no more important as we use noabort now
}
// Disabled as we use noabort
// if ($endts - time() < 10) {
// self::debug("🚨 End loop due to timeout limit reached " . $timeoutLimit . "s");
// break;
// }
/**
* Update cron timestamp to avoid duplicated running
* @since 1.6.2
*/
$this->_update_cron_running();
// Run requests in parallel
$requests = array(); // store each request URL for Requests::request_multiple()
$imgs_by_req = array(); // store original request data so that we can reference it in the response
$req_counter = 0;
foreach ($img_rows as $row_img) {
// request original image
$server_info = \json_decode($row_img->server_info, true);
if (!empty($server_info['ori'])) {
$image_url = $server_info['server'] . '/' . $server_info['ori'];
self::debug('Queueing pull: ' . $image_url);
$requests[$req_counter] = array(
'url' => $image_url,
'type' => 'GET',
);
$imgs_by_req[$req_counter++] = array(
'type' => 'ori',
'data' => $row_img,
);
}
// request webp image
$webp_size = 0;
if (!empty($server_info['webp'])) {
$image_url = $server_info['server'] . '/' . $server_info['webp'];
self::debug('Queueing pull WebP: ' . $image_url);
$requests[$req_counter] = array(
'url' => $image_url,
'type' => 'GET',
);
$imgs_by_req[$req_counter++] = array(
'type' => 'webp',
'data' => $row_img,
);
}
// request avif image
$avif_size = 0;
if (!empty($server_info['avif'])) {
$image_url = $server_info['server'] . '/' . $server_info['avif'];
self::debug('Queueing pull AVIF: ' . $image_url);
$requests[$req_counter] = array(
'url' => $image_url,
'type' => 'GET',
);
$imgs_by_req[$req_counter++] = array(
'type' => 'avif',
'data' => $row_img,
);
}
}
self::debug('Loaded images count: ' . $req_counter);
$complete_action = function ($response, $req_count) use ($imgs_by_req, $rm_ori_bkup, &$total_pulled_ori, &$total_pulled_webp, &$total_pulled_avif, &$server_list) {
global $wpdb;
$row_data = isset($imgs_by_req[$req_count]) ? $imgs_by_req[$req_count] : false;
if (false === $row_data) {
self::debug('❌ failed to pull image: Request not found in lookup variable.');
return;
}
$row_type = isset($row_data['type']) ? $row_data['type'] : 'ori';
$row_img = $row_data['data'];
$local_file = $this->wp_upload_dir['basedir'] . '/' . $row_img->src;
$server_info = \json_decode($row_img->server_info, true);
if (empty($response->success)) {
if (!empty($response->status_code) && 404 == $response->status_code) {
$this->_step_back_image($row_img->id);
$msg = __('Some optimized image file(s) has expired and was cleared.', 'litespeed-cache');
Admin_Display::error($msg);
return;
} else {
// handle error
$image_url = $server_info['server'] . '/' . $server_info[$row_type];
self::debug(
'❌ failed to pull image (' .
$row_type .
'): ' .
(!empty($response->status_code) ? $response->status_code : '') .
' [Local: ' .
$row_img->src .
'] / [remote: ' .
$image_url .
']'
);
throw new \Exception('Failed to pull image ' . (!empty($response->status_code) ? $response->status_code : '') . ' [url] ' . $image_url);
return;
}
}
// Handle wp_remote_get 404 as its success=true
if (!empty($response->status_code)) {
if ($response->status_code == 404) {
$this->_step_back_image($row_img->id);
$msg = __('Some optimized image file(s) has expired and was cleared.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
// Note: if there is other error status code found in future, handle here
}
if ('webp' === $row_type) {
file_put_contents($local_file . '.webp', $response->body);
if (!file_exists($local_file . '.webp') || !filesize($local_file . '.webp') || md5_file($local_file . '.webp') !== $server_info['webp_md5']) {
self::debug('❌ Failed to pull optimized webp img: file md5 mismatch, server md5: ' . $server_info['webp_md5']);
// Delete working table
$q = "DELETE FROM `$this->_table_img_optming` WHERE id = %d ";
$wpdb->query($wpdb->prepare($q, $row_img->id));
$msg = __('Pulled WebP image md5 does not match the notified WebP image md5.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
self::debug('Pulled optimized img WebP: ' . $local_file . '.webp');
$webp_size = filesize($local_file . '.webp');
/**
* API for WebP
* @since 2.9.5
* @since 3.0 $row_img less elements (see above one)
* @see #751737 - API docs for WEBP generation
*/
do_action('litespeed_img_pull_webp', $row_img, $local_file . '.webp');
$total_pulled_webp++;
} elseif ('avif' === $row_type) {
file_put_contents($local_file . '.avif', $response->body);
if (!file_exists($local_file . '.avif') || !filesize($local_file . '.avif') || md5_file($local_file . '.avif') !== $server_info['avif_md5']) {
self::debug('❌ Failed to pull optimized avif img: file md5 mismatch, server md5: ' . $server_info['avif_md5']);
// Delete working table
$q = "DELETE FROM `$this->_table_img_optming` WHERE id = %d ";
$wpdb->query($wpdb->prepare($q, $row_img->id));
$msg = __('Pulled AVIF image md5 does not match the notified AVIF image md5.', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
self::debug('Pulled optimized img AVIF: ' . $local_file . '.avif');
$avif_size = filesize($local_file . '.avif');
/**
* API for AVIF
* @since 7.0
*/
do_action('litespeed_img_pull_avif', $row_img, $local_file . '.avif');
$total_pulled_avif++;
} else {
// "ori" image type
file_put_contents($local_file . '.tmp', $response->body);
if (!file_exists($local_file . '.tmp') || !filesize($local_file . '.tmp') || md5_file($local_file . '.tmp') !== $server_info['ori_md5']) {
self::debug(
'❌ Failed to pull optimized img: file md5 mismatch [url] ' .
$server_info['server'] .
'/' .
$server_info['ori'] .
' [server_md5] ' .
$server_info['ori_md5']
);
// Delete working table
$q = "DELETE FROM `$this->_table_img_optming` WHERE id = %d ";
$wpdb->query($wpdb->prepare($q, $row_img->id));
$msg = __('One or more pulled images does not match with the notified image md5', 'litespeed-cache');
Admin_Display::error($msg);
return;
}
// Backup ori img
if (!$rm_ori_bkup) {
$extension = pathinfo($local_file, PATHINFO_EXTENSION);
$bk_file = substr($local_file, 0, -strlen($extension)) . 'bk.' . $extension;
file_exists($local_file) && rename($local_file, $bk_file);
}
// Replace ori img
rename($local_file . '.tmp', $local_file);
self::debug('Pulled optimized img: ' . $local_file);
/**
* API Hook
* @since 2.9.5
* @since 3.0 $row_img has less elements now. Most useful ones are `post_id`/`src`
*/
do_action('litespeed_img_pull_ori', $row_img, $local_file);
self::debug2('Remove _table_img_optming record [id] ' . $row_img->id);
}
// Delete working table
$q = "DELETE FROM `$this->_table_img_optming` WHERE id = %d ";
$wpdb->query($wpdb->prepare($q, $row_img->id));
// Save server_list to notify taken
if (empty($server_list[$server_info['server']])) {
$server_list[$server_info['server']] = array();
}
$server_info_id = !empty($server_info['file_id']) ? $server_info['file_id'] : $server_info['id'];
$server_list[$server_info['server']][] = $server_info_id;
$total_pulled_ori++;
};
$force_wp_remote_get = defined('LITESPEED_FORCE_WP_REMOTE_GET') && LITESPEED_FORCE_WP_REMOTE_GET;
if (!$force_wp_remote_get && class_exists('\WpOrg\Requests\Requests') && class_exists('\WpOrg\Requests\Autoload') && version_compare(PHP_VERSION, '5.6.0', '>=')) {
// Make sure Requests can load internal classes.
Autoload::register();
// Run pull requests in parallel
Requests::request_multiple($requests, array(
'timeout' => 60,
'connect_timeout' => 60,
'complete' => $complete_action,
));
} else {
foreach ($requests as $cnt => $req) {
$wp_response = wp_safe_remote_get($req['url'], array('timeout' => 60));
$request_response = array(
'success' => false,
'status_code' => 0,
'body' => null,
);
if (is_wp_error($wp_response)) {
$error_message = $wp_response->get_error_message();
self::debug('❌ failed to pull image: ' . $error_message);
} else {
$request_response['success'] = true;
$request_response['status_code'] = $wp_response['response']['code'];
$request_response['body'] = $wp_response['body'];
}
self::debug('response code [code] ' . $wp_response['response']['code'] . ' [url] ' . $req['url']);
$request_response = (object) $request_response;
$complete_action($request_response, $cnt);
}
}
self::debug('Current batch pull finished');
}
} catch (\Exception $e) {
Admin_Display::error('Image pull process failure: ' . $e->getMessage());
}
// Notify IAPI images taken
foreach ($server_list as $server => $img_list) {
$data = array(
'action' => self::CLOUD_ACTION_TAKEN,
'list' => $img_list,
'server' => $server,
);
// TODO: improve this so we do not call once per server, but just once and then filter on the server side
Cloud::post(Cloud::SVC_IMG_OPTM, $data);
}
if (empty($this->_summary['img_taken'])) {
$this->_summary['img_taken'] = 0;
}
$this->_summary['img_taken'] += $total_pulled_ori + $total_pulled_webp + $total_pulled_avif;
self::save_summary();
// Manually running needs to roll back timestamp for next running
if ($manual) {
$this->_update_cron_running(true);
}
// $msg = sprintf(__('Pulled %d image(s)', 'litespeed-cache'), $total_pulled_ori + $total_pulled_webp);
// Admin_Display::success($msg);
// Check if there is still task in queue
$q = "SELECT * FROM `$this->_table_img_optming` WHERE optm_status = %d LIMIT 1";
$to_be_continued = $wpdb->get_row($wpdb->prepare($q, self::STATUS_NOTIFIED));
if ($to_be_continued) {
self::debug('Task in queue, to be continued...');
return;
// return Router::self_redirect(Router::ACTION_IMG_OPTM, self::TYPE_PULL);
}
// If all pulled, update tag to done
self::debug('Marked pull status to all pulled');
self::update_option(self::DB_NEED_PULL, self::STATUS_PULLED);
}
/**
* Push image back to previous status
*
* @since 3.0
* @access private
*/
private function _step_back_image($id)
{
global $wpdb;
self::debug('Push image back to new status [id] ' . $id);
// Reset the image to gathered status
$q = "UPDATE `$this->_table_img_optming` SET optm_status = %d WHERE id = %d ";
$wpdb->query($wpdb->prepare($q, array(self::STATUS_RAW, $id)));
}
/**
* Parse wp's meta value
*
* @since 1.6.7
* @access private
*/
private function _parse_wp_meta_value($v)
{
if (empty($v)) {
self::debug('bypassed parsing meta due to null value');
return false;
}
if (!$v->meta_value) {
self::debug('bypassed parsing meta due to no meta_value: pid ' . $v->post_id);
return false;
}
$meta_value = @maybe_unserialize($v->meta_value);
if (!is_array($meta_value)) {
self::debug('bypassed parsing meta due to meta_value not json: pid ' . $v->post_id);
return false;
}
if (empty($meta_value['file'])) {
self::debug('bypassed parsing meta due to no ori file: pid ' . $v->post_id);
return false;
}
return $meta_value;
}
/**
* Clean up all unfinished queue locally and to Cloud server
*
* @since 2.1.2
* @access public
*/
public function clean()
{
global $wpdb;
// Reset img_optm table's queue
if ($this->__data->tb_exist('img_optming')) {
// Get min post id to mark
$q = "SELECT MIN(post_id) FROM `$this->_table_img_optming`";
$min_pid = $wpdb->get_var($q) - 1;
if ($this->_summary['next_post_id'] > $min_pid) {
$this->_summary['next_post_id'] = $min_pid;
self::save_summary();
}
$q = "DELETE FROM `$this->_table_img_optming`";
$wpdb->query($q);
}
$msg = __('Cleaned up unfinished data successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Reset image counter
*
* @since 7.0
* @access private
*/
private function _reset_counter()
{
self::debug('reset image optm counter');
$this->_summary['next_post_id'] = 0;
self::save_summary();
$this->clean();
$msg = __('Reset image optimization counter successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Destroy all optimized images
*
* @since 3.0
* @access private
*/
private function _destroy()
{
global $wpdb;
self::debug('executing DESTROY process');
$offset = !empty($_GET['litespeed_i']) ? $_GET['litespeed_i'] : 0;
/**
* Limit images each time before redirection to fix Out of memory issue. #665465
* @since 2.9.8
*/
// Start deleting files
$limit = apply_filters('litespeed_imgoptm_destroy_max_rows', 500);
$img_q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
ORDER BY a.ID
LIMIT %d,%d
";
$q = $wpdb->prepare($img_q, array($offset * $limit, $limit));
$list = $wpdb->get_results($q);
$i = 0;
foreach ($list as $v) {
if (!$v->post_id) {
continue;
}
$meta_value = $this->_parse_wp_meta_value($v);
if (!$meta_value) {
continue;
}
$i++;
$this->tmp_pid = $v->post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_destroy_optm_file($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_destroy_optm_file'), $meta_value['sizes']);
}
}
self::debug('batch switched images total: ' . $i);
$offset++;
$to_be_continued = $wpdb->get_row($wpdb->prepare($img_q, array($offset * $limit, 1)));
if ($to_be_continued) {
# Check if post_id is beyond next_post_id
self::debug('[next_post_id] ' . $this->_summary['next_post_id'] . ' [cursor post id] ' . $to_be_continued->post_id);
if ($to_be_continued->post_id <= $this->_summary['next_post_id']) {
self::debug('redirecting to next');
return Router::self_redirect(Router::ACTION_IMG_OPTM, self::TYPE_DESTROY);
}
self::debug('🎊 Finished destroying');
}
// Delete postmeta info
$q = "DELETE FROM `$wpdb->postmeta` WHERE meta_key = %s";
$wpdb->query($wpdb->prepare($q, self::DB_SIZE));
$wpdb->query($wpdb->prepare($q, self::DB_SET));
// Delete img_optm table
$this->__data->tb_del('img_optm');
$this->__data->tb_del('img_optming');
// Clear options table summary info
self::delete_option('_summary');
self::delete_option(self::DB_NEED_PULL);
$msg = __('Destroy all optimization data successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Destroy optm file
*/
private function _destroy_optm_file($meta_value, $is_ori_file = false)
{
$short_file_path = $meta_value['file'];
if (!$is_ori_file) {
$short_file_path = $this->tmp_path . $short_file_path;
}
self::debug('deleting ' . $short_file_path);
// del webp
$this->__media->info($short_file_path . '.webp', $this->tmp_pid) && $this->__media->del($short_file_path . '.webp', $this->tmp_pid);
$this->__media->info($short_file_path . '.optm.webp', $this->tmp_pid) && $this->__media->del($short_file_path . '.optm.webp', $this->tmp_pid);
// del avif
$this->__media->info($short_file_path . '.avif', $this->tmp_pid) && $this->__media->del($short_file_path . '.avif', $this->tmp_pid);
$this->__media->info($short_file_path . '.optm.avif', $this->tmp_pid) && $this->__media->del($short_file_path . '.optm.avif', $this->tmp_pid);
$extension = pathinfo($short_file_path, PATHINFO_EXTENSION);
$local_filename = substr($short_file_path, 0, -strlen($extension) - 1);
$bk_file = $local_filename . '.bk.' . $extension;
$bk_optm_file = $local_filename . '.bk.optm.' . $extension;
// del optimized ori
if ($this->__media->info($bk_file, $this->tmp_pid)) {
self::debug('deleting optim ori');
$this->__media->del($short_file_path, $this->tmp_pid);
$this->__media->rename($bk_file, $short_file_path, $this->tmp_pid);
}
$this->__media->info($bk_optm_file, $this->tmp_pid) && $this->__media->del($bk_optm_file, $this->tmp_pid);
}
/**
* Rescan to find new generated images
*
* @since 1.6.7
* @access private
*/
private function _rescan()
{
global $wpdb;
exit('tobedone');
$offset = !empty($_GET['litespeed_i']) ? $_GET['litespeed_i'] : 0;
$limit = 500;
self::debug('rescan images');
// Get images
$q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->posts` a, `$wpdb->postmeta` b
WHERE a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
AND a.ID = b.post_id
AND b.meta_key = '_wp_attachment_metadata'
ORDER BY a.ID
LIMIT %d, %d
";
$list = $wpdb->get_results($wpdb->prepare($q, $offset * $limit, $limit + 1)); // last one is the seed for next batch
if (!$list) {
$msg = __('Rescanned successfully.', 'litespeed-cache');
Admin_Display::success($msg);
self::debug('rescan bypass: no gathered image found');
return;
}
if (count($list) == $limit + 1) {
$to_be_continued = true;
array_pop($list); // last one is the seed for next round, discard here.
} else {
$to_be_continued = false;
}
// Prepare post_ids to inquery gathered images
$pid_set = array();
$scanned_list = array();
foreach ($list as $v) {
$meta_value = $this->_parse_wp_meta_value($v);
if (!$meta_value) {
continue;
}
$scanned_list[] = array(
'pid' => $v->post_id,
'meta' => $meta_value,
);
$pid_set[] = $v->post_id;
}
// Build gathered images
$q = "SELECT src, post_id FROM `$this->_table_img_optm` WHERE post_id IN (" . implode(',', array_fill(0, count($pid_set), '%d')) . ')';
$list = $wpdb->get_results($wpdb->prepare($q, $pid_set));
foreach ($list as $v) {
$this->_existed_src_list[] = $v->post_id . '.' . $v->src;
}
// Find new images
foreach ($scanned_list as $v) {
$meta_value = $v['meta'];
// Parse all child src and put them into $this->_img_in_queue, missing ones to $this->_img_in_queue_missed
$this->tmp_pid = $v['pid'];
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_append_img_queue($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_append_img_queue'), $meta_value['sizes']);
}
}
self::debug('rescanned [img] ' . count($this->_img_in_queue));
$count = count($this->_img_in_queue);
if ($count > 0) {
// Save to DB
$this->_save_raw();
}
if ($to_be_continued) {
return Router::self_redirect(Router::ACTION_IMG_OPTM, self::TYPE_RESCAN);
}
$msg = $count ? sprintf(__('Rescanned %d images successfully.', 'litespeed-cache'), $count) : __('Rescanned successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Calculate bkup original images storage
*
* @since 2.2.6
* @access private
*/
private function _calc_bkup()
{
global $wpdb;
$offset = !empty($_GET['litespeed_i']) ? $_GET['litespeed_i'] : 0;
$limit = 500;
if (!$offset) {
$this->_summary['bk_summary'] = array(
'date' => time(),
'count' => 0,
'sum' => 0,
);
}
$img_q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
ORDER BY a.ID
LIMIT %d,%d
";
$q = $wpdb->prepare($img_q, array($offset * $limit, $limit));
$list = $wpdb->get_results($q);
foreach ($list as $v) {
if (!$v->post_id) {
continue;
}
$meta_value = $this->_parse_wp_meta_value($v);
if (!$meta_value) {
continue;
}
$this->tmp_pid = $v->post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_get_bk_size($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_get_bk_size'), $meta_value['sizes']);
}
}
$this->_summary['bk_summary']['date'] = time();
self::save_summary();
self::debug('_calc_bkup total: ' . $this->_summary['bk_summary']['count'] . ' [size] ' . $this->_summary['bk_summary']['sum']);
$offset++;
$to_be_continued = $wpdb->get_row($wpdb->prepare($img_q, array($offset * $limit, 1)));
if ($to_be_continued) {
return Router::self_redirect(Router::ACTION_IMG_OPTM, self::TYPE_CALC_BKUP);
}
$msg = __('Calculated backups successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Calculate single size
*/
private function _get_bk_size($meta_value, $is_ori_file = false)
{
$short_file_path = $meta_value['file'];
if (!$is_ori_file) {
$short_file_path = $this->tmp_path . $short_file_path;
}
$extension = pathinfo($short_file_path, PATHINFO_EXTENSION);
$local_filename = substr($short_file_path, 0, -strlen($extension) - 1);
$bk_file = $local_filename . '.bk.' . $extension;
$img_info = $this->__media->info($bk_file, $this->tmp_pid);
if (!$img_info) {
return;
}
$this->_summary['bk_summary']['count']++;
$this->_summary['bk_summary']['sum'] += $img_info['size'];
}
/**
* Delete bkup original images storage
*
* @since 2.5
* @access public
*/
public function rm_bkup()
{
global $wpdb;
if (!$this->__data->tb_exist('img_optming')) {
return;
}
$offset = !empty($_GET['litespeed_i']) ? $_GET['litespeed_i'] : 0;
$limit = 500;
if (empty($this->_summary['rmbk_summary'])) {
$this->_summary['rmbk_summary'] = array(
'date' => time(),
'count' => 0,
'sum' => 0,
);
}
$img_q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
ORDER BY a.ID
LIMIT %d,%d
";
$q = $wpdb->prepare($img_q, array($offset * $limit, $limit));
$list = $wpdb->get_results($q);
foreach ($list as $v) {
if (!$v->post_id) {
continue;
}
$meta_value = $this->_parse_wp_meta_value($v);
if (!$meta_value) {
continue;
}
$this->tmp_pid = $v->post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_del_bk_file($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_del_bk_file'), $meta_value['sizes']);
}
}
$this->_summary['rmbk_summary']['date'] = time();
self::save_summary();
self::debug('rm_bkup total: ' . $this->_summary['rmbk_summary']['count'] . ' [size] ' . $this->_summary['rmbk_summary']['sum']);
$offset++;
$to_be_continued = $wpdb->get_row($wpdb->prepare($img_q, array($offset * $limit, 1)));
if ($to_be_continued) {
return Router::self_redirect(Router::ACTION_IMG_OPTM, self::TYPE_RM_BKUP);
}
$msg = __('Removed backups successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Delete single file
*/
private function _del_bk_file($meta_value, $is_ori_file = false)
{
$short_file_path = $meta_value['file'];
if (!$is_ori_file) {
$short_file_path = $this->tmp_path . $short_file_path;
}
$extension = pathinfo($short_file_path, PATHINFO_EXTENSION);
$local_filename = substr($short_file_path, 0, -strlen($extension) - 1);
$bk_file = $local_filename . '.bk.' . $extension;
$img_info = $this->__media->info($bk_file, $this->tmp_pid);
if (!$img_info) {
return;
}
$this->_summary['rmbk_summary']['count']++;
$this->_summary['rmbk_summary']['sum'] += $img_info['size'];
$this->__media->del($bk_file, $this->tmp_pid);
}
/**
* Count images
*
* @since 1.6
* @access public
*/
public function img_count()
{
global $wpdb;
$q = "SELECT count(*)
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
";
$groups_all = $wpdb->get_var($q);
$groups_new = $wpdb->get_var($q . ' AND ID>' . (int) $this->_summary['next_post_id'] . ' ORDER BY ID');
$groups_done = $wpdb->get_var($q . ' AND ID<=' . (int) $this->_summary['next_post_id'] . ' ORDER BY ID');
$q = "SELECT b.post_id
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
ORDER BY a.ID DESC
LIMIT 1
";
$max_id = $wpdb->get_var($q);
$count_list = array(
'max_id' => $max_id,
'groups_all' => $groups_all,
'groups_new' => $groups_new,
'groups_done' => $groups_done,
);
// images count from work table
if ($this->__data->tb_exist('img_optming')) {
$q = "SELECT COUNT(DISTINCT post_id),COUNT(*) FROM `$this->_table_img_optming` WHERE optm_status = %d";
$groups_to_check = array(self::STATUS_RAW, self::STATUS_REQUESTED, self::STATUS_NOTIFIED, self::STATUS_ERR_FETCH);
foreach ($groups_to_check as $v) {
$count_list['img.' . $v] = $count_list['group.' . $v] = 0;
list($count_list['group.' . $v], $count_list['img.' . $v]) = $wpdb->get_row($wpdb->prepare($q, $v), ARRAY_N);
}
}
return $count_list;
}
/**
* Check if fetch cron is running
*
* @since 1.6.2
* @access public
*/
public function cron_running($bool_res = true)
{
$last_run = !empty($this->_summary['last_pull']) ? $this->_summary['last_pull'] : 0;
$is_running = $last_run && time() - $last_run < 120;
if ($bool_res) {
return $is_running;
}
return array($last_run, $is_running);
}
/**
* Update fetch cron timestamp tag
*
* @since 1.6.2
* @access private
*/
private function _update_cron_running($done = false)
{
$this->_summary['last_pull'] = time();
if ($done) {
// Only update cron tag when its from the active running cron
if ($this->_cron_ran) {
// Rollback for next running
$this->_summary['last_pull'] -= 120;
} else {
return;
}
}
self::save_summary();
$this->_cron_ran = true;
}
/**
* Batch switch images to ori/optm version
*
* @since 1.6.2
* @access public
*/
public function batch_switch($type)
{
global $wpdb;
if (defined('LITESPEED_CLI') || defined('DOING_CRON')) {
$offset = 0;
while ($offset !== 'done') {
Admin_Display::info("Starting switch to $type [offset] $offset");
$offset = $this->_batch_switch($type, $offset);
}
} else {
$offset = !empty($_GET['litespeed_i']) ? $_GET['litespeed_i'] : 0;
$newOffset = $this->_batch_switch($type, $offset);
if ($newOffset !== 'done') {
return Router::self_redirect(Router::ACTION_IMG_OPTM, $type);
}
}
$msg = __('Switched images successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Switch images per offset
*/
private function _batch_switch($type, $offset)
{
global $wpdb;
$limit = 500;
$this->tmp_type = $type;
$img_q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->posts` a
LEFT JOIN `$wpdb->postmeta` b ON b.post_id = a.ID
WHERE b.meta_key = '_wp_attachment_metadata'
AND a.post_type = 'attachment'
AND a.post_status = 'inherit'
AND a.post_mime_type IN ('image/jpeg', 'image/png', 'image/gif')
ORDER BY a.ID
LIMIT %d,%d
";
$q = $wpdb->prepare($img_q, array($offset * $limit, $limit));
$list = $wpdb->get_results($q);
$i = 0;
foreach ($list as $v) {
if (!$v->post_id) {
continue;
}
$meta_value = $this->_parse_wp_meta_value($v);
if (!$meta_value) {
continue;
}
$i++;
$this->tmp_pid = $v->post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_switch_bk_file($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_switch_bk_file'), $meta_value['sizes']);
}
}
self::debug('batch switched images total: ' . $i . ' [type] ' . $type);
$offset++;
$to_be_continued = $wpdb->get_row($wpdb->prepare($img_q, array($offset * $limit, 1)));
if ($to_be_continued) {
return $offset;
}
return 'done';
}
/**
* Delete single file
*/
private function _switch_bk_file($meta_value, $is_ori_file = false)
{
$short_file_path = $meta_value['file'];
if (!$is_ori_file) {
$short_file_path = $this->tmp_path . $short_file_path;
}
$extension = pathinfo($short_file_path, PATHINFO_EXTENSION);
$local_filename = substr($short_file_path, 0, -strlen($extension) - 1);
$bk_file = $local_filename . '.bk.' . $extension;
$bk_optm_file = $local_filename . '.bk.optm.' . $extension;
// self::debug('_switch_bk_file ' . $bk_file . ' [type] ' . $this->tmp_type);
// switch to ori
if ($this->tmp_type === self::TYPE_BATCH_SWITCH_ORI || $this->tmp_type == 'orig') {
// self::debug('switch to orig ' . $bk_file);
if (!$this->__media->info($bk_file, $this->tmp_pid)) {
return;
}
$this->__media->rename($local_filename . '.' . $extension, $bk_optm_file, $this->tmp_pid);
$this->__media->rename($bk_file, $local_filename . '.' . $extension, $this->tmp_pid);
}
// switch to optm
elseif ($this->tmp_type === self::TYPE_BATCH_SWITCH_OPTM || $this->tmp_type == 'optm') {
// self::debug('switch to optm ' . $bk_file);
if (!$this->__media->info($bk_optm_file, $this->tmp_pid)) {
return;
}
$this->__media->rename($local_filename . '.' . $extension, $bk_file, $this->tmp_pid);
$this->__media->rename($bk_optm_file, $local_filename . '.' . $extension, $this->tmp_pid);
}
}
/**
* Switch image between original one and optimized one
*
* @since 1.6.2
* @access private
*/
private function _switch_optm_file($type)
{
Admin_Display::success(__('Switched to optimized file successfully.', 'litespeed-cache'));
return;
global $wpdb;
$pid = substr($type, 4);
$switch_type = substr($type, 0, 4);
$q = "SELECT src,post_id FROM `$this->_table_img_optm` WHERE post_id = %d AND optm_status = %d";
$list = $wpdb->get_results($wpdb->prepare($q, array($pid, self::STATUS_PULLED)));
$msg = 'Unknown Msg';
foreach ($list as $v) {
// to switch webp file
if ($switch_type === 'webp') {
if ($this->__media->info($v->src . '.webp', $v->post_id)) {
$this->__media->rename($v->src . '.webp', $v->src . '.optm.webp', $v->post_id);
self::debug('Disabled WebP: ' . $v->src);
$msg = __('Disabled WebP file successfully.', 'litespeed-cache');
} elseif ($this->__media->info($v->src . '.optm.webp', $v->post_id)) {
$this->__media->rename($v->src . '.optm.webp', $v->src . '.webp', $v->post_id);
self::debug('Enable WebP: ' . $v->src);
$msg = __('Enabled WebP file successfully.', 'litespeed-cache');
}
}
// to switch avif file
elseif ($switch_type === 'avif') {
if ($this->__media->info($v->src . '.avif', $v->post_id)) {
$this->__media->rename($v->src . '.avif', $v->src . '.optm.avif', $v->post_id);
self::debug('Disabled AVIF: ' . $v->src);
$msg = __('Disabled AVIF file successfully.', 'litespeed-cache');
} elseif ($this->__media->info($v->src . '.optm.avif', $v->post_id)) {
$this->__media->rename($v->src . '.optm.avif', $v->src . '.avif', $v->post_id);
self::debug('Enable AVIF: ' . $v->src);
$msg = __('Enabled AVIF file successfully.', 'litespeed-cache');
}
}
// to switch original file
else {
$extension = pathinfo($v->src, PATHINFO_EXTENSION);
$local_filename = substr($v->src, 0, -strlen($extension) - 1);
$bk_file = $local_filename . '.bk.' . $extension;
$bk_optm_file = $local_filename . '.bk.optm.' . $extension;
// revert ori back
if ($this->__media->info($bk_file, $v->post_id)) {
$this->__media->rename($v->src, $bk_optm_file, $v->post_id);
$this->__media->rename($bk_file, $v->src, $v->post_id);
self::debug('Restore original img: ' . $bk_file);
$msg = __('Restored original file successfully.', 'litespeed-cache');
} elseif ($this->__media->info($bk_optm_file, $v->post_id)) {
$this->__media->rename($v->src, $bk_file, $v->post_id);
$this->__media->rename($bk_optm_file, $v->src, $v->post_id);
self::debug('Switch to optm img: ' . $v->src);
$msg = __('Switched to optimized file successfully.', 'litespeed-cache');
}
}
}
Admin_Display::success($msg);
}
/**
* Delete one optm data and recover original file
*
* @since 2.4.2
* @access public
*/
public function reset_row($post_id)
{
global $wpdb;
if (!$post_id) {
return;
}
// Gathered image don't have DB_SIZE info yet
// $size_meta = get_post_meta( $post_id, self::DB_SIZE, true );
// if ( ! $size_meta ) {
// return;
// }
self::debug('_reset_row [pid] ' . $post_id);
# TODO: Load image sub files
$img_q = "SELECT b.post_id, b.meta_value
FROM `$wpdb->postmeta` b
WHERE b.post_id =%d AND b.meta_key = '_wp_attachment_metadata'";
$q = $wpdb->prepare($img_q, array($post_id));
$v = $wpdb->get_row($q);
$meta_value = $this->_parse_wp_meta_value($v);
if ($meta_value) {
$this->tmp_pid = $v->post_id;
$this->tmp_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
$this->_destroy_optm_file($meta_value, true);
if (!empty($meta_value['sizes'])) {
array_map(array($this, '_destroy_optm_file'), $meta_value['sizes']);
}
}
delete_post_meta($post_id, self::DB_SIZE);
delete_post_meta($post_id, self::DB_SET);
$msg = __('Reset the optimized data successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Show an image's optm status
*
* @since 1.6.5
* @access public
*/
public function check_img()
{
global $wpdb;
$pid = $_POST['data'];
self::debug('Check image [ID] ' . $pid);
$data = array();
$data['img_count'] = $this->img_count();
$data['optm_summary'] = self::get_summary();
$data['_wp_attached_file'] = get_post_meta($pid, '_wp_attached_file', true);
$data['_wp_attachment_metadata'] = get_post_meta($pid, '_wp_attachment_metadata', true);
// Get img_optm data
$q = "SELECT * FROM `$this->_table_img_optm` WHERE post_id = %d";
$list = $wpdb->get_results($wpdb->prepare($q, $pid));
$img_data = array();
if ($list) {
foreach ($list as $v) {
$img_data[] = array(
'id' => $v->id,
'optm_status' => $v->optm_status,
'src' => $v->src,
'srcpath_md5' => $v->srcpath_md5,
'src_md5' => $v->src_md5,
'server_info' => $v->server_info,
);
}
}
$data['img_data'] = $img_data;
return array('_res' => 'ok', 'data' => $data);
}
/**
* Handle all request actions from main cls
*
* @since 2.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_RESET_ROW:
$this->reset_row(!empty($_GET['id']) ? $_GET['id'] : false);
break;
case self::TYPE_CALC_BKUP:
$this->_calc_bkup();
break;
case self::TYPE_RM_BKUP:
$this->rm_bkup();
break;
case self::TYPE_NEW_REQ:
$this->new_req();
break;
case self::TYPE_RESCAN:
$this->_rescan();
break;
case self::TYPE_RESET_COUNTER:
$this->_reset_counter();
break;
case self::TYPE_DESTROY:
$this->_destroy();
break;
case self::TYPE_CLEAN:
$this->clean();
break;
case self::TYPE_PULL:
self::start_async();
break;
case self::TYPE_BATCH_SWITCH_ORI:
case self::TYPE_BATCH_SWITCH_OPTM:
$this->batch_switch($type);
break;
case substr($type, 0, 4) === 'avif':
case substr($type, 0, 4) === 'webp':
case substr($type, 0, 4) === 'orig':
$this->_switch_optm_file($type);
break;
default:
break;
}
Admin::redirect();
}
}
import.cls.php 0000644 00000010232 15153741266 0007357 0 ustar 00 <?php
/**
* The import/export class.
*
* @since 1.8.2
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Import extends Base
{
protected $_summary;
const TYPE_IMPORT = 'import';
const TYPE_EXPORT = 'export';
const TYPE_RESET = 'reset';
/**
* Init
*
* @since 1.8.2
*/
public function __construct()
{
Debug2::debug('Import init');
$this->_summary = self::get_summary();
}
/**
* Export settings to file
*
* @since 1.8.2
* @access public
*/
public function export($only_data_return = false)
{
$raw_data = $this->get_options(true);
$data = array();
foreach ($raw_data as $k => $v) {
$data[] = \json_encode(array($k, $v));
}
$data = implode("\n\n", $data);
if ($only_data_return) {
return $data;
}
$filename = $this->_generate_filename();
// Update log
$this->_summary['export_file'] = $filename;
$this->_summary['export_time'] = time();
self::save_summary();
Debug2::debug('Import: Saved to ' . $filename);
@header('Content-Disposition: attachment; filename=' . $filename);
echo $data;
exit();
}
/**
* Import settings from file
*
* @since 1.8.2
* @access public
*/
public function import($file = false)
{
if (!$file) {
if (empty($_FILES['ls_file']['name']) || substr($_FILES['ls_file']['name'], -5) != '.data' || empty($_FILES['ls_file']['tmp_name'])) {
Debug2::debug('Import: Failed to import, wrong ls_file');
$msg = __('Import failed due to file error.', 'litespeed-cache');
Admin_Display::error($msg);
return false;
}
$this->_summary['import_file'] = $_FILES['ls_file']['name'];
$data = file_get_contents($_FILES['ls_file']['tmp_name']);
} else {
$this->_summary['import_file'] = $file;
$data = file_get_contents($file);
}
// Update log
$this->_summary['import_time'] = time();
self::save_summary();
$ori_data = array();
try {
// Check if the data is v4+ or not
if (strpos($data, '["_version",') === 0) {
Debug2::debug('[Import] Data version: v4+');
$data = explode("\n", $data);
foreach ($data as $v) {
$v = trim($v);
if (!$v) {
continue;
}
list($k, $v) = \json_decode($v, true);
$ori_data[$k] = $v;
}
} else {
$ori_data = \json_decode(base64_decode($data), true);
}
} catch (\Exception $ex) {
Debug2::debug('[Import] ❌ Failed to parse serialized data');
return false;
}
if (!$ori_data) {
Debug2::debug('[Import] ❌ Failed to import, no data');
return false;
} else {
Debug2::debug('[Import] Importing data', $ori_data);
}
$this->cls('Conf')->update_confs($ori_data);
if (!$file) {
Debug2::debug('Import: Imported ' . $_FILES['ls_file']['name']);
$msg = sprintf(__('Imported setting file %s successfully.', 'litespeed-cache'), $_FILES['ls_file']['name']);
Admin_Display::success($msg);
} else {
Debug2::debug('Import: Imported ' . $file);
}
return true;
}
/**
* Reset all configs to default values.
*
* @since 2.6.3
* @access public
*/
public function reset()
{
$options = $this->cls('Conf')->load_default_vals();
$this->cls('Conf')->update_confs($options);
Debug2::debug('[Import] Reset successfully.');
$msg = __('Reset successfully.', 'litespeed-cache');
Admin_Display::success($msg);
}
/**
* Generate the filename to export
*
* @since 1.8.2
* @access private
*/
private function _generate_filename()
{
// Generate filename
$parsed_home = parse_url(get_home_url());
$filename = 'LSCWP_cfg-';
if (!empty($parsed_home['host'])) {
$filename .= $parsed_home['host'] . '_';
}
if (!empty($parsed_home['path'])) {
$filename .= $parsed_home['path'] . '_';
}
$filename = str_replace('/', '_', $filename);
$filename .= '-' . date('Ymd_His') . '.data';
return $filename;
}
/**
* Handle all request actions from main cls
*
* @since 1.8.2
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_IMPORT:
$this->import();
break;
case self::TYPE_EXPORT:
$this->export();
break;
case self::TYPE_RESET:
$this->reset();
break;
default:
break;
}
Admin::redirect();
}
}
import.preset.cls.php 0000644 00000012670 15153741266 0010670 0 ustar 00 <?php
/**
* The preset class.
*
* @since 5.3.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Preset extends Import
{
protected $_summary;
const MAX_BACKUPS = 10;
const TYPE_APPLY = 'apply';
const TYPE_RESTORE = 'restore';
const STANDARD_DIR = LSCWP_DIR . 'data/preset';
const BACKUP_DIR = LITESPEED_STATIC_DIR . '/auto-backup';
/**
* Returns sorted backup names
*
* @since 5.3.0
* @access public
*/
public static function get_backups()
{
self::init_filesystem();
global $wp_filesystem;
$backups = array_map(
function ($path) {
return self::basename($path['name']);
},
$wp_filesystem->dirlist(self::BACKUP_DIR) ?: array()
);
rsort($backups);
return $backups;
}
/**
* Removes extra backup files
*
* @since 5.3.0
* @access public
*/
public static function prune_backups()
{
$backups = self::get_backups();
global $wp_filesystem;
foreach (array_slice($backups, self::MAX_BACKUPS) as $backup) {
$path = self::get_backup($backup);
$wp_filesystem->delete($path);
Debug2::debug('[Preset] Deleted old backup from ' . $backup);
}
}
/**
* Returns a settings file's extensionless basename given its filesystem path
*
* @since 5.3.0
* @access public
*/
public static function basename($path)
{
return basename($path, '.data');
}
/**
* Returns a standard preset's path given its extensionless basename
*
* @since 5.3.0
* @access public
*/
public static function get_standard($name)
{
return path_join(self::STANDARD_DIR, $name . '.data');
}
/**
* Returns a backup's path given its extensionless basename
*
* @since 5.3.0
* @access public
*/
public static function get_backup($name)
{
return path_join(self::BACKUP_DIR, $name . '.data');
}
/**
* Initializes the global $wp_filesystem object and clears stat cache
*
* @since 5.3.0
*/
static function init_filesystem()
{
require_once ABSPATH . '/wp-admin/includes/file.php';
\WP_Filesystem();
clearstatcache();
}
/**
* Init
*
* @since 5.3.0
*/
public function __construct()
{
Debug2::debug('[Preset] Init');
$this->_summary = self::get_summary();
}
/**
* Applies a standard preset's settings given its extensionless basename
*
* @since 5.3.0
* @access public
*/
public function apply($preset)
{
$this->make_backup($preset);
$path = self::get_standard($preset);
$result = $this->import_file($path) ? $preset : 'error';
$this->log($result);
}
/**
* Restores settings from the backup file with the given timestamp, then deletes the file
*
* @since 5.3.0
* @access public
*/
public function restore($timestamp)
{
$backups = array();
foreach (self::get_backups() as $backup) {
if (preg_match('/^backup-' . $timestamp . '(-|$)/', $backup) === 1) {
$backups[] = $backup;
}
}
if (empty($backups)) {
$this->log('error');
return;
}
$backup = $backups[0];
$path = self::get_backup($backup);
if (!$this->import_file($path)) {
$this->log('error');
return;
}
self::init_filesystem();
global $wp_filesystem;
$wp_filesystem->delete($path);
Debug2::debug('[Preset] Deleted most recent backup from ' . $backup);
$this->log('backup');
}
/**
* Saves current settings as a backup file, then prunes extra backup files
*
* @since 5.3.0
* @access public
*/
public function make_backup($preset)
{
$backup = 'backup-' . time() . '-before-' . $preset;
$data = $this->export(true);
$path = self::get_backup($backup);
File::save($path, $data, true);
Debug2::debug('[Preset] Backup saved to ' . $backup);
self::prune_backups();
}
/**
* Tries to import from a given settings file
*
* @since 5.3.0
*/
function import_file($path)
{
$debug = function ($result, $name) {
$action = $result ? 'Applied' : 'Failed to apply';
Debug2::debug('[Preset] ' . $action . ' settings from ' . $name);
return $result;
};
$name = self::basename($path);
$contents = file_get_contents($path);
if (false === $contents) {
Debug2::debug('[Preset] ❌ Failed to get file contents');
return $debug(false, $name);
}
$parsed = array();
try {
// Check if the data is v4+
if (strpos($contents, '["_version",') === 0) {
$contents = explode("\n", $contents);
foreach ($contents as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
list($key, $value) = \json_decode($line, true);
$parsed[$key] = $value;
}
} else {
$parsed = \json_decode(base64_decode($contents), true);
}
} catch (\Exception $ex) {
Debug2::debug('[Preset] ❌ Failed to parse serialized data');
return $debug(false, $name);
}
if (empty($parsed)) {
Debug2::debug('[Preset] ❌ Nothing to apply');
return $debug(false, $name);
}
$this->cls('Conf')->update_confs($parsed);
return $debug(true, $name);
}
/**
* Updates the log
*
* @since 5.3.0
*/
function log($preset)
{
$this->_summary['preset'] = $preset;
$this->_summary['preset_timestamp'] = time();
self::save_summary();
}
/**
* Handles all request actions from main cls
*
* @since 5.3.0
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_APPLY:
$this->apply(!empty($_GET['preset']) ? $_GET['preset'] : false);
break;
case self::TYPE_RESTORE:
$this->restore(!empty($_GET['timestamp']) ? $_GET['timestamp'] : false);
break;
default:
break;
}
Admin::redirect();
}
}
lang.cls.php 0000644 00000035617 15153741266 0007004 0 ustar 00 <?php
/**
* The language class.
*
* @since 3.0
* @package LiteSpeed_Cache
* @subpackage LiteSpeed_Cache/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Lang extends Base
{
/**
* Get image status per status bit
*
* @since 3.0
*/
public static function img_status($status = null)
{
$list = array(
Img_Optm::STATUS_NEW => __('Images not requested', 'litespeed-cache'),
Img_Optm::STATUS_RAW => __('Images ready to request', 'litespeed-cache'),
Img_Optm::STATUS_REQUESTED => __('Images requested', 'litespeed-cache'),
Img_Optm::STATUS_NOTIFIED => __('Images notified to pull', 'litespeed-cache'),
Img_Optm::STATUS_PULLED => __('Images optimized and pulled', 'litespeed-cache'),
);
if ($status !== null) {
return !empty($list[$status]) ? $list[$status] : 'N/A';
}
return $list;
}
/**
* Try translating a string
*
* @since 4.7
*/
public static function maybe_translate($raw_string)
{
$map = array(
'auto_alias_failed_cdn' =>
__('Unable to automatically add %1$s as a Domain Alias for main %2$s domain, due to potential CDN conflict.', 'litespeed-cache') .
' ' .
Doc::learn_more('https://quic.cloud/docs/cdn/dns/how-to-setup-domain-alias/', false, false, false, true),
'auto_alias_failed_uid' =>
__('Unable to automatically add %1$s as a Domain Alias for main %2$s domain.', 'litespeed-cache') .
' ' .
__('Alias is in use by another QUIC.cloud account.', 'litespeed-cache') .
' ' .
Doc::learn_more('https://quic.cloud/docs/cdn/dns/how-to-setup-domain-alias/', false, false, false, true),
);
// Maybe has placeholder
if (strpos($raw_string, '::')) {
$replacements = explode('::', $raw_string);
if (empty($map[$replacements[0]])) {
return $raw_string;
}
$tpl = $map[$replacements[0]];
unset($replacements[0]);
return vsprintf($tpl, array_values($replacements));
}
// Direct translation only
if (empty($map[$raw_string])) {
return $raw_string;
}
return $map[$raw_string];
}
/**
* Get the title of id
*
* @since 3.0
* @access public
*/
public static function title($id)
{
$_lang_list = array(
self::O_SERVER_IP => __('Server IP', 'litespeed-cache'),
self::O_GUEST_UAS => __('Guest Mode User Agents', 'litespeed-cache'),
self::O_GUEST_IPS => __('Guest Mode IPs', 'litespeed-cache'),
self::O_CACHE => __('Enable Cache', 'litespeed-cache'),
self::O_CACHE_BROWSER => __('Browser Cache', 'litespeed-cache'),
self::O_CACHE_TTL_PUB => __('Default Public Cache TTL', 'litespeed-cache'),
self::O_CACHE_TTL_PRIV => __('Default Private Cache TTL', 'litespeed-cache'),
self::O_CACHE_TTL_FRONTPAGE => __('Default Front Page TTL', 'litespeed-cache'),
self::O_CACHE_TTL_FEED => __('Default Feed TTL', 'litespeed-cache'),
self::O_CACHE_TTL_REST => __('Default REST TTL', 'litespeed-cache'),
self::O_CACHE_TTL_STATUS => __('Default HTTP Status Code Page TTL', 'litespeed-cache'),
self::O_CACHE_TTL_BROWSER => __('Browser Cache TTL', 'litespeed-cache'),
self::O_CACHE_AJAX_TTL => __('AJAX Cache TTL', 'litespeed-cache'),
self::O_AUTO_UPGRADE => __('Automatically Upgrade', 'litespeed-cache'),
self::O_GUEST => __('Guest Mode', 'litespeed-cache'),
self::O_GUEST_OPTM => __('Guest Optimization', 'litespeed-cache'),
self::O_NEWS => __('Notifications', 'litespeed-cache'),
self::O_CACHE_PRIV => __('Cache Logged-in Users', 'litespeed-cache'),
self::O_CACHE_COMMENTER => __('Cache Commenters', 'litespeed-cache'),
self::O_CACHE_REST => __('Cache REST API', 'litespeed-cache'),
self::O_CACHE_PAGE_LOGIN => __('Cache Login Page', 'litespeed-cache'),
self::O_CACHE_RES => __('Cache PHP Resources', 'litespeed-cache'),
self::O_CACHE_MOBILE => __('Cache Mobile', 'litespeed-cache'),
self::O_CACHE_MOBILE_RULES => __('List of Mobile User Agents', 'litespeed-cache'),
self::O_CACHE_PRIV_URI => __('Private Cached URIs', 'litespeed-cache'),
self::O_CACHE_DROP_QS => __('Drop Query String', 'litespeed-cache'),
self::O_OBJECT => __('Object Cache', 'litespeed-cache'),
self::O_OBJECT_KIND => __('Method', 'litespeed-cache'),
self::O_OBJECT_HOST => __('Host', 'litespeed-cache'),
self::O_OBJECT_PORT => __('Port', 'litespeed-cache'),
self::O_OBJECT_LIFE => __('Default Object Lifetime', 'litespeed-cache'),
self::O_OBJECT_USER => __('Username', 'litespeed-cache'),
self::O_OBJECT_PSWD => __('Password', 'litespeed-cache'),
self::O_OBJECT_DB_ID => __('Redis Database ID', 'litespeed-cache'),
self::O_OBJECT_GLOBAL_GROUPS => __('Global Groups', 'litespeed-cache'),
self::O_OBJECT_NON_PERSISTENT_GROUPS => __('Do Not Cache Groups', 'litespeed-cache'),
self::O_OBJECT_PERSISTENT => __('Persistent Connection', 'litespeed-cache'),
self::O_OBJECT_ADMIN => __('Cache WP-Admin', 'litespeed-cache'),
self::O_OBJECT_TRANSIENTS => __('Store Transients', 'litespeed-cache'),
self::O_PURGE_ON_UPGRADE => __('Purge All On Upgrade', 'litespeed-cache'),
self::O_PURGE_STALE => __('Serve Stale', 'litespeed-cache'),
self::O_PURGE_TIMED_URLS => __('Scheduled Purge URLs', 'litespeed-cache'),
self::O_PURGE_TIMED_URLS_TIME => __('Scheduled Purge Time', 'litespeed-cache'),
self::O_CACHE_FORCE_URI => __('Force Cache URIs', 'litespeed-cache'),
self::O_CACHE_FORCE_PUB_URI => __('Force Public Cache URIs', 'litespeed-cache'),
self::O_CACHE_EXC => __('Do Not Cache URIs', 'litespeed-cache'),
self::O_CACHE_EXC_QS => __('Do Not Cache Query Strings', 'litespeed-cache'),
self::O_CACHE_EXC_CAT => __('Do Not Cache Categories', 'litespeed-cache'),
self::O_CACHE_EXC_TAG => __('Do Not Cache Tags', 'litespeed-cache'),
self::O_CACHE_EXC_ROLES => __('Do Not Cache Roles', 'litespeed-cache'),
self::O_OPTM_CSS_MIN => __('CSS Minify', 'litespeed-cache'),
self::O_OPTM_CSS_COMB => __('CSS Combine', 'litespeed-cache'),
self::O_OPTM_CSS_COMB_EXT_INL => __('CSS Combine External and Inline', 'litespeed-cache'),
self::O_OPTM_UCSS => __('Generate UCSS', 'litespeed-cache'),
self::O_OPTM_UCSS_INLINE => __('UCSS Inline', 'litespeed-cache'),
self::O_OPTM_UCSS_SELECTOR_WHITELIST => __('UCSS Selector Allowlist', 'litespeed-cache'),
self::O_OPTM_UCSS_FILE_EXC_INLINE => __('UCSS File Excludes and Inline', 'litespeed-cache'),
self::O_OPTM_UCSS_EXC => __('UCSS URI Excludes', 'litespeed-cache'),
self::O_OPTM_JS_MIN => __('JS Minify', 'litespeed-cache'),
self::O_OPTM_JS_COMB => __('JS Combine', 'litespeed-cache'),
self::O_OPTM_JS_COMB_EXT_INL => __('JS Combine External and Inline', 'litespeed-cache'),
self::O_OPTM_HTML_MIN => __('HTML Minify', 'litespeed-cache'),
self::O_OPTM_HTML_LAZY => __('HTML Lazy Load Selectors', 'litespeed-cache'),
self::O_OPTM_HTML_SKIP_COMMENTS => __('HTML Keep Comments', 'litespeed-cache'),
self::O_OPTM_CSS_ASYNC => __('Load CSS Asynchronously', 'litespeed-cache'),
self::O_OPTM_CCSS_PER_URL => __('CCSS Per URL', 'litespeed-cache'),
self::O_OPTM_CSS_ASYNC_INLINE => __('Inline CSS Async Lib', 'litespeed-cache'),
self::O_OPTM_CSS_FONT_DISPLAY => __('Font Display Optimization', 'litespeed-cache'),
self::O_OPTM_JS_DEFER => __('Load JS Deferred', 'litespeed-cache'),
self::O_OPTM_LOCALIZE => __('Localize Resources', 'litespeed-cache'),
self::O_OPTM_LOCALIZE_DOMAINS => __('Localization Files', 'litespeed-cache'),
self::O_OPTM_DNS_PREFETCH => __('DNS Prefetch', 'litespeed-cache'),
self::O_OPTM_DNS_PREFETCH_CTRL => __('DNS Prefetch Control', 'litespeed-cache'),
self::O_OPTM_DNS_PRECONNECT => __('DNS Preconnect', 'litespeed-cache'),
self::O_OPTM_CSS_EXC => __('CSS Excludes', 'litespeed-cache'),
self::O_OPTM_JS_DELAY_INC => __('JS Delayed Includes', 'litespeed-cache'),
self::O_OPTM_JS_EXC => __('JS Excludes', 'litespeed-cache'),
self::O_OPTM_QS_RM => __('Remove Query Strings', 'litespeed-cache'),
self::O_OPTM_GGFONTS_ASYNC => __('Load Google Fonts Asynchronously', 'litespeed-cache'),
self::O_OPTM_GGFONTS_RM => __('Remove Google Fonts', 'litespeed-cache'),
self::O_OPTM_CCSS_CON => __('Critical CSS Rules', 'litespeed-cache'),
self::O_OPTM_CCSS_SEP_POSTTYPE => __('Separate CCSS Cache Post Types', 'litespeed-cache'),
self::O_OPTM_CCSS_SEP_URI => __('Separate CCSS Cache URIs', 'litespeed-cache'),
self::O_OPTM_CCSS_SELECTOR_WHITELIST => __('CCSS Selector Allowlist', 'litespeed-cache'),
self::O_OPTM_JS_DEFER_EXC => __('JS Deferred / Delayed Excludes', 'litespeed-cache'),
self::O_OPTM_GM_JS_EXC => __('Guest Mode JS Excludes', 'litespeed-cache'),
self::O_OPTM_EMOJI_RM => __('Remove WordPress Emoji', 'litespeed-cache'),
self::O_OPTM_NOSCRIPT_RM => __('Remove Noscript Tags', 'litespeed-cache'),
self::O_OPTM_EXC => __('URI Excludes', 'litespeed-cache'),
self::O_OPTM_GUEST_ONLY => __('Optimize for Guests Only', 'litespeed-cache'),
self::O_OPTM_EXC_ROLES => __('Role Excludes', 'litespeed-cache'),
self::O_DISCUSS_AVATAR_CACHE => __('Gravatar Cache', 'litespeed-cache'),
self::O_DISCUSS_AVATAR_CRON => __('Gravatar Cache Cron', 'litespeed-cache'),
self::O_DISCUSS_AVATAR_CACHE_TTL => __('Gravatar Cache TTL', 'litespeed-cache'),
self::O_MEDIA_LAZY => __('Lazy Load Images', 'litespeed-cache'),
self::O_MEDIA_LAZY_EXC => __('Lazy Load Image Excludes', 'litespeed-cache'),
self::O_MEDIA_LAZY_CLS_EXC => __('Lazy Load Image Class Name Excludes', 'litespeed-cache'),
self::O_MEDIA_LAZY_PARENT_CLS_EXC => __('Lazy Load Image Parent Class Name Excludes', 'litespeed-cache'),
self::O_MEDIA_IFRAME_LAZY_CLS_EXC => __('Lazy Load Iframe Class Name Excludes', 'litespeed-cache'),
self::O_MEDIA_IFRAME_LAZY_PARENT_CLS_EXC => __('Lazy Load Iframe Parent Class Name Excludes', 'litespeed-cache'),
self::O_MEDIA_LAZY_URI_EXC => __('Lazy Load URI Excludes', 'litespeed-cache'),
self::O_MEDIA_LQIP_EXC => __('LQIP Excludes', 'litespeed-cache'),
self::O_MEDIA_LAZY_PLACEHOLDER => __('Basic Image Placeholder', 'litespeed-cache'),
self::O_MEDIA_PLACEHOLDER_RESP => __('Responsive Placeholder', 'litespeed-cache'),
self::O_MEDIA_PLACEHOLDER_RESP_COLOR => __('Responsive Placeholder Color', 'litespeed-cache'),
self::O_MEDIA_PLACEHOLDER_RESP_SVG => __('Responsive Placeholder SVG', 'litespeed-cache'),
self::O_MEDIA_LQIP => __('LQIP Cloud Generator', 'litespeed-cache'),
self::O_MEDIA_LQIP_QUAL => __('LQIP Quality', 'litespeed-cache'),
self::O_MEDIA_LQIP_MIN_W => __('LQIP Minimum Dimensions', 'litespeed-cache'),
// self::O_MEDIA_LQIP_MIN_H => __( 'LQIP Minimum Height', 'litespeed-cache' ),
self::O_MEDIA_PLACEHOLDER_RESP_ASYNC => __('Generate LQIP In Background', 'litespeed-cache'),
self::O_MEDIA_IFRAME_LAZY => __('Lazy Load Iframes', 'litespeed-cache'),
self::O_MEDIA_ADD_MISSING_SIZES => __('Add Missing Sizes', 'litespeed-cache'),
self::O_MEDIA_VPI => __('Viewport Images', 'litespeed-cache'),
self::O_MEDIA_VPI_CRON => __('Viewport Images Cron', 'litespeed-cache'),
self::O_IMG_OPTM_AUTO => __('Auto Request Cron', 'litespeed-cache'),
self::O_IMG_OPTM_ORI => __('Optimize Original Images', 'litespeed-cache'),
self::O_IMG_OPTM_RM_BKUP => __('Remove Original Backups', 'litespeed-cache'),
self::O_IMG_OPTM_WEBP => __('Next-Gen Image Format', 'litespeed-cache'),
self::O_IMG_OPTM_LOSSLESS => __('Optimize Losslessly', 'litespeed-cache'),
self::O_IMG_OPTM_EXIF => __('Preserve EXIF/XMP data', 'litespeed-cache'),
self::O_IMG_OPTM_WEBP_ATTR => __('WebP/AVIF Attribute To Replace', 'litespeed-cache'),
self::O_IMG_OPTM_WEBP_REPLACE_SRCSET => __('WebP/AVIF For Extra srcset', 'litespeed-cache'),
self::O_IMG_OPTM_JPG_QUALITY => __('WordPress Image Quality Control', 'litespeed-cache'),
self::O_ESI => __('Enable ESI', 'litespeed-cache'),
self::O_ESI_CACHE_ADMBAR => __('Cache Admin Bar', 'litespeed-cache'),
self::O_ESI_CACHE_COMMFORM => __('Cache Comment Form', 'litespeed-cache'),
self::O_ESI_NONCE => __('ESI Nonces', 'litespeed-cache'),
self::O_CACHE_VARY_GROUP => __('Vary Group', 'litespeed-cache'),
self::O_PURGE_HOOK_ALL => __('Purge All Hooks', 'litespeed-cache'),
self::O_UTIL_NO_HTTPS_VARY => __('Improve HTTP/HTTPS Compatibility', 'litespeed-cache'),
self::O_UTIL_INSTANT_CLICK => __('Instant Click', 'litespeed-cache'),
self::O_CACHE_EXC_COOKIES => __('Do Not Cache Cookies', 'litespeed-cache'),
self::O_CACHE_EXC_USERAGENTS => __('Do Not Cache User Agents', 'litespeed-cache'),
self::O_CACHE_LOGIN_COOKIE => __('Login Cookie', 'litespeed-cache'),
self::O_CACHE_VARY_COOKIES => __('Vary Cookies', 'litespeed-cache'),
self::O_MISC_HEARTBEAT_FRONT => __('Frontend Heartbeat Control', 'litespeed-cache'),
self::O_MISC_HEARTBEAT_FRONT_TTL => __('Frontend Heartbeat TTL', 'litespeed-cache'),
self::O_MISC_HEARTBEAT_BACK => __('Backend Heartbeat Control', 'litespeed-cache'),
self::O_MISC_HEARTBEAT_BACK_TTL => __('Backend Heartbeat TTL', 'litespeed-cache'),
self::O_MISC_HEARTBEAT_EDITOR => __('Editor Heartbeat', 'litespeed-cache'),
self::O_MISC_HEARTBEAT_EDITOR_TTL => __('Editor Heartbeat TTL', 'litespeed-cache'),
self::O_CDN => __('Use CDN Mapping', 'litespeed-cache'),
self::CDN_MAPPING_URL => __('CDN URL', 'litespeed-cache'),
self::CDN_MAPPING_INC_IMG => __('Include Images', 'litespeed-cache'),
self::CDN_MAPPING_INC_CSS => __('Include CSS', 'litespeed-cache'),
self::CDN_MAPPING_INC_JS => __('Include JS', 'litespeed-cache'),
self::CDN_MAPPING_FILETYPE => __('Include File Types', 'litespeed-cache'),
self::O_CDN_ATTR => __('HTML Attribute To Replace', 'litespeed-cache'),
self::O_CDN_ORI => __('Original URLs', 'litespeed-cache'),
self::O_CDN_ORI_DIR => __('Included Directories', 'litespeed-cache'),
self::O_CDN_EXC => __('Exclude Path', 'litespeed-cache'),
self::O_CDN_CLOUDFLARE => __('Cloudflare API', 'litespeed-cache'),
self::O_CRAWLER => __('Crawler', 'litespeed-cache'),
self::O_CRAWLER_CRAWL_INTERVAL => __('Crawl Interval', 'litespeed-cache'),
self::O_CRAWLER_LOAD_LIMIT => __('Server Load Limit', 'litespeed-cache'),
self::O_CRAWLER_ROLES => __('Role Simulation', 'litespeed-cache'),
self::O_CRAWLER_COOKIES => __('Cookie Simulation', 'litespeed-cache'),
self::O_CRAWLER_SITEMAP => __('Custom Sitemap', 'litespeed-cache'),
self::O_DEBUG_DISABLE_ALL => __('Disable All Features', 'litespeed-cache'),
self::O_DEBUG => __('Debug Log', 'litespeed-cache'),
self::O_DEBUG_IPS => __('Admin IPs', 'litespeed-cache'),
self::O_DEBUG_LEVEL => __('Debug Level', 'litespeed-cache'),
self::O_DEBUG_FILESIZE => __('Log File Size Limit', 'litespeed-cache'),
self::O_DEBUG_COLLAPSE_QS => __('Collapse Query Strings', 'litespeed-cache'),
self::O_DEBUG_INC => __('Debug URI Includes', 'litespeed-cache'),
self::O_DEBUG_EXC => __('Debug URI Excludes', 'litespeed-cache'),
self::O_DEBUG_EXC_STRINGS => __('Debug String Excludes', 'litespeed-cache'),
self::O_DB_OPTM_REVISIONS_MAX => __('Revisions Max Number', 'litespeed-cache'),
self::O_DB_OPTM_REVISIONS_AGE => __('Revisions Max Age', 'litespeed-cache'),
);
if (array_key_exists($id, $_lang_list)) {
return $_lang_list[$id];
}
return 'N/A';
}
}
localization.cls.php 0000644 00000006617 15153741266 0010551 0 ustar 00 <?php
/**
* The localization class.
*
* @since 3.3
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Localization extends Base
{
const LOG_TAG = '🛍️';
/**
* Init optimizer
*
* @since 3.0
* @access protected
*/
public function init()
{
add_filter('litespeed_buffer_finalize', array($this, 'finalize'), 23); // After page optm
}
/**
* Localize Resources
*
* @since 3.3
*/
public function serve_static($uri)
{
$url = base64_decode($uri);
if (!$this->conf(self::O_OPTM_LOCALIZE)) {
// wp_redirect( $url );
exit('Not supported');
}
if (substr($url, -3) !== '.js') {
// wp_redirect( $url );
// exit( 'Not supported ' . $uri );
}
$match = false;
$domains = $this->conf(self::O_OPTM_LOCALIZE_DOMAINS);
foreach ($domains as $v) {
if (!$v || strpos($v, '#') === 0) {
continue;
}
$type = 'js';
$domain = $v;
// Try to parse space split value
if (strpos($v, ' ')) {
$v = explode(' ', $v);
if (!empty($v[1])) {
$type = strtolower($v[0]);
$domain = $v[1];
}
}
if (strpos($domain, 'https://') !== 0) {
continue;
}
if ($type != 'js') {
continue;
}
// if ( strpos( $url, $domain ) !== 0 ) {
if ($url != $domain) {
continue;
}
$match = true;
break;
}
if (!$match) {
// wp_redirect( $url );
exit('Not supported2');
}
header('Content-Type: application/javascript');
// Generate
$this->_maybe_mk_cache_folder('localres');
$file = $this->_realpath($url);
self::debug('localize [url] ' . $url);
$response = wp_safe_remote_get($url, array('timeout' => 180, 'stream' => true, 'filename' => $file));
// Parse response data
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
file_exists($file) && unlink($file);
self::debug('failed to get: ' . $error_message);
wp_redirect($url);
exit();
}
$url = $this->_rewrite($url);
wp_redirect($url);
exit();
}
/**
* Get the final URL of local avatar
*
* @since 4.5
*/
private function _rewrite($url)
{
return LITESPEED_STATIC_URL . '/localres/' . $this->_filepath($url);
}
/**
* Generate realpath of the cache file
*
* @since 4.5
* @access private
*/
private function _realpath($url)
{
return LITESPEED_STATIC_DIR . '/localres/' . $this->_filepath($url);
}
/**
* Get filepath
*
* @since 4.5
*/
private function _filepath($url)
{
$filename = md5($url) . '.js';
if (is_multisite()) {
$filename = get_current_blog_id() . '/' . $filename;
}
return $filename;
}
/**
* Localize JS/Fonts
*
* @since 3.3
* @access public
*/
public function finalize($content)
{
if (is_admin()) {
return $content;
}
if (!$this->conf(self::O_OPTM_LOCALIZE)) {
return $content;
}
$domains = $this->conf(self::O_OPTM_LOCALIZE_DOMAINS);
if (!$domains) {
return $content;
}
foreach ($domains as $v) {
if (!$v || strpos($v, '#') === 0) {
continue;
}
$type = 'js';
$domain = $v;
// Try to parse space split value
if (strpos($v, ' ')) {
$v = explode(' ', $v);
if (!empty($v[1])) {
$type = strtolower($v[0]);
$domain = $v[1];
}
}
if (strpos($domain, 'https://') !== 0) {
continue;
}
if ($type != 'js') {
continue;
}
$content = str_replace($domain, LITESPEED_STATIC_URL . '/localres/' . base64_encode($domain), $content);
}
return $content;
}
}
media.cls.php 0000644 00000101321 15153741266 0007124 0 ustar 00 <?php
/**
* The class to operate media data.
*
* @since 1.4
* @since 1.5 Moved into /inc
* @package Core
* @subpackage Core/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Media extends Root
{
const LOG_TAG = '📺';
const LIB_FILE_IMG_LAZYLOAD = 'assets/js/lazyload.min.js';
private $content;
private $_wp_upload_dir;
private $_vpi_preload_list = array();
private $_format = '';
private $_sys_format = '';
/**
* Init
*
* @since 1.4
*/
public function __construct()
{
Debug2::debug2('[Media] init');
$this->_wp_upload_dir = wp_upload_dir();
if ($this->conf(Base::O_IMG_OPTM_WEBP)) {
$this->_sys_format = 'webp';
$this->_format = 'webp';
if ($this->conf(Base::O_IMG_OPTM_WEBP) == 2) {
$this->_sys_format = 'avif';
$this->_format = 'avif';
}
if (!$this->_browser_support_next_gen()) {
$this->_format = '';
}
}
}
/**
* Init optm features
*
* @since 3.0
* @access public
*/
public function init()
{
if (is_admin()) {
return;
}
// Due to ajax call doesn't send correct accept header, have to limit webp to HTML only
if ($this->webp_support()) {
// Hook to srcset
if (function_exists('wp_calculate_image_srcset')) {
add_filter('wp_calculate_image_srcset', array($this, 'webp_srcset'), 988);
}
// Hook to mime icon
// add_filter( 'wp_get_attachment_image_src', array( $this, 'webp_attach_img_src' ), 988 );// todo: need to check why not
// add_filter( 'wp_get_attachment_url', array( $this, 'webp_url' ), 988 ); // disabled to avoid wp-admin display
}
if ($this->conf(Base::O_MEDIA_LAZY) && !$this->cls('Metabox')->setting('litespeed_no_image_lazy')) {
self::debug('Suppress default WP lazyload');
add_filter('wp_lazy_loading_enabled', '__return_false');
}
/**
* Replace gravatar
* @since 3.0
*/
$this->cls('Avatar');
add_filter('litespeed_buffer_finalize', array($this, 'finalize'), 4);
add_filter('litespeed_optm_html_head', array($this, 'finalize_head'));
}
/**
* Add featured image to head
*/
public function finalize_head($content)
{
global $wp_query;
// <link rel="preload" as="image" href="xx">
if ($this->_vpi_preload_list) {
foreach ($this->_vpi_preload_list as $v) {
$content .= '<link rel="preload" as="image" href="' . Str::trim_quotes($v) . '">';
}
}
// $featured_image_url = get_the_post_thumbnail_url();
// if ($featured_image_url) {
// self::debug('Append featured image to head: ' . $featured_image_url);
// if ($this->webp_support()) {
// $featured_image_url = $this->replace_webp($featured_image_url) ?: $featured_image_url;
// }
// }
// }
return $content;
}
/**
* Adjust WP default JPG quality
*
* @since 3.0
* @access public
*/
public function adjust_jpg_quality($quality)
{
$v = $this->conf(Base::O_IMG_OPTM_JPG_QUALITY);
if ($v) {
return $v;
}
return $quality;
}
/**
* Register admin menu
*
* @since 1.6.3
* @access public
*/
public function after_admin_init()
{
/**
* JPG quality control
* @since 3.0
*/
add_filter('jpeg_quality', array($this, 'adjust_jpg_quality'));
add_filter('manage_media_columns', array($this, 'media_row_title'));
add_filter('manage_media_custom_column', array($this, 'media_row_actions'), 10, 2);
add_action('litespeed_media_row', array($this, 'media_row_con'));
// Hook to attachment delete action
add_action('delete_attachment', __CLASS__ . '::delete_attachment');
}
/**
* Media delete action hook
*
* @since 2.4.3
* @access public
*/
public static function delete_attachment($post_id)
{
// if (!Data::cls()->tb_exist('img_optm')) {
// return;
// }
self::debug('delete_attachment [pid] ' . $post_id);
Img_Optm::cls()->reset_row($post_id);
}
/**
* Return media file info if exists
*
* This is for remote attachment plugins
*
* @since 2.9.8
* @access public
*/
public function info($short_file_path, $post_id)
{
$short_file_path = wp_normalize_path($short_file_path);
$basedir = $this->_wp_upload_dir['basedir'] . '/';
if (strpos($short_file_path, $basedir) === 0) {
$short_file_path = substr($short_file_path, strlen($basedir));
}
$real_file = $basedir . $short_file_path;
if (file_exists($real_file)) {
return array(
'url' => $this->_wp_upload_dir['baseurl'] . '/' . $short_file_path,
'md5' => md5_file($real_file),
'size' => filesize($real_file),
);
}
/**
* WP Stateless compatibility #143 https://github.com/litespeedtech/lscache_wp/issues/143
* @since 2.9.8
* @return array( 'url', 'md5', 'size' )
*/
$info = apply_filters('litespeed_media_info', array(), $short_file_path, $post_id);
if (!empty($info['url']) && !empty($info['md5']) && !empty($info['size'])) {
return $info;
}
return false;
}
/**
* Delete media file
*
* @since 2.9.8
* @access public
*/
public function del($short_file_path, $post_id)
{
$real_file = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path;
if (file_exists($real_file)) {
unlink($real_file);
self::debug('deleted ' . $real_file);
}
do_action('litespeed_media_del', $short_file_path, $post_id);
}
/**
* Rename media file
*
* @since 2.9.8
* @access public
*/
public function rename($short_file_path, $short_file_path_new, $post_id)
{
// self::debug('renaming ' . $short_file_path . ' -> ' . $short_file_path_new);
$real_file = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path;
$real_file_new = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path_new;
if (file_exists($real_file)) {
rename($real_file, $real_file_new);
self::debug('renamed ' . $real_file . ' to ' . $real_file_new);
}
do_action('litespeed_media_rename', $short_file_path, $short_file_path_new, $post_id);
}
/**
* Media Admin Menu -> Image Optimization Column Title
*
* @since 1.6.3
* @access public
*/
public function media_row_title($posts_columns)
{
$posts_columns['imgoptm'] = __('LiteSpeed Optimization', 'litespeed-cache');
return $posts_columns;
}
/**
* Media Admin Menu -> Image Optimization Column
*
* @since 1.6.2
* @access public
*/
public function media_row_actions($column_name, $post_id)
{
if ($column_name !== 'imgoptm') {
return;
}
do_action('litespeed_media_row', $post_id);
}
/**
* Display image optm info
*
* @since 3.0
*/
public function media_row_con($post_id)
{
$att_info = wp_get_attachment_metadata($post_id);
if (empty($att_info['file'])) {
return;
}
$short_path = $att_info['file'];
$size_meta = get_post_meta($post_id, Img_Optm::DB_SIZE, true);
echo '<p>';
// Original image info
if ($size_meta && !empty($size_meta['ori_saved'])) {
$percent = ceil(($size_meta['ori_saved'] * 100) / $size_meta['ori_total']);
$extension = pathinfo($short_path, PATHINFO_EXTENSION);
$bk_file = substr($short_path, 0, -strlen($extension)) . 'bk.' . $extension;
$bk_optm_file = substr($short_path, 0, -strlen($extension)) . 'bk.optm.' . $extension;
$link = Utility::build_url(Router::ACTION_IMG_OPTM, 'orig' . $post_id);
$desc = false;
$cls = '';
if ($this->info($bk_file, $post_id)) {
$curr_status = __('(optm)', 'litespeed-cache');
$desc = __('Currently using optimized version of file.', 'litespeed-cache') . ' ' . __('Click to switch to original (unoptimized) version.', 'litespeed-cache');
} elseif ($this->info($bk_optm_file, $post_id)) {
$cls .= ' litespeed-warning';
$curr_status = __('(non-optm)', 'litespeed-cache');
$desc = __('Currently using original (unoptimized) version of file.', 'litespeed-cache') . ' ' . __('Click to switch to optimized version.', 'litespeed-cache');
}
echo GUI::pie_tiny(
$percent,
24,
sprintf(__('Original file reduced by %1$s (%2$s)', 'litespeed-cache'), $percent . '%', Utility::real_size($size_meta['ori_saved'])),
'left'
);
echo sprintf(__('Orig saved %s', 'litespeed-cache'), $percent . '%');
if ($desc) {
echo sprintf(
' <a href="%1$s" class="litespeed-media-href %2$s" data-balloon-pos="left" data-balloon-break aria-label="%3$s">%4$s</a>',
$link,
$cls,
$desc,
$curr_status
);
} else {
echo sprintf(
' <span class="litespeed-desc" data-balloon-pos="left" data-balloon-break aria-label="%1$s">%2$s</span>',
__('Using optimized version of file. ', 'litespeed-cache') . ' ' . __('No backup of original file exists.', 'litespeed-cache'),
__('(optm)', 'litespeed-cache')
);
}
} elseif ($size_meta && $size_meta['ori_saved'] === 0) {
echo GUI::pie_tiny(0, 24, __('Congratulation! Your file was already optimized', 'litespeed-cache'), 'left');
echo sprintf(__('Orig %s', 'litespeed-cache'), '<span class="litespeed-desc">' . __('(no savings)', 'litespeed-cache') . '</span>');
} else {
echo __('Orig', 'litespeed-cache') . '<span class="litespeed-left10">—</span>';
}
echo '</p>';
echo '<p>';
// WebP/AVIF info
if ($size_meta && $this->webp_support(true) && !empty($size_meta[$this->_sys_format . '_saved'])) {
$is_avif = 'avif' === $this->_sys_format;
$size_meta_saved = $size_meta[$this->_sys_format . '_saved'];
$size_meta_total = $size_meta[$this->_sys_format . '_total'];
$percent = ceil(($size_meta_saved * 100) / $size_meta_total);
$link = Utility::build_url(Router::ACTION_IMG_OPTM, $this->_sys_format . $post_id);
$desc = false;
$cls = '';
if ($this->info($short_path . '.' . $this->_sys_format, $post_id)) {
$curr_status = __('(optm)', 'litespeed-cache');
$desc = $is_avif
? __('Currently using optimized version of AVIF file.', 'litespeed-cache')
: __('Currently using optimized version of WebP file.', 'litespeed-cache');
$desc .= ' ' . __('Click to switch to original (unoptimized) version.', 'litespeed-cache');
} elseif ($this->info($short_path . '.optm.' . $this->_sys_format, $post_id)) {
$cls .= ' litespeed-warning';
$curr_status = __('(non-optm)', 'litespeed-cache');
$desc = $is_avif
? __('Currently using original (unoptimized) version of AVIF file.', 'litespeed-cache')
: __('Currently using original (unoptimized) version of WebP file.', 'litespeed-cache');
$desc .= ' ' . __('Click to switch to optimized version.', 'litespeed-cache');
}
echo GUI::pie_tiny(
$percent,
24,
sprintf(
$is_avif ? __('AVIF file reduced by %1$s (%2$s)', 'litespeed-cache') : __('WebP file reduced by %1$s (%2$s)', 'litespeed-cache'),
$percent . '%',
Utility::real_size($size_meta_saved)
),
'left'
);
echo sprintf($is_avif ? __('AVIF saved %s', 'litespeed-cache') : __('WebP saved %s', 'litespeed-cache'), $percent . '%');
if ($desc) {
echo sprintf(
' <a href="%1$s" class="litespeed-media-href %2$s" data-balloon-pos="left" data-balloon-break aria-label="%3$s">%4$s</a>',
$link,
$cls,
$desc,
$curr_status
);
} else {
echo sprintf(
' <span class="litespeed-desc" data-balloon-pos="left" data-balloon-break aria-label="%1$s %2$s">%3$s</span>',
__('Using optimized version of file. ', 'litespeed-cache'),
$is_avif ? __('No backup of unoptimized AVIF file exists.', 'litespeed-cache') : __('No backup of unoptimized WebP file exists.', 'litespeed-cache'),
__('(optm)', 'litespeed-cache')
);
}
} else {
echo $this->next_gen_image_title() . '<span class="litespeed-left10">—</span>';
}
echo '</p>';
// Delete row btn
if ($size_meta) {
echo sprintf(
'<div class="row-actions"><span class="delete"><a href="%1$s" class="">%2$s</a></span></div>',
Utility::build_url(Router::ACTION_IMG_OPTM, Img_Optm::TYPE_RESET_ROW, false, null, array('id' => $post_id)),
__('Restore from backup', 'litespeed-cache')
);
echo '</div>';
}
}
/**
* Get wp size info
*
* NOTE: this is not used because it has to be after admin_init
*
* @since 1.6.2
* @return array $sizes Data for all currently-registered image sizes.
*/
public function get_image_sizes()
{
global $_wp_additional_image_sizes;
$sizes = array();
foreach (get_intermediate_image_sizes() as $_size) {
if (in_array($_size, array('thumbnail', 'medium', 'medium_large', 'large'))) {
$sizes[$_size]['width'] = get_option($_size . '_size_w');
$sizes[$_size]['height'] = get_option($_size . '_size_h');
$sizes[$_size]['crop'] = (bool) get_option($_size . '_crop');
} elseif (isset($_wp_additional_image_sizes[$_size])) {
$sizes[$_size] = array(
'width' => $_wp_additional_image_sizes[$_size]['width'],
'height' => $_wp_additional_image_sizes[$_size]['height'],
'crop' => $_wp_additional_image_sizes[$_size]['crop'],
);
}
}
return $sizes;
}
/**
* Exclude role from optimization filter
*
* @since 1.6.2
* @access public
*/
public function webp_support($sys_level = false)
{
if ($sys_level) {
return $this->_sys_format;
}
return $this->_format; // User level next gen support
}
private function _browser_support_next_gen()
{
if (!empty($_SERVER['HTTP_ACCEPT'])) {
if (strpos($_SERVER['HTTP_ACCEPT'], 'image/' . $this->_sys_format) !== false) {
return true;
}
}
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
$user_agents = array('chrome-lighthouse', 'googlebot', 'page speed');
foreach ($user_agents as $user_agent) {
if (stripos($_SERVER['HTTP_USER_AGENT'], $user_agent) !== false) {
return true;
}
}
if (preg_match('/iPhone OS (\d+)_/i', $_SERVER['HTTP_USER_AGENT'], $matches)) {
if ($matches[1] >= 14) {
return true;
}
}
if (preg_match('/Firefox\/(\d+)/i', $_SERVER['HTTP_USER_AGENT'], $matches)) {
if ($matches[1] >= 65) {
return true;
}
}
}
return false;
}
/**
* Get next gen image title
*
* @since 7.0
*/
public function next_gen_image_title()
{
$next_gen_img = 'WebP';
if ($this->conf(Base::O_IMG_OPTM_WEBP) == 2) {
$next_gen_img = 'AVIF';
}
return $next_gen_img;
}
/**
* Run lazy load process
* NOTE: As this is after cache finalized, can NOT set any cache control anymore
*
* Only do for main page. Do NOT do for esi or dynamic content.
*
* @since 1.4
* @access public
* @return string The buffer
*/
public function finalize($content)
{
if (defined('LITESPEED_NO_LAZY')) {
Debug2::debug2('[Media] bypass: NO_LAZY const');
return $content;
}
if (!defined('LITESPEED_IS_HTML')) {
Debug2::debug2('[Media] bypass: Not frontend HTML type');
return $content;
}
if (!Control::is_cacheable()) {
self::debug('bypass: Not cacheable');
return $content;
}
self::debug('finalize');
$this->content = $content;
$this->_finalize();
return $this->content;
}
/**
* Run lazyload replacement for images in buffer
*
* @since 1.4
* @access private
*/
private function _finalize()
{
/**
* Use webp for optimized images
* @since 1.6.2
*/
if ($this->webp_support()) {
$this->content = $this->_replace_buffer_img_webp($this->content);
}
/**
* Check if URI is excluded
* @since 3.0
*/
$excludes = $this->conf(Base::O_MEDIA_LAZY_URI_EXC);
if (!defined('LITESPEED_GUEST_OPTM')) {
$result = Utility::str_hit_array($_SERVER['REQUEST_URI'], $excludes);
if ($result) {
self::debug('bypass lazyload: hit URI Excludes setting: ' . $result);
return;
}
}
$cfg_lazy = (defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_MEDIA_LAZY)) && !$this->cls('Metabox')->setting('litespeed_no_image_lazy');
$cfg_iframe_lazy = defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_MEDIA_IFRAME_LAZY);
$cfg_js_delay = defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_OPTM_JS_DEFER) == 2;
$cfg_trim_noscript = defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_OPTM_NOSCRIPT_RM);
$cfg_vpi = defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_MEDIA_VPI);
// Preload VPI
if ($cfg_vpi) {
$this->_parse_img_for_preload();
}
if ($cfg_lazy) {
if ($cfg_vpi) {
add_filter('litespeed_media_lazy_img_excludes', array($this->cls('Metabox'), 'lazy_img_excludes'));
}
list($src_list, $html_list, $placeholder_list) = $this->_parse_img();
$html_list_ori = $html_list;
} else {
self::debug('lazyload disabled');
}
// image lazy load
if ($cfg_lazy) {
$__placeholder = Placeholder::cls();
foreach ($html_list as $k => $v) {
$size = $placeholder_list[$k];
$src = $src_list[$k];
$html_list[$k] = $__placeholder->replace($v, $src, $size);
}
}
if ($cfg_lazy) {
$this->content = str_replace($html_list_ori, $html_list, $this->content);
}
// iframe lazy load
if ($cfg_iframe_lazy) {
$html_list = $this->_parse_iframe();
$html_list_ori = $html_list;
foreach ($html_list as $k => $v) {
$snippet = $cfg_trim_noscript ? '' : '<noscript>' . $v . '</noscript>';
if ($cfg_js_delay) {
$v = str_replace(' src=', ' data-litespeed-src=', $v);
} else {
$v = str_replace(' src=', ' data-src=', $v);
}
$v = str_replace('<iframe ', '<iframe data-lazyloaded="1" src="about:blank" ', $v);
$snippet = $v . $snippet;
$html_list[$k] = $snippet;
}
$this->content = str_replace($html_list_ori, $html_list, $this->content);
}
// Include lazyload lib js and init lazyload
if ($cfg_lazy || $cfg_iframe_lazy) {
$lazy_lib = '<script data-no-optimize="1">' . File::read(LSCWP_DIR . self::LIB_FILE_IMG_LAZYLOAD) . '</script>';
$this->content = str_replace('</body>', $lazy_lib . '</body>', $this->content);
}
}
/**
* Parse img src for VPI preload only
* Note: Didn't reuse the _parse_img() bcoz it contains parent cls replacement and other logic which is not needed for preload
*
* @since 6.2
*/
private function _parse_img_for_preload()
{
// Load VPI setting
$is_mobile = $this->_separate_mobile();
$vpi_files = $this->cls('Metabox')->setting($is_mobile ? 'litespeed_vpi_list_mobile' : 'litespeed_vpi_list');
if ($vpi_files) {
$vpi_files = Utility::sanitize_lines($vpi_files, 'basename');
}
if (!$vpi_files) {
return;
}
if (!$this->content) {
return;
}
$content = preg_replace(array('#<!--.*-->#sU', '#<noscript([^>]*)>.*</noscript>#isU'), '', $this->content);
if (!$content) {
return;
}
preg_match_all('#<img\s+([^>]+)/?>#isU', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$attrs = Utility::parse_attr($match[1]);
if (empty($attrs['src'])) {
continue;
}
if (strpos($attrs['src'], 'base64') !== false || substr($attrs['src'], 0, 5) === 'data:') {
Debug2::debug2('[Media] lazyload bypassed base64 img');
continue;
}
if (strpos($attrs['src'], '{') !== false) {
Debug2::debug2('[Media] image src has {} ' . $attrs['src']);
continue;
}
// If the src contains VPI filename, then preload it
if (!Utility::str_hit_array($attrs['src'], $vpi_files)) {
continue;
}
Debug2::debug2('[Media] VPI preload found and matched: ' . $attrs['src']);
$this->_vpi_preload_list[] = $attrs['src'];
}
}
/**
* Parse img src
*
* @since 1.4
* @access private
* @return array All the src & related raw html list
*/
private function _parse_img()
{
/**
* Exclude list
* @since 1.5
* @since 2.7.1 Changed to array
*/
$excludes = apply_filters('litespeed_media_lazy_img_excludes', $this->conf(Base::O_MEDIA_LAZY_EXC));
$cls_excludes = apply_filters('litespeed_media_lazy_img_cls_excludes', $this->conf(Base::O_MEDIA_LAZY_CLS_EXC));
$cls_excludes[] = 'skip-lazy'; // https://core.trac.wordpress.org/ticket/44427
$src_list = array();
$html_list = array();
$placeholder_list = array();
$content = preg_replace(
array(
'#<!--.*-->#sU',
'#<noscript([^>]*)>.*</noscript>#isU',
'#<script([^>]*)>.*</script>#isU', // Added to remove warning of file not found when image size detection is turned ON.
),
'',
$this->content
);
/**
* Exclude parent classes
* @since 3.0
*/
$parent_cls_exc = apply_filters('litespeed_media_lazy_img_parent_cls_excludes', $this->conf(Base::O_MEDIA_LAZY_PARENT_CLS_EXC));
if ($parent_cls_exc) {
Debug2::debug2('[Media] Lazyload Class excludes', $parent_cls_exc);
foreach ($parent_cls_exc as $v) {
$content = preg_replace('#<(\w+) [^>]*class=(\'|")[^\'"]*' . preg_quote($v, '#') . '[^\'"]*\2[^>]*>.*</\1>#sU', '', $content);
}
}
preg_match_all('#<img\s+([^>]+)/?>#isU', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$attrs = Utility::parse_attr($match[1]);
if (empty($attrs['src'])) {
continue;
}
/**
* Add src validation to bypass base64 img src
* @since 1.6
*/
if (strpos($attrs['src'], 'base64') !== false || substr($attrs['src'], 0, 5) === 'data:') {
Debug2::debug2('[Media] lazyload bypassed base64 img');
continue;
}
Debug2::debug2('[Media] lazyload found: ' . $attrs['src']);
if (
!empty($attrs['data-no-lazy']) ||
!empty($attrs['data-skip-lazy']) ||
!empty($attrs['data-lazyloaded']) ||
!empty($attrs['data-src']) ||
!empty($attrs['data-srcset'])
) {
Debug2::debug2('[Media] bypassed');
continue;
}
if (!empty($attrs['class']) && ($hit = Utility::str_hit_array($attrs['class'], $cls_excludes))) {
Debug2::debug2('[Media] lazyload image cls excludes [hit] ' . $hit);
continue;
}
/**
* Exclude from lazyload by setting
* @since 1.5
*/
if ($excludes && Utility::str_hit_array($attrs['src'], $excludes)) {
Debug2::debug2('[Media] lazyload image exclude ' . $attrs['src']);
continue;
}
/**
* Excldues invalid image src from buddypress avatar crop
* @see https://wordpress.org/support/topic/lazy-load-breaking-buddypress-upload-avatar-feature
* @since 3.0
*/
if (strpos($attrs['src'], '{') !== false) {
Debug2::debug2('[Media] image src has {} ' . $attrs['src']);
continue;
}
// to avoid multiple replacement
if (in_array($match[0], $html_list)) {
continue;
}
// Add missing dimensions
if (defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_MEDIA_ADD_MISSING_SIZES)) {
if (!apply_filters('litespeed_media_add_missing_sizes', true)) {
Debug2::debug2('[Media] add_missing_sizes bypassed via litespeed_media_add_missing_sizes filter');
} elseif (empty($attrs['width']) || $attrs['width'] == 'auto' || empty($attrs['height']) || $attrs['height'] == 'auto') {
self::debug('⚠️ Missing sizes for image [src] ' . $attrs['src']);
$dimensions = $this->_detect_dimensions($attrs['src']);
if ($dimensions) {
$ori_width = $dimensions[0];
$ori_height = $dimensions[1];
// Calculate height based on width
if (!empty($attrs['width']) && $attrs['width'] != 'auto') {
$ori_height = intval(($ori_height * $attrs['width']) / $ori_width);
} elseif (!empty($attrs['height']) && $attrs['height'] != 'auto') {
$ori_width = intval(($ori_width * $attrs['height']) / $ori_height);
}
$attrs['width'] = $ori_width;
$attrs['height'] = $ori_height;
$new_html = preg_replace('#\s+(width|height)=(["\'])[^\2]*?\2#', '', $match[0]);
$new_html = preg_replace(
'#<img\s+#i',
'<img width="' . Str::trim_quotes($attrs['width']) . '" height="' . Str::trim_quotes($attrs['height']) . '" ',
$new_html
);
self::debug('Add missing sizes ' . $attrs['width'] . 'x' . $attrs['height'] . ' to ' . $attrs['src']);
$this->content = str_replace($match[0], $new_html, $this->content);
$match[0] = $new_html;
}
}
}
$placeholder = false;
if (!empty($attrs['width']) && $attrs['width'] != 'auto' && !empty($attrs['height']) && $attrs['height'] != 'auto') {
$placeholder = intval($attrs['width']) . 'x' . intval($attrs['height']);
}
$src_list[] = $attrs['src'];
$html_list[] = $match[0];
$placeholder_list[] = $placeholder;
}
return array($src_list, $html_list, $placeholder_list);
}
/**
* Detect the original sizes
*
* @since 4.0
*/
private function _detect_dimensions($src)
{
if ($pathinfo = Utility::is_internal_file($src)) {
$src = $pathinfo[0];
} elseif (apply_filters('litespeed_media_ignore_remote_missing_sizes', false)) {
return false;
}
if (substr($src, 0, 2) == '//') {
$src = 'https:' . $src;
}
try {
$sizes = getimagesize($src);
} catch (\Exception $e) {
return false;
}
if (!empty($sizes[0]) && !empty($sizes[1])) {
return $sizes;
}
return false;
}
/**
* Parse iframe src
*
* @since 1.4
* @access private
* @return array All the src & related raw html list
*/
private function _parse_iframe()
{
$cls_excludes = apply_filters('litespeed_media_iframe_lazy_cls_excludes', $this->conf(Base::O_MEDIA_IFRAME_LAZY_CLS_EXC));
$cls_excludes[] = 'skip-lazy'; // https://core.trac.wordpress.org/ticket/44427
$html_list = array();
$content = preg_replace('#<!--.*-->#sU', '', $this->content);
/**
* Exclude parent classes
* @since 3.0
*/
$parent_cls_exc = apply_filters('litespeed_media_iframe_lazy_parent_cls_excludes', $this->conf(Base::O_MEDIA_IFRAME_LAZY_PARENT_CLS_EXC));
if ($parent_cls_exc) {
Debug2::debug2('[Media] Iframe Lazyload Class excludes', $parent_cls_exc);
foreach ($parent_cls_exc as $v) {
$content = preg_replace('#<(\w+) [^>]*class=(\'|")[^\'"]*' . preg_quote($v, '#') . '[^\'"]*\2[^>]*>.*</\1>#sU', '', $content);
}
}
preg_match_all('#<iframe \s*([^>]+)></iframe>#isU', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$attrs = Utility::parse_attr($match[1]);
if (empty($attrs['src'])) {
continue;
}
Debug2::debug2('[Media] found iframe: ' . $attrs['src']);
if (!empty($attrs['data-no-lazy']) || !empty($attrs['data-skip-lazy']) || !empty($attrs['data-lazyloaded']) || !empty($attrs['data-src'])) {
Debug2::debug2('[Media] bypassed');
continue;
}
if (!empty($attrs['class']) && ($hit = Utility::str_hit_array($attrs['class'], $cls_excludes))) {
Debug2::debug2('[Media] iframe lazyload cls excludes [hit] ' . $hit);
continue;
}
if (apply_filters('litespeed_iframe_lazyload_exc', false, $attrs['src'])) {
Debug2::debug2('[Media] bypassed by filter');
continue;
}
// to avoid multiple replacement
if (in_array($match[0], $html_list)) {
continue;
}
$html_list[] = $match[0];
}
return $html_list;
}
/**
* Replace image src to webp
*
* @since 1.6.2
* @access private
*/
private function _replace_buffer_img_webp($content)
{
/**
* Added custom element & attribute support
* @since 2.2.2
*/
$webp_ele_to_check = $this->conf(Base::O_IMG_OPTM_WEBP_ATTR);
foreach ($webp_ele_to_check as $v) {
if (!$v || strpos($v, '.') === false) {
Debug2::debug2('[Media] buffer_webp no . attribute ' . $v);
continue;
}
Debug2::debug2('[Media] buffer_webp attribute ' . $v);
$v = explode('.', $v);
$attr = preg_quote($v[1], '#');
if ($v[0]) {
$pattern = '#<' . preg_quote($v[0], '#') . '([^>]+)' . $attr . '=([\'"])(.+)\2#iU';
} else {
$pattern = '# ' . $attr . '=([\'"])(.+)\1#iU';
}
preg_match_all($pattern, $content, $matches);
foreach ($matches[$v[0] ? 3 : 2] as $k2 => $url) {
// Check if is a DATA-URI
if (strpos($url, 'data:image') !== false) {
continue;
}
if (!($url2 = $this->replace_webp($url))) {
continue;
}
if ($v[0]) {
$html_snippet = sprintf('<' . $v[0] . '%1$s' . $v[1] . '=%2$s', $matches[1][$k2], $matches[2][$k2] . $url2 . $matches[2][$k2]);
} else {
$html_snippet = sprintf(' ' . $v[1] . '=%1$s', $matches[1][$k2] . $url2 . $matches[1][$k2]);
}
$content = str_replace($matches[0][$k2], $html_snippet, $content);
}
}
// parse srcset
// todo: should apply this to cdn too
if ((defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_IMG_OPTM_WEBP_REPLACE_SRCSET)) && $this->webp_support()) {
$content = Utility::srcset_replace($content, array($this, 'replace_webp'));
}
// Replace background-image
if ((defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_IMG_OPTM_WEBP)) && $this->webp_support()) {
$content = $this->replace_background_webp($content);
}
return $content;
}
/**
* Replace background image
*
* @since 4.0
*/
public function replace_background_webp($content)
{
Debug2::debug2('[Media] Start replacing background WebP/AVIF.');
// Handle Elementors data-settings json encode background-images
$content = $this->replace_urls_in_json($content);
// preg_match_all( '#background-image:(\s*)url\((.*)\)#iU', $content, $matches );
preg_match_all('#url\(([^)]+)\)#iU', $content, $matches);
foreach ($matches[1] as $k => $url) {
// Check if is a DATA-URI
if (strpos($url, 'data:image') !== false) {
continue;
}
/**
* Support quotes in src `background-image: url('src')`
* @since 2.9.3
*/
$url = trim($url, '\'"');
// Fix Elementors Slideshow unusual background images like style="background-image: url("https://xxxx.png");"
if (strpos($url, '"') === 0 && substr($url, -6) == '"') {
$url = substr($url, 6, -6);
}
if (!($url2 = $this->replace_webp($url))) {
continue;
}
// $html_snippet = sprintf( 'background-image:%1$surl(%2$s)', $matches[ 1 ][ $k ], $url2 );
$html_snippet = str_replace($url, $url2, $matches[0][$k]);
$content = str_replace($matches[0][$k], $html_snippet, $content);
}
return $content;
}
/**
* Replace images in json data settings attributes
*
* @since 6.2
*/
public function replace_urls_in_json($content)
{
$pattern = '/data-settings="(.*?)"/i';
$parent_class = $this;
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
// Check if the string contains HTML entities
$isEncoded = preg_match('/"|<|>|&|'/', $match[1]);
// Decode HTML entities in the JSON string
$jsonString = html_entity_decode($match[1]);
$jsonData = \json_decode($jsonString, true);
if (json_last_error() === JSON_ERROR_NONE) {
$did_webp_replace = false;
array_walk_recursive($jsonData, function (&$item, $key) use (&$did_webp_replace, $parent_class) {
if ($key == 'url') {
$item_image = $parent_class->replace_webp($item);
if ($item_image) {
$item = $item_image;
$did_webp_replace = true;
}
}
});
if ($did_webp_replace) {
// Re-encode the modified array back to a JSON string
$newJsonString = \json_encode($jsonData);
// Re-encode the JSON string to HTML entities only if it was originally encoded
if ($isEncoded) {
$newJsonString = htmlspecialchars($newJsonString, ENT_QUOTES | 0); // ENT_HTML401 is for PHPv5.4+
}
// Replace the old JSON string in the content with the new, modified JSON string
$content = str_replace($match[1], $newJsonString, $content);
}
}
}
return $content;
}
/**
* Replace internal image src to webp or avif
*
* @since 1.6.2
* @access public
*/
public function replace_webp($url)
{
if (!$this->webp_support()) {
self::debug2('No next generation format chosen in setting, bypassed');
return false;
}
Debug2::debug2('[Media] ' . $this->_sys_format . ' replacing: ' . substr($url, 0, 200));
if (substr($url, -5) === '.' . $this->_sys_format) {
Debug2::debug2('[Media] already ' . $this->_sys_format);
return false;
}
/**
* WebP API hook
* NOTE: As $url may contain query strings, WebP check will need to parse_url before appending .webp
* @since 2.9.5
* @see #751737 - API docs for WebP generation
*/
if (apply_filters('litespeed_media_check_ori', Utility::is_internal_file($url), $url)) {
// check if has webp file
if (apply_filters('litespeed_media_check_webp', Utility::is_internal_file($url, $this->_sys_format), $url)) {
$url .= '.' . $this->_sys_format;
} else {
Debug2::debug2('[Media] -no WebP or AVIF file, bypassed');
return false;
}
} else {
Debug2::debug2('[Media] -no file, bypassed');
return false;
}
Debug2::debug2('[Media] - replaced to: ' . $url);
return $url;
}
/**
* Hook to wp_get_attachment_image_src
*
* @since 1.6.2
* @access public
* @param array $img The URL of the attachment image src, the width, the height
* @return array
*/
public function webp_attach_img_src($img)
{
Debug2::debug2('[Media] changing attach src: ' . $img[0]);
if ($img && ($url = $this->replace_webp($img[0]))) {
$img[0] = $url;
}
return $img;
}
/**
* Try to replace img url
*
* @since 1.6.2
* @access public
* @param string $url
* @return string
*/
public function webp_url($url)
{
if ($url && ($url2 = $this->replace_webp($url))) {
$url = $url2;
}
return $url;
}
/**
* Hook to replace WP responsive images
*
* @since 1.6.2
* @access public
* @param array $srcs
* @return array
*/
public function webp_srcset($srcs)
{
if ($srcs) {
foreach ($srcs as $w => $data) {
if (!($url = $this->replace_webp($data['url']))) {
continue;
}
$srcs[$w]['url'] = $url;
}
}
return $srcs;
}
}
metabox.cls.php 0000644 00000010322 15153741266 0007504 0 ustar 00 <?php
/**
* The class to operate post editor metabox settings
*
* @since 4.7
* @package Core
* @subpackage Core/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Metabox extends Root
{
const LOG_TAG = '📦';
const POST_NONCE_ACTION = 'post_nonce_action';
private $_postmeta_settings;
/**
* Get the setting list
* @since 4.7
*/
public function __construct()
{
// Append meta box
$this->_postmeta_settings = array(
'litespeed_no_cache' => __('Disable Cache', 'litespeed-cache'),
'litespeed_no_image_lazy' => __('Disable Image Lazyload', 'litespeed-cache'),
'litespeed_no_vpi' => __('Disable VPI', 'litespeed-cache'),
'litespeed_vpi_list' => __('Viewport Images', 'litespeed-cache'),
'litespeed_vpi_list_mobile' => __('Viewport Images', 'litespeed-cache') . ' - ' . __('Mobile', 'litespeed-cache'),
);
}
/**
* Register post edit settings
* @since 4.7
*/
public function register_settings()
{
add_action('add_meta_boxes', array($this, 'add_meta_boxes'));
add_action('save_post', array($this, 'save_meta_box_settings'), 15, 2);
add_action('attachment_updated', array($this, 'save_meta_box_settings'), 15, 2);
}
/**
* Register meta box
* @since 4.7
*/
public function add_meta_boxes($post_type)
{
if (apply_filters('litespeed_bypass_metabox', false, $post_type)) {
return;
}
$post_type_obj = get_post_type_object($post_type);
if (!empty($post_type_obj) && !$post_type_obj->public) {
self::debug('post type public=false, bypass add_meta_boxes');
return;
}
add_meta_box('litespeed_meta_boxes', __('LiteSpeed Options', 'litespeed-cache'), array($this, 'meta_box_options'), $post_type, 'side', 'core');
}
/**
* Show meta box content
* @since 4.7
*/
public function meta_box_options()
{
require_once LSCWP_DIR . 'tpl/inc/metabox.php';
}
/**
* Save settings
* @since 4.7
*/
public function save_meta_box_settings($post_id, $post)
{
global $pagenow;
self::debug('Maybe save post2 [post_id] ' . $post_id);
if ($pagenow != 'post.php' || !$post || !is_object($post)) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!$this->cls('Router')->verify_nonce(self::POST_NONCE_ACTION)) {
return;
}
self::debug('Saving post [post_id] ' . $post_id);
foreach ($this->_postmeta_settings as $k => $v) {
$val = isset($_POST[$k]) ? $_POST[$k] : false;
$this->save($post_id, $k, $val);
}
}
/**
* Load setting per post
* @since 4.7
*/
public function setting($conf, $post_id = false)
{
// Check if has metabox non-cacheable setting or not
if (!$post_id) {
$home_id = get_option('page_for_posts');
if (is_singular()) {
$post_id = get_the_ID();
} elseif ($home_id > 0 && is_home()) {
$post_id = $home_id;
}
}
if ($post_id && ($val = get_post_meta($post_id, $conf, true))) {
return $val;
}
return null;
}
/**
* Save a metabox value
* @since 4.7
*/
public function save($post_id, $name, $val, $is_append = false)
{
if (strpos($name, 'litespeed_vpi_list') !== false) {
$val = Utility::sanitize_lines($val, 'basename,drop_webp');
}
// Load existing data if has set
if ($is_append) {
$existing_data = $this->setting($name, $post_id);
if ($existing_data) {
$existing_data = Utility::sanitize_lines($existing_data, 'basename');
$val = array_unique(array_merge($val, $existing_data));
}
}
if ($val) {
update_post_meta($post_id, $name, $val);
} else {
delete_post_meta($post_id, $name);
}
}
/**
* Load exclude images per post
* @since 4.7
*/
public function lazy_img_excludes($list)
{
$is_mobile = $this->_separate_mobile();
$excludes = $this->setting($is_mobile ? 'litespeed_vpi_list_mobile' : 'litespeed_vpi_list');
if ($excludes !== null) {
$excludes = Utility::sanitize_lines($excludes, 'basename');
if ($excludes) {
// Check if contains `data:` (invalid result, need to clear existing result) or not
if (Utility::str_hit_array('data:', $excludes)) {
$this->cls('VPI')->add_to_queue();
} else {
return array_merge($list, $excludes);
}
}
return $list;
}
$this->cls('VPI')->add_to_queue();
return $list;
}
}
object-cache.cls.php 0000644 00000037530 15153741266 0010366 0 ustar 00 <?php
/**
* The object cache class
*
* @since 1.8
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
require_once dirname(__DIR__) . '/autoload.php';
class Object_Cache extends Root
{
const O_DEBUG = 'debug';
const O_OBJECT = 'object';
const O_OBJECT_KIND = 'object-kind';
const O_OBJECT_HOST = 'object-host';
const O_OBJECT_PORT = 'object-port';
const O_OBJECT_LIFE = 'object-life';
const O_OBJECT_PERSISTENT = 'object-persistent';
const O_OBJECT_ADMIN = 'object-admin';
const O_OBJECT_TRANSIENTS = 'object-transients';
const O_OBJECT_DB_ID = 'object-db_id';
const O_OBJECT_USER = 'object-user';
const O_OBJECT_PSWD = 'object-pswd';
const O_OBJECT_GLOBAL_GROUPS = 'object-global_groups';
const O_OBJECT_NON_PERSISTENT_GROUPS = 'object-non_persistent_groups';
private $_conn;
private $_cfg_debug;
private $_cfg_enabled;
private $_cfg_method;
private $_cfg_host;
private $_cfg_port;
private $_cfg_life;
private $_cfg_persistent;
private $_cfg_admin;
private $_cfg_transients;
private $_cfg_db;
private $_cfg_user;
private $_cfg_pswd;
private $_default_life = 360;
private $_oc_driver = 'Memcached'; // Redis or Memcached
private $_global_groups = array();
private $_non_persistent_groups = array();
/**
* Init
*
* NOTE: this class may be included without initialized core
*
* @since 1.8
*/
public function __construct($cfg = false)
{
if ($cfg) {
if (!is_array($cfg[Base::O_OBJECT_GLOBAL_GROUPS])) {
$cfg[Base::O_OBJECT_GLOBAL_GROUPS] = explode("\n", $cfg[Base::O_OBJECT_GLOBAL_GROUPS]);
}
if (!is_array($cfg[Base::O_OBJECT_NON_PERSISTENT_GROUPS])) {
$cfg[Base::O_OBJECT_NON_PERSISTENT_GROUPS] = explode("\n", $cfg[Base::O_OBJECT_NON_PERSISTENT_GROUPS]);
}
$this->_cfg_debug = $cfg[Base::O_DEBUG] ? $cfg[Base::O_DEBUG] : false;
$this->_cfg_method = $cfg[Base::O_OBJECT_KIND] ? true : false;
$this->_cfg_host = $cfg[Base::O_OBJECT_HOST];
$this->_cfg_port = $cfg[Base::O_OBJECT_PORT];
$this->_cfg_life = $cfg[Base::O_OBJECT_LIFE];
$this->_cfg_persistent = $cfg[Base::O_OBJECT_PERSISTENT];
$this->_cfg_admin = $cfg[Base::O_OBJECT_ADMIN];
$this->_cfg_transients = $cfg[Base::O_OBJECT_TRANSIENTS];
$this->_cfg_db = $cfg[Base::O_OBJECT_DB_ID];
$this->_cfg_user = $cfg[Base::O_OBJECT_USER];
$this->_cfg_pswd = $cfg[Base::O_OBJECT_PSWD];
$this->_global_groups = $cfg[Base::O_OBJECT_GLOBAL_GROUPS];
$this->_non_persistent_groups = $cfg[Base::O_OBJECT_NON_PERSISTENT_GROUPS];
if ($this->_cfg_method) {
$this->_oc_driver = 'Redis';
}
$this->_cfg_enabled = $cfg[Base::O_OBJECT] && class_exists($this->_oc_driver) && $this->_cfg_host;
}
// If OC is OFF, will hit here to init OC after conf initialized
elseif (defined('LITESPEED_CONF_LOADED')) {
$this->_cfg_debug = $this->conf(Base::O_DEBUG) ? $this->conf(Base::O_DEBUG) : false;
$this->_cfg_method = $this->conf(Base::O_OBJECT_KIND) ? true : false;
$this->_cfg_host = $this->conf(Base::O_OBJECT_HOST);
$this->_cfg_port = $this->conf(Base::O_OBJECT_PORT);
$this->_cfg_life = $this->conf(Base::O_OBJECT_LIFE);
$this->_cfg_persistent = $this->conf(Base::O_OBJECT_PERSISTENT);
$this->_cfg_admin = $this->conf(Base::O_OBJECT_ADMIN);
$this->_cfg_transients = $this->conf(Base::O_OBJECT_TRANSIENTS);
$this->_cfg_db = $this->conf(Base::O_OBJECT_DB_ID);
$this->_cfg_user = $this->conf(Base::O_OBJECT_USER);
$this->_cfg_pswd = $this->conf(Base::O_OBJECT_PSWD);
$this->_global_groups = $this->conf(Base::O_OBJECT_GLOBAL_GROUPS);
$this->_non_persistent_groups = $this->conf(Base::O_OBJECT_NON_PERSISTENT_GROUPS);
if ($this->_cfg_method) {
$this->_oc_driver = 'Redis';
}
$this->_cfg_enabled = $this->conf(Base::O_OBJECT) && class_exists($this->_oc_driver) && $this->_cfg_host;
} elseif (defined('self::CONF_FILE') && file_exists(WP_CONTENT_DIR . '/' . self::CONF_FILE)) {
// Get cfg from _data_file
// Use self::const to avoid loading more classes
$cfg = \json_decode(file_get_contents(WP_CONTENT_DIR . '/' . self::CONF_FILE), true);
if (!empty($cfg[self::O_OBJECT_HOST])) {
$this->_cfg_debug = !empty($cfg[Base::O_DEBUG]) ? $cfg[Base::O_DEBUG] : false;
$this->_cfg_method = !empty($cfg[self::O_OBJECT_KIND]) ? $cfg[self::O_OBJECT_KIND] : false;
$this->_cfg_host = $cfg[self::O_OBJECT_HOST];
$this->_cfg_port = $cfg[self::O_OBJECT_PORT];
$this->_cfg_life = !empty($cfg[self::O_OBJECT_LIFE]) ? $cfg[self::O_OBJECT_LIFE] : $this->_default_life;
$this->_cfg_persistent = !empty($cfg[self::O_OBJECT_PERSISTENT]) ? $cfg[self::O_OBJECT_PERSISTENT] : false;
$this->_cfg_admin = !empty($cfg[self::O_OBJECT_ADMIN]) ? $cfg[self::O_OBJECT_ADMIN] : false;
$this->_cfg_transients = !empty($cfg[self::O_OBJECT_TRANSIENTS]) ? $cfg[self::O_OBJECT_TRANSIENTS] : false;
$this->_cfg_db = !empty($cfg[self::O_OBJECT_DB_ID]) ? $cfg[self::O_OBJECT_DB_ID] : 0;
$this->_cfg_user = !empty($cfg[self::O_OBJECT_USER]) ? $cfg[self::O_OBJECT_USER] : '';
$this->_cfg_pswd = !empty($cfg[self::O_OBJECT_PSWD]) ? $cfg[self::O_OBJECT_PSWD] : '';
$this->_global_groups = !empty($cfg[self::O_OBJECT_GLOBAL_GROUPS]) ? $cfg[self::O_OBJECT_GLOBAL_GROUPS] : array();
$this->_non_persistent_groups = !empty($cfg[self::O_OBJECT_NON_PERSISTENT_GROUPS]) ? $cfg[self::O_OBJECT_NON_PERSISTENT_GROUPS] : array();
if ($this->_cfg_method) {
$this->_oc_driver = 'Redis';
}
$this->_cfg_enabled = class_exists($this->_oc_driver) && $this->_cfg_host;
} else {
$this->_cfg_enabled = false;
}
} else {
$this->_cfg_enabled = false;
}
}
/**
* Add debug.
*
* @since 6.3
* @access private
*/
private function debug_oc($text, $show_error = false)
{
if (defined('LSCWP_LOG')) {
Debug2::debug($text);
return;
}
if (!$show_error && $this->_cfg_debug != BASE::VAL_ON2) {
return;
}
$LITESPEED_DATA_FOLDER = defined('LITESPEED_DATA_FOLDER') ? LITESPEED_DATA_FOLDER : 'litespeed';
$LSCWP_CONTENT_DIR = defined('LSCWP_CONTENT_DIR') ? LSCWP_CONTENT_DIR : WP_CONTENT_DIR;
$LITESPEED_STATIC_DIR = $LSCWP_CONTENT_DIR . '/' . $LITESPEED_DATA_FOLDER;
$log_path_prefix = $LITESPEED_STATIC_DIR . '/debug/';
$log_file = $log_path_prefix . Debug2::FilePath('debug');
if (file_exists($log_path_prefix . 'index.php') && file_exists($log_file)) {
error_log(gmdate('m/d/y H:i:s') . ' - OC - ' . $text . PHP_EOL, 3, $log_file);
}
}
/**
* Get `Store Transients` setting value
*
* @since 1.8.3
* @access public
*/
public function store_transients($group)
{
return $this->_cfg_transients && $this->_is_transients_group($group);
}
/**
* Check if the group belongs to transients or not
*
* @since 1.8.3
* @access private
*/
private function _is_transients_group($group)
{
return in_array($group, array('transient', 'site-transient'));
}
/**
* Update WP object cache file config
*
* @since 1.8
* @access public
*/
public function update_file($options)
{
$changed = false;
// NOTE: When included in oc.php, `LSCWP_DIR` will show undefined, so this must be assigned/generated when used
$_oc_ori_file = LSCWP_DIR . 'lib/object-cache.php';
$_oc_wp_file = WP_CONTENT_DIR . '/object-cache.php';
// Update cls file
if (!file_exists($_oc_wp_file) || md5_file($_oc_wp_file) !== md5_file($_oc_ori_file)) {
$this->debug_oc('copying object-cache.php file to ' . $_oc_wp_file);
copy($_oc_ori_file, $_oc_wp_file);
$changed = true;
}
/**
* Clear object cache
*/
if ($changed) {
$this->_reconnect($options);
}
}
/**
* Remove object cache file
*
* @since 1.8.2
* @access public
*/
public function del_file()
{
// NOTE: When included in oc.php, `LSCWP_DIR` will show undefined, so this must be assigned/generated when used
$_oc_ori_file = LSCWP_DIR . 'lib/object-cache.php';
$_oc_wp_file = WP_CONTENT_DIR . '/object-cache.php';
if (file_exists($_oc_wp_file) && md5_file($_oc_wp_file) === md5_file($_oc_ori_file)) {
$this->debug_oc('removing ' . $_oc_wp_file);
unlink($_oc_wp_file);
}
}
/**
* Try to build connection
*
* @since 1.8
* @access public
*/
public function test_connection()
{
return $this->_connect();
}
/**
* Force to connect with this setting
*
* @since 1.8
* @access private
*/
private function _reconnect($cfg)
{
$this->debug_oc('Reconnecting');
if (isset($this->_conn)) {
// error_log( 'Object: Quitting existing connection!' );
$this->debug_oc('Quitting existing connection');
$this->flush();
$this->_conn = null;
$this->cls(false, true);
}
$cls = $this->cls(false, false, $cfg);
$cls->_connect();
if (isset($cls->_conn)) {
$cls->flush();
}
}
/**
* Connect to Memcached/Redis server
*
* @since 1.8
* @access private
*/
private function _connect()
{
if (isset($this->_conn)) {
// error_log( 'Object: _connected' );
return true;
}
if (!class_exists($this->_oc_driver) || !$this->_cfg_host) {
return null;
}
if (defined('LITESPEED_OC_FAILURE')) {
return false;
}
$this->debug_oc('Init ' . $this->_oc_driver . ' connection to ' . $this->_cfg_host . ':' . $this->_cfg_port);
$failed = false;
/**
* Connect to Redis
*
* @since 1.8.1
* @see https://github.com/phpredis/phpredis/#example-1
*/
if ($this->_oc_driver == 'Redis') {
set_error_handler('litespeed_exception_handler');
try {
$this->_conn = new \Redis();
// error_log( 'Object: _connect Redis' );
if ($this->_cfg_persistent) {
if ($this->_cfg_port) {
$this->_conn->pconnect($this->_cfg_host, $this->_cfg_port);
} else {
$this->_conn->pconnect($this->_cfg_host);
}
} else {
if ($this->_cfg_port) {
$this->_conn->connect($this->_cfg_host, $this->_cfg_port);
} else {
$this->_conn->connect($this->_cfg_host);
}
}
if ($this->_cfg_pswd) {
if ($this->_cfg_user) {
$this->_conn->auth(array($this->_cfg_user, $this->_cfg_pswd));
} else {
$this->_conn->auth($this->_cfg_pswd);
}
}
if ($this->_cfg_db) {
$this->_conn->select($this->_cfg_db);
}
$res = $this->_conn->ping();
if ($res != '+PONG') {
$failed = true;
}
} catch (\Exception $e) {
$this->debug_oc('Redis connect exception: ' . $e->getMessage(), true);
$failed = true;
} catch (\ErrorException $e) {
$this->debug_oc('Redis connect error: ' . $e->getMessage(), true);
$failed = true;
}
restore_error_handler();
} else {
// Connect to Memcached
if ($this->_cfg_persistent) {
$this->_conn = new \Memcached($this->_get_mem_id());
// Check memcached persistent connection
if ($this->_validate_mem_server()) {
// error_log( 'Object: _validate_mem_server' );
$this->debug_oc('Got persistent ' . $this->_oc_driver . ' connection');
return true;
}
$this->debug_oc('No persistent ' . $this->_oc_driver . ' server list!');
} else {
// error_log( 'Object: new memcached!' );
$this->_conn = new \Memcached();
}
$this->_conn->addServer($this->_cfg_host, (int) $this->_cfg_port);
/**
* Add SASL auth
* @since 1.8.1
* @since 2.9.6 Fixed SASL connection @see https://www.litespeedtech.com/support/wiki/doku.php/litespeed_wiki:lsmcd:new_sasl
*/
if ($this->_cfg_user && $this->_cfg_pswd && method_exists($this->_conn, 'setSaslAuthData')) {
$this->_conn->setOption(\Memcached::OPT_BINARY_PROTOCOL, true);
$this->_conn->setOption(\Memcached::OPT_COMPRESSION, false);
$this->_conn->setSaslAuthData($this->_cfg_user, $this->_cfg_pswd);
}
// Check connection
if (!$this->_validate_mem_server()) {
$failed = true;
}
}
// If failed to connect
if ($failed) {
$this->debug_oc('❌ Failed to connect ' . $this->_oc_driver . ' server!', true);
$this->_conn = null;
$this->_cfg_enabled = false;
!defined('LITESPEED_OC_FAILURE') && define('LITESPEED_OC_FAILURE', true);
// error_log( 'Object: false!' );
return false;
}
$this->debug_oc('Connected');
return true;
}
/**
* Check if the connected memcached host is the one in cfg
*
* @since 1.8
* @access private
*/
private function _validate_mem_server()
{
$mem_list = $this->_conn->getStats();
if (empty($mem_list)) {
return false;
}
foreach ($mem_list as $k => $v) {
if (substr($k, 0, strlen($this->_cfg_host)) != $this->_cfg_host) {
continue;
}
if (!empty($v['pid']) || !empty($v['curr_connections'])) {
return true;
}
}
return false;
}
/**
* Get memcached unique id to be used for connecting
*
* @since 1.8
* @access private
*/
private function _get_mem_id()
{
$mem_id = 'litespeed';
if (is_multisite()) {
$mem_id .= '_' . get_current_blog_id();
}
return $mem_id;
}
/**
* Get cache
*
* @since 1.8
* @access public
*/
public function get($key)
{
if (!$this->_cfg_enabled) {
return null;
}
if (!$this->_can_cache()) {
return null;
}
if (!$this->_connect()) {
return null;
}
$res = $this->_conn->get($key);
return $res;
}
/**
* Set cache
*
* @since 1.8
* @access public
*/
public function set($key, $data, $expire)
{
if (!$this->_cfg_enabled) {
return null;
}
/**
* To fix the Cloud callback cached as its frontend call but the hash is generated in backend
* Bug found by Stan at Jan/10/2020
*/
// if ( ! $this->_can_cache() ) {
// return null;
// }
if (!$this->_connect()) {
return null;
}
$ttl = $expire ?: $this->_cfg_life;
if ($this->_oc_driver == 'Redis') {
try {
$res = $this->_conn->setEx($key, $ttl, $data);
} catch (\RedisException $ex) {
$res = false;
$msg = sprintf(__('Redis encountered a fatal error: %s (code: %d)', 'litespeed-cache'), $ex->getMessage(), $ex->getCode());
$this->debug_oc($msg);
Admin_Display::error($msg);
}
} else {
$res = $this->_conn->set($key, $data, $ttl);
}
return $res;
}
/**
* Check if can cache or not
*
* @since 1.8
* @access private
*/
private function _can_cache()
{
if (!$this->_cfg_admin && defined('WP_ADMIN')) {
return false;
}
return true;
}
/**
* Delete cache
*
* @since 1.8
* @access public
*/
public function delete($key)
{
if (!$this->_cfg_enabled) {
return null;
}
if (!$this->_connect()) {
return null;
}
if ($this->_oc_driver == 'Redis') {
$res = $this->_conn->del($key);
} else {
$res = $this->_conn->delete($key);
}
return (bool) $res;
}
/**
* Clear all cache
*
* @since 1.8
* @access public
*/
public function flush()
{
if (!$this->_cfg_enabled) {
$this->debug_oc('bypass flushing');
return null;
}
if (!$this->_connect()) {
return null;
}
$this->debug_oc('flush!');
if ($this->_oc_driver == 'Redis') {
$res = $this->_conn->flushDb();
} else {
$res = $this->_conn->flush();
$this->_conn->resetServerList();
}
return $res;
}
/**
* Add global groups
*
* @since 1.8
* @access public
*/
public function add_global_groups($groups)
{
if (!is_array($groups)) {
$groups = array($groups);
}
$this->_global_groups = array_merge($this->_global_groups, $groups);
$this->_global_groups = array_unique($this->_global_groups);
}
/**
* Check if is in global groups or not
*
* @since 1.8
* @access public
*/
public function is_global($group)
{
return in_array($group, $this->_global_groups);
}
/**
* Add non persistent groups
*
* @since 1.8
* @access public
*/
public function add_non_persistent_groups($groups)
{
if (!is_array($groups)) {
$groups = array($groups);
}
$this->_non_persistent_groups = array_merge($this->_non_persistent_groups, $groups);
$this->_non_persistent_groups = array_unique($this->_non_persistent_groups);
}
/**
* Check if is in non persistent groups or not
*
* @since 1.8
* @access public
*/
public function is_non_persistent($group)
{
return in_array($group, $this->_non_persistent_groups);
}
}
object.lib.php 0000644 00000103740 15153741266 0007307 0 ustar 00 <?php
/**
* LiteSpeed Object Cache Library
*
* @since 1.8
*/
defined('WPINC') || exit();
/**
* Handle exception
*/
if (!function_exists('litespeed_exception_handler')) {
function litespeed_exception_handler($errno, $errstr, $errfile, $errline)
{
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
}
}
require_once __DIR__ . '/object-cache.cls.php';
/**
* Sets up Object Cache Global and assigns it.
*
* @since 1.8
*
* @global WP_Object_Cache $wp_object_cache
*/
function wp_cache_init()
{
$GLOBALS['wp_object_cache'] = WP_Object_Cache::get_instance();
}
/**
* Adds data to the cache, if the cache key doesn't already exist.
*
* @since 1.8
*
* @see WP_Object_Cache::add()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key The cache key to use for retrieval later.
* @param mixed $data The data to add to the cache.
* @param string $group Optional. The group to add the cache to. Enables the same key
* to be used across groups. Default empty.
* @param int $expire Optional. When the cache data should expire, in seconds.
* Default 0 (no expiration).
* @return bool True on success, false if cache key and group already exist.
*/
function wp_cache_add($key, $data, $group = '', $expire = 0)
{
global $wp_object_cache;
return $wp_object_cache->add($key, $data, $group, (int) $expire);
}
/**
* Adds multiple values to the cache in one call.
*
* @since 5.4
*
* @see WP_Object_Cache::add_multiple()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param array $data Array of keys and values to be set.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool[] Array of return values, grouped by key. Each value is either
* true on success, or false if cache key and group already exist.
*/
function wp_cache_add_multiple(array $data, $group = '', $expire = 0)
{
global $wp_object_cache;
return $wp_object_cache->add_multiple($data, $group, $expire);
}
/**
* Replaces the contents of the cache with new data.
*
* @since 1.8
*
* @see WP_Object_Cache::replace()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key The key for the cache data that should be replaced.
* @param mixed $data The new data to store in the cache.
* @param string $group Optional. The group for the cache data that should be replaced.
* Default empty.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool True if contents were replaced, false if original value does not exist.
*/
function wp_cache_replace($key, $data, $group = '', $expire = 0)
{
global $wp_object_cache;
return $wp_object_cache->replace($key, $data, $group, (int) $expire);
}
/**
* Saves the data to the cache.
*
* Differs from wp_cache_add() and wp_cache_replace() in that it will always write data.
*
* @since 1.8
*
* @see WP_Object_Cache::set()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key The cache key to use for retrieval later.
* @param mixed $data The contents to store in the cache.
* @param string $group Optional. Where to group the cache contents. Enables the same key
* to be used across groups. Default empty.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool True on success, false on failure.
*/
function wp_cache_set($key, $data, $group = '', $expire = 0)
{
global $wp_object_cache;
return $wp_object_cache->set($key, $data, $group, (int) $expire);
}
/**
* Sets multiple values to the cache in one call.
*
* @since 5.4
*
* @see WP_Object_Cache::set_multiple()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param array $data Array of keys and values to be set.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool[] Array of return values, grouped by key. Each value is either
* true on success, or false on failure.
*/
function wp_cache_set_multiple(array $data, $group = '', $expire = 0)
{
global $wp_object_cache;
return $wp_object_cache->set_multiple($data, $group, $expire);
}
/**
* Retrieves the cache contents from the cache by key and group.
*
* @since 1.8
*
* @see WP_Object_Cache::get()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key The key under which the cache contents are stored.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @param bool $force Optional. Whether to force an update of the local cache
* from the persistent cache. Default false.
* @param bool $found Optional. Whether the key was found in the cache (passed by reference).
* Disambiguates a return of false, a storable value. Default null.
* @return mixed|false The cache contents on success, false on failure to retrieve contents.
*/
function wp_cache_get($key, $group = '', $force = false, &$found = null)
{
global $wp_object_cache;
return $wp_object_cache->get($key, $group, $force, $found);
}
/**
* Retrieves multiple values from the cache in one call.
*
* @since 5.4
*
* @see WP_Object_Cache::get_multiple()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param array $keys Array of keys under which the cache contents are stored.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @param bool $force Optional. Whether to force an update of the local cache
* from the persistent cache. Default false.
* @return array Array of return values, grouped by key. Each value is either
* the cache contents on success, or false on failure.
*/
function wp_cache_get_multiple($keys, $group = '', $force = false)
{
global $wp_object_cache;
return $wp_object_cache->get_multiple($keys, $group, $force);
}
/**
* Removes the cache contents matching key and group.
*
* @since 1.8
*
* @see WP_Object_Cache::delete()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key What the contents in the cache are called.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @return bool True on successful removal, false on failure.
*/
function wp_cache_delete($key, $group = '')
{
global $wp_object_cache;
return $wp_object_cache->delete($key, $group);
}
/**
* Deletes multiple values from the cache in one call.
*
* @since 5.4
*
* @see WP_Object_Cache::delete_multiple()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param array $keys Array of keys under which the cache to deleted.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @return bool[] Array of return values, grouped by key. Each value is either
* true on success, or false if the contents were not deleted.
*/
function wp_cache_delete_multiple(array $keys, $group = '')
{
global $wp_object_cache;
return $wp_object_cache->delete_multiple($keys, $group);
}
/**
* Increments numeric cache item's value.
*
* @since 1.8
*
* @see WP_Object_Cache::incr()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key The key for the cache contents that should be incremented.
* @param int $offset Optional. The amount by which to increment the item's value.
* Default 1.
* @param string $group Optional. The group the key is in. Default empty.
* @return int|false The item's new value on success, false on failure.
*/
function wp_cache_incr($key, $offset = 1, $group = '')
{
global $wp_object_cache;
return $wp_object_cache->incr($key, $offset, $group);
}
/**
* Decrements numeric cache item's value.
*
* @since 1.8
*
* @see WP_Object_Cache::decr()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int|string $key The cache key to decrement.
* @param int $offset Optional. The amount by which to decrement the item's value.
* Default 1.
* @param string $group Optional. The group the key is in. Default empty.
* @return int|false The item's new value on success, false on failure.
*/
function wp_cache_decr($key, $offset = 1, $group = '')
{
global $wp_object_cache;
return $wp_object_cache->decr($key, $offset, $group);
}
/**
* Removes all cache items.
*
* @since 1.8
*
* @see WP_Object_Cache::flush()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @return bool True on success, false on failure.
*/
function wp_cache_flush()
{
global $wp_object_cache;
return $wp_object_cache->flush();
}
/**
* Removes all cache items from the in-memory runtime cache.
*
* @since 5.4
*
* @see WP_Object_Cache::flush_runtime()
*
* @return bool True on success, false on failure.
*/
function wp_cache_flush_runtime()
{
global $wp_object_cache;
return $wp_object_cache->flush_runtime();
}
/**
* Removes all cache items in a group, if the object cache implementation supports it.
*
* Before calling this function, always check for group flushing support using the
* `wp_cache_supports( 'flush_group' )` function.
*
* @since 5.4
*
* @see WP_Object_Cache::flush_group()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param string $group Name of group to remove from cache.
* @return bool True if group was flushed, false otherwise.
*/
function wp_cache_flush_group($group)
{
global $wp_object_cache;
return $wp_object_cache->flush_group($group);
}
/**
* Determines whether the object cache implementation supports a particular feature.
*
* @since 5.4
*
* @param string $feature Name of the feature to check for. Possible values include:
* 'add_multiple', 'set_multiple', 'get_multiple', 'delete_multiple',
* 'flush_runtime', 'flush_group'.
* @return bool True if the feature is supported, false otherwise.
*/
function wp_cache_supports($feature)
{
switch ($feature) {
case 'add_multiple':
case 'set_multiple':
case 'get_multiple':
case 'delete_multiple':
case 'flush_runtime':
return true;
case 'flush_group':
default:
return false;
}
}
/**
* Closes the cache.
*
* This function has ceased to do anything since WordPress 2.5. The
* functionality was removed along with the rest of the persistent cache.
*
* This does not mean that plugins can't implement this function when they need
* to make sure that the cache is cleaned up after WordPress no longer needs it.
*
* @since 1.8
*
* @return true Always returns true.
*/
function wp_cache_close()
{
return true;
}
/**
* Adds a group or set of groups to the list of global groups.
*
* @since 1.8
*
* @see WP_Object_Cache::add_global_groups()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param string|string[] $groups A group or an array of groups to add.
*/
function wp_cache_add_global_groups($groups)
{
global $wp_object_cache;
$wp_object_cache->add_global_groups($groups);
}
/**
* Adds a group or set of groups to the list of non-persistent groups.
*
* @since 1.8
*
* @param string|string[] $groups A group or an array of groups to add.
*/
function wp_cache_add_non_persistent_groups($groups)
{
global $wp_object_cache;
$wp_object_cache->add_non_persistent_groups($groups);
}
/**
* Switches the internal blog ID.
*
* This changes the blog id used to create keys in blog specific groups.
*
* @since 1.8
*
* @see WP_Object_Cache::switch_to_blog()
* @global WP_Object_Cache $wp_object_cache Object cache global instance.
*
* @param int $blog_id Site ID.
*/
function wp_cache_switch_to_blog($blog_id)
{
global $wp_object_cache;
$wp_object_cache->switch_to_blog($blog_id);
}
class WP_Object_Cache
{
protected static $_instance;
private $_object_cache;
private $_cache = array();
private $_cache_404 = array();
private $cache_total = 0;
private $count_hit_incall = 0;
private $count_hit = 0;
private $count_miss_incall = 0;
private $count_miss = 0;
private $count_set = 0;
protected $global_groups = array();
private $blog_prefix;
private $multisite;
/**
* Init.
*
* @since 1.8
*/
public function __construct()
{
$this->_object_cache = \LiteSpeed\Object_Cache::cls();
$this->multisite = is_multisite();
$this->blog_prefix = $this->multisite ? get_current_blog_id() . ':' : '';
/**
* Fix multiple instance using same oc issue
* @since 1.8.2
*/
!defined('LSOC_PREFIX') && define('LSOC_PREFIX', substr(md5(__FILE__), -5));
}
/**
* Makes private properties readable for backward compatibility.
*
* @since 5.4
* @access public
*
* @param string $name Property to get.
* @return mixed Property.
*/
public function __get($name)
{
return $this->$name;
}
/**
* Makes private properties settable for backward compatibility.
*
* @since 5.4
* @access public
*
* @param string $name Property to set.
* @param mixed $value Property value.
* @return mixed Newly-set property.
*/
public function __set($name, $value)
{
return $this->$name = $value;
}
/**
* Makes private properties checkable for backward compatibility.
*
* @since 5.4
* @access public
*
* @param string $name Property to check if set.
* @return bool Whether the property is set.
*/
public function __isset($name)
{
return isset($this->$name);
}
/**
* Makes private properties un-settable for backward compatibility.
*
* @since 5.4
* @access public
*
* @param string $name Property to unset.
*/
public function __unset($name)
{
unset($this->$name);
}
/**
* Serves as a utility function to determine whether a key is valid.
*
* @since 5.4
* @access protected
*
* @param int|string $key Cache key to check for validity.
* @return bool Whether the key is valid.
*/
protected function is_valid_key($key)
{
if (is_int($key)) {
return true;
}
if (is_string($key) && trim($key) !== '') {
return true;
}
$type = gettype($key);
if (!function_exists('__')) {
wp_load_translations_early();
}
$message = is_string($key)
? __('Cache key must not be an empty string.')
: /* translators: %s: The type of the given cache key. */
sprintf(__('Cache key must be integer or non-empty string, %s given.'), $type);
_doing_it_wrong(sprintf('%s::%s', __CLASS__, debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']), $message, '6.1.0');
return false;
}
/**
* Get the final key.
*
* @since 1.8
* @access private
*/
private function _key($key, $group = 'default')
{
if (empty($group)) {
$group = 'default';
}
$prefix = $this->_object_cache->is_global($group) ? '' : $this->blog_prefix;
return LSOC_PREFIX . $prefix . $group . '.' . $key;
}
/**
* Output debug info.
*
* @since 1.8
* @access public
*/
public function debug()
{
return ' [total] ' .
$this->cache_total .
' [hit_incall] ' .
$this->count_hit_incall .
' [hit] ' .
$this->count_hit .
' [miss_incall] ' .
$this->count_miss_incall .
' [miss] ' .
$this->count_miss .
' [set] ' .
$this->count_set;
}
/**
* Adds data to the cache if it doesn't already exist.
*
* @since 1.8
* @access public
*
* @uses WP_Object_Cache::_exists() Checks to see if the cache already has data.
* @uses WP_Object_Cache::set() Sets the data after the checking the cache
* contents existence.
*
* @param int|string $key What to call the contents in the cache.
* @param mixed $data The contents to store in the cache.
* @param string $group Optional. Where to group the cache contents. Default 'default'.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool True on success, false if cache key and group already exist.
*/
public function add($key, $data, $group = 'default', $expire = 0)
{
if (wp_suspend_cache_addition()) {
return false;
}
if (!$this->is_valid_key($key)) {
return false;
}
if (empty($group)) {
$group = 'default';
}
$id = $this->_key($key, $group);
if (array_key_exists($id, $this->_cache)) {
return false;
}
return $this->set($key, $data, $group, (int) $expire);
}
/**
* Adds multiple values to the cache in one call.
*
* @since 5.4
* @access public
*
* @param array $data Array of keys and values to be added.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool[] Array of return values, grouped by key. Each value is either
* true on success, or false if cache key and group already exist.
*/
public function add_multiple(array $data, $group = '', $expire = 0)
{
$values = array();
foreach ($data as $key => $value) {
$values[$key] = $this->add($key, $value, $group, $expire);
}
return $values;
}
/**
* Replaces the contents in the cache, if contents already exist.
*
* @since 1.8
* @access public
*
* @see WP_Object_Cache::set()
*
* @param int|string $key What to call the contents in the cache.
* @param mixed $data The contents to store in the cache.
* @param string $group Optional. Where to group the cache contents. Default 'default'.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool True if contents were replaced, false if original value does not exist.
*/
public function replace($key, $data, $group = 'default', $expire = 0)
{
if (!$this->is_valid_key($key)) {
return false;
}
if (empty($group)) {
$group = 'default';
}
$id = $this->_key($key, $group);
if (!array_key_exists($id, $this->_cache)) {
return false;
}
return $this->set($key, $data, $group, (int) $expire);
}
/**
* Sets the data contents into the cache.
*
* The cache contents are grouped by the $group parameter followed by the
* $key. This allows for duplicate IDs in unique groups. Therefore, naming of
* the group should be used with care and should follow normal function
* naming guidelines outside of core WordPress usage.
*
* The $expire parameter is not used, because the cache will automatically
* expire for each time a page is accessed and PHP finishes. The method is
* more for cache plugins which use files.
*
* @since 1.8
* @since 5.4 Returns false if cache key is invalid.
* @access public
*
* @param int|string $key What to call the contents in the cache.
* @param mixed $data The contents to store in the cache.
* @param string $group Optional. Where to group the cache contents. Default 'default'.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool True if contents were set, false if key is invalid.
*/
public function set($key, $data, $group = 'default', $expire = 0)
{
if (!$this->is_valid_key($key)) {
return false;
}
if (empty($group)) {
$group = 'default';
}
$id = $this->_key($key, $group);
if (is_object($data)) {
$data = clone $data;
}
// error_log("oc: set \t\t\t[key] " . $id );
$this->_cache[$id] = $data;
if (array_key_exists($id, $this->_cache_404)) {
// error_log("oc: unset404\t\t\t[key] " . $id );
unset($this->_cache_404[$id]);
}
if (!$this->_object_cache->is_non_persistent($group)) {
$this->_object_cache->set($id, serialize(array('data' => $data)), (int) $expire);
$this->count_set++;
}
if ($this->_object_cache->store_transients($group)) {
$this->_transient_set($key, $data, $group, (int) $expire);
}
return true;
}
/**
* Sets multiple values to the cache in one call.
*
* @since 5.4
* @access public
*
* @param array $data Array of key and value to be set.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @param int $expire Optional. When to expire the cache contents, in seconds.
* Default 0 (no expiration).
* @return bool[] Array of return values, grouped by key. Each value is always true.
*/
public function set_multiple(array $data, $group = '', $expire = 0)
{
$values = array();
foreach ($data as $key => $value) {
$values[$key] = $this->set($key, $value, $group, $expire);
}
return $values;
}
/**
* Retrieves the cache contents, if it exists.
*
* The contents will be first attempted to be retrieved by searching by the
* key in the cache group. If the cache is hit (success) then the contents
* are returned.
*
* On failure, the number of cache misses will be incremented.
*
* @since 1.8
* @access public
*
* @param int|string $key The key under which the cache contents are stored.
* @param string $group Optional. Where the cache contents are grouped. Default 'default'.
* @param bool $force Optional. Unused. Whether to force an update of the local cache
* from the persistent cache. Default false.
* @param bool $found Optional. Whether the key was found in the cache (passed by reference).
* Disambiguates a return of false, a storable value. Default null.
* @return mixed|false The cache contents on success, false on failure to retrieve contents.
*/
public function get($key, $group = 'default', $force = false, &$found = null)
{
if (!$this->is_valid_key($key)) {
return false;
}
if (empty($group)) {
$group = 'default';
}
$id = $this->_key($key, $group);
// error_log('');
// error_log("oc: get \t\t\t[key] " . $id . ( $force ? "\t\t\t [forced] " : '' ) );
$found = false;
$found_in_oc = false;
$cache_val = false;
if (array_key_exists($id, $this->_cache) && !$force) {
$found = true;
$cache_val = $this->_cache[$id];
$this->count_hit_incall++;
} elseif (!array_key_exists($id, $this->_cache_404) && !$this->_object_cache->is_non_persistent($group)) {
$v = $this->_object_cache->get($id);
if ($v !== null) {
$v = @maybe_unserialize($v);
}
// To be compatible with false val
if (is_array($v) && array_key_exists('data', $v)) {
$this->count_hit++;
$found = true;
$found_in_oc = true;
$cache_val = $v['data'];
} else {
// Can't find key, cache it to 404
// error_log("oc: add404\t\t\t[key] " . $id );
$this->_cache_404[$id] = 1;
$this->count_miss++;
}
} else {
$this->count_miss_incall++;
}
if (is_object($cache_val)) {
$cache_val = clone $cache_val;
}
// If not found but has `Store Transients` cfg on, still need to follow WP's get_transient() logic
if (!$found && $this->_object_cache->store_transients($group)) {
$cache_val = $this->_transient_get($key, $group);
if ($cache_val) {
$found = true; // $found not used for now (v1.8.3)
}
}
if ($found_in_oc) {
$this->_cache[$id] = $cache_val;
}
$this->cache_total++;
return $cache_val;
}
/**
* Retrieves multiple values from the cache in one call.
*
* @since 5.4
* @access public
*
* @param array $keys Array of keys under which the cache contents are stored.
* @param string $group Optional. Where the cache contents are grouped. Default 'default'.
* @param bool $force Optional. Whether to force an update of the local cache
* from the persistent cache. Default false.
* @return array Array of return values, grouped by key. Each value is either
* the cache contents on success, or false on failure.
*/
public function get_multiple($keys, $group = 'default', $force = false)
{
$values = array();
foreach ($keys as $key) {
$values[$key] = $this->get($key, $group, $force);
}
return $values;
}
/**
* Removes the contents of the cache key in the group.
*
* If the cache key does not exist in the group, then nothing will happen.
*
* @since 1.8
* @access public
*
* @param int|string $key What the contents in the cache are called.
* @param string $group Optional. Where the cache contents are grouped. Default 'default'.
* @param bool $deprecated Optional. Unused. Default false.
* @return bool True on success, false if the contents were not deleted.
*/
public function delete($key, $group = 'default', $deprecated = false)
{
if (!$this->is_valid_key($key)) {
return false;
}
if (empty($group)) {
$group = 'default';
}
$id = $this->_key($key, $group);
if ($this->_object_cache->store_transients($group)) {
$this->_transient_del($key, $group);
}
if (array_key_exists($id, $this->_cache)) {
unset($this->_cache[$id]);
}
// error_log("oc: delete \t\t\t[key] " . $id );
if ($this->_object_cache->is_non_persistent($group)) {
return false;
}
return $this->_object_cache->delete($id);
}
/**
* Deletes multiple values from the cache in one call.
*
* @since 5.4
* @access public
*
* @param array $keys Array of keys to be deleted.
* @param string $group Optional. Where the cache contents are grouped. Default empty.
* @return bool[] Array of return values, grouped by key. Each value is either
* true on success, or false if the contents were not deleted.
*/
public function delete_multiple(array $keys, $group = '')
{
$values = array();
foreach ($keys as $key) {
$values[$key] = $this->delete($key, $group);
}
return $values;
}
/**
* Increments numeric cache item's value.
*
* @since 5.4
*
* @param int|string $key The cache key to increment.
* @param int $offset Optional. The amount by which to increment the item's value.
* Default 1.
* @param string $group Optional. The group the key is in. Default 'default'.
* @return int|false The item's new value on success, false on failure.
*/
public function incr($key, $offset = 1, $group = 'default')
{
return $this->incr_desr($key, $offset, $group, true);
}
/**
* Decrements numeric cache item's value.
*
* @since 5.4
*
* @param int|string $key The cache key to decrement.
* @param int $offset Optional. The amount by which to decrement the item's value.
* Default 1.
* @param string $group Optional. The group the key is in. Default 'default'.
* @return int|false The item's new value on success, false on failure.
*/
public function decr($key, $offset = 1, $group = 'default')
{
return $this->incr_desr($key, $offset, $group, false);
}
/**
* Increments or decrements numeric cache item's value.
*
* @since 1.8
* @access public
*/
public function incr_desr($key, $offset = 1, $group = 'default', $incr = true)
{
if (!$this->is_valid_key($key)) {
return false;
}
if (empty($group)) {
$group = 'default';
}
$cache_val = $this->get($key, $group);
if (false === $cache_val) {
return false;
}
if (!is_numeric($cache_val)) {
$cache_val = 0;
}
$offset = (int) $offset;
if ($incr) {
$cache_val += $offset;
} else {
$cache_val -= $offset;
}
if ($cache_val < 0) {
$cache_val = 0;
}
$this->set($key, $cache_val, $group);
return $cache_val;
}
/**
* Clears the object cache of all data.
*
* @since 1.8
* @access public
*
* @return true Always returns true.
*/
public function flush()
{
$this->flush_runtime();
$this->_object_cache->flush();
return true;
}
/**
* Removes all cache items from the in-memory runtime cache.
*
* @since 5.4
* @access public
*
* @return true Always returns true.
*/
public function flush_runtime()
{
$this->_cache = array();
$this->_cache_404 = array();
return true;
}
/**
* Removes all cache items in a group.
*
* @since 5.4
* @access public
*
* @param string $group Name of group to remove from cache.
* @return true Always returns true.
*/
public function flush_group($group)
{
// unset( $this->cache[ $group ] );
return true;
}
/**
* Sets the list of global cache groups.
*
* @since 1.8
* @access public
*
* @param string|string[] $groups List of groups that are global.
*/
public function add_global_groups($groups)
{
$groups = (array) $groups;
$this->_object_cache->add_global_groups($groups);
}
/**
* Sets the list of non-persistent cache groups.
*
* @since 1.8
* @access public
*/
public function add_non_persistent_groups($groups)
{
$groups = (array) $groups;
$this->_object_cache->add_non_persistent_groups($groups);
}
/**
* Switches the internal blog ID.
*
* This changes the blog ID used to create keys in blog specific groups.
*
* @since 1.8
* @access public
*
* @param int $blog_id Blog ID.
*/
public function switch_to_blog($blog_id)
{
$blog_id = (int) $blog_id;
$this->blog_prefix = $this->multisite ? $blog_id . ':' : '';
}
/**
* Get transient from wp table
*
* @since 1.8.3
* @access private
* @see `wp-includes/option.php` function `get_transient`/`set_site_transient`
*/
private function _transient_get($transient, $group)
{
if ($group == 'transient') {
/**** Ori WP func start ****/
$transient_option = '_transient_' . $transient;
if (!wp_installing()) {
// If option is not in alloptions, it is not autoloaded and thus has a timeout
$alloptions = wp_load_alloptions();
if (!isset($alloptions[$transient_option])) {
$transient_timeout = '_transient_timeout_' . $transient;
$timeout = get_option($transient_timeout);
if (false !== $timeout && $timeout < time()) {
delete_option($transient_option);
delete_option($transient_timeout);
$value = false;
}
}
}
if (!isset($value)) {
$value = get_option($transient_option);
}
/**** Ori WP func end ****/
} elseif ($group == 'site-transient') {
/**** Ori WP func start ****/
$no_timeout = array('update_core', 'update_plugins', 'update_themes');
$transient_option = '_site_transient_' . $transient;
if (!in_array($transient, $no_timeout)) {
$transient_timeout = '_site_transient_timeout_' . $transient;
$timeout = get_site_option($transient_timeout);
if (false !== $timeout && $timeout < time()) {
delete_site_option($transient_option);
delete_site_option($transient_timeout);
$value = false;
}
}
if (!isset($value)) {
$value = get_site_option($transient_option);
}
/**** Ori WP func end ****/
} else {
$value = false;
}
return $value;
}
/**
* Set transient to WP table
*
* @since 1.8.3
* @access private
* @see `wp-includes/option.php` function `set_transient`/`set_site_transient`
*/
private function _transient_set($transient, $value, $group, $expiration)
{
if ($group == 'transient') {
/**** Ori WP func start ****/
$transient_timeout = '_transient_timeout_' . $transient;
$transient_option = '_transient_' . $transient;
if (false === get_option($transient_option)) {
$autoload = 'yes';
if ((int) $expiration) {
$autoload = 'no';
add_option($transient_timeout, time() + (int) $expiration, '', 'no');
}
$result = add_option($transient_option, $value, '', $autoload);
} else {
// If expiration is requested, but the transient has no timeout option,
// delete, then re-create transient rather than update.
$update = true;
if ((int) $expiration) {
if (false === get_option($transient_timeout)) {
delete_option($transient_option);
add_option($transient_timeout, time() + (int) $expiration, '', 'no');
$result = add_option($transient_option, $value, '', 'no');
$update = false;
} else {
update_option($transient_timeout, time() + (int) $expiration);
}
}
if ($update) {
$result = update_option($transient_option, $value);
}
}
/**** Ori WP func end ****/
} elseif ($group == 'site-transient') {
/**** Ori WP func start ****/
$transient_timeout = '_site_transient_timeout_' . $transient;
$option = '_site_transient_' . $transient;
if (false === get_site_option($option)) {
if ((int) $expiration) {
add_site_option($transient_timeout, time() + (int) $expiration);
}
$result = add_site_option($option, $value);
} else {
if ((int) $expiration) {
update_site_option($transient_timeout, time() + (int) $expiration);
}
$result = update_site_option($option, $value);
}
/**** Ori WP func end ****/
} else {
$result = null;
}
return $result;
}
/**
* Delete transient from WP table
*
* @since 1.8.3
* @access private
* @see `wp-includes/option.php` function `delete_transient`/`delete_site_transient`
*/
private function _transient_del($transient, $group)
{
if ($group == 'transient') {
/**** Ori WP func start ****/
$option_timeout = '_transient_timeout_' . $transient;
$option = '_transient_' . $transient;
$result = delete_option($option);
if ($result) {
delete_option($option_timeout);
}
/**** Ori WP func end ****/
} elseif ($group == 'site-transient') {
/**** Ori WP func start ****/
$option_timeout = '_site_transient_timeout_' . $transient;
$option = '_site_transient_' . $transient;
$result = delete_site_option($option);
if ($result) {
delete_site_option($option_timeout);
}
/**** Ori WP func end ****/
}
}
/**
* Get the current instance object.
*
* @since 1.8
* @access public
*/
public static function get_instance()
{
if (!isset(self::$_instance)) {
self::$_instance = new self();
}
return self::$_instance;
}
}
optimize.cls.php 0000644 00000111727 15153741267 0007721 0 ustar 00 <?php
/**
* The optimize class.
*
* @since 1.2.2
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Optimize extends Base
{
const LIB_FILE_CSS_ASYNC = 'assets/js/css_async.min.js';
const LIB_FILE_WEBFONTLOADER = 'assets/js/webfontloader.min.js';
const LIB_FILE_JS_DELAY = 'assets/js/js_delay.min.js';
const ITEM_TIMESTAMP_PURGE_CSS = 'timestamp_purge_css';
private $content;
private $content_ori;
private $cfg_css_min;
private $cfg_css_comb;
private $cfg_js_min;
private $cfg_js_comb;
private $cfg_css_async;
private $cfg_js_delay_inc = array();
private $cfg_js_defer;
private $cfg_js_defer_exc = false;
private $cfg_ggfonts_async;
private $_conf_css_font_display;
private $cfg_ggfonts_rm;
private $dns_prefetch;
private $dns_preconnect;
private $_ggfonts_urls = array();
private $_ccss;
private $_ucss = false;
private $__optimizer;
private $html_foot = ''; // The html info append to <body>
private $html_head = ''; // The html info prepend to <body>
private static $_var_i = 0;
private $_var_preserve_js = array();
private $_request_url;
/**
* Constructor
* @since 4.0
*/
public function __construct()
{
Debug2::debug('[Optm] init');
$this->__optimizer = $this->cls('Optimizer');
}
/**
* Init optimizer
*
* @since 3.0
* @access protected
*/
public function init()
{
$this->cfg_css_async = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_CSS_ASYNC);
if ($this->cfg_css_async) {
if (!$this->cls('Cloud')->activated()) {
Debug2::debug('[Optm] ❌ CCSS set to OFF due to QC not activated');
$this->cfg_css_async = false;
}
if ((defined('LITESPEED_GUEST_OPTM') || ($this->conf(self::O_OPTM_UCSS) && $this->conf(self::O_OPTM_CSS_COMB))) && $this->conf(self::O_OPTM_UCSS_INLINE)) {
Debug2::debug('[Optm] ⚠️ CCSS set to OFF due to UCSS Inline');
$this->cfg_css_async = false;
}
}
$this->cfg_js_defer = $this->conf(self::O_OPTM_JS_DEFER);
if (defined('LITESPEED_GUEST_OPTM')) {
$this->cfg_js_defer = 2;
}
if ($this->cfg_js_defer == 2) {
add_filter(
'litespeed_optm_cssjs',
function ($con, $file_type) {
if ($file_type == 'js') {
$con = str_replace('DOMContentLoaded', 'DOMContentLiteSpeedLoaded', $con);
// $con = str_replace( 'addEventListener("load"', 'addEventListener("litespeedLoad"', $con );
}
return $con;
},
20,
2
);
}
// To remove emoji from WP
if ($this->conf(self::O_OPTM_EMOJI_RM)) {
$this->_emoji_rm();
}
if ($this->conf(self::O_OPTM_QS_RM)) {
add_filter('style_loader_src', array($this, 'remove_query_strings'), 999);
add_filter('script_loader_src', array($this, 'remove_query_strings'), 999);
}
// GM JS exclude @since 4.1
if (defined('LITESPEED_GUEST_OPTM')) {
$this->cfg_js_defer_exc = apply_filters('litespeed_optm_gm_js_exc', $this->conf(self::O_OPTM_GM_JS_EXC));
} else {
/**
* Exclude js from deferred setting
* @since 1.5
*/
if ($this->cfg_js_defer) {
add_filter('litespeed_optm_js_defer_exc', array($this->cls('Data'), 'load_js_defer_exc'));
$this->cfg_js_defer_exc = apply_filters('litespeed_optm_js_defer_exc', $this->conf(self::O_OPTM_JS_DEFER_EXC));
$this->cfg_js_delay_inc = apply_filters('litespeed_optm_js_delay_inc', $this->conf(self::O_OPTM_JS_DELAY_INC));
}
}
/**
* Add vary filter for Role Excludes
* @since 1.6
*/
add_filter('litespeed_vary', array($this, 'vary_add_role_exclude'));
/**
* Prefetch DNS
* @since 1.7.1
*/
$this->_dns_prefetch_init();
/**
* Preconnect
* @since 5.6.1
*/
$this->_dns_preconnect_init();
add_filter('litespeed_buffer_finalize', array($this, 'finalize'), 20);
}
/**
* Exclude role from optimization filter
*
* @since 1.6
* @access public
*/
public function vary_add_role_exclude($vary)
{
if ($this->cls('Conf')->in_optm_exc_roles()) {
$vary['role_exclude_optm'] = 1;
}
return $vary;
}
/**
* Remove emoji from WP
*
* @since 1.4
* @since 2.9.8 Changed to private
* @access private
*/
private function _emoji_rm()
{
remove_action('wp_head', 'print_emoji_detection_script', 7);
remove_action('admin_print_scripts', 'print_emoji_detection_script');
remove_filter('the_content_feed', 'wp_staticize_emoji');
remove_filter('comment_text_rss', 'wp_staticize_emoji');
/**
* Added for better result
* @since 1.6.2.1
*/
remove_action('wp_print_styles', 'print_emoji_styles');
remove_action('admin_print_styles', 'print_emoji_styles');
remove_filter('wp_mail', 'wp_staticize_emoji_for_email');
}
/**
* Delete file-based cache folder
*
* @since 2.1
* @access public
*/
public function rm_cache_folder($subsite_id = false)
{
if ($subsite_id) {
file_exists(LITESPEED_STATIC_DIR . '/css/' . $subsite_id) && File::rrmdir(LITESPEED_STATIC_DIR . '/css/' . $subsite_id);
file_exists(LITESPEED_STATIC_DIR . '/js/' . $subsite_id) && File::rrmdir(LITESPEED_STATIC_DIR . '/js/' . $subsite_id);
return;
}
file_exists(LITESPEED_STATIC_DIR . '/css') && File::rrmdir(LITESPEED_STATIC_DIR . '/css');
file_exists(LITESPEED_STATIC_DIR . '/js') && File::rrmdir(LITESPEED_STATIC_DIR . '/js');
}
/**
* Remove QS
*
* @since 1.3
* @access public
*/
public function remove_query_strings($src)
{
if (strpos($src, '_litespeed_rm_qs=0') || strpos($src, '/recaptcha')) {
return $src;
}
if (!Utility::is_internal_file($src)) {
return $src;
}
if (strpos($src, '.js?') !== false || strpos($src, '.css?') !== false) {
$src = preg_replace('/\?.*/', '', $src);
}
return $src;
}
/**
* Run optimize process
* NOTE: As this is after cache finalized, can NOT set any cache control anymore
*
* @since 1.2.2
* @access public
* @return string The content that is after optimization
*/
public function finalize($content)
{
if (defined('LITESPEED_NO_PAGEOPTM')) {
Debug2::debug2('[Optm] bypass: NO_PAGEOPTM const');
return $content;
}
if (!defined('LITESPEED_IS_HTML')) {
Debug2::debug('[Optm] bypass: Not frontend HTML type');
return $content;
}
if (!defined('LITESPEED_GUEST_OPTM')) {
if (!Control::is_cacheable()) {
Debug2::debug('[Optm] bypass: Not cacheable');
return $content;
}
// Check if hit URI excludes
add_filter('litespeed_optm_uri_exc', array($this->cls('Data'), 'load_optm_uri_exc'));
$excludes = apply_filters('litespeed_optm_uri_exc', $this->conf(self::O_OPTM_EXC));
$result = Utility::str_hit_array($_SERVER['REQUEST_URI'], $excludes);
if ($result) {
Debug2::debug('[Optm] bypass: hit URI Excludes setting: ' . $result);
return $content;
}
}
Debug2::debug('[Optm] start');
$this->content_ori = $this->content = $content;
$this->_optimize();
return $this->content;
}
/**
* Optimize css src
*
* @since 1.2.2
* @access private
*/
private function _optimize()
{
global $wp;
$this->_request_url = get_permalink();
// Backup, in case get_permalink() fails.
if (!$this->_request_url) {
$this->_request_url = home_url($wp->request);
}
$this->cfg_css_min = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_CSS_MIN);
$this->cfg_css_comb = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_CSS_COMB);
$this->cfg_js_min = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_JS_MIN);
$this->cfg_js_comb = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_JS_COMB);
$this->cfg_ggfonts_rm = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_GGFONTS_RM);
$this->cfg_ggfonts_async = !defined('LITESPEED_GUEST_OPTM') && $this->conf(self::O_OPTM_GGFONTS_ASYNC); // forced rm already
$this->_conf_css_font_display = !defined('LITESPEED_GUEST_OPTM') && $this->conf(self::O_OPTM_CSS_FONT_DISPLAY);
if (!$this->cls('Router')->can_optm()) {
Debug2::debug('[Optm] bypass: admin/feed/preview');
return;
}
if ($this->cfg_css_async) {
$this->_ccss = $this->cls('CSS')->prepare_ccss();
if (!$this->_ccss) {
Debug2::debug('[Optm] ❌ CCSS set to OFF due to CCSS not generated yet');
$this->cfg_css_async = false;
} elseif (strpos($this->_ccss, '<style id="litespeed-ccss" data-error') === 0) {
Debug2::debug('[Optm] ❌ CCSS set to OFF due to CCSS failed to generate');
$this->cfg_css_async = false;
}
}
do_action('litespeed_optm');
// Parse css from content
$src_list = false;
if ($this->cfg_css_min || $this->cfg_css_comb || $this->cfg_ggfonts_rm || $this->cfg_css_async || $this->cfg_ggfonts_async || $this->_conf_css_font_display) {
add_filter('litespeed_optimize_css_excludes', array($this->cls('Data'), 'load_css_exc'));
list($src_list, $html_list) = $this->_parse_css();
}
// css optimizer
if ($this->cfg_css_min || $this->cfg_css_comb) {
if ($src_list) {
// IF combine
if ($this->cfg_css_comb) {
// Check if has inline UCSS enabled or not
if ((defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_UCSS)) && $this->conf(self::O_OPTM_UCSS_INLINE)) {
$filename = $this->cls('UCSS')->load($this->_request_url, true);
if ($filename) {
$filepath_prefix = $this->_build_filepath_prefix('ucss');
$this->_ucss = File::read(LITESPEED_STATIC_DIR . $filepath_prefix . $filename);
// Drop all css
$this->content = str_replace($html_list, '', $this->content);
}
}
if (!$this->_ucss) {
$url = $this->_build_hash_url($src_list);
if ($url) {
// Handle css async load
if ($this->cfg_css_async) {
$this->html_head .=
'<link rel="preload" data-asynced="1" data-optimized="2" as="style" onload="this.onload=null;this.rel=\'stylesheet\'" href="' .
Str::trim_quotes($url) .
'" />'; // todo: How to use " in attr wrapper "
} else {
$this->html_head .= '<link data-optimized="2" rel="stylesheet" href="' . Str::trim_quotes($url) . '" />'; // use 2 as combined
}
// Move all css to top
$this->content = str_replace($html_list, '', $this->content);
}
}
}
// Only minify
elseif ($this->cfg_css_min) {
// will handle async css load inside
$this->_src_queue_handler($src_list, $html_list);
}
// Only HTTP2 push
else {
foreach ($src_list as $src_info) {
if (!empty($src_info['inl'])) {
continue;
}
}
}
}
}
// Handle css lazy load if not handled async loaded yet
if ($this->cfg_css_async && !$this->cfg_css_min && !$this->cfg_css_comb) {
// async html
$html_list_async = $this->_async_css_list($html_list, $src_list);
// Replace async css
$this->content = str_replace($html_list, $html_list_async, $this->content);
}
// Parse js from buffer as needed
$src_list = false;
if ($this->cfg_js_min || $this->cfg_js_comb || $this->cfg_js_defer || $this->cfg_js_delay_inc) {
add_filter('litespeed_optimize_js_excludes', array($this->cls('Data'), 'load_js_exc'));
list($src_list, $html_list) = $this->_parse_js();
}
// js optimizer
if ($src_list) {
// IF combine
if ($this->cfg_js_comb) {
$url = $this->_build_hash_url($src_list, 'js');
if ($url) {
$this->html_foot .= $this->_build_js_tag($url);
// Will move all JS to bottom combined one
$this->content = str_replace($html_list, '', $this->content);
}
}
// Only minify
elseif ($this->cfg_js_min) {
// Will handle js defer inside
$this->_src_queue_handler($src_list, $html_list, 'js');
}
// Only HTTP2 push and Defer
else {
foreach ($src_list as $k => $src_info) {
// Inline JS
if (!empty($src_info['inl'])) {
if ($this->cfg_js_defer) {
$attrs = !empty($src_info['attrs']) ? $src_info['attrs'] : '';
$deferred = $this->_js_inline_defer($src_info['src'], $attrs);
if ($deferred) {
$this->content = str_replace($html_list[$k], $deferred, $this->content);
}
}
}
// JS files
else {
if ($this->cfg_js_defer) {
$deferred = $this->_js_defer($html_list[$k], $src_info['src']);
if ($deferred) {
$this->content = str_replace($html_list[$k], $deferred, $this->content);
}
} elseif ($this->cfg_js_delay_inc) {
$deferred = $this->_js_delay($html_list[$k], $src_info['src']);
if ($deferred) {
$this->content = str_replace($html_list[$k], $deferred, $this->content);
}
}
}
}
}
}
// Append JS inline var for preserved ESI
// Shouldn't give any optm (defer/delay) @since 4.4
if ($this->_var_preserve_js) {
$this->html_head .= '<script>var ' . implode(',', $this->_var_preserve_js) . ';</script>';
Debug2::debug2('[Optm] Inline JS defer vars', $this->_var_preserve_js);
}
// Append async compatibility lib to head
if ($this->cfg_css_async) {
// Inline css async lib
if ($this->conf(self::O_OPTM_CSS_ASYNC_INLINE)) {
$this->html_head .= $this->_build_js_inline(File::read(LSCWP_DIR . self::LIB_FILE_CSS_ASYNC), true);
} else {
$css_async_lib_url = LSWCP_PLUGIN_URL . self::LIB_FILE_CSS_ASYNC;
$this->html_head .= $this->_build_js_tag($css_async_lib_url, 'litespeed-css-async-lib'); // Don't exclude it from defer for now
}
}
/**
* Handle google fonts async
* This will result in a JS snippet in head, so need to put it in the end to avoid being replaced by JS parser
*/
$this->_async_ggfonts();
/**
* Font display optm
* @since 3.0
*/
$this->_font_optm();
// Inject JS Delay lib
$this->_maybe_js_delay();
/**
* HTML Lazyload
*/
if ($this->conf(self::O_OPTM_HTML_LAZY)) {
$this->html_head = $this->cls('CSS')->prepare_html_lazy() . $this->html_head;
}
// Maybe prepend inline UCSS
if ($this->_ucss) {
$this->html_head = '<style id="litespeed-ucss">' . $this->_ucss . '</style>' . $this->html_head;
}
// Check if there is any critical css rules setting
if ($this->cfg_css_async && $this->_ccss) {
$this->html_head = $this->_ccss . $this->html_head;
}
// Replace html head part
$this->html_head = apply_filters('litespeed_optm_html_head', $this->html_head);
if ($this->html_head) {
if (apply_filters('litespeed_optm_html_after_head', false)) {
$this->content = str_replace('</head>', $this->html_head . '</head>', $this->content);
} else {
// Put header content to be after charset
if (strpos($this->content, '<meta charset') !== false) {
$this->content = preg_replace('#<meta charset([^>]*)>#isU', '<meta charset$1>' . $this->html_head, $this->content, 1);
} else {
$this->content = preg_replace('#<head([^>]*)>#isU', '<head$1>' . $this->html_head, $this->content, 1);
}
}
}
// Replace html foot part
$this->html_foot = apply_filters('litespeed_optm_html_foot', $this->html_foot);
if ($this->html_foot) {
$this->content = str_replace('</body>', $this->html_foot . '</body>', $this->content);
}
// Drop noscript if enabled
if ($this->conf(self::O_OPTM_NOSCRIPT_RM)) {
// $this->content = preg_replace( '#<noscript>.*</noscript>#isU', '', $this->content );
}
// HTML minify
if (defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_HTML_MIN)) {
$this->content = $this->__optimizer->html_min($this->content);
}
}
/**
* Build a full JS tag
*
* @since 4.0
*/
private function _build_js_tag($src)
{
if ($this->cfg_js_defer === 2 || Utility::str_hit_array($src, $this->cfg_js_delay_inc)) {
return '<script data-optimized="1" type="litespeed/javascript" data-src="' . Str::trim_quotes($src) . '"></script>';
}
if ($this->cfg_js_defer) {
return '<script data-optimized="1" src="' . Str::trim_quotes($src) . '" defer></script>';
}
return '<script data-optimized="1" src="' . Str::trim_quotes($src) . '"></script>';
}
/**
* Build a full inline JS snippet
*
* @since 4.0
*/
private function _build_js_inline($script, $minified = false)
{
if ($this->cfg_js_defer) {
$deferred = $this->_js_inline_defer($script, false, $minified);
if ($deferred) {
return $deferred;
}
}
return '<script>' . $script . '</script>';
}
/**
* Load JS delay lib
*
* @since 4.0
*/
private function _maybe_js_delay()
{
if ($this->cfg_js_defer !== 2 && !$this->cfg_js_delay_inc) {
return;
}
$this->html_foot .= '<script>' . File::read(LSCWP_DIR . self::LIB_FILE_JS_DELAY) . '</script>';
}
/**
* Google font async
*
* @since 2.7.3
* @access private
*/
private function _async_ggfonts()
{
if (!$this->cfg_ggfonts_async || !$this->_ggfonts_urls) {
return;
}
Debug2::debug2('[Optm] google fonts async found: ', $this->_ggfonts_urls);
$html = '<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin />';
/**
* Append fonts
*
* Could be multiple fonts
*
* <link rel='stylesheet' href='//fonts.googleapis.com/css?family=Open+Sans%3A400%2C600%2C700%2C800%2C300&ver=4.9.8' type='text/css' media='all' />
* <link rel='stylesheet' href='//fonts.googleapis.com/css?family=PT+Sans%3A400%2C700%7CPT+Sans+Narrow%3A400%7CMontserrat%3A600&subset=latin&ver=4.9.8' type='text/css' media='all' />
* -> family: PT Sans:400,700|PT Sans Narrow:400|Montserrat:600
* <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,300,300italic,400italic,600,700,900&subset=latin%2Clatin-ext' />
*/
$script = 'WebFontConfig={google:{families:[';
$families = array();
foreach ($this->_ggfonts_urls as $v) {
$qs = wp_specialchars_decode($v);
$qs = urldecode($qs);
$qs = parse_url($qs, PHP_URL_QUERY);
parse_str($qs, $qs);
if (empty($qs['family'])) {
Debug2::debug('[Optm] ERR ggfonts failed to find family: ' . $v);
continue;
}
$subset = empty($qs['subset']) ? '' : ':' . $qs['subset'];
foreach (array_filter(explode('|', $qs['family'])) as $v2) {
$families[] = Str::trim_quotes($v2 . $subset);
}
}
$script .= '"' . implode('","', $families) . ($this->_conf_css_font_display ? '&display=swap' : '') . '"';
$script .= ']}};';
// if webfontloader lib was loaded before WebFontConfig variable, call WebFont.load
$script .= 'if ( typeof WebFont === "object" && typeof WebFont.load === "function" ) { WebFont.load( WebFontConfig ); }';
$html .= $this->_build_js_inline($script);
// https://cdnjs.cloudflare.com/ajax/libs/webfont/1.6.28/webfontloader.js
$webfont_lib_url = LSWCP_PLUGIN_URL . self::LIB_FILE_WEBFONTLOADER;
// default async, if js defer set use defer
$html .= $this->_build_js_tag($webfont_lib_url);
// Put this in the very beginning for preconnect
$this->html_head = $html . $this->html_head;
}
/**
* Font optm
*
* @since 3.0
* @access private
*/
private function _font_optm()
{
if (!$this->_conf_css_font_display || !$this->_ggfonts_urls) {
return;
}
Debug2::debug2('[Optm] google fonts optm ', $this->_ggfonts_urls);
foreach ($this->_ggfonts_urls as $v) {
if (strpos($v, 'display=')) {
continue;
}
$this->html_head = str_replace($v, $v . '&display=swap', $this->html_head);
$this->html_foot = str_replace($v, $v . '&display=swap', $this->html_foot);
$this->content = str_replace($v, $v . '&display=swap', $this->content);
}
}
/**
* Prefetch DNS
*
* @since 1.7.1
* @access private
*/
private function _dns_prefetch_init()
{
// Widely enable link DNS prefetch
if (defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_DNS_PREFETCH_CTRL)) {
@header('X-DNS-Prefetch-Control: on');
}
$this->dns_prefetch = $this->conf(self::O_OPTM_DNS_PREFETCH);
if (!$this->dns_prefetch) {
return;
}
if (function_exists('wp_resource_hints')) {
add_filter('wp_resource_hints', array($this, 'dns_prefetch_filter'), 10, 2);
} else {
add_action('litespeed_optm', array($this, 'dns_prefetch_output'));
}
}
/**
* Preconnect init
*
* @since 5.6.1
*/
private function _dns_preconnect_init()
{
$this->dns_preconnect = $this->conf(self::O_OPTM_DNS_PRECONNECT);
if ($this->dns_preconnect) {
add_action('litespeed_optm', array($this, 'dns_preconnect_output'));
}
}
/**
* Prefetch DNS hook for WP
*
* @since 1.7.1
* @access public
*/
public function dns_prefetch_filter($urls, $relation_type)
{
if ($relation_type !== 'dns-prefetch') {
return $urls;
}
foreach ($this->dns_prefetch as $v) {
if ($v) {
$urls[] = $v;
}
}
return $urls;
}
/**
* Prefetch DNS
*
* @since 1.7.1
* @access public
*/
public function dns_prefetch_output()
{
foreach ($this->dns_prefetch as $v) {
if ($v) {
$this->html_head .= '<link rel="dns-prefetch" href="' . Str::trim_quotes($v) . '" />';
}
}
}
/**
* Preconnect
*
* @since 5.6.1
* @access public
*/
public function dns_preconnect_output()
{
foreach ($this->dns_preconnect as $v) {
if ($v) {
$this->html_head .= '<link rel="preconnect" href="' . Str::trim_quotes($v) . '" />';
}
}
}
/**
* Run minify with src queue list
*
* @since 1.2.2
* @access private
*/
private function _src_queue_handler($src_list, $html_list, $file_type = 'css')
{
$html_list_ori = $html_list;
$can_webp = $this->cls('Media')->webp_support();
$tag = $file_type == 'css' ? 'link' : 'script';
foreach ($src_list as $key => $src_info) {
// Minify inline CSS/JS
if (!empty($src_info['inl'])) {
if ($file_type == 'css') {
$code = Optimizer::minify_css($src_info['src']);
$can_webp && ($code = $this->cls('Media')->replace_background_webp($code));
$snippet = str_replace($src_info['src'], $code, $html_list[$key]);
} else {
// Inline defer JS
if ($this->cfg_js_defer) {
$attrs = !empty($src_info['attrs']) ? $src_info['attrs'] : '';
$snippet = $this->_js_inline_defer($src_info['src'], $attrs) ?: $html_list[$key];
} else {
$code = Optimizer::minify_js($src_info['src']);
$snippet = str_replace($src_info['src'], $code, $html_list[$key]);
}
}
}
// CSS/JS files
else {
$url = $this->_build_single_hash_url($src_info['src'], $file_type);
if ($url) {
$snippet = str_replace($src_info['src'], $url, $html_list[$key]);
}
// Handle css async load
if ($file_type == 'css' && $this->cfg_css_async) {
$snippet = $this->_async_css($snippet);
}
// Handle js defer
if ($file_type === 'js' && $this->cfg_js_defer) {
$snippet = $this->_js_defer($snippet, $src_info['src']) ?: $snippet;
}
}
$snippet = str_replace("<$tag ", '<' . $tag . ' data-optimized="1" ', $snippet);
$html_list[$key] = $snippet;
}
$this->content = str_replace($html_list_ori, $html_list, $this->content);
}
/**
* Build a single URL mapped filename (This will not save in DB)
* @since 4.0
*/
private function _build_single_hash_url($src, $file_type = 'css')
{
$content = $this->__optimizer->load_file($src, $file_type);
$is_min = $this->__optimizer->is_min($src);
$content = $this->__optimizer->optm_snippet($content, $file_type, !$is_min, $src);
$filepath_prefix = $this->_build_filepath_prefix($file_type);
// Save to file
$filename = $filepath_prefix . md5($this->remove_query_strings($src)) . '.' . $file_type;
$static_file = LITESPEED_STATIC_DIR . $filename;
File::save($static_file, $content, true);
// QS is required as $src may contains version info
$qs_hash = substr(md5($src), -5);
return LITESPEED_STATIC_URL . "$filename?ver=$qs_hash";
}
/**
* Generate full URL path with hash for a list of src
*
* @since 1.2.2
* @access private
*/
private function _build_hash_url($src_list, $file_type = 'css')
{
// $url_sensitive = $this->conf( self::O_OPTM_CSS_UNIQUE ) && $file_type == 'css'; // If need to keep unique CSS per URI
// Replace preserved ESI (before generating hash)
if ($file_type == 'js') {
foreach ($src_list as $k => $v) {
if (empty($v['inl'])) {
continue;
}
$src_list[$k]['src'] = $this->_preserve_esi($v['src']);
}
}
$minify = $file_type === 'css' ? $this->cfg_css_min : $this->cfg_js_min;
$filename_info = $this->__optimizer->serve($this->_request_url, $file_type, $minify, $src_list);
if (!$filename_info) {
return false; // Failed to generate
}
list($filename, $type) = $filename_info;
// Add cache tag in case later file deleted to avoid lscache served stale non-existed files @since 4.4.1
Tag::add(Tag::TYPE_MIN . '.' . $filename);
$qs_hash = substr(md5(self::get_option(self::ITEM_TIMESTAMP_PURGE_CSS)), -5);
// As filename is already related to filecon md5, no need QS anymore
$filepath_prefix = $this->_build_filepath_prefix($type);
return LITESPEED_STATIC_URL . $filepath_prefix . $filename . '?ver=' . $qs_hash;
}
/**
* Parse js src
*
* @since 1.2.2
* @access private
*/
private function _parse_js()
{
$excludes = apply_filters('litespeed_optimize_js_excludes', $this->conf(self::O_OPTM_JS_EXC));
$combine_ext_inl = $this->conf(self::O_OPTM_JS_COMB_EXT_INL);
if (!apply_filters('litespeed_optm_js_comb_ext_inl', true)) {
Debug2::debug2('[Optm] js_comb_ext_inl bypassed via litespeed_optm_js_comb_ext_inl filter');
$combine_ext_inl = false;
}
$src_list = array();
$html_list = array();
// V7 added: (?:\r\n?|\n?) to fix replacement leaving empty new line
$content = preg_replace('#<!--.*-->(?:\r\n?|\n?)#sU', '', $this->content);
preg_match_all('#<script([^>]*)>(.*)</script>(?:\r\n?|\n?)#isU', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$attrs = empty($match[1]) ? array() : Utility::parse_attr($match[1]);
if (isset($attrs['data-optimized'])) {
continue;
}
if (!empty($attrs['data-no-optimize'])) {
continue;
}
if (!empty($attrs['data-cfasync']) && $attrs['data-cfasync'] === 'false') {
continue;
}
if (!empty($attrs['type']) && $attrs['type'] != 'text/javascript') {
continue;
}
// to avoid multiple replacement
if (in_array($match[0], $html_list)) {
continue;
}
$this_src_arr = array();
// JS files
if (!empty($attrs['src'])) {
// Exclude check
$js_excluded = Utility::str_hit_array($attrs['src'], $excludes);
$is_internal = Utility::is_internal_file($attrs['src']);
$is_file = substr($attrs['src'], 0, 5) != 'data:';
$ext_excluded = !$combine_ext_inl && !$is_internal;
if ($js_excluded || $ext_excluded || !$is_file) {
// Maybe defer
if ($this->cfg_js_defer) {
$deferred = $this->_js_defer($match[0], $attrs['src']);
if ($deferred) {
$this->content = str_replace($match[0], $deferred, $this->content);
}
}
Debug2::debug2('[Optm] _parse_js bypassed due to ' . ($js_excluded ? 'js files excluded [hit] ' . $js_excluded : 'external js'));
continue;
}
if (strpos($attrs['src'], '/localres/') !== false) {
continue;
}
if (strpos($attrs['src'], 'instant_click') !== false) {
continue;
}
$this_src_arr['src'] = $attrs['src'];
}
// Inline JS
elseif (!empty($match[2])) {
// Debug2::debug( '🌹🌹🌹 ' . $match[2] . '🌹' );
// Exclude check
$js_excluded = Utility::str_hit_array($match[2], $excludes);
if ($js_excluded || !$combine_ext_inl) {
// Maybe defer
if ($this->cfg_js_defer) {
$deferred = $this->_js_inline_defer($match[2], $match[1]);
if ($deferred) {
$this->content = str_replace($match[0], $deferred, $this->content);
}
}
Debug2::debug2('[Optm] _parse_js bypassed due to ' . ($js_excluded ? 'js excluded [hit] ' . $js_excluded : 'inline js'));
continue;
}
$this_src_arr['inl'] = true;
$this_src_arr['src'] = $match[2];
if ($match[1]) {
$this_src_arr['attrs'] = $match[1];
}
} else {
// Compatibility to those who changed src to data-src already
Debug2::debug2('[Optm] No JS src or inline JS content');
continue;
}
$src_list[] = $this_src_arr;
$html_list[] = $match[0];
}
return array($src_list, $html_list);
}
/**
* Inline JS defer
*
* @since 3.0
* @access private
*/
private function _js_inline_defer($con, $attrs = false, $minified = false)
{
if (strpos($attrs, 'data-no-defer') !== false) {
Debug2::debug2('[Optm] bypass: attr api data-no-defer');
return false;
}
$hit = Utility::str_hit_array($con, $this->cfg_js_defer_exc);
if ($hit) {
Debug2::debug2('[Optm] inline js defer excluded [setting] ' . $hit);
return false;
}
$con = trim($con);
// Minify JS first
if (!$minified) {
// && $this->cfg_js_defer !== 2
$con = Optimizer::minify_js($con);
}
if (!$con) {
return false;
}
// Check if the content contains ESI nonce or not
$con = $this->_preserve_esi($con);
if ($this->cfg_js_defer === 2) {
// Drop type attribute from $attrs
if (strpos($attrs, ' type=') !== false) {
$attrs = preg_replace('# type=([\'"])([^\1]+)\1#isU', '', $attrs);
}
// Replace DOMContentLoaded
$con = str_replace('DOMContentLoaded', 'DOMContentLiteSpeedLoaded', $con);
return '<script' . $attrs . ' type="litespeed/javascript">' . $con . '</script>';
// return '<script' . $attrs . ' type="litespeed/javascript" src="data:text/javascript;base64,' . base64_encode( $con ) . '"></script>';
// return '<script' . $attrs . ' type="litespeed/javascript">' . $con . '</script>';
}
return '<script' . $attrs . ' src="data:text/javascript;base64,' . base64_encode($con) . '" defer></script>';
}
/**
* Replace ESI to JS inline var (mainly used to avoid nonce timeout)
*
* @since 3.5.1
*/
private function _preserve_esi($con)
{
$esi_placeholder_list = $this->cls('ESI')->contain_preserve_esi($con);
if (!$esi_placeholder_list) {
return $con;
}
foreach ($esi_placeholder_list as $esi_placeholder) {
$js_var = '__litespeed_var_' . self::$_var_i++ . '__';
$con = str_replace($esi_placeholder, $js_var, $con);
$this->_var_preserve_js[] = $js_var . '=' . $esi_placeholder;
}
return $con;
}
/**
* Parse css src and remove to-be-removed css
*
* @since 1.2.2
* @access private
* @return array All the src & related raw html list
*/
private function _parse_css()
{
$excludes = apply_filters('litespeed_optimize_css_excludes', $this->conf(self::O_OPTM_CSS_EXC));
$ucss_file_exc_inline = apply_filters('litespeed_optimize_ucss_file_exc_inline', $this->conf(self::O_OPTM_UCSS_FILE_EXC_INLINE));
$combine_ext_inl = $this->conf(self::O_OPTM_CSS_COMB_EXT_INL);
if (!apply_filters('litespeed_optm_css_comb_ext_inl', true)) {
Debug2::debug2('[Optm] css_comb_ext_inl bypassed via litespeed_optm_css_comb_ext_inl filter');
$combine_ext_inl = false;
}
$css_to_be_removed = apply_filters('litespeed_optm_css_to_be_removed', array());
$src_list = array();
$html_list = array();
// $dom = new \PHPHtmlParser\Dom;
// $dom->load( $content );return $val;
// $items = $dom->find( 'link' );
// V7 added: (?:\r\n?|\n?) to fix replacement leaving empty new line
$content = preg_replace(
array('#<!--.*-->(?:\r\n?|\n?)#sU', '#<script([^>]*)>.*</script>(?:\r\n?|\n?)#isU', '#<noscript([^>]*)>.*</noscript>(?:\r\n?|\n?)#isU'),
'',
$this->content
);
preg_match_all('#<link ([^>]+)/?>|<style([^>]*)>([^<]+)</style>(?:\r\n?|\n?)#isU', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
// to avoid multiple replacement
if (in_array($match[0], $html_list)) {
continue;
}
if ($exclude = Utility::str_hit_array($match[0], $excludes)) {
Debug2::debug2('[Optm] _parse_css bypassed exclude ' . $exclude);
continue;
}
$this_src_arr = array();
if (strpos($match[0], '<link') === 0) {
$attrs = Utility::parse_attr($match[1]);
if (empty($attrs['rel']) || $attrs['rel'] !== 'stylesheet') {
continue;
}
if (empty($attrs['href'])) {
continue;
}
// Check if need to remove this css
if (Utility::str_hit_array($attrs['href'], $css_to_be_removed)) {
Debug2::debug('[Optm] rm css snippet ' . $attrs['href']);
// Delete this css snippet from orig html
$this->content = str_replace($match[0], '', $this->content);
continue;
}
// Check if need to inline this css file
if ($this->conf(self::O_OPTM_UCSS) && Utility::str_hit_array($attrs['href'], $ucss_file_exc_inline)) {
Debug2::debug('[Optm] ucss_file_exc_inline hit ' . $attrs['href']);
// Replace this css to inline from orig html
$inline_script = '<style>' . $this->__optimizer->load_file($attrs['href']) . '</style>';
$this->content = str_replace($match[0], $inline_script, $this->content);
continue;
}
// Check Google fonts hit
if (strpos($attrs['href'], 'fonts.googleapis.com') !== false) {
/**
* For async gg fonts, will add webfont into head, hence remove it from buffer and store the matches to use later
* @since 2.7.3
* @since 3.0 For font display optm, need to parse google fonts URL too
*/
if (!in_array($attrs['href'], $this->_ggfonts_urls)) {
$this->_ggfonts_urls[] = $attrs['href'];
}
if ($this->cfg_ggfonts_rm || $this->cfg_ggfonts_async) {
Debug2::debug('[Optm] rm css snippet [Google fonts] ' . $attrs['href']);
$this->content = str_replace($match[0], '', $this->content);
continue;
}
}
if (isset($attrs['data-optimized'])) {
// $this_src_arr[ 'exc' ] = true;
continue;
} elseif (!empty($attrs['data-no-optimize'])) {
// $this_src_arr[ 'exc' ] = true;
continue;
}
$is_internal = Utility::is_internal_file($attrs['href']);
$ext_excluded = !$combine_ext_inl && !$is_internal;
if ($ext_excluded) {
Debug2::debug2('[Optm] Bypassed due to external link');
// Maybe defer
if ($this->cfg_css_async) {
$snippet = $this->_async_css($match[0]);
if ($snippet != $match[0]) {
$this->content = str_replace($match[0], $snippet, $this->content);
}
}
continue;
}
if (!empty($attrs['media']) && $attrs['media'] !== 'all') {
$this_src_arr['media'] = $attrs['media'];
}
$this_src_arr['src'] = $attrs['href'];
} else {
// Inline style
if (!$combine_ext_inl) {
Debug2::debug2('[Optm] Bypassed due to inline');
continue;
}
$attrs = Utility::parse_attr($match[2]);
if (!empty($attrs['data-no-optimize'])) {
continue;
}
if (!empty($attrs['media']) && $attrs['media'] !== 'all') {
$this_src_arr['media'] = $attrs['media'];
}
$this_src_arr['inl'] = true;
$this_src_arr['src'] = $match[3];
}
$src_list[] = $this_src_arr;
$html_list[] = $match[0];
}
return array($src_list, $html_list);
}
/**
* Replace css to async loaded css
*
* @since 1.3
* @access private
*/
private function _async_css_list($html_list, $src_list)
{
foreach ($html_list as $k => $ori) {
if (!empty($src_list[$k]['inl'])) {
continue;
}
$html_list[$k] = $this->_async_css($ori);
}
return $html_list;
}
/**
* Async CSS snippet
* @since 3.5
*/
private function _async_css($ori)
{
if (strpos($ori, 'data-asynced') !== false) {
Debug2::debug2('[Optm] bypass: attr data-asynced exist');
return $ori;
}
if (strpos($ori, 'data-no-async') !== false) {
Debug2::debug2('[Optm] bypass: attr api data-no-async');
return $ori;
}
// async replacement
$v = str_replace('stylesheet', 'preload', $ori);
$v = str_replace('<link', '<link data-asynced="1" as="style" onload="this.onload=null;this.rel=\'stylesheet\'" ', $v);
// Append to noscript content
if (!defined('LITESPEED_GUEST_OPTM') && !$this->conf(self::O_OPTM_NOSCRIPT_RM)) {
$v .= '<noscript>' . preg_replace('/ id=\'[\w-]+\' /U', ' ', $ori) . '</noscript>';
}
return $v;
}
/**
* Defer JS snippet
*
* @since 3.5
*/
private function _js_defer($ori, $src)
{
if (strpos($ori, ' async') !== false) {
$ori = preg_replace('# async(?:=([\'"])(?:[^\1]+)\1)?#isU', '', $ori);
}
if (strpos($ori, 'defer') !== false) {
return false;
}
if (strpos($ori, 'data-deferred') !== false) {
Debug2::debug2('[Optm] bypass: attr data-deferred exist');
return false;
}
if (strpos($ori, 'data-no-defer') !== false) {
Debug2::debug2('[Optm] bypass: attr api data-no-defer');
return false;
}
/**
* Exclude JS from setting
* @since 1.5
*/
if (Utility::str_hit_array($src, $this->cfg_js_defer_exc)) {
Debug2::debug('[Optm] js defer exclude ' . $src);
return false;
}
if ($this->cfg_js_defer === 2 || Utility::str_hit_array($src, $this->cfg_js_delay_inc)) {
if (strpos($ori, ' type=') !== false) {
$ori = preg_replace('# type=([\'"])([^\1]+)\1#isU', '', $ori);
}
return str_replace(' src=', ' type="litespeed/javascript" data-src=', $ori);
}
return str_replace('></script>', ' defer data-deferred="1"></script>', $ori);
}
/**
* Delay JS for included setting
*
* @since 5.6
*/
private function _js_delay($ori, $src)
{
if (strpos($ori, ' async') !== false) {
$ori = str_replace(' async', '', $ori);
}
if (strpos($ori, 'defer') !== false) {
return false;
}
if (strpos($ori, 'data-deferred') !== false) {
Debug2::debug2('[Optm] bypass: attr data-deferred exist');
return false;
}
if (strpos($ori, 'data-no-defer') !== false) {
Debug2::debug2('[Optm] bypass: attr api data-no-defer');
return false;
}
if (!Utility::str_hit_array($src, $this->cfg_js_delay_inc)) {
return;
}
if (strpos($ori, ' type=') !== false) {
$ori = preg_replace('# type=([\'"])([^\1]+)\1#isU', '', $ori);
}
return str_replace(' src=', ' type="litespeed/javascript" data-src=', $ori);
}
}
optimizer.cls.php 0000644 00000022625 15153741267 0010101 0 ustar 00 <?php
/**
* The optimize4 class.
*
* @since 1.9
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Optimizer extends Root
{
private $_conf_css_font_display;
/**
* Init optimizer
*
* @since 1.9
*/
public function __construct()
{
$this->_conf_css_font_display = $this->conf(Base::O_OPTM_CSS_FONT_DISPLAY);
}
/**
* Run HTML minify process and return final content
*
* @since 1.9
* @access public
*/
public function html_min($content, $force_inline_minify = false)
{
if (!apply_filters('litespeed_html_min', true)) {
Debug2::debug2('[Optmer] html_min bypassed via litespeed_html_min filter');
return $content;
}
$options = array();
if ($force_inline_minify) {
$options['jsMinifier'] = __CLASS__ . '::minify_js';
}
$skip_comments = $this->conf(Base::O_OPTM_HTML_SKIP_COMMENTS);
if ($skip_comments) {
$options['skipComments'] = $skip_comments;
}
/**
* Added exception capture when minify
* @since 2.2.3
*/
try {
$obj = new Lib\HTML_MIN($content, $options);
$content_final = $obj->process();
// check if content from minification is empty
if ($content_final == '') {
Debug2::debug('Failed to minify HTML: HTML minification resulted in empty HTML');
return $content;
}
if (!defined('LSCACHE_ESI_SILENCE')) {
$content_final .= "\n" . '<!-- Page optimized by LiteSpeed Cache @' . date('Y-m-d H:i:s', time() + LITESPEED_TIME_OFFSET) . ' -->';
}
return $content_final;
} catch (\Exception $e) {
Debug2::debug('******[Optmer] html_min failed: ' . $e->getMessage());
error_log('****** LiteSpeed Optimizer html_min failed: ' . $e->getMessage());
return $content;
}
}
/**
* Run minify process and save content
*
* @since 1.9
* @access public
*/
public function serve($request_url, $file_type, $minify, $src_list)
{
// Try Unique CSS
if ($file_type == 'css') {
$content = false;
if (defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_OPTM_UCSS)) {
$filename = $this->cls('UCSS')->load($request_url);
if ($filename) {
return array($filename, 'ucss');
}
}
}
// Before generated, don't know the contented hash filename yet, so used url hash as tmp filename
$file_path_prefix = $this->_build_filepath_prefix($file_type);
$url_tag = $request_url;
$url_tag_for_file = md5($request_url);
if (is_404()) {
$url_tag_for_file = $url_tag = '404';
} elseif ($file_type == 'css' && apply_filters('litespeed_ucss_per_pagetype', false)) {
$url_tag_for_file = $url_tag = Utility::page_type();
}
$static_file = LITESPEED_STATIC_DIR . $file_path_prefix . $url_tag_for_file . '.' . $file_type;
// Create tmp file to avoid conflict
$tmp_static_file = $static_file . '.tmp';
if (file_exists($tmp_static_file) && time() - filemtime($tmp_static_file) <= 600) {
// some other request is generating
return false;
}
// File::save( $tmp_static_file, '/* ' . ( is_404() ? '404' : $request_url ) . ' */', true ); // Can't use this bcos this will get filecon md5 changed
File::save($tmp_static_file, '', true);
// Load content
$real_files = array();
foreach ($src_list as $src_info) {
$is_min = false;
if (!empty($src_info['inl'])) {
// Load inline
$content = $src_info['src'];
} else {
// Load file
$content = $this->load_file($src_info['src'], $file_type);
if (!$content) {
continue;
}
$is_min = $this->is_min($src_info['src']);
}
$content = $this->optm_snippet($content, $file_type, $minify && !$is_min, $src_info['src'], !empty($src_info['media']) ? $src_info['media'] : false);
// Write to file
File::save($tmp_static_file, $content, true, true);
}
// if CSS - run the minification on the saved file.
// Will move imports to the top of file and remove extra spaces.
if ($file_type == 'css') {
$obj = new Lib\CSS_JS_MIN\Minify\CSS();
$file_content_combined = $obj->moveImportsToTop(File::read($tmp_static_file));
File::save($tmp_static_file, $file_content_combined);
}
// validate md5
$filecon_md5 = md5_file($tmp_static_file);
$final_file_path = $file_path_prefix . $filecon_md5 . '.' . $file_type;
$realfile = LITESPEED_STATIC_DIR . $final_file_path;
if (!file_exists($realfile)) {
rename($tmp_static_file, $realfile);
Debug2::debug2('[Optmer] Saved static file [path] ' . $realfile);
} else {
unlink($tmp_static_file);
}
$vary = $this->cls('Vary')->finalize_full_varies();
Debug2::debug2("[Optmer] Save URL to file for [file_type] $file_type [file] $filecon_md5 [vary] $vary ");
$this->cls('Data')->save_url($url_tag, $vary, $file_type, $filecon_md5, dirname($realfile));
return array($filecon_md5 . '.' . $file_type, $file_type);
}
/**
* Load a single file
* @since 4.0
*/
public function optm_snippet($content, $file_type, $minify, $src, $media = false)
{
// CSS related features
if ($file_type == 'css') {
// Font optimize
if ($this->_conf_css_font_display) {
$content = preg_replace('#(@font\-face\s*\{)#isU', '${1}font-display:swap;', $content);
}
$content = preg_replace('/@charset[^;]+;\\s*/', '', $content);
if ($media) {
$content = '@media ' . $media . '{' . $content . "\n}";
}
if ($minify) {
$content = self::minify_css($content);
}
$content = $this->cls('CDN')->finalize($content);
if ((defined('LITESPEED_GUEST_OPTM') || $this->conf(Base::O_IMG_OPTM_WEBP)) && $this->cls('Media')->webp_support()) {
$content = $this->cls('Media')->replace_background_webp($content);
}
} else {
if ($minify) {
$content = self::minify_js($content);
} else {
$content = $this->_null_minifier($content);
}
$content .= "\n;";
}
// Add filter
$content = apply_filters('litespeed_optm_cssjs', $content, $file_type, $src);
return $content;
}
/**
* Load remote resource from cache if existed
*
* @since 4.7
*/
private function load_cached_file($url, $file_type)
{
$file_path_prefix = $this->_build_filepath_prefix($file_type);
$folder_name = LITESPEED_STATIC_DIR . $file_path_prefix;
$to_be_deleted_folder = $folder_name . date('Ymd', strtotime('-2 days'));
if (file_exists($to_be_deleted_folder)) {
Debug2::debug('[Optimizer] ❌ Clearing folder [name] ' . $to_be_deleted_folder);
File::rrmdir($to_be_deleted_folder);
}
$today_file = $folder_name . date('Ymd') . '/' . md5($url);
if (file_exists($today_file)) {
return File::read($today_file);
}
// Write file
$res = wp_safe_remote_get($url);
$res_code = wp_remote_retrieve_response_code($res);
if (is_wp_error($res) || $res_code != 200) {
Debug2::debug2('[Optimizer] ❌ Load Remote error [code] ' . $res_code);
return false;
}
$con = wp_remote_retrieve_body($res);
if (!$con) {
return false;
}
Debug2::debug('[Optimizer] ✅ Save remote file to cache [name] ' . $today_file);
File::save($today_file, $con, true);
return $con;
}
/**
* Load remote/local resource
*
* @since 3.5
*/
public function load_file($src, $file_type = 'css')
{
$real_file = Utility::is_internal_file($src);
$postfix = pathinfo(parse_url($src, PHP_URL_PATH), PATHINFO_EXTENSION);
if (!$real_file || $postfix != $file_type) {
Debug2::debug2('[CSS] Load Remote [' . $file_type . '] ' . $src);
$this_url = substr($src, 0, 2) == '//' ? set_url_scheme($src) : $src;
$con = $this->load_cached_file($this_url, $file_type);
if ($file_type == 'css') {
$dirname = dirname($this_url) . '/';
$con = Lib\UriRewriter::prepend($con, $dirname);
}
} else {
Debug2::debug2('[CSS] Load local [' . $file_type . '] ' . $real_file[0]);
$con = File::read($real_file[0]);
if ($file_type == 'css') {
$dirname = dirname($real_file[0]);
$con = Lib\UriRewriter::rewrite($con, $dirname);
}
}
return $con;
}
/**
* Minify CSS
*
* @since 2.2.3
* @access private
*/
public static function minify_css($data)
{
try {
$obj = new Lib\CSS_JS_MIN\Minify\CSS();
$obj->add($data);
return $obj->minify();
} catch (\Exception $e) {
Debug2::debug('******[Optmer] minify_css failed: ' . $e->getMessage());
error_log('****** LiteSpeed Optimizer minify_css failed: ' . $e->getMessage());
return $data;
}
}
/**
* Minify JS
*
* Added exception capture when minify
*
* @since 2.2.3
* @access private
*/
public static function minify_js($data, $js_type = '')
{
// For inline JS optimize, need to check if it's js type
if ($js_type) {
preg_match('#type=([\'"])(.+)\g{1}#isU', $js_type, $matches);
if ($matches && $matches[2] != 'text/javascript') {
Debug2::debug('******[Optmer] minify_js bypass due to type: ' . $matches[2]);
return $data;
}
}
try {
$obj = new Lib\CSS_JS_MIN\Minify\JS();
$obj->add($data);
return $obj->minify();
} catch (\Exception $e) {
Debug2::debug('******[Optmer] minify_js failed: ' . $e->getMessage());
// error_log( '****** LiteSpeed Optimizer minify_js failed: ' . $e->getMessage() );
return $data;
}
}
/**
* Basic minifier
*
* @access private
*/
private function _null_minifier($content)
{
$content = str_replace("\r\n", "\n", $content);
return trim($content);
}
/**
* Check if the file is already min file
*
* @since 1.9
*/
public function is_min($filename)
{
$basename = basename($filename);
if (preg_match('/[-\.]min\.(?:[a-zA-Z]+)$/i', $basename)) {
return true;
}
return false;
}
}
placeholder.cls.php 0000644 00000034130 15153741267 0010333 0 ustar 00 <?php
/**
* The PlaceHolder class
*
* @since 3.0
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Placeholder extends Base
{
const TYPE_GENERATE = 'generate';
const TYPE_CLEAR_Q = 'clear_q';
private $_conf_placeholder_resp;
private $_conf_placeholder_resp_svg;
private $_conf_lqip;
private $_conf_lqip_qual;
private $_conf_lqip_min_w;
private $_conf_lqip_min_h;
private $_conf_placeholder_resp_color;
private $_conf_placeholder_resp_async;
private $_conf_ph_default;
private $_placeholder_resp_dict = array();
private $_ph_queue = array();
protected $_summary;
/**
* Init
*
* @since 3.0
*/
public function __construct()
{
$this->_conf_placeholder_resp = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_MEDIA_PLACEHOLDER_RESP);
$this->_conf_placeholder_resp_svg = $this->conf(self::O_MEDIA_PLACEHOLDER_RESP_SVG);
$this->_conf_lqip = !defined('LITESPEED_GUEST_OPTM') && $this->conf(self::O_MEDIA_LQIP);
$this->_conf_lqip_qual = $this->conf(self::O_MEDIA_LQIP_QUAL);
$this->_conf_lqip_min_w = $this->conf(self::O_MEDIA_LQIP_MIN_W);
$this->_conf_lqip_min_h = $this->conf(self::O_MEDIA_LQIP_MIN_H);
$this->_conf_placeholder_resp_async = $this->conf(self::O_MEDIA_PLACEHOLDER_RESP_ASYNC);
$this->_conf_placeholder_resp_color = $this->conf(self::O_MEDIA_PLACEHOLDER_RESP_COLOR);
$this->_conf_ph_default = $this->conf(self::O_MEDIA_LAZY_PLACEHOLDER) ?: LITESPEED_PLACEHOLDER;
$this->_summary = self::get_summary();
}
/**
* Init Placeholder
*/
public function init()
{
Debug2::debug2('[LQIP] init');
add_action('litspeed_after_admin_init', array($this, 'after_admin_init'));
}
/**
* Display column in Media
*
* @since 3.0
* @access public
*/
public function after_admin_init()
{
if ($this->_conf_lqip) {
add_filter('manage_media_columns', array($this, 'media_row_title'));
add_filter('manage_media_custom_column', array($this, 'media_row_actions'), 10, 2);
add_action('litespeed_media_row_lqip', array($this, 'media_row_con'));
}
}
/**
* Media Admin Menu -> LQIP col
*
* @since 3.0
* @access public
*/
public function media_row_title($posts_columns)
{
$posts_columns['lqip'] = __('LQIP', 'litespeed-cache');
return $posts_columns;
}
/**
* Media Admin Menu -> LQIP Column
*
* @since 3.0
* @access public
*/
public function media_row_actions($column_name, $post_id)
{
if ($column_name !== 'lqip') {
return;
}
do_action('litespeed_media_row_lqip', $post_id);
}
/**
* Display LQIP column
*
* @since 3.0
* @access public
*/
public function media_row_con($post_id)
{
$meta_value = wp_get_attachment_metadata($post_id);
if (empty($meta_value['file'])) {
return;
}
$total_files = 0;
// List all sizes
$all_sizes = array($meta_value['file']);
$size_path = pathinfo($meta_value['file'], PATHINFO_DIRNAME) . '/';
foreach ($meta_value['sizes'] as $v) {
$all_sizes[] = $size_path . $v['file'];
}
foreach ($all_sizes as $short_path) {
$lqip_folder = LITESPEED_STATIC_DIR . '/lqip/' . $short_path;
if (is_dir($lqip_folder)) {
Debug2::debug('[LQIP] Found folder: ' . $short_path);
// List all files
foreach (scandir($lqip_folder) as $v) {
if ($v == '.' || $v == '..') {
continue;
}
if ($total_files == 0) {
echo '<div class="litespeed-media-lqip"><img src="' .
Str::trim_quotes(File::read($lqip_folder . '/' . $v)) .
'" alt="' .
sprintf(__('LQIP image preview for size %s', 'litespeed-cache'), $v) .
'"></div>';
}
echo '<div class="litespeed-media-size"><a href="' . Str::trim_quotes(File::read($lqip_folder . '/' . $v)) . '" target="_blank">' . $v . '</a></div>';
$total_files++;
}
}
}
if ($total_files == 0) {
echo '—';
}
}
/**
* Replace image with placeholder
*
* @since 3.0
* @access public
*/
public function replace($html, $src, $size)
{
// Check if need to enable responsive placeholder or not
$this_placeholder = $this->_placeholder($src, $size) ?: $this->_conf_ph_default;
$additional_attr = '';
if ($this->_conf_lqip && $this_placeholder != $this->_conf_ph_default) {
Debug2::debug2('[LQIP] Use resp LQIP [size] ' . $size);
$additional_attr = ' data-placeholder-resp="' . Str::trim_quotes($size) . '"';
}
$snippet = defined('LITESPEED_GUEST_OPTM') || $this->conf(self::O_OPTM_NOSCRIPT_RM) ? '' : '<noscript>' . $html . '</noscript>';
$html = str_replace(array(' src=', ' srcset=', ' sizes='), array(' data-src=', ' data-srcset=', ' data-sizes='), $html);
$html = str_replace('<img ', '<img data-lazyloaded="1"' . $additional_attr . ' src="' . Str::trim_quotes($this_placeholder) . '" ', $html);
$snippet = $html . $snippet;
return $snippet;
}
/**
* Generate responsive placeholder
*
* @since 2.5.1
* @access private
*/
private function _placeholder($src, $size)
{
// Low Quality Image Placeholders
if (!$size) {
Debug2::debug2('[LQIP] no size ' . $src);
return false;
}
if (!$this->_conf_placeholder_resp) {
return false;
}
// If use local generator
if (!$this->_conf_lqip || !$this->_lqip_size_check($size)) {
return $this->_generate_placeholder_locally($size);
}
Debug2::debug2('[LQIP] Resp LQIP process [src] ' . $src . ' [size] ' . $size);
$arr_key = $size . ' ' . $src;
// Check if its already in dict or not
if (!empty($this->_placeholder_resp_dict[$arr_key])) {
Debug2::debug2('[LQIP] already in dict');
return $this->_placeholder_resp_dict[$arr_key];
}
// Need to generate the responsive placeholder
$placeholder_realpath = $this->_placeholder_realpath($src, $size); // todo: give offload API
if (file_exists($placeholder_realpath)) {
Debug2::debug2('[LQIP] file exists');
$this->_placeholder_resp_dict[$arr_key] = File::read($placeholder_realpath);
return $this->_placeholder_resp_dict[$arr_key];
}
// Add to cron queue
// Prevent repeated requests
if (in_array($arr_key, $this->_ph_queue)) {
Debug2::debug2('[LQIP] file bypass generating due to in queue');
return $this->_generate_placeholder_locally($size);
}
if ($hit = Utility::str_hit_array($src, $this->conf(self::O_MEDIA_LQIP_EXC))) {
Debug2::debug2('[LQIP] file bypass generating due to exclude setting [hit] ' . $hit);
return $this->_generate_placeholder_locally($size);
}
$this->_ph_queue[] = $arr_key;
// Send request to generate placeholder
if (!$this->_conf_placeholder_resp_async) {
// If requested recently, bypass
if ($this->_summary && !empty($this->_summary['curr_request']) && time() - $this->_summary['curr_request'] < 300) {
Debug2::debug2('[LQIP] file bypass generating due to interval limit');
return false;
}
// Generate immediately
$this->_placeholder_resp_dict[$arr_key] = $this->_generate_placeholder($arr_key);
return $this->_placeholder_resp_dict[$arr_key];
}
// Prepare default svg placeholder as tmp placeholder
$tmp_placeholder = $this->_generate_placeholder_locally($size);
// Store it to prepare for cron
$queue = $this->load_queue('lqip');
if (in_array($arr_key, $queue)) {
Debug2::debug2('[LQIP] already in queue');
return $tmp_placeholder;
}
if (count($queue) > 500) {
Debug2::debug2('[LQIP] queue is full');
return $tmp_placeholder;
}
$queue[] = $arr_key;
$this->save_queue('lqip', $queue);
Debug2::debug('[LQIP] Added placeholder queue');
return $tmp_placeholder;
}
/**
* Generate realpath of placeholder file
*
* @since 2.5.1
* @access private
*/
private function _placeholder_realpath($src, $size)
{
// Use LQIP Cloud generator, each image placeholder will be separately stored
// Compatibility with WebP and AVIF
$src = Utility::drop_webp($src);
$filepath_prefix = $this->_build_filepath_prefix('lqip');
// External images will use cache folder directly
$domain = parse_url($src, PHP_URL_HOST);
if ($domain && !Utility::internal($domain)) {
// todo: need to improve `util:internal()` to include `CDN::internal()`
$md5 = md5($src);
return LITESPEED_STATIC_DIR . $filepath_prefix . 'remote/' . substr($md5, 0, 1) . '/' . substr($md5, 1, 1) . '/' . $md5 . '.' . $size;
}
// Drop domain
$short_path = Utility::att_short_path($src);
return LITESPEED_STATIC_DIR . $filepath_prefix . $short_path . '/' . $size;
}
/**
* Cron placeholder generation
*
* @since 2.5.1
* @access public
*/
public static function cron($continue = false)
{
$_instance = self::cls();
$queue = $_instance->load_queue('lqip');
if (empty($queue)) {
return;
}
// For cron, need to check request interval too
if (!$continue) {
if (!empty($_instance->_summary['curr_request']) && time() - $_instance->_summary['curr_request'] < 300) {
Debug2::debug('[LQIP] Last request not done');
return;
}
}
foreach ($queue as $v) {
Debug2::debug('[LQIP] cron job [size] ' . $v);
$res = $_instance->_generate_placeholder($v, true);
// Exit queue if out of quota
if ($res === 'out_of_quota') {
return;
}
// only request first one
if (!$continue) {
return;
}
}
}
/**
* Generate placeholder locally
*
* @since 3.0
* @access private
*/
private function _generate_placeholder_locally($size)
{
Debug2::debug2('[LQIP] _generate_placeholder local [size] ' . $size);
$size = explode('x', $size);
$svg = str_replace(array('{width}', '{height}', '{color}'), array($size[0], $size[1], $this->_conf_placeholder_resp_color), $this->_conf_placeholder_resp_svg);
return 'data:image/svg+xml;base64,' . base64_encode($svg);
}
/**
* Send to LiteSpeed API to generate placeholder
*
* @since 2.5.1
* @access private
*/
private function _generate_placeholder($raw_size_and_src, $from_cron = false)
{
// Parse containing size and src info
$size_and_src = explode(' ', $raw_size_and_src, 2);
$size = $size_and_src[0];
if (empty($size_and_src[1])) {
$this->_popup_and_save($raw_size_and_src);
Debug2::debug('[LQIP] ❌ No src [raw] ' . $raw_size_and_src);
return $this->_generate_placeholder_locally($size);
}
$src = $size_and_src[1];
$file = $this->_placeholder_realpath($src, $size);
// Local generate SVG to serve ( Repeatedly doing this here to remove stored cron queue in case the setting _conf_lqip is changed )
if (!$this->_conf_lqip || !$this->_lqip_size_check($size)) {
$data = $this->_generate_placeholder_locally($size);
} else {
$err = false;
$allowance = Cloud::cls()->allowance(Cloud::SVC_LQIP, $err);
if (!$allowance) {
Debug2::debug('[LQIP] ❌ No credit: ' . $err);
$err && Admin_Display::error(Error::msg($err));
if ($from_cron) {
return 'out_of_quota';
}
return $this->_generate_placeholder_locally($size);
}
// Generate LQIP
list($width, $height) = explode('x', $size);
$req_data = array(
'width' => $width,
'height' => $height,
'url' => Utility::drop_webp($src),
'quality' => $this->_conf_lqip_qual,
);
// CHeck if the image is 404 first
if (File::is_404($req_data['url'])) {
$this->_popup_and_save($raw_size_and_src, true);
$this->_append_exc($src);
Debug2::debug('[LQIP] 404 before request [src] ' . $req_data['url']);
return $this->_generate_placeholder_locally($size);
}
// Update request status
$this->_summary['curr_request'] = time();
self::save_summary();
$json = Cloud::post(Cloud::SVC_LQIP, $req_data, 120);
if (!is_array($json)) {
return $this->_generate_placeholder_locally($size);
}
if (empty($json['lqip']) || strpos($json['lqip'], 'data:image/svg+xml') !== 0) {
// image error, pop up the current queue
$this->_popup_and_save($raw_size_and_src, true);
$this->_append_exc($src);
Debug2::debug('[LQIP] wrong response format', $json);
return $this->_generate_placeholder_locally($size);
}
$data = $json['lqip'];
Debug2::debug('[LQIP] _generate_placeholder LQIP');
}
// Write to file
File::save($file, $data, true);
// Save summary data
$this->_summary['last_spent'] = time() - $this->_summary['curr_request'];
$this->_summary['last_request'] = $this->_summary['curr_request'];
$this->_summary['curr_request'] = 0;
self::save_summary();
$this->_popup_and_save($raw_size_and_src);
Debug2::debug('[LQIP] saved LQIP ' . $file);
return $data;
}
/**
* Check if the size is valid to send LQIP request or not
*
* @since 3.0
*/
private function _lqip_size_check($size)
{
$size = explode('x', $size);
if ($size[0] >= $this->_conf_lqip_min_w || $size[1] >= $this->_conf_lqip_min_h) {
return true;
}
Debug2::debug2('[LQIP] Size too small');
return false;
}
/**
* Add to LQIP exclude list
*
* @since 3.4
*/
private function _append_exc($src)
{
$val = $this->conf(self::O_MEDIA_LQIP_EXC);
$val[] = $src;
$this->cls('Conf')->update(self::O_MEDIA_LQIP_EXC, $val);
Debug2::debug('[LQIP] Appended to LQIP Excludes [URL] ' . $src);
}
/**
* Pop up the current request and save
*
* @since 3.0
*/
private function _popup_and_save($raw_size_and_src, $append_to_exc = false)
{
$queue = $this->load_queue('lqip');
if (!empty($queue) && in_array($raw_size_and_src, $queue)) {
unset($queue[array_search($raw_size_and_src, $queue)]);
}
if ($append_to_exc) {
$size_and_src = explode(' ', $raw_size_and_src, 2);
$this_src = $size_and_src[1];
// Append to lqip exc setting first
$this->_append_exc($this_src);
// Check if other queues contain this src or not
if ($queue) {
foreach ($queue as $k => $raw_size_and_src) {
$size_and_src = explode(' ', $raw_size_and_src, 2);
if (empty($size_and_src[1])) {
continue;
}
if ($size_and_src[1] == $this_src) {
unset($queue[$k]);
}
}
}
}
$this->save_queue('lqip', $queue);
}
/**
* Handle all request actions from main cls
*
* @since 2.5.1
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_GENERATE:
self::cron(true);
break;
case self::TYPE_CLEAR_Q:
$this->clear_q('lqip');
break;
default:
break;
}
Admin::redirect();
}
}
purge.cls.php 0000644 00000074764 15153741267 0007214 0 ustar 00 <?php
/**
* The plugin purge class for X-LiteSpeed-Purge
*
* @since 1.1.3
* @since 2.2 Refactored. Changed access from public to private for most func and class variables.
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Purge extends Base
{
const LOG_TAG = '🧹';
protected $_pub_purge = array();
protected $_pub_purge2 = array();
protected $_priv_purge = array();
protected $_purge_single = false;
const X_HEADER = 'X-LiteSpeed-Purge';
const X_HEADER2 = 'X-LiteSpeed-Purge2';
const DB_QUEUE = 'queue';
const DB_QUEUE2 = 'queue2';
const TYPE_PURGE_ALL = 'purge_all';
const TYPE_PURGE_ALL_LSCACHE = 'purge_all_lscache';
const TYPE_PURGE_ALL_CSSJS = 'purge_all_cssjs';
const TYPE_PURGE_ALL_LOCALRES = 'purge_all_localres';
const TYPE_PURGE_ALL_CCSS = 'purge_all_ccss';
const TYPE_PURGE_ALL_UCSS = 'purge_all_ucss';
const TYPE_PURGE_ALL_LQIP = 'purge_all_lqip';
const TYPE_PURGE_ALL_AVATAR = 'purge_all_avatar';
const TYPE_PURGE_ALL_OBJECT = 'purge_all_object';
const TYPE_PURGE_ALL_OPCACHE = 'purge_all_opcache';
const TYPE_PURGE_FRONT = 'purge_front';
const TYPE_PURGE_UCSS = 'purge_ucss';
const TYPE_PURGE_FRONTPAGE = 'purge_frontpage';
const TYPE_PURGE_PAGES = 'purge_pages';
const TYPE_PURGE_ERROR = 'purge_error';
/**
* Init hooks
*
* @since 3.0
*/
public function init()
{
// Register purge actions.
// Most used values: edit_post, save_post, delete_post, wp_trash_post, clean_post_cache, wp_update_comment_count
$purge_post_events = apply_filters('litespeed_purge_post_events', array(
'delete_post',
'wp_trash_post',
// 'clean_post_cache', // This will disable wc's not purge product when stock status not change setting
'wp_update_comment_count', // TODO: check if needed for non ESI
));
foreach ($purge_post_events as $event) {
// this will purge all related tags
add_action($event, array($this, 'purge_post'));
}
// Purge post only when status is/was publish
add_action('transition_post_status', array($this, 'purge_publish'), 10, 3);
add_action('wp_update_comment_count', array($this, 'purge_feeds'));
if ($this->conf(self::O_OPTM_UCSS)) {
add_action('edit_post', __NAMESPACE__ . '\Purge::purge_ucss');
}
}
/**
* Only purge publish related status post
*
* @since 3.0
* @access public
*/
public function purge_publish($new_status, $old_status, $post)
{
if ($new_status != 'publish' && $old_status != 'publish') {
return;
}
$this->purge_post($post->ID);
}
/**
* Handle all request actions from main cls
*
* @since 1.8
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_PURGE_ALL:
$this->_purge_all();
break;
case self::TYPE_PURGE_ALL_LSCACHE:
$this->_purge_all_lscache();
break;
case self::TYPE_PURGE_ALL_CSSJS:
$this->_purge_all_cssjs();
break;
case self::TYPE_PURGE_ALL_LOCALRES:
$this->_purge_all_localres();
break;
case self::TYPE_PURGE_ALL_CCSS:
$this->_purge_all_ccss();
break;
case self::TYPE_PURGE_ALL_UCSS:
$this->_purge_all_ucss();
break;
case self::TYPE_PURGE_ALL_LQIP:
$this->_purge_all_lqip();
break;
case self::TYPE_PURGE_ALL_AVATAR:
$this->_purge_all_avatar();
break;
case self::TYPE_PURGE_ALL_OBJECT:
$this->_purge_all_object();
break;
case self::TYPE_PURGE_ALL_OPCACHE:
$this->purge_all_opcache();
break;
case self::TYPE_PURGE_FRONT:
$this->_purge_front();
break;
case self::TYPE_PURGE_UCSS:
$this->_purge_ucss();
break;
case self::TYPE_PURGE_FRONTPAGE:
$this->_purge_frontpage();
break;
case self::TYPE_PURGE_PAGES:
$this->_purge_pages();
break;
case strpos($type, self::TYPE_PURGE_ERROR) === 0:
$this->_purge_error(substr($type, strlen(self::TYPE_PURGE_ERROR)));
break;
default:
break;
}
Admin::redirect();
}
/**
* Shortcut to purge all lscache
*
* @since 1.0.0
* @access public
*/
public static function purge_all($reason = false)
{
self::cls()->_purge_all($reason);
}
/**
* Purge all caches (lscache/op/oc)
*
* @since 2.2
* @access private
*/
private function _purge_all($reason = false)
{
// if ( defined( 'LITESPEED_CLI' ) ) {
// // Can't send, already has output, need to save and wait for next run
// self::update_option( self::DB_QUEUE, $curr_built );
// self::debug( 'CLI request, queue stored: ' . $curr_built );
// }
// else {
$this->_purge_all_lscache(true);
$this->_purge_all_cssjs(true);
$this->_purge_all_localres(true);
// $this->_purge_all_ccss( true );
// $this->_purge_all_lqip( true );
$this->_purge_all_object(true);
$this->purge_all_opcache(true);
// }
if (!is_string($reason)) {
$reason = false;
}
if ($reason) {
$reason = "( $reason )";
}
self::debug('Purge all ' . $reason, 3);
$msg = __('Purged all caches successfully.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
do_action('litespeed_purged_all');
}
/**
* Alerts LiteSpeed Web Server to purge all pages.
*
* For multisite installs, if this is called by a site admin (not network admin),
* it will only purge all posts associated with that site.
*
* @since 2.2
* @access public
*/
private function _purge_all_lscache($silence = false)
{
$this->_add('*');
// Action to run after server was notified to delete LSCache entries.
do_action('litespeed_purged_all_lscache');
if (!$silence) {
$msg = __('Notified LiteSpeed Web Server to purge all LSCache entries.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Delete all critical css
*
* @since 2.3
* @access private
*/
private function _purge_all_ccss($silence = false)
{
do_action('litespeed_purged_all_ccss');
$this->cls('CSS')->rm_cache_folder('ccss');
$this->cls('Data')->url_file_clean('ccss');
if (!$silence) {
$msg = __('Cleaned all Critical CSS files.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Delete all unique css
*
* @since 2.3
* @access private
*/
private function _purge_all_ucss($silence = false)
{
do_action('litespeed_purged_all_ucss');
$this->cls('CSS')->rm_cache_folder('ucss');
$this->cls('Data')->url_file_clean('ucss');
if (!$silence) {
$msg = __('Cleaned all Unique CSS files.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Purge one UCSS by URL
*
* @since 4.5
* @access public
*/
public static function purge_ucss($post_id_or_url)
{
self::debug('Purge a single UCSS: ' . $post_id_or_url);
// If is post_id, generate URL
if (!preg_match('/\D/', $post_id_or_url)) {
$post_id_or_url = get_permalink($post_id_or_url);
}
$post_id_or_url = untrailingslashit($post_id_or_url);
$existing_url_files = Data::cls()->mark_as_expired($post_id_or_url, true);
if ($existing_url_files) {
// Add to UCSS Q
self::cls('UCSS')->add_to_q($existing_url_files);
}
}
/**
* Delete all LQIP images
*
* @since 3.0
* @access private
*/
private function _purge_all_lqip($silence = false)
{
do_action('litespeed_purged_all_lqip');
$this->cls('Placeholder')->rm_cache_folder('lqip');
if (!$silence) {
$msg = __('Cleaned all LQIP files.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Delete all avatar images
*
* @since 3.0
* @access private
*/
private function _purge_all_avatar($silence = false)
{
do_action('litespeed_purged_all_avatar');
$this->cls('Avatar')->rm_cache_folder('avatar');
if (!$silence) {
$msg = __('Cleaned all Gravatar files.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Delete all localized JS
*
* @since 3.3
* @access private
*/
private function _purge_all_localres($silence = false)
{
do_action('litespeed_purged_all_localres');
$this->_add(Tag::TYPE_LOCALRES);
if (!$silence) {
$msg = __('Cleaned all localized resource entries.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Alerts LiteSpeed Web Server to purge pages.
*
* @since 1.2.2
* @access private
*/
private function _purge_all_cssjs($silence = false)
{
if (defined('DOING_CRON') || defined('LITESPEED_DID_send_headers')) {
self::debug('❌ Bypassed cssjs delete as header sent (lscache purge after this point will fail) or doing cron');
return;
}
$this->_purge_all_lscache($silence); // Purge CSSJS must purge lscache too to avoid 404
do_action('litespeed_purged_all_cssjs');
Optimize::update_option(Optimize::ITEM_TIMESTAMP_PURGE_CSS, time());
$this->_add(Tag::TYPE_MIN);
$this->cls('CSS')->rm_cache_folder('css');
$this->cls('CSS')->rm_cache_folder('js');
$this->cls('Data')->url_file_clean('css');
$this->cls('Data')->url_file_clean('js');
// Clear UCSS queue as it used combined CSS to generate
$this->clear_q('ucss', true);
if (!$silence) {
$msg = __('Notified LiteSpeed Web Server to purge CSS/JS entries.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
}
/**
* Purge opcode cache
*
* @since 1.8.2
* @access public
*/
public function purge_all_opcache($silence = false)
{
if (!Router::opcache_enabled()) {
self::debug('Failed to reset opcode cache due to opcache not enabled');
if (!$silence) {
$msg = __('Opcode cache is not enabled.', 'litespeed-cache');
Admin_Display::error($msg);
}
return false;
}
// Action to run after opcache purge.
do_action('litespeed_purged_all_opcache');
// Purge opcode cache
opcache_reset();
self::debug('Reset opcode cache');
if (!$silence) {
$msg = __('Reset the entire opcode cache successfully.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
return true;
}
/**
* Purge object cache
*
* @since 3.4
* @access public
*/
public static function purge_all_object($silence = true)
{
self::cls()->_purge_all_object($silence);
}
/**
* Purge object cache
*
* @since 1.8
* @access private
*/
private function _purge_all_object($silence = false)
{
if (!defined('LSCWP_OBJECT_CACHE')) {
self::debug('Failed to flush object cache due to object cache not enabled');
if (!$silence) {
$msg = __('Object cache is not enabled.', 'litespeed-cache');
Admin_Display::error($msg);
}
return false;
}
do_action('litespeed_purged_all_object');
$this->cls('Object_Cache')->flush();
self::debug('Flushed object cache');
if (!$silence) {
$msg = __('Purge all object caches successfully.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
return true;
}
/**
* Adds new public purge tags to the array of purge tags for the request.
*
* @since 1.1.3
* @access public
* @param mixed $tags Tags to add to the list.
*/
public static function add($tags, $purge2 = false)
{
self::cls()->_add($tags, $purge2);
}
/**
* Add tags to purge
*
* @since 2.2
* @access private
*/
private function _add($tags, $purge2 = false)
{
if (!is_array($tags)) {
$tags = array($tags);
}
$tags = $this->_prepend_bid($tags);
if (!array_diff($tags, $purge2 ? $this->_pub_purge2 : $this->_pub_purge)) {
return;
}
if ($purge2) {
$this->_pub_purge2 = array_merge($this->_pub_purge2, $tags);
$this->_pub_purge2 = array_unique($this->_pub_purge2);
} else {
$this->_pub_purge = array_merge($this->_pub_purge, $tags);
$this->_pub_purge = array_unique($this->_pub_purge);
}
self::debug('added ' . implode(',', $tags) . ($purge2 ? ' [Purge2]' : ''), 8);
// Send purge header immediately
$curr_built = $this->_build($purge2);
if (defined('LITESPEED_CLI')) {
// Can't send, already has output, need to save and wait for next run
self::update_option($purge2 ? self::DB_QUEUE2 : self::DB_QUEUE, $curr_built);
self::debug('CLI request, queue stored: ' . $curr_built);
} else {
@header($curr_built);
if (defined('DOING_CRON') || defined('LITESPEED_DID_send_headers') || apply_filters('litespeed_delay_purge', false)) {
self::update_option($purge2 ? self::DB_QUEUE2 : self::DB_QUEUE, $curr_built);
self::debug('Output existed, queue stored: ' . $curr_built);
}
self::debug($curr_built);
}
}
/**
* Adds new private purge tags to the array of purge tags for the request.
*
* @since 1.1.3
* @access public
* @param mixed $tags Tags to add to the list.
*/
public static function add_private($tags)
{
self::cls()->_add_private($tags);
}
/**
* Add private ESI tag to purge list
*
* @since 3.0
* @access public
*/
public static function add_private_esi($tag)
{
self::add_private(Tag::TYPE_ESI . $tag);
}
/**
* Add private all tag to purge list
*
* @since 3.0
* @access public
*/
public static function add_private_all()
{
self::add_private('*');
}
/**
* Add tags to private purge
*
* @since 2.2
* @access private
*/
private function _add_private($tags)
{
if (!is_array($tags)) {
$tags = array($tags);
}
$tags = $this->_prepend_bid($tags);
if (!array_diff($tags, $this->_priv_purge)) {
return;
}
self::debug('added [private] ' . implode(',', $tags), 3);
$this->_priv_purge = array_merge($this->_priv_purge, $tags);
$this->_priv_purge = array_unique($this->_priv_purge);
// Send purge header immediately
@header($this->_build());
}
/**
* Incorporate blog_id into purge tags for multisite
*
* @since 4.0
* @access private
* @param mixed $tags Tags to add to the list.
*/
private function _prepend_bid($tags)
{
if (in_array('*', $tags)) {
return array('*');
}
$curr_bid = is_multisite() ? get_current_blog_id() : '';
foreach ($tags as $k => $v) {
$tags[$k] = $curr_bid . '_' . $v;
}
return $tags;
}
/**
* Activate `purge related tags` for Admin QS.
*
* @since 1.1.3
* @access public
* @deprecated @7.0 Drop @v7.5
*/
public static function set_purge_related()
{
}
/**
* Activate `purge single url tag` for Admin QS.
*
* @since 1.1.3
* @access public
*/
public static function set_purge_single()
{
self::cls()->_purge_single = true;
do_action('litespeed_purged_single');
}
/**
* Purge frontend url
*
* @since 1.3
* @since 2.2 Renamed from `frontend_purge`; Access changed from public
* @access private
*/
private function _purge_front()
{
if (empty($_SERVER['HTTP_REFERER'])) {
exit('no referer');
}
$this->purge_url($_SERVER['HTTP_REFERER']);
do_action('litespeed_purged_front', $_SERVER['HTTP_REFERER']);
wp_redirect($_SERVER['HTTP_REFERER']);
exit();
}
/**
* Purge single UCSS
* @since 4.7
*/
private function _purge_ucss()
{
if (empty($_SERVER['HTTP_REFERER'])) {
exit('no referer');
}
$url_tag = empty($_GET['url_tag']) ? $_SERVER['HTTP_REFERER'] : $_GET['url_tag'];
self::debug('Purge ucss [url_tag] ' . $url_tag);
do_action('litespeed_purge_ucss', $url_tag);
$this->purge_url($_SERVER['HTTP_REFERER']);
wp_redirect($_SERVER['HTTP_REFERER']);
exit();
}
/**
* Alerts LiteSpeed Web Server to purge the front page.
*
* @since 1.0.3
* @since 2.2 Access changed from public to private, renamed from `_purge_front`
* @access private
*/
private function _purge_frontpage()
{
$this->_add(Tag::TYPE_FRONTPAGE);
if (LITESPEED_SERVER_TYPE !== 'LITESPEED_SERVER_OLS') {
$this->_add_private(Tag::TYPE_FRONTPAGE);
}
$msg = __('Notified LiteSpeed Web Server to purge the front page.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
do_action('litespeed_purged_frontpage');
}
/**
* Alerts LiteSpeed Web Server to purge pages.
*
* @since 1.0.15
* @access private
*/
private function _purge_pages()
{
$this->_add(Tag::TYPE_PAGES);
$msg = __('Notified LiteSpeed Web Server to purge all pages.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
do_action('litespeed_purged_pages');
}
/**
* Alerts LiteSpeed Web Server to purge error pages.
*
* @since 1.0.14
* @access private
*/
private function _purge_error($type = false)
{
$this->_add(Tag::TYPE_HTTP);
if (!$type || !in_array($type, array('403', '404', '500'))) {
return;
}
$this->_add(Tag::TYPE_HTTP . $type);
$msg = __('Notified LiteSpeed Web Server to purge error pages.', 'litespeed-cache');
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success($msg);
}
/**
* Callback to add purge tags if admin selects to purge selected category pages.
*
* @since 1.0.7
* @access public
*/
public function purge_cat($value)
{
$val = trim($value);
if (empty($val)) {
return;
}
if (preg_match('/^[a-zA-Z0-9-]+$/', $val) == 0) {
self::debug("$val cat invalid");
return;
}
$cat = get_category_by_slug($val);
if ($cat == false) {
self::debug("$val cat not existed/published");
return;
}
self::add(Tag::TYPE_ARCHIVE_TERM . $cat->term_id);
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success(sprintf(__('Purge category %s', 'litespeed-cache'), $val));
// Action to run after category purge.
do_action('litespeed_purged_cat', $value);
}
/**
* Callback to add purge tags if admin selects to purge selected tag pages.
*
* @since 1.0.7
* @access public
*/
public function purge_tag($val)
{
$val = trim($val);
if (empty($val)) {
return;
}
if (preg_match('/^[a-zA-Z0-9-]+$/', $val) == 0) {
self::debug("$val tag invalid");
return;
}
$term = get_term_by('slug', $val, 'post_tag');
if ($term == 0) {
self::debug("$val tag not exist");
return;
}
self::add(Tag::TYPE_ARCHIVE_TERM . $term->term_id);
!defined('LITESPEED_PURGE_SILENT') && Admin_Display::success(sprintf(__('Purge tag %s', 'litespeed-cache'), $val));
// Action to run after tag purge.
do_action('litespeed_purged_tag', $val);
}
/**
* Callback to add purge tags if admin selects to purge selected urls.
*
* @since 1.0.7
* @access public
*/
public function purge_url($url, $purge2 = false, $quite = false)
{
$val = trim($url);
if (empty($val)) {
return;
}
if (strpos($val, '<') !== false) {
self::debug("$val url contains <");
return;
}
$val = Utility::make_relative($val);
$hash = Tag::get_uri_tag($val);
if ($hash === false) {
self::debug("$val url invalid");
return;
}
self::add($hash, $purge2);
!$quite && !defined('LITESPEED_PURGE_SILENT') && Admin_Display::success(sprintf(__('Purge url %s', 'litespeed-cache'), $val));
// Action to run after url purge.
do_action('litespeed_purged_link', $url);
}
/**
* Purge a list of pages when selected by admin. This method will look at the post arguments to determine how and what to purge.
*
* @since 1.0.7
* @access public
*/
public function purge_list()
{
if (!isset($_REQUEST[Admin_Display::PURGEBYOPT_SELECT]) || !isset($_REQUEST[Admin_Display::PURGEBYOPT_LIST])) {
return;
}
$sel = $_REQUEST[Admin_Display::PURGEBYOPT_SELECT];
$list_buf = $_REQUEST[Admin_Display::PURGEBYOPT_LIST];
if (empty($list_buf)) {
return;
}
$list_buf = str_replace(',', "\n", $list_buf); // for cli
$list = explode("\n", $list_buf);
switch ($sel) {
case Admin_Display::PURGEBY_CAT:
$cb = 'purge_cat';
break;
case Admin_Display::PURGEBY_PID:
$cb = 'purge_post';
break;
case Admin_Display::PURGEBY_TAG:
$cb = 'purge_tag';
break;
case Admin_Display::PURGEBY_URL:
$cb = 'purge_url';
break;
default:
return;
}
array_map(array($this, $cb), $list);
// for redirection
$_GET[Admin_Display::PURGEBYOPT_SELECT] = $sel;
}
/**
* Purge ESI
*
* @since 3.0
* @access public
*/
public static function purge_esi($tag)
{
self::add(Tag::TYPE_ESI . $tag);
do_action('litespeed_purged_esi', $tag);
}
/**
* Purge a certain post type
*
* @since 3.0
* @access public
*/
public static function purge_posttype($post_type)
{
self::add(Tag::TYPE_ARCHIVE_POSTTYPE . $post_type);
self::add($post_type);
do_action('litespeed_purged_posttype', $post_type);
}
/**
* Purge all related tags to a post.
*
* @since 1.0.0
* @access public
*/
public function purge_post($pid)
{
$pid = intval($pid);
// ignore the status we don't care
if (!$pid || !in_array(get_post_status($pid), array('publish', 'trash', 'private', 'draft'))) {
return;
}
$purge_tags = $this->_get_purge_tags_by_post($pid);
if (!$purge_tags) {
return;
}
self::add($purge_tags);
if ($this->conf(self::O_CACHE_REST)) {
self::add(Tag::TYPE_REST);
}
// $this->cls( 'Control' )->set_stale();
do_action('litespeed_purged_post', $pid);
}
/**
* Hooked to the load-widgets.php action.
* Attempts to purge a single widget from cache.
* If no widget id is passed in, the method will attempt to find the widget id.
*
* @since 1.1.3
* @access public
*/
public static function purge_widget($widget_id = null)
{
if (is_null($widget_id)) {
$widget_id = $_POST['widget-id'];
if (is_null($widget_id)) {
return;
}
}
self::add(Tag::TYPE_WIDGET . $widget_id);
self::add_private(Tag::TYPE_WIDGET . $widget_id);
do_action('litespeed_purged_widget', $widget_id);
}
/**
* Hooked to the wp_update_comment_count action.
* Purges the comment widget when the count is updated.
*
* @access public
* @since 1.1.3
* @global type $wp_widget_factory
*/
public static function purge_comment_widget()
{
global $wp_widget_factory;
if (!isset($wp_widget_factory->widgets['WP_Widget_Recent_Comments'])) {
return;
}
$recent_comments = $wp_widget_factory->widgets['WP_Widget_Recent_Comments'];
if (!is_null($recent_comments)) {
self::add(Tag::TYPE_WIDGET . $recent_comments->id);
self::add_private(Tag::TYPE_WIDGET . $recent_comments->id);
do_action('litespeed_purged_comment_widget', $recent_comments->id);
}
}
/**
* Purges feeds on comment count update.
*
* @since 1.0.9
* @access public
*/
public function purge_feeds()
{
if ($this->conf(self::O_CACHE_TTL_FEED) > 0) {
self::add(Tag::TYPE_FEED);
}
do_action('litespeed_purged_feeds');
}
/**
* Purges all private cache entries when the user logs out.
*
* @access public
* @since 1.1.3
*/
public static function purge_on_logout()
{
self::add_private_all();
do_action('litespeed_purged_on_logout');
}
/**
* Generate all purge tags before output
*
* @access private
* @since 1.1.3
*/
private function _finalize()
{
// Make sure header output only run once
if (!defined('LITESPEED_DID_' . __FUNCTION__)) {
define('LITESPEED_DID_' . __FUNCTION__, true);
} else {
return;
}
do_action('litespeed_purge_finalize');
// Append unique uri purge tags if Admin QS is `PURGESINGLE` or `PURGE`
if ($this->_purge_single) {
$tags = array(Tag::build_uri_tag());
$this->_pub_purge = array_merge($this->_pub_purge, $this->_prepend_bid($tags));
}
if (!empty($this->_pub_purge)) {
$this->_pub_purge = array_unique($this->_pub_purge);
}
if (!empty($this->_priv_purge)) {
$this->_priv_purge = array_unique($this->_priv_purge);
}
}
/**
* Gathers all the purge headers.
*
* This will collect all site wide purge tags as well as third party plugin defined purge tags.
*
* @since 1.1.0
* @access public
* @return string the built purge header
*/
public static function output()
{
$instance = self::cls();
$instance->_finalize();
return $instance->_build();
}
/**
* Build the current purge headers.
*
* @since 1.1.5
* @access private
* @return string the built purge header
*/
private function _build($purge2 = false)
{
if ($purge2) {
if (empty($this->_pub_purge2)) {
return;
}
} else {
if (empty($this->_pub_purge) && empty($this->_priv_purge)) {
return;
}
}
$purge_header = '';
// Handle purge2 @since 4.4.1
if ($purge2) {
$public_tags = $this->_append_prefix($this->_pub_purge2);
if (empty($public_tags)) {
return;
}
$purge_header = self::X_HEADER2 . ': public,';
if (Control::is_stale()) {
$purge_header .= 'stale,';
}
$purge_header .= implode(',', $public_tags);
return $purge_header;
}
$private_prefix = self::X_HEADER . ': private,';
if (!empty($this->_pub_purge)) {
$public_tags = $this->_append_prefix($this->_pub_purge);
if (empty($public_tags)) {
// If this ends up empty, private will also end up empty
return;
}
$purge_header = self::X_HEADER . ': public,';
if (Control::is_stale()) {
$purge_header .= 'stale,';
}
$purge_header .= implode(',', $public_tags);
$private_prefix = ';private,';
}
// Handle priv purge tags
if (!empty($this->_priv_purge)) {
$private_tags = $this->_append_prefix($this->_priv_purge, true);
$purge_header .= $private_prefix . implode(',', $private_tags);
}
return $purge_header;
}
/**
* Append prefix to an array of purge headers
*
* @since 1.1.0
* @access private
*/
private function _append_prefix($purge_tags, $is_private = false)
{
$curr_bid = is_multisite() ? get_current_blog_id() : '';
if (!in_array('*', $purge_tags)) {
$tags = array();
foreach ($purge_tags as $val) {
$tags[] = LSWCP_TAG_PREFIX . $val;
}
return $tags;
}
// Purge All need to check if need to reset crawler or not
if (!$is_private && $this->conf(self::O_CRAWLER)) {
Crawler::cls()->reset_pos();
}
if ((defined('LSWCP_EMPTYCACHE') && LSWCP_EMPTYCACHE) || $is_private) {
return array('*');
}
if (is_multisite() && !$this->_is_subsite_purge()) {
$blogs = Activation::get_network_ids();
if (empty($blogs)) {
self::debug('build_purge_headers: blog list is empty');
return '';
}
$tags = array();
foreach ($blogs as $blog_id) {
$tags[] = LSWCP_TAG_PREFIX . $blog_id . '_';
}
return $tags;
} else {
return array(LSWCP_TAG_PREFIX . $curr_bid . '_');
}
}
/**
* Check if this purge belongs to a subsite purge
*
* @since 4.0
*/
private function _is_subsite_purge()
{
if (!is_multisite()) {
return false;
}
if (is_network_admin()) {
return false;
}
if (defined('LSWCP_EMPTYCACHE') && LSWCP_EMPTYCACHE) {
return false;
}
// Would only use multisite and network admin except is_network_admin is false for ajax calls, which is used by wordpress updates v4.6+
if (Router::is_ajax() && (check_ajax_referer('updates', false, false) || check_ajax_referer('litespeed-purgeall-network', false, false))) {
return false;
}
return true;
}
/**
* Gets all the purge tags correlated with the post about to be purged.
*
* If the purge all pages configuration is set, all pages will be purged.
*
* This includes site wide post types (e.g. front page) as well as any third party plugin specific post tags.
*
* @since 1.0.0
* @access private
*/
private function _get_purge_tags_by_post($post_id)
{
// If this is a valid post we want to purge the post, the home page and any associated tags & cats
// If not, purge everything on the site.
$purge_tags = array();
if ($this->conf(self::O_PURGE_POST_ALL)) {
// ignore the rest if purge all
return array('*');
}
// now do API hook action for post purge
do_action('litespeed_api_purge_post', $post_id);
// post
$purge_tags[] = Tag::TYPE_POST . $post_id;
$post_status = get_post_status($post_id);
if (function_exists('is_post_status_viewable')) {
$viewable = is_post_status_viewable($post_status);
if ($viewable) {
$purge_tags[] = Tag::get_uri_tag(wp_make_link_relative(get_permalink($post_id)));
}
}
// for archive of categories|tags|custom tax
global $post;
$original_post = $post;
$post = get_post($post_id);
$post_type = $post->post_type;
global $wp_widget_factory;
// recent_posts
$recent_posts = isset($wp_widget_factory->widgets['WP_Widget_Recent_Posts']) ? $wp_widget_factory->widgets['WP_Widget_Recent_Posts'] : null;
if (!is_null($recent_posts)) {
$purge_tags[] = Tag::TYPE_WIDGET . $recent_posts->id;
}
// get adjacent posts id as related post tag
if ($post_type == 'post') {
$prev_post = get_previous_post();
$next_post = get_next_post();
if (!empty($prev_post->ID)) {
$purge_tags[] = Tag::TYPE_POST . $prev_post->ID;
self::debug('--------purge_tags prev is: ' . $prev_post->ID);
}
if (!empty($next_post->ID)) {
$purge_tags[] = Tag::TYPE_POST . $next_post->ID;
self::debug('--------purge_tags next is: ' . $next_post->ID);
}
}
if ($this->conf(self::O_PURGE_POST_TERM)) {
$taxonomies = get_object_taxonomies($post_type);
//self::debug('purge by post, check tax = ' . var_export($taxonomies, true));
foreach ($taxonomies as $tax) {
$terms = get_the_terms($post_id, $tax);
if (!empty($terms)) {
foreach ($terms as $term) {
$purge_tags[] = Tag::TYPE_ARCHIVE_TERM . $term->term_id;
}
}
}
}
if ($this->conf(self::O_CACHE_TTL_FEED)) {
$purge_tags[] = Tag::TYPE_FEED;
}
// author, for author posts and feed list
if ($this->conf(self::O_PURGE_POST_AUTHOR)) {
$purge_tags[] = Tag::TYPE_AUTHOR . get_post_field('post_author', $post_id);
}
// archive and feed of post type
// todo: check if type contains space
if ($this->conf(self::O_PURGE_POST_POSTTYPE)) {
if (get_post_type_archive_link($post_type)) {
$purge_tags[] = Tag::TYPE_ARCHIVE_POSTTYPE . $post_type;
$purge_tags[] = $post_type;
}
}
if ($this->conf(self::O_PURGE_POST_FRONTPAGE)) {
$purge_tags[] = Tag::TYPE_FRONTPAGE;
}
if ($this->conf(self::O_PURGE_POST_HOMEPAGE)) {
$purge_tags[] = Tag::TYPE_HOME;
}
if ($this->conf(self::O_PURGE_POST_PAGES)) {
$purge_tags[] = Tag::TYPE_PAGES;
}
if ($this->conf(self::O_PURGE_POST_PAGES_WITH_RECENT_POSTS)) {
$purge_tags[] = Tag::TYPE_PAGES_WITH_RECENT_POSTS;
}
// if configured to have archived by date
$date = $post->post_date;
$date = strtotime($date);
if ($this->conf(self::O_PURGE_POST_DATE)) {
$purge_tags[] = Tag::TYPE_ARCHIVE_DATE . date('Ymd', $date);
}
if ($this->conf(self::O_PURGE_POST_MONTH)) {
$purge_tags[] = Tag::TYPE_ARCHIVE_DATE . date('Ym', $date);
}
if ($this->conf(self::O_PURGE_POST_YEAR)) {
$purge_tags[] = Tag::TYPE_ARCHIVE_DATE . date('Y', $date);
}
// Set back to original post as $post_id might affecting the global $post value
$post = $original_post;
return array_unique($purge_tags);
}
/**
* The dummy filter for purge all
*
* @since 1.1.5
* @access public
* @param string $val The filter value
* @return string The filter value
*/
public static function filter_with_purge_all($val)
{
self::purge_all();
return $val;
}
}
report.cls.php 0000644 00000014221 15153741267 0007363 0 ustar 00 <?php
/**
* The report class
*
*
* @since 1.1.0
* @package LiteSpeed
* @subpackage LiteSpeed/src
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Report extends Base
{
const TYPE_SEND_REPORT = 'send_report';
/**
* Handle all request actions from main cls
*
* @since 1.6.5
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_SEND_REPORT:
$this->post_env();
break;
default:
break;
}
Admin::redirect();
}
/**
* post env report number to ls center server
*
* @since 1.6.5
* @access public
*/
public function post_env()
{
$report_con = $this->generate_environment_report();
// Generate link
$link = !empty($_POST['link']) ? esc_url($_POST['link']) : '';
$notes = !empty($_POST['notes']) ? esc_html($_POST['notes']) : '';
$php_info = !empty($_POST['attach_php']) ? esc_html($_POST['attach_php']) : '';
$report_php = $php_info === '1' ? $this->generate_php_report() : '';
if ($report_php) {
$report_con .= "\nPHPINFO\n" . $report_php;
}
$data = array(
'env' => $report_con,
'link' => $link,
'notes' => $notes,
);
$json = Cloud::post(Cloud::API_REPORT, $data);
if (!is_array($json)) {
return;
}
$num = !empty($json['num']) ? $json['num'] : '--';
$summary = array(
'num' => $num,
'dateline' => time(),
);
self::save_summary($summary);
return $num;
}
/**
* Gathers the PHP information.
*
* @since 7.0
* @access public
*/
public function generate_php_report($flags = INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES)
{
// INFO_ENVIRONMENT
$report = '';
ob_start();
phpinfo($flags);
$report = ob_get_contents();
ob_end_clean();
preg_match('%<style type="text/css">(.*?)</style>.*?<body>(.*?)</body>%s', $report, $report);
return $report[2];
}
/**
* Gathers the environment details and creates the report.
* Will write to the environment report file.
*
* @since 1.0.12
* @access public
*/
public function generate_environment_report($options = null)
{
global $wp_version, $_SERVER;
$frontend_htaccess = Htaccess::get_frontend_htaccess();
$backend_htaccess = Htaccess::get_backend_htaccess();
$paths = array($frontend_htaccess);
if ($frontend_htaccess != $backend_htaccess) {
$paths[] = $backend_htaccess;
}
if (is_multisite()) {
$active_plugins = get_site_option('active_sitewide_plugins');
if (!empty($active_plugins)) {
$active_plugins = array_keys($active_plugins);
}
} else {
$active_plugins = get_option('active_plugins');
}
if (function_exists('wp_get_theme')) {
$theme_obj = wp_get_theme();
$active_theme = $theme_obj->get('Name');
} else {
$active_theme = get_current_theme();
}
$extras = array(
'wordpress version' => $wp_version,
'siteurl' => get_option('siteurl'),
'home' => get_option('home'),
'home_url' => home_url(),
'locale' => get_locale(),
'active theme' => $active_theme,
);
$extras['active plugins'] = $active_plugins;
$extras['cloud'] = Cloud::get_summary();
foreach (array('mini_html', 'pk_b64', 'sk_b64', 'cdn_dash', 'ips') as $v) {
if (!empty($extras['cloud'][$v])) {
unset($extras['cloud'][$v]);
}
}
if (is_null($options)) {
$options = $this->get_options(true);
if (is_multisite()) {
$options2 = $this->get_options();
foreach ($options2 as $k => $v) {
if (isset($options[$k]) && $options[$k] !== $v) {
$options['[Overwritten] ' . $k] = $v;
}
}
}
}
if (!is_null($options) && is_multisite()) {
$blogs = Activation::get_network_ids();
if (!empty($blogs)) {
$i = 0;
foreach ($blogs as $blog_id) {
if (++$i > 3) {
// Only log 3 subsites
break;
}
$opts = $this->cls('Conf')->load_options($blog_id, true);
if (isset($opts[self::O_CACHE])) {
$options['blog ' . $blog_id . ' radio select'] = $opts[self::O_CACHE];
}
}
}
}
// Security: Remove cf key in report
$secure_fields = array(self::O_CDN_CLOUDFLARE_KEY, self::O_OBJECT_PSWD);
foreach ($secure_fields as $v) {
if (!empty($options[$v])) {
$options[$v] = str_repeat('*', strlen($options[$v]));
}
}
$report = $this->build_environment_report($_SERVER, $options, $extras, $paths);
return $report;
}
/**
* Builds the environment report buffer with the given parameters
*
* @access private
*/
private function build_environment_report($server, $options, $extras = array(), $htaccess_paths = array())
{
$server_keys = array(
'DOCUMENT_ROOT' => '',
'SERVER_SOFTWARE' => '',
'X-LSCACHE' => '',
'HTTP_X_LSCACHE' => '',
);
$server_vars = array_intersect_key($server, $server_keys);
$server_vars[] = 'LSWCP_TAG_PREFIX = ' . LSWCP_TAG_PREFIX;
$server_vars = array_merge($server_vars, $this->cls('Base')->server_vars());
$buf = $this->_format_report_section('Server Variables', $server_vars);
$buf .= $this->_format_report_section('WordPress Specific Extras', $extras);
$buf .= $this->_format_report_section('LSCache Plugin Options', $options);
if (empty($htaccess_paths)) {
return $buf;
}
foreach ($htaccess_paths as $path) {
if (!file_exists($path) || !is_readable($path)) {
$buf .= $path . " does not exist or is not readable.\n";
continue;
}
$content = file_get_contents($path);
if ($content === false) {
$buf .= $path . " returned false for file_get_contents.\n";
continue;
}
$buf .= $path . " contents:\n" . $content . "\n\n";
}
return $buf;
}
/**
* Creates a part of the environment report based on a section header and an array for the section parameters.
*
* @since 1.0.12
* @access private
*/
private function _format_report_section($section_header, $section)
{
$tab = ' '; // four spaces
if (empty($section)) {
return 'No matching ' . $section_header . "\n\n";
}
$buf = $section_header;
foreach ($section as $k => $v) {
$buf .= "\n" . $tab;
if (!is_numeric($k)) {
$buf .= $k . ' = ';
}
if (!is_string($v)) {
$v = var_export($v, true);
} else {
$v = esc_html($v);
}
$buf .= $v;
}
return $buf . "\n\n";
}
}
rest.cls.php 0000644 00000016715 15153741267 0007037 0 ustar 00 <?php
/**
* The REST related class.
*
* @since 2.9.4
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class REST extends Root
{
const LOG_TAG = '☎️';
private $_internal_rest_status = false;
/**
* Confructor of ESI
*
* @since 2.9.4
*/
public function __construct()
{
// Hook to internal REST call
add_filter('rest_request_before_callbacks', array($this, 'set_internal_rest_on'));
add_filter('rest_request_after_callbacks', array($this, 'set_internal_rest_off'));
add_action('rest_api_init', array($this, 'rest_api_init'));
}
/**
* Register REST hooks
*
* @since 3.0
* @access public
*/
public function rest_api_init()
{
// Activate or deactivate a specific crawler callback
register_rest_route('litespeed/v1', '/toggle_crawler_state', array(
'methods' => 'POST',
'callback' => array($this, 'toggle_crawler_state'),
'permission_callback' => function () {
return current_user_can('manage_network_options') || current_user_can('manage_options');
},
));
register_rest_route('litespeed/v1', '/tool/check_ip', array(
'methods' => 'GET',
'callback' => array($this, 'check_ip'),
'permission_callback' => function () {
return current_user_can('manage_network_options') || current_user_can('manage_options');
},
));
// IP callback validate
register_rest_route('litespeed/v3', '/ip_validate', array(
'methods' => 'POST',
'callback' => array($this, 'ip_validate'),
'permission_callback' => array($this, 'is_from_cloud'),
));
## 1.2. WP REST Dryrun Callback
register_rest_route('litespeed/v3', '/wp_rest_echo', array(
'methods' => 'POST',
'callback' => array($this, 'wp_rest_echo'),
'permission_callback' => array($this, 'is_from_cloud'),
));
register_rest_route('litespeed/v3', '/ping', array(
'methods' => 'POST',
'callback' => array($this, 'ping'),
'permission_callback' => array($this, 'is_from_cloud'),
));
// CDN setup callback notification
register_rest_route('litespeed/v3', '/cdn_status', array(
'methods' => 'POST',
'callback' => array($this, 'cdn_status'),
'permission_callback' => array($this, 'is_from_cloud'),
));
// Image optm notify_img
// Need validation
register_rest_route('litespeed/v1', '/notify_img', array(
'methods' => 'POST',
'callback' => array($this, 'notify_img'),
'permission_callback' => array($this, 'is_from_cloud'),
));
register_rest_route('litespeed/v1', '/notify_ccss', array(
'methods' => 'POST',
'callback' => array($this, 'notify_ccss'),
'permission_callback' => array($this, 'is_from_cloud'),
));
register_rest_route('litespeed/v1', '/notify_ucss', array(
'methods' => 'POST',
'callback' => array($this, 'notify_ucss'),
'permission_callback' => array($this, 'is_from_cloud'),
));
register_rest_route('litespeed/v1', '/notify_vpi', array(
'methods' => 'POST',
'callback' => array($this, 'notify_vpi'),
'permission_callback' => array($this, 'is_from_cloud'),
));
register_rest_route('litespeed/v3', '/err_domains', array(
'methods' => 'POST',
'callback' => array($this, 'err_domains'),
'permission_callback' => array($this, 'is_from_cloud'),
));
// Image optm check_img
// Need validation
register_rest_route('litespeed/v1', '/check_img', array(
'methods' => 'POST',
'callback' => array($this, 'check_img'),
'permission_callback' => array($this, 'is_from_cloud'),
));
}
/**
* Call to freeze or melt the crawler clicked
*
* @since 4.3
*/
public function toggle_crawler_state()
{
if (isset($_POST['crawler_id'])) {
return $this->cls('Crawler')->toggle_activeness($_POST['crawler_id']) ? 1 : 0;
}
}
/**
* Check if the request is from cloud nodes
*
* @since 4.2
* @since 4.4.7 As there is always token/api key validation, ip validation is redundant
*/
public function is_from_cloud()
{
// return true;
return $this->cls('Cloud')->is_from_cloud();
}
/**
* Ping pong
*
* @since 3.0.4
*/
public function ping()
{
return $this->cls('Cloud')->ping();
}
/**
* Launch api call
*
* @since 3.0
*/
public function check_ip()
{
return Tool::cls()->check_ip();
}
/**
* Launch api call
*
* @since 3.0
*/
public function ip_validate()
{
return $this->cls('Cloud')->ip_validate();
}
/**
* Launch api call
*
* @since 3.0
*/
public function wp_rest_echo()
{
return $this->cls('Cloud')->wp_rest_echo();
}
/**
* Endpoint for QC to notify plugin of CDN status update.
*
* @since 7.0
*/
public function cdn_status()
{
return $this->cls('Cloud')->update_cdn_status();
}
/**
* Launch api call
*
* @since 3.0
*/
public function notify_img()
{
return Img_Optm::cls()->notify_img();
}
/**
* @since 7.1
*/
public function notify_ccss()
{
self::debug('notify_ccss');
return CSS::cls()->notify();
}
/**
* @since 5.2
*/
public function notify_ucss()
{
self::debug('notify_ucss');
return UCSS::cls()->notify();
}
/**
* @since 4.7
*/
public function notify_vpi()
{
self::debug('notify_vpi');
return VPI::cls()->notify();
}
/**
* @since 4.7
*/
public function err_domains()
{
self::debug('err_domains');
return $this->cls('Cloud')->rest_err_domains();
}
/**
* Launch api call
*
* @since 3.0
*/
public function check_img()
{
return Img_Optm::cls()->check_img();
}
/**
* Return error
*
* @since 5.7.0.1
*/
public static function err($code)
{
return array('_res' => 'err', '_msg' => $code);
}
/**
* Set internal REST tag to ON
*
* @since 2.9.4
* @access public
*/
public function set_internal_rest_on($not_used = null)
{
$this->_internal_rest_status = true;
Debug2::debug2('[REST] ✅ Internal REST ON [filter] rest_request_before_callbacks');
return $not_used;
}
/**
* Set internal REST tag to OFF
*
* @since 2.9.4
* @access public
*/
public function set_internal_rest_off($not_used = null)
{
$this->_internal_rest_status = false;
Debug2::debug2('[REST] ❎ Internal REST OFF [filter] rest_request_after_callbacks');
return $not_used;
}
/**
* Get internal REST tag
*
* @since 2.9.4
* @access public
*/
public function is_internal_rest()
{
return $this->_internal_rest_status;
}
/**
* Check if an URL or current page is REST req or not
*
* @since 2.9.3
* @since 2.9.4 Moved here from Utility, dropped static
* @access public
*/
public function is_rest($url = false)
{
// For WP 4.4.0- compatibility
if (!function_exists('rest_get_url_prefix')) {
return defined('REST_REQUEST') && REST_REQUEST;
}
$prefix = rest_get_url_prefix();
// Case #1: After WP_REST_Request initialisation
if (defined('REST_REQUEST') && REST_REQUEST) {
return true;
}
// Case #2: Support "plain" permalink settings
if (isset($_GET['rest_route']) && strpos(trim($_GET['rest_route'], '\\/'), $prefix, 0) === 0) {
return true;
}
if (!$url) {
return false;
}
// Case #3: URL Path begins with wp-json/ (REST prefix) Safe for subfolder installation
$rest_url = wp_parse_url(site_url($prefix));
$current_url = wp_parse_url($url);
// Debug2::debug( '[Util] is_rest check [base] ', $rest_url );
// Debug2::debug( '[Util] is_rest check [curr] ', $current_url );
// Debug2::debug( '[Util] is_rest check [curr2] ', wp_parse_url( add_query_arg( array( ) ) ) );
if ($current_url !== false && !empty($current_url['path']) && $rest_url !== false && !empty($rest_url['path'])) {
return strpos($current_url['path'], $rest_url['path']) === 0;
}
return false;
}
}
root.cls.php 0000644 00000031435 15153741267 0007041 0 ustar 00 <?php
/**
* The abstract instance
*
* @since 3.0
*/
namespace LiteSpeed;
defined('WPINC') || exit();
abstract class Root
{
const CONF_FILE = '.litespeed_conf.dat';
// Instance set
private static $_instances;
private static $_options = array();
private static $_const_options = array();
private static $_primary_options = array();
private static $_network_options = array();
/**
* Check if need to separate ccss for mobile
*
* @since 4.7
* @access protected
*/
protected function _separate_mobile()
{
return (wp_is_mobile() || apply_filters('litespeed_is_mobile', false)) && $this->conf(Base::O_CACHE_MOBILE);
}
/**
* Log an error message
*
* @since 7.0
*/
public static function debugErr($msg, $backtrace_limit = false)
{
$msg = '❌ ' . $msg;
self::debug($msg, $backtrace_limit);
}
/**
* Log a debug message.
*
* @since 4.4
* @access public
*/
public static function debug($msg, $backtrace_limit = false)
{
if (!defined('LSCWP_LOG')) {
return;
}
if (defined('static::LOG_TAG')) {
$msg = static::LOG_TAG . ' ' . $msg;
}
Debug2::debug($msg, $backtrace_limit);
}
/**
* Log an advanced debug message.
*
* @since 4.4
* @access public
*/
public static function debug2($msg, $backtrace_limit = false)
{
if (!defined('LSCWP_LOG_MORE')) {
return;
}
if (defined('static::LOG_TAG')) {
$msg = static::LOG_TAG . ' ' . $msg;
}
Debug2::debug2($msg, $backtrace_limit);
}
/**
* Check if there is cache folder for that type
*
* @since 3.0
*/
public function has_cache_folder($type)
{
$subsite_id = is_multisite() && !is_network_admin() ? get_current_blog_id() : '';
if (file_exists(LITESPEED_STATIC_DIR . '/' . $type . '/' . $subsite_id)) {
return true;
}
return false;
}
/**
* Maybe make the cache folder if not existed
*
* @since 4.4.2
*/
protected function _maybe_mk_cache_folder($type)
{
if (!$this->has_cache_folder($type)) {
$subsite_id = is_multisite() && !is_network_admin() ? get_current_blog_id() : '';
$path = LITESPEED_STATIC_DIR . '/' . $type . '/' . $subsite_id;
mkdir($path, 0755, true);
}
}
/**
* Delete file-based cache folder for that type
*
* @since 3.0
*/
public function rm_cache_folder($type)
{
if (!$this->has_cache_folder($type)) {
return;
}
$subsite_id = is_multisite() && !is_network_admin() ? get_current_blog_id() : '';
File::rrmdir(LITESPEED_STATIC_DIR . '/' . $type . '/' . $subsite_id);
// Clear All summary data
self::save_summary(false, false, true);
if ($type == 'ccss' || $type == 'ucss') {
Debug2::debug('[CSS] Cleared ' . $type . ' queue');
} elseif ($type == 'avatar') {
Debug2::debug('[Avatar] Cleared ' . $type . ' queue');
} elseif ($type == 'css' || $type == 'js') {
return;
} else {
Debug2::debug('[' . strtoupper($type) . '] Cleared ' . $type . ' queue');
}
}
/**
* Build the static filepath
*
* @since 4.0
*/
protected function _build_filepath_prefix($type)
{
$filepath_prefix = '/' . $type . '/';
if (is_multisite()) {
$filepath_prefix .= get_current_blog_id() . '/';
}
return $filepath_prefix;
}
/**
* Load current queues from data file
*
* @since 4.1
* @since 4.3 Elevated to root.cls
*/
public function load_queue($type)
{
$filepath_prefix = $this->_build_filepath_prefix($type);
$static_path = LITESPEED_STATIC_DIR . $filepath_prefix . '.litespeed_conf.dat';
$queue = array();
if (file_exists($static_path)) {
$queue = \json_decode(file_get_contents($static_path), true) ?: array();
}
return $queue;
}
/**
* Save current queues to data file
*
* @since 4.1
* @since 4.3 Elevated to root.cls
*/
public function save_queue($type, $list)
{
$filepath_prefix = $this->_build_filepath_prefix($type);
$static_path = LITESPEED_STATIC_DIR . $filepath_prefix . '.litespeed_conf.dat';
$data = \json_encode($list);
File::save($static_path, $data, true);
}
/**
* Clear all waiting queues
*
* @since 3.4
* @since 4.3 Elevated to root.cls
*/
public function clear_q($type, $silent = false)
{
$filepath_prefix = $this->_build_filepath_prefix($type);
$static_path = LITESPEED_STATIC_DIR . $filepath_prefix . '.litespeed_conf.dat';
if (file_exists($static_path)) {
$silent = false;
unlink($static_path);
}
if (!$silent) {
$msg = __('All QUIC.cloud service queues have been cleared.', 'litespeed-cache');
Admin_Display::success($msg);
}
}
/**
* Load an instance or create it if not existed
* @since 4.0
*/
public static function cls($cls = false, $unset = false, $data = false)
{
if (!$cls) {
$cls = self::ori_cls();
}
$cls = __NAMESPACE__ . '\\' . $cls;
$cls_tag = strtolower($cls);
if (!isset(self::$_instances[$cls_tag])) {
if ($unset) {
return;
}
self::$_instances[$cls_tag] = new $cls($data);
} else {
if ($unset) {
unset(self::$_instances[$cls_tag]);
return;
}
}
return self::$_instances[$cls_tag];
}
/**
* Set one conf or confs
*/
public function set_conf($id, $val = null)
{
if (is_array($id)) {
foreach ($id as $k => $v) {
$this->set_conf($k, $v);
}
return;
}
self::$_options[$id] = $val;
}
/**
* Set one primary conf or confs
*/
public function set_primary_conf($id, $val = null)
{
if (is_array($id)) {
foreach ($id as $k => $v) {
$this->set_primary_conf($k, $v);
}
return;
}
self::$_primary_options[$id] = $val;
}
/**
* Set one network conf
*/
public function set_network_conf($id, $val = null)
{
if (is_array($id)) {
foreach ($id as $k => $v) {
$this->set_network_conf($k, $v);
}
return;
}
self::$_network_options[$id] = $val;
}
/**
* Set one const conf
*/
public function set_const_conf($id, $val)
{
self::$_const_options[$id] = $val;
}
/**
* Check if is overwritten by const
*
* @since 3.0
*/
public function const_overwritten($id)
{
if (!isset(self::$_const_options[$id]) || self::$_const_options[$id] == self::$_options[$id]) {
return null;
}
return self::$_const_options[$id];
}
/**
* Check if is overwritten by primary site
*
* @since 3.2.2
*/
public function primary_overwritten($id)
{
if (!isset(self::$_primary_options[$id]) || self::$_primary_options[$id] == self::$_options[$id]) {
return null;
}
// Network admin settings is impossible to be overwritten by primary
if (is_network_admin()) {
return null;
}
return self::$_primary_options[$id];
}
/**
* Get the list of configured options for the blog.
*
* @since 1.0
*/
public function get_options($ori = false)
{
if (!$ori) {
return array_merge(self::$_options, self::$_primary_options, self::$_network_options, self::$_const_options);
}
return self::$_options;
}
/**
* If has a conf or not
*/
public function has_conf($id)
{
return array_key_exists($id, self::$_options);
}
/**
* If has a primary conf or not
*/
public function has_primary_conf($id)
{
return array_key_exists($id, self::$_primary_options);
}
/**
* If has a network conf or not
*/
public function has_network_conf($id)
{
return array_key_exists($id, self::$_network_options);
}
/**
* Get conf
*/
public function conf($id, $ori = false)
{
if (isset(self::$_options[$id])) {
if (!$ori) {
$val = $this->const_overwritten($id);
if ($val !== null) {
defined('LSCWP_LOG') && Debug2::debug('[Conf] 🏛️ const option ' . $id . '=' . var_export($val, true));
return $val;
}
$val = $this->primary_overwritten($id); // Network Use primary site settings
if ($val !== null) {
return $val;
}
}
// Network original value will be in _network_options
if (!is_network_admin() || !$this->has_network_conf($id)) {
return self::$_options[$id];
}
}
if ($this->has_network_conf($id)) {
if (!$ori) {
$val = $this->const_overwritten($id);
if ($val !== null) {
defined('LSCWP_LOG') && Debug2::debug('[Conf] 🏛️ const option ' . $id . '=' . var_export($val, true));
return $val;
}
}
return $this->network_conf($id);
}
defined('LSCWP_LOG') && Debug2::debug('[Conf] Invalid option ID ' . $id);
return null;
}
/**
* Get primary conf
*/
public function primary_conf($id)
{
return self::$_primary_options[$id];
}
/**
* Get network conf
*/
public function network_conf($id)
{
if (!$this->has_network_conf($id)) {
return null;
}
return self::$_network_options[$id];
}
/**
* Get called class short name
*/
public static function ori_cls()
{
$cls = new \ReflectionClass(get_called_class());
$shortname = $cls->getShortName();
$namespace = str_replace(__NAMESPACE__ . '\\', '', $cls->getNamespaceName() . '\\');
if ($namespace) {
// the left namespace after dropped LiteSpeed
$shortname = $namespace . $shortname;
}
return $shortname;
}
/**
* Generate conf name for wp_options record
*
* @since 3.0
*/
public static function name($id)
{
$name = strtolower(self::ori_cls());
if ($name == 'conf2') {
// For a certain 3.7rc correction, can be dropped after v4
$name = 'conf';
}
return 'litespeed.' . $name . '.' . $id;
}
/**
* Dropin with prefix for WP's get_option
*
* @since 3.0
*/
public static function get_option($id, $default_v = false)
{
$v = get_option(self::name($id), $default_v);
// Maybe decode array
if (is_array($default_v)) {
$v = self::_maybe_decode($v);
}
return $v;
}
/**
* Dropin with prefix for WP's get_site_option
*
* @since 3.0
*/
public static function get_site_option($id, $default_v = false)
{
$v = get_site_option(self::name($id), $default_v);
// Maybe decode array
if (is_array($default_v)) {
$v = self::_maybe_decode($v);
}
return $v;
}
/**
* Dropin with prefix for WP's get_blog_option
*
* @since 3.0
*/
public static function get_blog_option($blog_id, $id, $default_v = false)
{
$v = get_blog_option($blog_id, self::name($id), $default_v);
// Maybe decode array
if (is_array($default_v)) {
$v = self::_maybe_decode($v);
}
return $v;
}
/**
* Dropin with prefix for WP's add_option
*
* @since 3.0
*/
public static function add_option($id, $v)
{
add_option(self::name($id), self::_maybe_encode($v));
}
/**
* Dropin with prefix for WP's add_site_option
*
* @since 3.0
*/
public static function add_site_option($id, $v)
{
add_site_option(self::name($id), self::_maybe_encode($v));
}
/**
* Dropin with prefix for WP's update_option
*
* @since 3.0
*/
public static function update_option($id, $v)
{
update_option(self::name($id), self::_maybe_encode($v));
}
/**
* Dropin with prefix for WP's update_site_option
*
* @since 3.0
*/
public static function update_site_option($id, $v)
{
update_site_option(self::name($id), self::_maybe_encode($v));
}
/**
* Decode an array
*
* @since 4.0
*/
private static function _maybe_decode($v)
{
if (!is_array($v)) {
$v2 = \json_decode($v, true);
if ($v2 !== null) {
$v = $v2;
}
}
return $v;
}
/**
* Encode an array
*
* @since 4.0
*/
private static function _maybe_encode($v)
{
if (is_array($v)) {
$v = \json_encode($v) ?: $v; // Non utf-8 encoded value will get failed, then used ori value
}
return $v;
}
/**
* Dropin with prefix for WP's delete_option
*
* @since 3.0
*/
public static function delete_option($id)
{
delete_option(self::name($id));
}
/**
* Dropin with prefix for WP's delete_site_option
*
* @since 3.0
*/
public static function delete_site_option($id)
{
delete_site_option(self::name($id));
}
/**
* Read summary
*
* @since 3.0
* @access public
*/
public static function get_summary($field = false)
{
$summary = self::get_option('_summary', array());
if (!is_array($summary)) {
$summary = array();
}
if (!$field) {
return $summary;
}
if (array_key_exists($field, $summary)) {
return $summary[$field];
}
return null;
}
/**
* Save summary
*
* @since 3.0
* @access public
*/
public static function save_summary($data = false, $reload = false, $overwrite = false)
{
if ($reload || empty(static::cls()->_summary)) {
self::reload_summary();
}
$existing_summary = static::cls()->_summary;
if ($overwrite || !is_array($existing_summary)) {
$existing_summary = array();
}
$new_summary = array_merge($existing_summary, $data ?: array());
// self::debug2('Save after Reloaded summary', $new_summary);
static::cls()->_summary = $new_summary;
self::update_option('_summary', $new_summary);
}
/**
* Reload summary
* @since 5.0
*/
public static function reload_summary()
{
static::cls()->_summary = self::get_summary();
// self::debug2( 'Reloaded summary', static::cls()->_summary );
}
/**
* Get the current instance object. To be inherited.
*
* @since 3.0
*/
public static function get_instance()
{
return static::cls();
}
}
router.cls.php 0000644 00000047007 15153741267 0007400 0 ustar 00 <?php
/**
* The core plugin router class.
*
* This generate the valid action.
*
* @since 1.1.0
* @since 1.5 Moved into /inc
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Router extends Base
{
const LOG_TAG = '[Router]';
const NONCE = 'LSCWP_NONCE';
const ACTION = 'LSCWP_CTRL';
const ACTION_SAVE_SETTINGS_NETWORK = 'save-settings-network';
const ACTION_DB_OPTM = 'db_optm';
const ACTION_PLACEHOLDER = 'placeholder';
const ACTION_AVATAR = 'avatar';
const ACTION_SAVE_SETTINGS = 'save-settings';
const ACTION_CLOUD = 'cloud';
const ACTION_IMG_OPTM = 'img_optm';
const ACTION_HEALTH = 'health';
const ACTION_CRAWLER = 'crawler';
const ACTION_PURGE = 'purge';
const ACTION_CONF = 'conf';
const ACTION_ACTIVATION = 'activation';
const ACTION_CSS = 'css';
const ACTION_UCSS = 'ucss';
const ACTION_VPI = 'vpi';
const ACTION_PRESET = 'preset';
const ACTION_IMPORT = 'import';
const ACTION_REPORT = 'report';
const ACTION_DEBUG2 = 'debug2';
const ACTION_CDN_CLOUDFLARE = 'CDN\Cloudflare';
const ACTION_ADMIN_DISPLAY = 'admin_display';
// List all handlers here
private static $_HANDLERS = array(
self::ACTION_ADMIN_DISPLAY,
self::ACTION_ACTIVATION,
self::ACTION_AVATAR,
self::ACTION_CDN_CLOUDFLARE,
self::ACTION_CLOUD,
self::ACTION_CONF,
self::ACTION_CRAWLER,
self::ACTION_CSS,
self::ACTION_UCSS,
self::ACTION_VPI,
self::ACTION_DB_OPTM,
self::ACTION_DEBUG2,
self::ACTION_HEALTH,
self::ACTION_IMG_OPTM,
self::ACTION_PRESET,
self::ACTION_IMPORT,
self::ACTION_PLACEHOLDER,
self::ACTION_PURGE,
self::ACTION_REPORT,
);
const TYPE = 'litespeed_type';
const ITEM_HASH = 'hash';
const ITEM_FLASH_HASH = 'flash_hash';
private static $_esi_enabled;
private static $_is_ajax;
private static $_is_logged_in;
private static $_ip;
private static $_action;
private static $_is_admin_ip;
private static $_frontend_path;
/**
* Redirect to self to continue operation
*
* Note: must return when use this func. CLI/Cron call won't die in this func.
*
* @since 3.0
* @access public
*/
public static function self_redirect($action, $type)
{
if (defined('LITESPEED_CLI') || defined('DOING_CRON')) {
Admin_Display::success('To be continued'); // Show for CLI
return;
}
// Add i to avoid browser too many redirected warning
$i = !empty($_GET['litespeed_i']) ? $_GET['litespeed_i'] : 0;
$i++;
$link = Utility::build_url($action, $type, false, null, array('litespeed_i' => $i));
$url = html_entity_decode($link);
exit("<meta http-equiv='refresh' content='0;url=$url'>");
}
/**
* Check if can run optimize
*
* @since 1.3
* @since 2.3.1 Relocated from cdn.cls
* @access public
*/
public function can_optm()
{
$can = true;
if (is_user_logged_in() && $this->conf(self::O_OPTM_GUEST_ONLY)) {
$can = false;
} elseif (is_admin()) {
$can = false;
} elseif (is_feed()) {
$can = false;
} elseif (is_preview()) {
$can = false;
} elseif (self::is_ajax()) {
$can = false;
}
if (self::_is_login_page()) {
Debug2::debug('[Router] Optm bypassed: login/reg page');
$can = false;
}
$can_final = apply_filters('litespeed_can_optm', $can);
if ($can_final != $can) {
Debug2::debug('[Router] Optm bypassed: filter');
}
return $can_final;
}
/**
* Check referer page to see if its from admin
*
* @since 2.4.2.1
* @access public
*/
public static function from_admin()
{
return !empty($_SERVER['HTTP_REFERER']) && strpos($_SERVER['HTTP_REFERER'], get_admin_url()) === 0;
}
/**
* Check if it can use CDN replacement
*
* @since 1.2.3
* @since 2.3.1 Relocated from cdn.cls
* @access public
*/
public static function can_cdn()
{
$can = true;
if (is_admin()) {
if (!self::is_ajax()) {
Debug2::debug2('[Router] CDN bypassed: is not ajax call');
$can = false;
}
if (self::from_admin()) {
Debug2::debug2('[Router] CDN bypassed: ajax call from admin');
$can = false;
}
} elseif (is_feed()) {
$can = false;
} elseif (is_preview()) {
$can = false;
}
/**
* Bypass cron to avoid deregister jq notice `Do not deregister the <code>jquery-core</code> script in the administration area.`
* @since 2.7.2
*/
if (defined('DOING_CRON')) {
$can = false;
}
/**
* Bypass login/reg page
* @since 1.6
*/
if (self::_is_login_page()) {
Debug2::debug('[Router] CDN bypassed: login/reg page');
$can = false;
}
/**
* Bypass post/page link setting
* @since 2.9.8.5
*/
$rest_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : apply_filters('rest_url_prefix', 'wp-json');
if (
!empty($_SERVER['REQUEST_URI']) &&
strpos($_SERVER['REQUEST_URI'], $rest_prefix . '/wp/v2/media') !== false &&
isset($_SERVER['HTTP_REFERER']) &&
strpos($_SERVER['HTTP_REFERER'], 'wp-admin') !== false
) {
Debug2::debug('[Router] CDN bypassed: wp-json on admin page');
$can = false;
}
$can_final = apply_filters('litespeed_can_cdn', $can);
if ($can_final != $can) {
Debug2::debug('[Router] CDN bypassed: filter');
}
return $can_final;
}
/**
* Check if is login page or not
*
* @since 2.3.1
* @access protected
*/
protected static function _is_login_page()
{
if (in_array($GLOBALS['pagenow'], array('wp-login.php', 'wp-register.php'), true)) {
return true;
}
return false;
}
/**
* UCSS/Crawler role simulator
*
* @since 1.9.1
* @since 3.3 Renamed from `is_crawler_role_simulation`
*/
public function is_role_simulation()
{
if (is_admin()) {
return;
}
if (empty($_COOKIE['litespeed_hash']) && empty($_COOKIE['litespeed_flash_hash'])) {
return;
}
self::debug('🪪 starting role validation');
// Check if is from crawler
// if ( empty( $_SERVER[ 'HTTP_USER_AGENT' ] ) || strpos( $_SERVER[ 'HTTP_USER_AGENT' ], Crawler::FAST_USER_AGENT ) !== 0 ) {
// Debug2::debug( '[Router] user agent not match' );
// return;
// }
$server_ip = $this->conf(self::O_SERVER_IP);
if (!$server_ip || self::get_ip() !== $server_ip) {
self::debug('❌❌ Role simulate uid denied! Not localhost visit!');
Control::set_nocache('Role simulate uid denied');
return;
}
// Flash hash validation
if (!empty($_COOKIE['litespeed_flash_hash'])) {
$hash_data = self::get_option(self::ITEM_FLASH_HASH, array());
if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts']) && !empty($hash_data['uid'])) {
if (time() - $hash_data['ts'] < 120 && $_COOKIE['litespeed_flash_hash'] == $hash_data['hash']) {
self::debug('🪪 Role simulator flash hash matched, escalating user to be uid=' . $hash_data['uid']);
self::delete_option(self::ITEM_FLASH_HASH);
wp_set_current_user($hash_data['uid']);
return;
}
}
}
// Hash validation
if (!empty($_COOKIE['litespeed_hash'])) {
$hash_data = self::get_option(self::ITEM_HASH, array());
if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts']) && !empty($hash_data['uid'])) {
$RUN_DURATION = $this->cls('Crawler')->get_crawler_duration();
if (time() - $hash_data['ts'] < $RUN_DURATION && $_COOKIE['litespeed_hash'] == $hash_data['hash']) {
self::debug('🪪 Role simulator hash matched, escalating user to be uid=' . $hash_data['uid']);
wp_set_current_user($hash_data['uid']);
return;
}
}
}
self::debug('❌ WARNING: role simulator hash not match');
}
/**
* Get a short ttl hash (2mins)
*
* @since 6.4
*/
public function get_flash_hash($uid)
{
$hash_data = self::get_option(self::ITEM_FLASH_HASH, array());
if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts'])) {
if (time() - $hash_data['ts'] < 60) {
return $hash_data['hash'];
}
}
// Check if this user has editor access or not
if (user_can($uid, 'edit_posts')) {
self::debug('🛑 The user with id ' . $uid . ' has editor access, which is not allowed for the role simulator.');
return '';
}
$hash = Str::rrand(32);
self::update_option(self::ITEM_FLASH_HASH, array('hash' => $hash, 'ts' => time(), 'uid' => $uid));
return $hash;
}
/**
* Get a security hash
*
* @since 3.3
*/
public function get_hash($uid)
{
// Check if this user has editor access or not
if (user_can($uid, 'edit_posts')) {
self::debug('🛑 The user with id ' . $uid . ' has editor access, which is not allowed for the role simulator.');
return '';
}
// As this is called only when starting crawling, not per page, no need to reuse
$hash = Str::rrand(32);
self::update_option(self::ITEM_HASH, array('hash' => $hash, 'ts' => time(), 'uid' => $uid));
return $hash;
}
/**
* Get user role
*
* @since 1.6.2
*/
public static function get_role($uid = null)
{
if (defined('LITESPEED_WP_ROLE')) {
return LITESPEED_WP_ROLE;
}
if ($uid === null) {
$uid = get_current_user_id();
}
$role = false;
if ($uid) {
$user = get_userdata($uid);
if (isset($user->roles) && is_array($user->roles)) {
$tmp = array_values($user->roles);
$role = implode(',', $tmp); // Combine for PHP5.3 const comaptibility
}
}
Debug2::debug('[Router] get_role: ' . $role);
if (!$role) {
return $role;
// Guest user
Debug2::debug('[Router] role: guest');
/**
* Fix double login issue
* The previous user init refactoring didn't fix this bcos this is in login process and the user role could change
* @see https://github.com/litespeedtech/lscache_wp/commit/69e7bc71d0de5cd58961bae953380b581abdc088
* @since 2.9.8 Won't assign const if in login process
*/
if (substr_compare(wp_login_url(), $GLOBALS['pagenow'], -strlen($GLOBALS['pagenow'])) === 0) {
return $role;
}
}
define('LITESPEED_WP_ROLE', $role);
return LITESPEED_WP_ROLE;
}
/**
* Get frontend path
*
* @since 1.2.2
* @access public
* @return boolean
*/
public static function frontend_path()
{
//todo: move to htaccess.cls ?
if (!isset(self::$_frontend_path)) {
$frontend = rtrim(ABSPATH, '/'); // /home/user/public_html/frontend
// get home path failed. Trac ticket #37668 (e.g. frontend:/blog backend:/wordpress)
if (!$frontend) {
Debug2::debug('[Router] No ABSPATH, generating from home option');
$frontend = parse_url(get_option('home'));
$frontend = !empty($frontend['path']) ? $frontend['path'] : '';
$frontend = $_SERVER['DOCUMENT_ROOT'] . $frontend;
}
$frontend = realpath($frontend);
self::$_frontend_path = $frontend;
}
return self::$_frontend_path;
}
/**
* Check if ESI is enabled or not
*
* @since 1.2.0
* @access public
* @return boolean
*/
public function esi_enabled()
{
if (!isset(self::$_esi_enabled)) {
self::$_esi_enabled = defined('LITESPEED_ON') && $this->conf(self::O_ESI);
if (!empty($_REQUEST[self::ACTION])) {
self::$_esi_enabled = false;
}
}
return self::$_esi_enabled;
}
/**
* Check if crawler is enabled on server level
*
* @since 1.1.1
* @access public
*/
public static function can_crawl()
{
if (isset($_SERVER['X-LSCACHE']) && strpos($_SERVER['X-LSCACHE'], 'crawler') === false) {
return false;
}
// CLI will bypass this check as crawler library can always do the 428 check
if (defined('LITESPEED_CLI')) {
return true;
}
return true;
}
/**
* Check action
*
* @since 1.1.0
* @access public
* @return string
*/
public static function get_action()
{
if (!isset(self::$_action)) {
self::$_action = false;
self::cls()->verify_action();
if (self::$_action) {
defined('LSCWP_LOG') && Debug2::debug('[Router] LSCWP_CTRL verified: ' . var_export(self::$_action, true));
}
}
return self::$_action;
}
/**
* Check if is logged in
*
* @since 1.1.3
* @access public
* @return boolean
*/
public static function is_logged_in()
{
if (!isset(self::$_is_logged_in)) {
self::$_is_logged_in = is_user_logged_in();
}
return self::$_is_logged_in;
}
/**
* Check if is ajax call
*
* @since 1.1.0
* @access public
* @return boolean
*/
public static function is_ajax()
{
if (!isset(self::$_is_ajax)) {
self::$_is_ajax = defined('DOING_AJAX') && DOING_AJAX;
}
return self::$_is_ajax;
}
/**
* Check if is admin ip
*
* @since 1.1.0
* @access public
* @return boolean
*/
public function is_admin_ip()
{
if (!isset(self::$_is_admin_ip)) {
$ips = $this->conf(self::O_DEBUG_IPS);
self::$_is_admin_ip = $this->ip_access($ips);
}
return self::$_is_admin_ip;
}
/**
* Get type value
*
* @since 1.6
* @access public
*/
public static function verify_type()
{
if (empty($_REQUEST[self::TYPE])) {
Debug2::debug('[Router] no type', 2);
return false;
}
Debug2::debug('[Router] parsed type: ' . $_REQUEST[self::TYPE], 2);
return $_REQUEST[self::TYPE];
}
/**
* Check privilege and nonce for the action
*
* @since 1.1.0
* @access private
*/
private function verify_action()
{
if (empty($_REQUEST[Router::ACTION])) {
Debug2::debug2('[Router] LSCWP_CTRL bypassed empty');
return;
}
$action = stripslashes($_REQUEST[Router::ACTION]);
if (!$action) {
return;
}
$_is_public_action = false;
// Each action must have a valid nonce unless its from admin ip and is public action
// Validate requests nonce (from admin logged in page or cli)
if (!$this->verify_nonce($action)) {
// check if it is from admin ip
if (!$this->is_admin_ip()) {
Debug2::debug('[Router] LSCWP_CTRL query string - did not match admin IP: ' . $action);
return;
}
// check if it is public action
if (
!in_array($action, array(
Core::ACTION_QS_NOCACHE,
Core::ACTION_QS_PURGE,
Core::ACTION_QS_PURGE_SINGLE,
Core::ACTION_QS_SHOW_HEADERS,
Core::ACTION_QS_PURGE_ALL,
Core::ACTION_QS_PURGE_EMPTYCACHE,
))
) {
Debug2::debug('[Router] LSCWP_CTRL query string - did not match admin IP Actions: ' . $action);
return;
}
if (apply_filters('litespeed_qs_forbidden', false)) {
Debug2::debug('[Router] LSCWP_CTRL forbidden by hook litespeed_qs_forbidden');
return;
}
$_is_public_action = true;
}
/* Now it is a valid action, lets log and check the permission */
Debug2::debug('[Router] LSCWP_CTRL: ' . $action);
// OK, as we want to do something magic, lets check if its allowed
$_is_multisite = is_multisite();
$_is_network_admin = $_is_multisite && is_network_admin();
$_can_network_option = $_is_network_admin && current_user_can('manage_network_options');
$_can_option = current_user_can('manage_options');
switch ($action) {
case self::ACTION_SAVE_SETTINGS_NETWORK: // Save network settings
if ($_can_network_option) {
self::$_action = $action;
}
return;
case Core::ACTION_PURGE_BY:
if (defined('LITESPEED_ON') && ($_can_network_option || $_can_option || self::is_ajax())) {
//here may need more security
self::$_action = $action;
}
return;
case self::ACTION_DB_OPTM:
if ($_can_network_option || $_can_option) {
self::$_action = $action;
}
return;
case Core::ACTION_PURGE_EMPTYCACHE: // todo: moved to purge.cls type action
if ((defined('LITESPEED_ON') || $_is_network_admin) && ($_can_network_option || (!$_is_multisite && $_can_option))) {
self::$_action = $action;
}
return;
case Core::ACTION_QS_NOCACHE:
case Core::ACTION_QS_PURGE:
case Core::ACTION_QS_PURGE_SINGLE:
case Core::ACTION_QS_SHOW_HEADERS:
case Core::ACTION_QS_PURGE_ALL:
case Core::ACTION_QS_PURGE_EMPTYCACHE:
if (defined('LITESPEED_ON') && ($_is_public_action || self::is_ajax())) {
self::$_action = $action;
}
return;
case self::ACTION_ADMIN_DISPLAY:
case self::ACTION_PLACEHOLDER:
case self::ACTION_AVATAR:
case self::ACTION_IMG_OPTM:
case self::ACTION_CLOUD:
case self::ACTION_CDN_CLOUDFLARE:
case self::ACTION_CRAWLER:
case self::ACTION_PRESET:
case self::ACTION_IMPORT:
case self::ACTION_REPORT:
case self::ACTION_CSS:
case self::ACTION_UCSS:
case self::ACTION_VPI:
case self::ACTION_CONF:
case self::ACTION_ACTIVATION:
case self::ACTION_HEALTH:
case self::ACTION_SAVE_SETTINGS: // Save settings
if ($_can_option && !$_is_network_admin) {
self::$_action = $action;
}
return;
case self::ACTION_PURGE:
case self::ACTION_DEBUG2:
if ($_can_network_option || $_can_option) {
self::$_action = $action;
}
return;
case Core::ACTION_DISMISS:
/**
* Non ajax call can dismiss too
* @since 2.9
*/
// if ( self::is_ajax() ) {
self::$_action = $action;
// }
return;
default:
Debug2::debug('[Router] LSCWP_CTRL match failed: ' . $action);
return;
}
}
/**
* Verify nonce
*
* @since 1.1.0
* @access public
* @param string $action
* @return bool
*/
public function verify_nonce($action)
{
if (!isset($_REQUEST[Router::NONCE]) || !wp_verify_nonce($_REQUEST[Router::NONCE], $action)) {
return false;
} else {
return true;
}
}
/**
* Check if the ip is in the range
*
* @since 1.1.0
* @access public
*/
public function ip_access($ip_list)
{
if (!$ip_list) {
return false;
}
if (!isset(self::$_ip)) {
self::$_ip = self::get_ip();
}
if (!self::$_ip) {
return false;
}
// $uip = explode('.', $_ip);
// if(empty($uip) || count($uip) != 4) Return false;
// foreach($ip_list as $key => $ip) $ip_list[$key] = explode('.', trim($ip));
// foreach($ip_list as $key => $ip) {
// if(count($ip) != 4) continue;
// for($i = 0; $i <= 3; $i++) if($ip[$i] == '*') $ip_list[$key][$i] = $uip[$i];
// }
return in_array(self::$_ip, $ip_list);
}
/**
* Get client ip
*
* @since 1.1.0
* @since 1.6.5 changed to public
* @access public
* @return string
*/
public static function get_ip()
{
$_ip = '';
// if ( function_exists( 'apache_request_headers' ) ) {
// $apache_headers = apache_request_headers();
// $_ip = ! empty( $apache_headers['True-Client-IP'] ) ? $apache_headers['True-Client-IP'] : false;
// if ( ! $_ip ) {
// $_ip = ! empty( $apache_headers['X-Forwarded-For'] ) ? $apache_headers['X-Forwarded-For'] : false;
// $_ip = explode( ',', $_ip );
// $_ip = $_ip[ 0 ];
// }
// }
if (!$_ip) {
$_ip = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : false;
}
return $_ip;
}
/**
* Check if opcode cache is enabled
*
* @since 1.8.2
* @access public
*/
public static function opcache_enabled()
{
return function_exists('opcache_reset') && ini_get('opcache.enable');
}
/**
* Handle static files
*
* @since 3.0
*/
public function serve_static()
{
if (!empty($_SERVER['SCRIPT_URI'])) {
if (strpos($_SERVER['SCRIPT_URI'], LITESPEED_STATIC_URL . '/') !== 0) {
return;
}
$path = substr($_SERVER['SCRIPT_URI'], strlen(LITESPEED_STATIC_URL . '/'));
} elseif (!empty($_SERVER['REQUEST_URI'])) {
$static_path = parse_url(LITESPEED_STATIC_URL, PHP_URL_PATH) . '/';
if (strpos($_SERVER['REQUEST_URI'], $static_path) !== 0) {
return;
}
$path = substr(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), strlen($static_path));
} else {
return;
}
$path = explode('/', $path, 2);
if (empty($path[0]) || empty($path[1])) {
return;
}
switch ($path[0]) {
case 'avatar':
$this->cls('Avatar')->serve_static($path[1]);
break;
case 'localres':
$this->cls('Localization')->serve_static($path[1]);
break;
default:
break;
}
}
/**
* Handle all request actions from main cls
*
* This is different than other handlers
*
* @since 3.0
* @access public
*/
public function handler($cls)
{
if (!in_array($cls, self::$_HANDLERS)) {
return;
}
return $this->cls($cls)->handler();
}
}
str.cls.php 0000644 00000004575 15153741267 0006673 0 ustar 00 <?php
/**
* LiteSpeed String Operator Library Class
*
* @since 1.3
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Str
{
/**
* Translate QC HTML links from html. Convert `<a href="{#xxx#}">xxxx</a>` to `<a href="xxx">xxxx</a>`
*
* @since 7.0
*/
public static function translate_qc_apis($html)
{
preg_match_all('/<a href="{#(\w+)#}"/U', $html, $matches);
if (!$matches) {
return $html;
}
foreach ($matches[0] as $k => $html_to_be_replaced) {
$link = '<a href="' . Utility::build_url(Router::ACTION_CLOUD, Cloud::TYPE_API, false, null, array('action2' => $matches[1][$k])) . '"';
$html = str_replace($html_to_be_replaced, $link, $html);
}
return $html;
}
/**
* Return safe HTML
*
* @since 7.0
*/
public static function safe_html($html)
{
$common_attrs = array(
'style' => array(),
'class' => array(),
'target' => array(),
'src' => array(),
'color' => array(),
'href' => array(),
);
$tags = array('hr', 'h3', 'h4', 'h5', 'ul', 'li', 'br', 'strong', 'p', 'span', 'img', 'a', 'div', 'font');
$allowed_tags = array();
foreach ($tags as $tag) {
$allowed_tags[$tag] = $common_attrs;
}
return wp_kses($html, $allowed_tags);
}
/**
* Generate random string
*
* @since 1.3
* @access public
* @param int $len Length of string
* @param int $type 1-Number 2-LowerChar 4-UpperChar
* @return string
*/
public static function rrand($len, $type = 7)
{
switch ($type) {
case 0:
$charlist = '012';
break;
case 1:
$charlist = '0123456789';
break;
case 2:
$charlist = 'abcdefghijklmnopqrstuvwxyz';
break;
case 3:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyz';
break;
case 4:
$charlist = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 5:
$charlist = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 6:
$charlist = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 7:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
}
$str = '';
$max = strlen($charlist) - 1;
for ($i = 0; $i < $len; $i++) {
$str .= $charlist[random_int(0, $max)];
}
return $str;
}
/**
* Trim double quotes from a string to be used as a preformatted src in HTML.
* @since 6.5.3
*/
public static function trim_quotes($string)
{
return str_replace('"', '', $string);
}
}
tag.cls.php 0000644 00000021633 15153741267 0006630 0 ustar 00 <?php
/**
* The plugin cache-tag class for X-LiteSpeed-Tag
*
* @since 1.1.3
* @since 1.5 Moved into /inc
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Tag extends Root
{
const TYPE_FEED = 'FD';
const TYPE_FRONTPAGE = 'F';
const TYPE_HOME = 'H';
const TYPE_PAGES = 'PGS';
const TYPE_PAGES_WITH_RECENT_POSTS = 'PGSRP';
const TYPE_HTTP = 'HTTP.';
const TYPE_POST = 'Po.'; // Post. Cannot use P, reserved for litemage.
const TYPE_ARCHIVE_POSTTYPE = 'PT.';
const TYPE_ARCHIVE_TERM = 'T.'; //for is_category|is_tag|is_tax
const TYPE_AUTHOR = 'A.';
const TYPE_ARCHIVE_DATE = 'D.';
const TYPE_BLOG = 'B.';
const TYPE_LOGIN = 'L';
const TYPE_URL = 'URL.';
const TYPE_WIDGET = 'W.';
const TYPE_ESI = 'ESI.';
const TYPE_REST = 'REST';
const TYPE_AJAX = 'AJAX.';
const TYPE_LIST = 'LIST';
const TYPE_MIN = 'MIN';
const TYPE_LOCALRES = 'LOCALRES';
const X_HEADER = 'X-LiteSpeed-Tag';
private static $_tags = array();
private static $_tags_priv = array('tag_priv');
public static $error_code_tags = array(403, 404, 500);
/**
* Initialize
*
* @since 4.0
*/
public function init()
{
// register recent posts widget tag before theme renders it to make it work
add_filter('widget_posts_args', array($this, 'add_widget_recent_posts'));
}
/**
* Check if the login page is cacheable.
* If not, unset the cacheable member variable.
*
* NOTE: This is checked separately because login page doesn't go through WP logic.
*
* @since 1.0.0
* @access public
*/
public function check_login_cacheable()
{
if (!$this->conf(Base::O_CACHE_PAGE_LOGIN)) {
return;
}
if (Control::isset_notcacheable()) {
return;
}
if (!empty($_GET)) {
Control::set_nocache('has GET request');
return;
}
$this->cls('Control')->set_cacheable();
self::add(self::TYPE_LOGIN);
// we need to send lsc-cookie manually to make it be sent to all other users when is cacheable
$list = headers_list();
if (empty($list)) {
return;
}
foreach ($list as $hdr) {
if (strncasecmp($hdr, 'set-cookie:', 11) == 0) {
$cookie = substr($hdr, 12);
@header('lsc-cookie: ' . $cookie, false);
}
}
}
/**
* Register purge tag for pages with recent posts widget
* of the plugin.
*
* @since 1.0.15
* @access public
* @param array $params [wordpress params for widget_posts_args]
*/
public function add_widget_recent_posts($params)
{
self::add(self::TYPE_PAGES_WITH_RECENT_POSTS);
return $params;
}
/**
* Adds cache tags to the list of cache tags for the current page.
*
* @since 1.0.5
* @access public
* @param mixed $tags A string or array of cache tags to add to the current list.
*/
public static function add($tags)
{
if (!is_array($tags)) {
$tags = array($tags);
}
Debug2::debug('💰 [Tag] Add ', $tags);
self::$_tags = array_merge(self::$_tags, $tags);
// Send purge header immediately
$tag_header = self::cls()->output(true);
@header($tag_header);
}
/**
* Add a post id to cache tag
*
* @since 3.0
* @access public
*/
public static function add_post($pid)
{
self::add(self::TYPE_POST . $pid);
}
/**
* Add a widget id to cache tag
*
* @since 3.0
* @access public
*/
public static function add_widget($id)
{
self::add(self::TYPE_WIDGET . $id);
}
/**
* Add a private ESI to cache tag
*
* @since 3.0
* @access public
*/
public static function add_private_esi($tag)
{
self::add_private(self::TYPE_ESI . $tag);
}
/**
* Adds private cache tags to the list of cache tags for the current page.
*
* @since 1.6.3
* @access public
* @param mixed $tags A string or array of cache tags to add to the current list.
*/
public static function add_private($tags)
{
if (!is_array($tags)) {
$tags = array($tags);
}
self::$_tags_priv = array_merge(self::$_tags_priv, $tags);
}
/**
* Return tags for Admin QS
*
* @since 1.1.3
* @access public
*/
public static function output_tags()
{
return self::$_tags;
}
/**
* Will get a hash of the URI. Removes query string and appends a '/' if it is missing.
*
* @since 1.0.12
* @access public
* @param string $uri The uri to get the hash of.
* @param boolean $ori Return the original url or not
* @return bool|string False on input error, hash otherwise.
*/
public static function get_uri_tag($uri, $ori = false)
{
$no_qs = strtok($uri, '?');
if (empty($no_qs)) {
return false;
}
$slashed = trailingslashit($no_qs);
// If only needs uri tag
if ($ori) {
return $slashed;
}
if (defined('LSCWP_LOG')) {
return self::TYPE_URL . $slashed;
}
return self::TYPE_URL . md5($slashed);
}
/**
* Get the unique tag based on self url.
*
* @since 1.1.3
* @access public
* @param boolean $ori Return the original url or not
*/
public static function build_uri_tag($ori = false)
{
return self::get_uri_tag(urldecode($_SERVER['REQUEST_URI']), $ori);
}
/**
* Gets the cache tags to set for the page.
*
* This includes site wide post types (e.g. front page) as well as
* any third party plugin specific cache tags.
*
* @since 1.0.0
* @access private
* @return array The list of cache tags to set.
*/
private static function _build_type_tags()
{
$tags = array();
$tags[] = Utility::page_type();
$tags[] = self::build_uri_tag();
if (is_front_page()) {
$tags[] = self::TYPE_FRONTPAGE;
} elseif (is_home()) {
$tags[] = self::TYPE_HOME;
}
global $wp_query;
if (isset($wp_query)) {
$queried_obj_id = get_queried_object_id();
if (is_archive()) {
//An Archive is a Category, Tag, Author, Date, Custom Post Type or Custom Taxonomy based pages.
if (is_category() || is_tag() || is_tax()) {
$tags[] = self::TYPE_ARCHIVE_TERM . $queried_obj_id;
} elseif (is_post_type_archive() && ($post_type = get_post_type())) {
$tags[] = self::TYPE_ARCHIVE_POSTTYPE . $post_type;
} elseif (is_author()) {
$tags[] = self::TYPE_AUTHOR . $queried_obj_id;
} elseif (is_date()) {
global $post;
if ($post && isset($post->post_date)) {
$date = $post->post_date;
$date = strtotime($date);
if (is_day()) {
$tags[] = self::TYPE_ARCHIVE_DATE . date('Ymd', $date);
} elseif (is_month()) {
$tags[] = self::TYPE_ARCHIVE_DATE . date('Ym', $date);
} elseif (is_year()) {
$tags[] = self::TYPE_ARCHIVE_DATE . date('Y', $date);
}
}
}
} elseif (is_singular()) {
//$this->is_singular = $this->is_single || $this->is_page || $this->is_attachment;
$tags[] = self::TYPE_POST . $queried_obj_id;
if (is_page()) {
$tags[] = self::TYPE_PAGES;
}
} elseif (is_feed()) {
$tags[] = self::TYPE_FEED;
}
}
// Check REST API
if (REST::cls()->is_rest()) {
$tags[] = self::TYPE_REST;
$path = !empty($_SERVER['SCRIPT_URL']) ? $_SERVER['SCRIPT_URL'] : false;
if ($path) {
// posts collections tag
if (substr($path, -6) == '/posts') {
$tags[] = self::TYPE_LIST; // Not used for purge yet
}
// single post tag
global $post;
if (!empty($post->ID) && substr($path, -strlen($post->ID) - 1) === '/' . $post->ID) {
$tags[] = self::TYPE_POST . $post->ID;
}
// pages collections & single page tag
if (stripos($path, '/pages') !== false) {
$tags[] = self::TYPE_PAGES;
}
}
}
// Append AJAX action tag
if (Router::is_ajax() && !empty($_REQUEST['action'])) {
$tags[] = self::TYPE_AJAX . $_REQUEST['action'];
}
return $tags;
}
/**
* Generate all cache tags before output
*
* @access private
* @since 1.1.3
*/
private static function _finalize()
{
// run 3rdparty hooks to tag
do_action('litespeed_tag_finalize');
// generate wp tags
if (!defined('LSCACHE_IS_ESI')) {
$type_tags = self::_build_type_tags();
self::$_tags = array_merge(self::$_tags, $type_tags);
}
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
self::$_tags[] = 'guest';
}
// append blog main tag
self::$_tags[] = '';
// removed duplicates
self::$_tags = array_unique(self::$_tags);
}
/**
* Sets up the Cache Tags header.
* ONLY need to run this if is cacheable
*
* @since 1.1.3
* @access public
* @return string empty string if empty, otherwise the cache tags header.
*/
public function output($no_finalize = false)
{
if (defined('LSCACHE_NO_CACHE') && LSCACHE_NO_CACHE) {
return;
}
if (!$no_finalize) {
self::_finalize();
}
$prefix_tags = array();
/**
* Only append blog_id when is multisite
* @since 2.9.3
*/
$prefix = LSWCP_TAG_PREFIX . (is_multisite() ? get_current_blog_id() : '') . '_';
// If is_private and has private tags, append them first, then specify prefix to `public` for public tags
if (Control::is_private()) {
foreach (self::$_tags_priv as $priv_tag) {
$prefix_tags[] = $prefix . $priv_tag;
}
$prefix = 'public:' . $prefix;
}
foreach (self::$_tags as $tag) {
$prefix_tags[] = $prefix . $tag;
}
$hdr = self::X_HEADER . ': ' . implode(',', $prefix_tags);
return $hdr;
}
}
task.cls.php 0000644 00000013661 15153741267 0007021 0 ustar 00 <?php
/**
* The cron task class.
*
* @since 1.1.3
* @since 1.5 Moved into /inc
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Task extends Root
{
const LOG_TAG = '⏰';
private static $_triggers = array(
Base::O_IMG_OPTM_CRON => array('name' => 'litespeed_task_imgoptm_pull', 'hook' => 'LiteSpeed\Img_Optm::start_async_cron'), // always fetch immediately
Base::O_OPTM_CSS_ASYNC => array('name' => 'litespeed_task_ccss', 'hook' => 'LiteSpeed\CSS::cron_ccss'),
Base::O_OPTM_UCSS => array('name' => 'litespeed_task_ucss', 'hook' => 'LiteSpeed\UCSS::cron'),
Base::O_MEDIA_VPI_CRON => array('name' => 'litespeed_task_vpi', 'hook' => 'LiteSpeed\VPI::cron'),
Base::O_MEDIA_PLACEHOLDER_RESP_ASYNC => array('name' => 'litespeed_task_lqip', 'hook' => 'LiteSpeed\Placeholder::cron'),
Base::O_DISCUSS_AVATAR_CRON => array('name' => 'litespeed_task_avatar', 'hook' => 'LiteSpeed\Avatar::cron'),
Base::O_IMG_OPTM_AUTO => array('name' => 'litespeed_task_imgoptm_req', 'hook' => 'LiteSpeed\Img_Optm::cron_auto_request'),
Base::O_CRAWLER => array('name' => 'litespeed_task_crawler', 'hook' => 'LiteSpeed\Crawler::start_async_cron'), // Set crawler to last one to use above results
);
private static $_guest_options = array(Base::O_OPTM_CSS_ASYNC, Base::O_OPTM_UCSS, Base::O_MEDIA_VPI);
const FILTER_CRAWLER = 'litespeed_crawl_filter';
const FILTER = 'litespeed_filter';
/**
* Keep all tasks in cron
*
* @since 3.0
* @access public
*/
public function init()
{
self::debug2('Init');
add_filter('cron_schedules', array($this, 'lscache_cron_filter'));
$guest_optm = $this->conf(Base::O_GUEST) && $this->conf(Base::O_GUEST_OPTM);
foreach (self::$_triggers as $id => $trigger) {
if ($id != Base::O_IMG_OPTM_CRON && !$this->conf($id)) {
if (!$guest_optm || !in_array($id, self::$_guest_options)) {
continue;
}
}
// Special check for crawler
if ($id == Base::O_CRAWLER) {
if (!Router::can_crawl()) {
continue;
}
add_filter('cron_schedules', array($this, 'lscache_cron_filter_crawler'));
}
if (!wp_next_scheduled($trigger['name'])) {
self::debug('Cron hook register [name] ' . $trigger['name']);
wp_schedule_event(time(), $id == Base::O_CRAWLER ? self::FILTER_CRAWLER : self::FILTER, $trigger['name']);
}
add_action($trigger['name'], $trigger['hook']);
}
}
/**
* Handle all async noabort requests
*
* @since 5.5
*/
public static function async_litespeed_handler()
{
$hash_data = self::get_option('async_call-hash', array());
if (!$hash_data || !is_array($hash_data) || empty($hash_data['hash']) || empty($hash_data['ts'])) {
self::debug('async_litespeed_handler no hash data', $hash_data);
return;
}
if (time() - $hash_data['ts'] > 120 || empty($_GET['nonce']) || $_GET['nonce'] != $hash_data['hash']) {
self::debug('async_litespeed_handler nonce mismatch');
return;
}
self::delete_option('async_call-hash');
$type = Router::verify_type();
self::debug('type=' . $type);
// Don't lock up other requests while processing
session_write_close();
switch ($type) {
case 'crawler':
Crawler::async_handler();
break;
case 'crawler_force':
Crawler::async_handler(true);
break;
case 'imgoptm':
Img_Optm::async_handler();
break;
case 'imgoptm_force':
Img_Optm::async_handler(true);
break;
default:
}
}
/**
* Async caller wrapper func
*
* @since 5.5
*/
public static function async_call($type)
{
$hash = Str::rrand(32);
self::update_option('async_call-hash', array('hash' => $hash, 'ts' => time()));
$args = array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
// 'cookies' => $_COOKIE,
);
$qs = array(
'action' => 'async_litespeed',
'nonce' => $hash,
Router::TYPE => $type,
);
$url = add_query_arg($qs, admin_url('admin-ajax.php'));
self::debug('async call to ' . $url);
wp_safe_remote_post(esc_url_raw($url), $args);
}
/**
* Clean all potential existing crons
*
* @since 3.0
* @access public
*/
public static function destroy()
{
Utility::compatibility();
array_map('wp_clear_scheduled_hook', array_column(self::$_triggers, 'name'));
}
/**
* Try to clean the crons if disabled
*
* @since 3.0
* @access public
*/
public function try_clean($id)
{
// Clean v2's leftover cron ( will remove in v3.1 )
// foreach ( wp_get_ready_cron_jobs() as $hooks ) {
// foreach ( $hooks as $hook => $v ) {
// if ( strpos( $hook, 'litespeed_' ) === 0 && ( substr( $hook, -8 ) === '_trigger' || strpos( $hook, 'litespeed_task_' ) !== 0 ) ) {
// self::debug( 'Cron clear legacy [hook] ' . $hook );
// wp_clear_scheduled_hook( $hook );
// }
// }
// }
if ($id && !empty(self::$_triggers[$id])) {
if (!$this->conf($id) || ($id == Base::O_CRAWLER && !Router::can_crawl())) {
self::debug('Cron clear [id] ' . $id . ' [hook] ' . self::$_triggers[$id]['name']);
wp_clear_scheduled_hook(self::$_triggers[$id]['name']);
}
return;
}
self::debug('❌ Unknown cron [id] ' . $id);
}
/**
* Register cron interval imgoptm
*
* @since 1.6.1
* @access public
*/
public function lscache_cron_filter($schedules)
{
if (!array_key_exists(self::FILTER, $schedules)) {
$schedules[self::FILTER] = array(
'interval' => 60,
'display' => __('Every Minute', 'litespeed-cache'),
);
}
return $schedules;
}
/**
* Register cron interval
*
* @since 1.1.0
* @access public
*/
public function lscache_cron_filter_crawler($schedules)
{
$CRAWLER_RUN_INTERVAL = defined('LITESPEED_CRAWLER_RUN_INTERVAL') ? LITESPEED_CRAWLER_RUN_INTERVAL : 600;
// $wp_schedules = wp_get_schedules();
if (!array_key_exists(self::FILTER_CRAWLER, $schedules)) {
// self::debug('Crawler cron log: cron filter '.$interval.' added');
$schedules[self::FILTER_CRAWLER] = array(
'interval' => $CRAWLER_RUN_INTERVAL,
'display' => __('LiteSpeed Crawler Cron', 'litespeed-cache'),
);
}
return $schedules;
}
}
tool.cls.php 0000644 00000006653 15153741267 0007037 0 ustar 00 <?php
/**
* The tools
*
* @since 3.0
* @package LiteSpeed
* @subpackage LiteSpeed/inc
* @author LiteSpeed Technologies <info@litespeedtech.com>
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Tool extends Root
{
const LOG_TAG = '[Tool]';
/**
* Get public IP
*
* @since 3.0
* @access public
*/
public function check_ip()
{
self::debug('✅ check_ip');
$response = wp_safe_remote_get('https://cyberpanel.sh/?ip', array(
'headers' => array(
'User-Agent' => 'curl/8.7.1',
),
));
if (is_wp_error($response)) {
return __('Failed to detect IP', 'litespeed-cache');
}
$ip = trim($response['body']);
self::debug('result [ip] ' . $ip);
if (Utility::valid_ipv4($ip)) {
return $ip;
}
return __('Failed to detect IP', 'litespeed-cache');
}
/**
* Heartbeat Control
*
* NOTE: since WP4.9, there could be a core bug that sometimes the hook is not working.
*
* @since 3.0
* @access public
*/
public function heartbeat()
{
add_action('wp_enqueue_scripts', array($this, 'heartbeat_frontend'));
add_action('admin_enqueue_scripts', array($this, 'heartbeat_backend'));
add_filter('heartbeat_settings', array($this, 'heartbeat_settings'));
}
/**
* Heartbeat Control frontend control
*
* @since 3.0
* @access public
*/
public function heartbeat_frontend()
{
if (!$this->conf(Base::O_MISC_HEARTBEAT_FRONT)) {
return;
}
if (!$this->conf(Base::O_MISC_HEARTBEAT_FRONT_TTL)) {
wp_deregister_script('heartbeat');
Debug2::debug('[Tool] Deregistered frontend heartbeat');
}
}
/**
* Heartbeat Control backend control
*
* @since 3.0
* @access public
*/
public function heartbeat_backend()
{
if ($this->_is_editor()) {
if (!$this->conf(Base::O_MISC_HEARTBEAT_EDITOR)) {
return;
}
if (!$this->conf(Base::O_MISC_HEARTBEAT_EDITOR_TTL)) {
wp_deregister_script('heartbeat');
Debug2::debug('[Tool] Deregistered editor heartbeat');
}
} else {
if (!$this->conf(Base::O_MISC_HEARTBEAT_BACK)) {
return;
}
if (!$this->conf(Base::O_MISC_HEARTBEAT_BACK_TTL)) {
wp_deregister_script('heartbeat');
Debug2::debug('[Tool] Deregistered backend heartbeat');
}
}
}
/**
* Heartbeat Control settings
*
* @since 3.0
* @access public
*/
public function heartbeat_settings($settings)
{
// Check editor first to make frontend editor valid too
if ($this->_is_editor()) {
if ($this->conf(Base::O_MISC_HEARTBEAT_EDITOR)) {
$settings['interval'] = $this->conf(Base::O_MISC_HEARTBEAT_EDITOR_TTL);
Debug2::debug('[Tool] Heartbeat interval set to ' . $this->conf(Base::O_MISC_HEARTBEAT_EDITOR_TTL));
}
} elseif (!is_admin()) {
if ($this->conf(Base::O_MISC_HEARTBEAT_FRONT)) {
$settings['interval'] = $this->conf(Base::O_MISC_HEARTBEAT_FRONT_TTL);
Debug2::debug('[Tool] Heartbeat interval set to ' . $this->conf(Base::O_MISC_HEARTBEAT_FRONT_TTL));
}
} else {
if ($this->conf(Base::O_MISC_HEARTBEAT_BACK)) {
$settings['interval'] = $this->conf(Base::O_MISC_HEARTBEAT_BACK_TTL);
Debug2::debug('[Tool] Heartbeat interval set to ' . $this->conf(Base::O_MISC_HEARTBEAT_BACK_TTL));
}
}
return $settings;
}
/**
* If is in editor
*
* @since 3.0
* @access public
*/
private function _is_editor()
{
$res = is_admin() && Utility::str_hit_array($_SERVER['REQUEST_URI'], array('post.php', 'post-new.php'));
return apply_filters('litespeed_is_editor', $res);
}
}
ucss.cls.php 0000644 00000034261 15153741267 0007033 0 ustar 00 <?php
/**
* The ucss class.
*
* @since 5.1
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class UCSS extends Base
{
const LOG_TAG = '[UCSS]';
const TYPE_GEN = 'gen';
const TYPE_CLEAR_Q = 'clear_q';
protected $_summary;
private $_ucss_whitelist;
private $_queue;
/**
* Init
*
* @since 3.0
*/
public function __construct()
{
$this->_summary = self::get_summary();
add_filter('litespeed_ucss_whitelist', array($this->cls('Data'), 'load_ucss_whitelist'));
}
/**
* Uniform url tag for ucss usage
* @since 4.7
*/
public static function get_url_tag($request_url = false)
{
$url_tag = $request_url;
if (is_404()) {
$url_tag = '404';
} elseif (apply_filters('litespeed_ucss_per_pagetype', false)) {
$url_tag = Utility::page_type();
self::debug('litespeed_ucss_per_pagetype filter altered url to ' . $url_tag);
}
return $url_tag;
}
/**
* Get UCSS path
*
* @since 4.0
*/
public function load($request_url, $dry_run = false)
{
// Check UCSS URI excludes
$ucss_exc = apply_filters('litespeed_ucss_exc', $this->conf(self::O_OPTM_UCSS_EXC));
if ($ucss_exc && ($hit = Utility::str_hit_array($request_url, $ucss_exc))) {
self::debug('UCSS bypassed due to UCSS URI Exclude setting: ' . $hit);
Core::comment('QUIC.cloud UCSS bypassed by setting');
return false;
}
$filepath_prefix = $this->_build_filepath_prefix('ucss');
$url_tag = self::get_url_tag($request_url);
$vary = $this->cls('Vary')->finalize_full_varies();
$filename = $this->cls('Data')->load_url_file($url_tag, $vary, 'ucss');
if ($filename) {
$static_file = LITESPEED_STATIC_DIR . $filepath_prefix . $filename . '.css';
if (file_exists($static_file)) {
self::debug2('existing ucss ' . $static_file);
// Check if is error comment inside only
$tmp = File::read($static_file);
if (substr($tmp, 0, 2) == '/*' && substr(trim($tmp), -2) == '*/') {
self::debug2('existing ucss is error only: ' . $tmp);
Core::comment('QUIC.cloud UCSS bypassed due to generation error ❌ ' . $filepath_prefix . $filename . '.css');
return false;
}
Core::comment('QUIC.cloud UCSS loaded ✅');
return $filename . '.css';
}
}
if ($dry_run) {
return false;
}
Core::comment('QUIC.cloud UCSS in queue');
$uid = get_current_user_id();
$ua = $this->_get_ua();
// Store it for cron
$this->_queue = $this->load_queue('ucss');
if (count($this->_queue) > 500) {
self::debug('UCSS Queue is full - 500');
return false;
}
$queue_k = (strlen($vary) > 32 ? md5($vary) : $vary) . ' ' . $url_tag;
$this->_queue[$queue_k] = array(
'url' => apply_filters('litespeed_ucss_url', $request_url),
'user_agent' => substr($ua, 0, 200),
'is_mobile' => $this->_separate_mobile(),
'is_webp' => $this->cls('Media')->webp_support() ? 1 : 0,
'uid' => $uid,
'vary' => $vary,
'url_tag' => $url_tag,
); // Current UA will be used to request
$this->save_queue('ucss', $this->_queue);
self::debug('Added queue_ucss [url_tag] ' . $url_tag . ' [UA] ' . $ua . ' [vary] ' . $vary . ' [uid] ' . $uid);
// Prepare cache tag for later purge
Tag::add('UCSS.' . md5($queue_k));
return false;
}
/**
* Get User Agent
*
* @since 5.3
*/
private function _get_ua()
{
return !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
}
/**
* Add rows to q
*
* @since 5.3
*/
public function add_to_q($url_files)
{
// Store it for cron
$this->_queue = $this->load_queue('ucss');
if (count($this->_queue) > 500) {
self::debug('UCSS Queue is full - 500');
return false;
}
$ua = $this->_get_ua();
foreach ($url_files as $url_file) {
$vary = $url_file['vary'];
$request_url = $url_file['url'];
$is_mobile = $url_file['mobile'];
$is_webp = $url_file['webp'];
$url_tag = self::get_url_tag($request_url);
$queue_k = (strlen($vary) > 32 ? md5($vary) : $vary) . ' ' . $url_tag;
$q = array(
'url' => apply_filters('litespeed_ucss_url', $request_url),
'user_agent' => substr($ua, 0, 200),
'is_mobile' => $is_mobile,
'is_webp' => $is_webp,
'uid' => false,
'vary' => $vary,
'url_tag' => $url_tag,
); // Current UA will be used to request
self::debug('Added queue_ucss [url_tag] ' . $url_tag . ' [UA] ' . $ua . ' [vary] ' . $vary . ' [uid] false');
$this->_queue[$queue_k] = $q;
}
$this->save_queue('ucss', $this->_queue);
}
/**
* Generate UCSS
*
* @since 4.0
*/
public static function cron($continue = false)
{
$_instance = self::cls();
return $_instance->_cron_handler($continue);
}
/**
* Handle UCSS cron
*
* @since 4.2
*/
private function _cron_handler($continue)
{
$this->_queue = $this->load_queue('ucss');
if (empty($this->_queue)) {
return;
}
// For cron, need to check request interval too
if (!$continue) {
if (!empty($this->_summary['curr_request']) && time() - $this->_summary['curr_request'] < 300 && !$this->conf(self::O_DEBUG)) {
self::debug('Last request not done');
return;
}
}
$i = 0;
foreach ($this->_queue as $k => $v) {
if (!empty($v['_status'])) {
continue;
}
self::debug('cron job [tag] ' . $k . ' [url] ' . $v['url'] . ($v['is_mobile'] ? ' 📱 ' : '') . ' [UA] ' . $v['user_agent']);
if (!isset($v['is_webp'])) {
$v['is_webp'] = false;
}
$i++;
$res = $this->_send_req($v['url'], $k, $v['uid'], $v['user_agent'], $v['vary'], $v['url_tag'], $v['is_mobile'], $v['is_webp']);
if (!$res) {
// Status is wrong, drop this this->_queue
$this->_queue = $this->load_queue('ucss');
unset($this->_queue[$k]);
$this->save_queue('ucss', $this->_queue);
if (!$continue) {
return;
}
if ($i > 3) {
GUI::print_loading(count($this->_queue), 'UCSS');
return Router::self_redirect(Router::ACTION_UCSS, self::TYPE_GEN);
}
continue;
}
// Exit queue if out of quota or service is hot
if ($res === 'out_of_quota' || $res === 'svc_hot') {
return;
}
$this->_queue = $this->load_queue('ucss');
$this->_queue[$k]['_status'] = 'requested';
$this->save_queue('ucss', $this->_queue);
self::debug('Saved to queue [k] ' . $k);
// only request first one
if (!$continue) {
return;
}
if ($i > 3) {
GUI::print_loading(count($this->_queue), 'UCSS');
return Router::self_redirect(Router::ACTION_UCSS, self::TYPE_GEN);
}
}
}
/**
* Send to QC API to generate UCSS
*
* @since 2.3
* @access private
*/
private function _send_req($request_url, $queue_k, $uid, $user_agent, $vary, $url_tag, $is_mobile, $is_webp)
{
// Check if has credit to push or not
$err = false;
$allowance = $this->cls('Cloud')->allowance(Cloud::SVC_UCSS, $err);
if (!$allowance) {
self::debug('❌ No credit: ' . $err);
$err && Admin_Display::error(Error::msg($err));
return 'out_of_quota';
}
set_time_limit(120);
// Update css request status
$this->_summary['curr_request'] = time();
self::save_summary();
// Gather guest HTML to send
$html = $this->cls('CSS')->prepare_html($request_url, $user_agent, $uid);
if (!$html) {
return false;
}
// Parse HTML to gather all CSS content before requesting
$css = false;
list(, $html) = $this->prepare_css($html, $is_webp, true); // Use this to drop CSS from HTML as we don't need those CSS to generate UCSS
$filename = $this->cls('Data')->load_url_file($url_tag, $vary, 'css');
$filepath_prefix = $this->_build_filepath_prefix('css');
$static_file = LITESPEED_STATIC_DIR . $filepath_prefix . $filename . '.css';
self::debug('Checking combined file ' . $static_file);
if (file_exists($static_file)) {
$css = File::read($static_file);
}
if (!$css) {
self::debug('❌ No combined css');
return false;
}
$data = array(
'url' => $request_url,
'queue_k' => $queue_k,
'user_agent' => $user_agent,
'is_mobile' => $is_mobile ? 1 : 0, // todo:compatible w/ tablet
'is_webp' => $is_webp ? 1 : 0,
'html' => $html,
'css' => $css,
);
if (!isset($this->_ucss_whitelist)) {
$this->_ucss_whitelist = $this->_filter_whitelist();
}
$data['whitelist'] = $this->_ucss_whitelist;
self::debug('Generating: ', $data);
$json = Cloud::post(Cloud::SVC_UCSS, $data, 30);
if (!is_array($json)) {
return $json;
}
// Old version compatibility
if (empty($json['status'])) {
if (!empty($json['ucss'])) {
$this->_save_con('ucss', $json['ucss'], $queue_k, $is_mobile, $is_webp);
}
// Delete the row
return false;
}
// Unknown status, remove this line
if ($json['status'] != 'queued') {
return false;
}
// Save summary data
$this->_summary['last_spent'] = time() - $this->_summary['curr_request'];
$this->_summary['last_request'] = $this->_summary['curr_request'];
$this->_summary['curr_request'] = 0;
self::save_summary();
return true;
}
/**
* Save UCSS content
*
* @since 4.2
*/
private function _save_con($type, $css, $queue_k, $is_mobile, $is_webp)
{
// Add filters
$css = apply_filters('litespeed_' . $type, $css, $queue_k);
self::debug2('con: ', $css);
if (substr($css, 0, 2) == '/*' && substr($css, -2) == '*/') {
self::debug('❌ empty ' . $type . ' [content] ' . $css);
// continue; // Save the error info too
}
// Write to file
$filecon_md5 = md5($css);
$filepath_prefix = $this->_build_filepath_prefix($type);
$static_file = LITESPEED_STATIC_DIR . $filepath_prefix . $filecon_md5 . '.css';
File::save($static_file, $css, true);
$url_tag = $this->_queue[$queue_k]['url_tag'];
$vary = $this->_queue[$queue_k]['vary'];
self::debug2("Save URL to file [file] $static_file [vary] $vary");
$this->cls('Data')->save_url($url_tag, $vary, $type, $filecon_md5, dirname($static_file), $is_mobile, $is_webp);
Purge::add(strtoupper($type) . '.' . md5($queue_k));
}
/**
* Prepare CSS from HTML for CCSS generation only. UCSS will used combined CSS directly.
* Prepare refined HTML for both CCSS and UCSS.
*
* @since 3.4.3
*/
public function prepare_css($html, $is_webp = false, $dryrun = false)
{
$css = '';
preg_match_all('#<link ([^>]+)/?>|<style([^>]*)>([^<]+)</style>#isU', $html, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$debug_info = '';
if (strpos($match[0], '<link') === 0) {
$attrs = Utility::parse_attr($match[1]);
if (empty($attrs['rel'])) {
continue;
}
if ($attrs['rel'] != 'stylesheet') {
if ($attrs['rel'] != 'preload' || empty($attrs['as']) || $attrs['as'] != 'style') {
continue;
}
}
if (!empty($attrs['media']) && strpos($attrs['media'], 'print') !== false) {
continue;
}
if (empty($attrs['href'])) {
continue;
}
// Check Google fonts hit
if (strpos($attrs['href'], 'fonts.googleapis.com') !== false) {
$html = str_replace($match[0], '', $html);
continue;
}
$debug_info = $attrs['href'];
// Load CSS content
if (!$dryrun) {
// Dryrun will not load CSS but just drop them
$con = $this->cls('Optimizer')->load_file($attrs['href']);
if (!$con) {
continue;
}
} else {
$con = '';
}
} else {
// Inline style
$attrs = Utility::parse_attr($match[2]);
if (!empty($attrs['media']) && strpos($attrs['media'], 'print') !== false) {
continue;
}
Debug2::debug2('[CSS] Load inline CSS ' . substr($match[3], 0, 100) . '...', $attrs);
$con = $match[3];
$debug_info = '__INLINE__';
}
$con = Optimizer::minify_css($con);
if ($is_webp && $this->cls('Media')->webp_support()) {
$con = $this->cls('Media')->replace_background_webp($con);
}
if (!empty($attrs['media']) && $attrs['media'] !== 'all') {
$con = '@media ' . $attrs['media'] . '{' . $con . "}\n";
} else {
$con = $con . "\n";
}
$con = '/* ' . $debug_info . ' */' . $con;
$css .= $con;
$html = str_replace($match[0], '', $html);
}
return array($css, $html);
}
/**
* Filter the comment content, add quotes to selector from whitelist. Return the json
*
* @since 3.3
*/
private function _filter_whitelist()
{
$whitelist = array();
$list = apply_filters('litespeed_ucss_whitelist', $this->conf(self::O_OPTM_UCSS_SELECTOR_WHITELIST));
foreach ($list as $k => $v) {
if (substr($v, 0, 2) === '//') {
continue;
}
// Wrap in quotes for selectors
if (substr($v, 0, 1) !== '/' && strpos($v, '"') === false && strpos($v, "'") === false) {
// $v = "'$v'";
}
$whitelist[] = $v;
}
return $whitelist;
}
/**
* Notify finished from server
* @since 5.1
*/
public function notify()
{
$post_data = \json_decode(file_get_contents('php://input'), true);
if (is_null($post_data)) {
$post_data = $_POST;
}
self::debug('notify() data', $post_data);
$this->_queue = $this->load_queue('ucss');
list($post_data) = $this->cls('Cloud')->extract_msg($post_data, 'ucss');
$notified_data = $post_data['data'];
if (empty($notified_data) || !is_array($notified_data)) {
self::debug('❌ notify exit: no notified data');
return Cloud::err('no notified data');
}
// Check if its in queue or not
$valid_i = 0;
foreach ($notified_data as $v) {
if (empty($v['request_url'])) {
self::debug('❌ notify bypass: no request_url', $v);
continue;
}
if (empty($v['queue_k'])) {
self::debug('❌ notify bypass: no queue_k', $v);
continue;
}
if (empty($this->_queue[$v['queue_k']])) {
self::debug('❌ notify bypass: no this queue [q_k]' . $v['queue_k']);
continue;
}
// Save data
if (!empty($v['data_ucss'])) {
$is_mobile = $this->_queue[$v['queue_k']]['is_mobile'];
$is_webp = $this->_queue[$v['queue_k']]['is_webp'];
$this->_save_con('ucss', $v['data_ucss'], $v['queue_k'], $is_mobile, $is_webp);
$valid_i++;
}
unset($this->_queue[$v['queue_k']]);
self::debug('notify data handled, unset queue [q_k] ' . $v['queue_k']);
}
$this->save_queue('ucss', $this->_queue);
self::debug('notified');
return Cloud::ok(array('count' => $valid_i));
}
/**
* Handle all request actions from main cls
*
* @since 2.3
* @access public
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_GEN:
self::cron(true);
break;
case self::TYPE_CLEAR_Q:
$this->clear_q('ucss');
break;
default:
break;
}
Admin::redirect();
}
}
utility.cls.php 0000644 00000051252 15153741267 0007560 0 ustar 00 <?php
/**
* The utility class.
*
* @since 1.1.5
* @since 1.5 Moved into /inc
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Utility extends Root
{
private static $_internal_domains;
/**
* Validate regex
*
* @since 1.0.9
* @since 3.0 Moved here from admin-settings.cls
* @access public
* @return bool True for valid rules, false otherwise.
*/
public static function syntax_checker($rules)
{
return preg_match(self::arr2regex($rules), '') !== false;
}
/**
* Combine regex array to regex rule
*
* @since 3.0
*/
public static function arr2regex($arr, $drop_delimiter = false)
{
$arr = self::sanitize_lines($arr);
$new_arr = array();
foreach ($arr as $v) {
$new_arr[] = preg_quote($v, '#');
}
$regex = implode('|', $new_arr);
$regex = str_replace(' ', '\\ ', $regex);
if ($drop_delimiter) {
return $regex;
}
return '#' . $regex . '#';
}
/**
* Replace wildcard to regex
*
* @since 3.2.2
*/
public static function wildcard2regex($string)
{
if (is_array($string)) {
return array_map(__CLASS__ . '::wildcard2regex', $string);
}
if (strpos($string, '*') !== false) {
$string = preg_quote($string, '#');
$string = str_replace('\*', '.*', $string);
}
return $string;
}
/**
* Check if an URL or current page is REST req or not
*
* @since 2.9.3
* @deprecated 2.9.4 Moved to REST class
* @access public
*/
public static function is_rest($url = false)
{
return false;
}
/**
* Get current page type
*
* @since 2.9
*/
public static function page_type()
{
global $wp_query;
$page_type = 'default';
if ($wp_query->is_page) {
$page_type = is_front_page() ? 'front' : 'page';
} elseif ($wp_query->is_home) {
$page_type = 'home';
} elseif ($wp_query->is_single) {
// $page_type = $wp_query->is_attachment ? 'attachment' : 'single';
$page_type = get_post_type();
} elseif ($wp_query->is_category) {
$page_type = 'category';
} elseif ($wp_query->is_tag) {
$page_type = 'tag';
} elseif ($wp_query->is_tax) {
$page_type = 'tax';
// $page_type = get_queried_object()->taxonomy;
} elseif ($wp_query->is_archive) {
if ($wp_query->is_day) {
$page_type = 'day';
} elseif ($wp_query->is_month) {
$page_type = 'month';
} elseif ($wp_query->is_year) {
$page_type = 'year';
} elseif ($wp_query->is_author) {
$page_type = 'author';
} else {
$page_type = 'archive';
}
} elseif ($wp_query->is_search) {
$page_type = 'search';
} elseif ($wp_query->is_404) {
$page_type = '404';
}
return $page_type;
// if ( is_404() ) {
// $page_type = '404';
// }
// elseif ( is_singular() ) {
// $page_type = get_post_type();
// }
// elseif ( is_home() && get_option( 'show_on_front' ) == 'page' ) {
// $page_type = 'home';
// }
// elseif ( is_front_page() ) {
// $page_type = 'front';
// }
// elseif ( is_tax() ) {
// $page_type = get_queried_object()->taxonomy;
// }
// elseif ( is_category() ) {
// $page_type = 'category';
// }
// elseif ( is_tag() ) {
// $page_type = 'tag';
// }
// return $page_type;
}
/**
* Get ping speed
*
* @since 2.9
*/
public static function ping($domain)
{
if (strpos($domain, ':')) {
$domain = parse_url($domain, PHP_URL_HOST);
}
$starttime = microtime(true);
$file = fsockopen($domain, 443, $errno, $errstr, 10);
$stoptime = microtime(true);
$status = 0;
if (!$file) {
$status = 99999;
}
// Site is down
else {
fclose($file);
$status = ($stoptime - $starttime) * 1000;
$status = floor($status);
}
Debug2::debug("[Util] ping [Domain] $domain \t[Speed] $status");
return $status;
}
/**
* Set seconds/timestamp to readable format
*
* @since 1.6.5
* @access public
*/
public static function readable_time($seconds_or_timestamp, $timeout = 3600, $forward = false)
{
if (strlen($seconds_or_timestamp) == 10) {
$seconds = time() - $seconds_or_timestamp;
if ($seconds > $timeout) {
return date('m/d/Y H:i:s', $seconds_or_timestamp + LITESPEED_TIME_OFFSET);
}
} else {
$seconds = $seconds_or_timestamp;
}
$res = '';
if ($seconds > 86400) {
$num = floor($seconds / 86400);
$res .= $num . 'd';
$seconds %= 86400;
}
if ($seconds > 3600) {
if ($res) {
$res .= ', ';
}
$num = floor($seconds / 3600);
$res .= $num . 'h';
$seconds %= 3600;
}
if ($seconds > 60) {
if ($res) {
$res .= ', ';
}
$num = floor($seconds / 60);
$res .= $num . 'm';
$seconds %= 60;
}
if ($seconds > 0) {
if ($res) {
$res .= ' ';
}
$res .= $seconds . 's';
}
if (!$res) {
return $forward ? __('right now', 'litespeed-cache') : __('just now', 'litespeed-cache');
}
$res = $forward ? $res : sprintf(__(' %s ago', 'litespeed-cache'), $res);
return $res;
}
/**
* Convert array to string
*
* @since 1.6
* @access public
*/
public static function arr2str($arr)
{
if (!is_array($arr)) {
return $arr;
}
return base64_encode(\json_encode($arr));
}
/**
* Get human readable size
*
* @since 1.6
* @access public
*/
public static function real_size($filesize, $is_1000 = false)
{
$unit = $is_1000 ? 1000 : 1024;
if ($filesize >= pow($unit, 3)) {
$filesize = round(($filesize / pow($unit, 3)) * 100) / 100 . 'G';
} elseif ($filesize >= pow($unit, 2)) {
$filesize = round(($filesize / pow($unit, 2)) * 100) / 100 . 'M';
} elseif ($filesize >= $unit) {
$filesize = round(($filesize / $unit) * 100) / 100 . 'K';
} else {
$filesize = $filesize . 'B';
}
return $filesize;
}
/**
* Parse attributes from string
*
* @since 1.2.2
* @since 1.4 Moved from optimize to utility
* @access private
* @param string $str
* @return array All the attributes
*/
public static function parse_attr($str)
{
$attrs = array();
preg_match_all('#([\w-]+)=(["\'])([^\2]*)\2#isU', $str, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$attrs[$match[1]] = trim($match[3]);
}
return $attrs;
}
/**
* Check if an array has a string
*
* Support $ exact match
*
* @since 1.3
* @access private
* @param string $needle The string to search with
* @param array $haystack
* @return bool|string False if not found, otherwise return the matched string in haystack.
*/
public static function str_hit_array($needle, $haystack, $has_ttl = false)
{
if (!$haystack) {
return false;
}
/**
* Safety check to avoid PHP warning
* @see https://github.com/litespeedtech/lscache_wp/pull/131/commits/45fc03af308c7d6b5583d1664fad68f75fb6d017
*/
if (!is_array($haystack)) {
Debug2::debug('[Util] ❌ bad param in str_hit_array()!');
return false;
}
$hit = false;
$this_ttl = 0;
foreach ($haystack as $item) {
if (!$item) {
continue;
}
if ($has_ttl) {
$this_ttl = 0;
$item = explode(' ', $item);
if (!empty($item[1])) {
$this_ttl = $item[1];
}
$item = $item[0];
}
if (substr($item, 0, 1) === '^' && substr($item, -1) === '$') {
// do exact match
if (substr($item, 1, -1) === $needle) {
$hit = $item;
break;
}
} elseif (substr($item, -1) === '$') {
// match end
if (substr($item, 0, -1) === substr($needle, -strlen($item) + 1)) {
$hit = $item;
break;
}
} elseif (substr($item, 0, 1) === '^') {
// match beginning
if (substr($item, 1) === substr($needle, 0, strlen($item) - 1)) {
$hit = $item;
break;
}
} else {
if (strpos($needle, $item) !== false) {
$hit = $item;
break;
}
}
}
if ($hit) {
if ($has_ttl) {
return array($hit, $this_ttl);
}
return $hit;
}
return false;
}
/**
* Improve compatibility to PHP old versions
*
* @since 1.2.2
*
*/
public static function compatibility()
{
require_once LSCWP_DIR . 'lib/php-compatibility.func.php';
}
/**
* Convert URI to URL
*
* @since 1.3
* @access public
* @param string $uri `xx/xx.html` or `/subfolder/xx/xx.html`
* @return string http://www.example.com/subfolder/xx/xx.html
*/
public static function uri2url($uri)
{
if (substr($uri, 0, 1) === '/') {
self::domain_const();
$url = LSCWP_DOMAIN . $uri;
} else {
$url = home_url('/') . $uri;
}
return $url;
}
/**
* Convert URL to basename (filename)
*
* @since 4.7
*/
public static function basename($url)
{
$url = trim($url);
$uri = @parse_url($url, PHP_URL_PATH);
$basename = pathinfo($uri, PATHINFO_BASENAME);
return $basename;
}
/**
* Drop .webp and .avif if existed in filename
*
* @since 4.7
*/
public static function drop_webp($filename)
{
if (in_array(substr($filename, -5), array('.webp', '.avif'))) {
$filename = substr($filename, 0, -5);
}
return $filename;
}
/**
* Convert URL to URI
*
* @since 1.2.2
* @since 1.6.2.1 Added 2nd param keep_qs
* @access public
*/
public static function url2uri($url, $keep_qs = false)
{
$url = trim($url);
$uri = @parse_url($url, PHP_URL_PATH);
$qs = @parse_url($url, PHP_URL_QUERY);
if (!$keep_qs || !$qs) {
return $uri;
}
return $uri . '?' . $qs;
}
/**
* Get attachment relative path to upload folder
*
* @since 3.0
* @access public
* @param string `https://aa.com/bbb/wp-content/upload/2018/08/test.jpg` or `/bbb/wp-content/upload/2018/08/test.jpg`
* @return string `2018/08/test.jpg`
*/
public static function att_short_path($url)
{
if (!defined('LITESPEED_UPLOAD_PATH')) {
$_wp_upload_dir = wp_upload_dir();
$upload_path = self::url2uri($_wp_upload_dir['baseurl']);
define('LITESPEED_UPLOAD_PATH', $upload_path);
}
$local_file = self::url2uri($url);
$short_path = substr($local_file, strlen(LITESPEED_UPLOAD_PATH) + 1);
return $short_path;
}
/**
* Make URL to be relative
*
* NOTE: for subfolder home_url, will keep subfolder part (strip nothing but scheme and host)
*
* @param string $url
* @return string Relative URL, start with /
*/
public static function make_relative($url)
{
// replace home_url if the url is full url
self::domain_const();
if (strpos($url, LSCWP_DOMAIN) === 0) {
$url = substr($url, strlen(LSCWP_DOMAIN));
}
return trim($url);
}
/**
* Convert URL to domain only
*
* @since 1.7.1
*/
public static function parse_domain($url)
{
$url = @parse_url($url);
if (empty($url['host'])) {
return '';
}
if (!empty($url['scheme'])) {
return $url['scheme'] . '://' . $url['host'];
}
return '//' . $url['host'];
}
/**
* Drop protocol `https:` from https://example.com
*
* @since 3.3
*/
public static function noprotocol($url)
{
$tmp = parse_url(trim($url));
if (!empty($tmp['scheme'])) {
$url = str_replace($tmp['scheme'] . ':', '', $url);
}
return $url;
}
/**
* Validate ip v4
* @since 5.5
*/
public static function valid_ipv4($ip)
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
/**
* Generate domain const
*
* This will generate http://www.example.com even there is a subfolder in home_url setting
*
* Conf LSCWP_DOMAIN has NO trailing /
*
* @since 1.3
* @access public
*/
public static function domain_const()
{
if (defined('LSCWP_DOMAIN')) {
return;
}
self::compatibility();
$domain = http_build_url(get_home_url(), array(), HTTP_URL_STRIP_ALL);
define('LSCWP_DOMAIN', $domain);
}
/**
* Array map one textarea to sanitize the url
*
* @since 1.3
* @access public
* @param string $content
* @param bool $type String handler type
* @return string|array
*/
public static function sanitize_lines($arr, $type = null)
{
$types = $type ? explode(',', $type) : array();
if (!$arr) {
if ($type === 'string') {
return '';
}
return array();
}
if (!is_array($arr)) {
$arr = explode("\n", $arr);
}
$arr = array_map('trim', $arr);
$changed = false;
if (in_array('uri', $types)) {
$arr = array_map(__CLASS__ . '::url2uri', $arr);
$changed = true;
}
if (in_array('basename', $types)) {
$arr = array_map(__CLASS__ . '::basename', $arr);
$changed = true;
}
if (in_array('drop_webp', $types)) {
$arr = array_map(__CLASS__ . '::drop_webp', $arr);
$changed = true;
}
if (in_array('relative', $types)) {
$arr = array_map(__CLASS__ . '::make_relative', $arr); // Remove domain
$changed = true;
}
if (in_array('domain', $types)) {
$arr = array_map(__CLASS__ . '::parse_domain', $arr); // Only keep domain
$changed = true;
}
if (in_array('noprotocol', $types)) {
$arr = array_map(__CLASS__ . '::noprotocol', $arr); // Drop protocol, `https://example.com` -> `//example.com`
$changed = true;
}
if (in_array('trailingslash', $types)) {
$arr = array_map('trailingslashit', $arr); // Append trailing slash, `https://example.com` -> `https://example.com/`
$changed = true;
}
if ($changed) {
$arr = array_map('trim', $arr);
}
$arr = array_unique($arr);
$arr = array_filter($arr);
if (in_array('string', $types)) {
return implode("\n", $arr);
}
return $arr;
}
/**
* Builds an url with an action and a nonce.
*
* Assumes user capabilities are already checked.
*
* @since 1.6 Changed order of 2nd&3rd param, changed 3rd param `append_str` to 2nd `type`
* @access public
* @return string The built url.
*/
public static function build_url($action, $type = false, $is_ajax = false, $page = null, $append_arr = array())
{
$prefix = '?';
if ($page === '_ori') {
$page = true;
$append_arr['_litespeed_ori'] = 1;
}
if (!$is_ajax) {
if ($page) {
// If use admin url
if ($page === true) {
$page = 'admin.php';
} else {
if (strpos($page, '?') !== false) {
$prefix = '&';
}
}
$combined = $page . $prefix . Router::ACTION . '=' . $action;
} else {
// Current page rebuild URL
$params = $_GET;
if (!empty($params)) {
if (isset($params[Router::ACTION])) {
unset($params[Router::ACTION]);
}
if (isset($params['_wpnonce'])) {
unset($params['_wpnonce']);
}
if (!empty($params)) {
$prefix .= http_build_query($params) . '&';
}
}
global $pagenow;
$combined = $pagenow . $prefix . Router::ACTION . '=' . $action;
}
} else {
$combined = 'admin-ajax.php?action=litespeed_ajax&' . Router::ACTION . '=' . $action;
}
if (is_network_admin()) {
$prenonce = network_admin_url($combined);
} else {
$prenonce = admin_url($combined);
}
$url = wp_nonce_url($prenonce, $action, Router::NONCE);
if ($type) {
// Remove potential param `type` from url
$url = parse_url(htmlspecialchars_decode($url));
parse_str($url['query'], $query);
$built_arr = array_merge($query, array(Router::TYPE => $type));
if ($append_arr) {
$built_arr = array_merge($built_arr, $append_arr);
}
$url['query'] = http_build_query($built_arr);
self::compatibility();
$url = http_build_url($url);
$url = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
}
return $url;
}
/**
* Check if the host is the internal host
*
* @since 1.2.3
*
*/
public static function internal($host)
{
if (!defined('LITESPEED_FRONTEND_HOST')) {
if (defined('WP_HOME')) {
$home_host = WP_HOME; // Also think of `WP_SITEURL`
} else {
$home_host = get_option('home');
}
define('LITESPEED_FRONTEND_HOST', parse_url($home_host, PHP_URL_HOST));
}
if ($host === LITESPEED_FRONTEND_HOST) {
return true;
}
/**
* Filter for multiple domains
* @since 2.9.4
*/
if (!isset(self::$_internal_domains)) {
self::$_internal_domains = apply_filters('litespeed_internal_domains', array());
}
if (self::$_internal_domains) {
return in_array($host, self::$_internal_domains);
}
return false;
}
/**
* Check if an URL is a internal existing file
*
* @since 1.2.2
* @since 1.6.2 Moved here from optm.cls due to usage of media.cls
* @access public
* @return string|bool The real path of file OR false
*/
public static function is_internal_file($url, $addition_postfix = false)
{
if (substr($url, 0, 5) == 'data:') {
Debug2::debug2('[Util] data: content not file');
return false;
}
$url_parsed = parse_url($url);
if (isset($url_parsed['host']) && !self::internal($url_parsed['host'])) {
// Check if is cdn path
// Do this to avoid user hardcoded src in tpl
if (!CDN::internal($url_parsed['host'])) {
Debug2::debug2('[Util] external');
return false;
}
}
if (empty($url_parsed['path'])) {
return false;
}
// Need to replace child blog path for assets, ref: .htaccess
if (is_multisite() && defined('PATH_CURRENT_SITE')) {
$pattern = '#^' . PATH_CURRENT_SITE . '([_0-9a-zA-Z-]+/)(wp-(content|admin|includes))#U';
$replacement = PATH_CURRENT_SITE . '$2';
$url_parsed['path'] = preg_replace($pattern, $replacement, $url_parsed['path']);
// $current_blog = (int) get_current_blog_id();
// $main_blog_id = (int) get_network()->site_id;
// if ( $current_blog === $main_blog_id ) {
// define( 'LITESPEED_IS_MAIN_BLOG', true );
// }
// else {
// define( 'LITESPEED_IS_MAIN_BLOG', false );
// }
}
// Parse file path
/**
* Trying to fix pure /.htaccess rewrite to /wordpress case
*
* Add `define( 'LITESPEED_WP_REALPATH', '/wordpress' );` in wp-config.php in this case
*
* @internal #611001 - Combine & Minify not working?
* @since 1.6.3
*/
if (substr($url_parsed['path'], 0, 1) === '/') {
if (defined('LITESPEED_WP_REALPATH')) {
$file_path_ori = $_SERVER['DOCUMENT_ROOT'] . LITESPEED_WP_REALPATH . $url_parsed['path'];
} else {
$file_path_ori = $_SERVER['DOCUMENT_ROOT'] . $url_parsed['path'];
}
} else {
$file_path_ori = Router::frontend_path() . '/' . $url_parsed['path'];
}
/**
* Added new file postfix to be check if passed in
* @since 2.2.4
*/
if ($addition_postfix) {
$file_path_ori .= '.' . $addition_postfix;
}
/**
* Added this filter for those plugins which overwrite the filepath
* @see #101091 plugin `Hide My WordPress`
* @since 2.2.3
*/
$file_path_ori = apply_filters('litespeed_realpath', $file_path_ori);
$file_path = realpath($file_path_ori);
if (!is_file($file_path)) {
Debug2::debug2('[Util] file not exist: ' . $file_path_ori);
return false;
}
return array($file_path, filesize($file_path));
}
/**
* Safely parse URL for v5.3 compatibility
*
* @since 3.4.3
*/
public static function parse_url_safe($url, $component = -1)
{
if (substr($url, 0, 2) == '//') {
$url = 'https:' . $url;
}
return parse_url($url, $component);
}
/**
* Replace url in srcset to new value
*
* @since 2.2.3
*/
public static function srcset_replace($content, $callback)
{
preg_match_all('# srcset=([\'"])(.+)\g{1}#iU', $content, $matches);
$srcset_ori = array();
$srcset_final = array();
foreach ($matches[2] as $k => $urls_ori) {
$urls_final = explode(',', $urls_ori);
$changed = false;
foreach ($urls_final as $k2 => $url_info) {
$url_info_arr = explode(' ', trim($url_info));
if (!($url2 = call_user_func($callback, $url_info_arr[0]))) {
continue;
}
$changed = true;
$urls_final[$k2] = str_replace($url_info_arr[0], $url2, $url_info);
Debug2::debug2('[Util] - srcset replaced to ' . $url2 . (!empty($url_info_arr[1]) ? ' ' . $url_info_arr[1] : ''));
}
if (!$changed) {
continue;
}
$urls_final = implode(',', $urls_final);
$srcset_ori[] = $matches[0][$k];
$srcset_final[] = str_replace($urls_ori, $urls_final, $matches[0][$k]);
}
if ($srcset_ori) {
$content = str_replace($srcset_ori, $srcset_final, $content);
Debug2::debug2('[Util] - srcset replaced');
}
return $content;
}
/**
* Generate pagination
*
* @since 3.0
* @access public
*/
public static function pagination($total, $limit, $return_offset = false)
{
$pagenum = isset($_GET['pagenum']) ? absint($_GET['pagenum']) : 1;
$offset = ($pagenum - 1) * $limit;
$num_of_pages = ceil($total / $limit);
if ($offset > $total) {
$offset = $total - $limit;
}
if ($offset < 0) {
$offset = 0;
}
if ($return_offset) {
return $offset;
}
$page_links = paginate_links(array(
'base' => add_query_arg('pagenum', '%#%'),
'format' => '',
'prev_text' => '«',
'next_text' => '»',
'total' => $num_of_pages,
'current' => $pagenum,
));
return '<div class="tablenav"><div class="tablenav-pages" style="margin: 1em 0">' . $page_links . '</div></div>';
}
/**
* Generate placeholder for an array to query
*
* @since 2.0
* @access public
*/
public static function chunk_placeholder($data, $fields)
{
$division = substr_count($fields, ',') + 1;
$q = implode(
',',
array_map(function ($el) {
return '(' . implode(',', $el) . ')';
}, array_chunk(array_fill(0, count($data), '%s'), $division))
);
return $q;
}
}
vary.cls.php 0000644 00000050134 15153741267 0007034 0 ustar 00 <?php
/**
* The plugin vary class to manage X-LiteSpeed-Vary
*
* @since 1.1.3
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class Vary extends Root
{
const LOG_TAG = '🔱';
const X_HEADER = 'X-LiteSpeed-Vary';
private static $_vary_name = '_lscache_vary'; // this default vary cookie is used for logged in status check
private static $_can_change_vary = false; // Currently only AJAX used this
/**
* Adds the actions used for setting up cookies on log in/out.
*
* Also checks if the database matches the rewrite rule.
*
* @since 1.0.4
*/
// public function init()
// {
// $this->_update_vary_name();
// }
/**
* Update the default vary name if changed
*
* @since 4.0
* @since 7.0 Moved to after_user_init to allow ESI no-vary no conflict w/ LSCACHE_VARY_COOKIE/O_CACHE_LOGIN_COOKIE
*/
private function _update_vary_name()
{
$db_cookie = $this->conf(Base::O_CACHE_LOGIN_COOKIE); // [3.0] todo: check if works in network's sites
// If no vary set in rewrite rule
if (!isset($_SERVER['LSCACHE_VARY_COOKIE'])) {
if ($db_cookie) {
// Check if is from ESI req or not. If from ESI no-vary, no need to set no-cache
$something_wrong = true;
if (!empty($_GET[ESI::QS_ACTION]) && !empty($_GET['_control'])) {
// Have to manually build this checker bcoz ESI is not init yet.
$control = explode(',', $_GET['_control']);
if (in_array('no-vary', $control)) {
self::debug('no-vary control existed, bypass vary_name update');
$something_wrong = false;
self::$_vary_name = $db_cookie;
}
}
if (defined('LITESPEED_CLI') || defined('DOING_CRON')) {
$something_wrong = false;
}
if ($something_wrong) {
// Display cookie error msg to admin
if (is_multisite() ? is_network_admin() : is_admin()) {
Admin_Display::show_error_cookie();
}
Control::set_nocache('❌❌ vary cookie setting error');
}
}
return;
}
// If db setting does not exist, skip checking db value
if (!$db_cookie) {
return;
}
// beyond this point, need to make sure db vary setting is in $_SERVER env.
$vary_arr = explode(',', $_SERVER['LSCACHE_VARY_COOKIE']);
if (in_array($db_cookie, $vary_arr)) {
self::$_vary_name = $db_cookie;
return;
}
if (is_multisite() ? is_network_admin() : is_admin()) {
Admin_Display::show_error_cookie();
}
Control::set_nocache('vary cookie setting lost error');
}
/**
* Hooks after user init
*
* @since 4.0
*/
public function after_user_init()
{
$this->_update_vary_name();
// logged in user
if (Router::is_logged_in()) {
// If not esi, check cache logged-in user setting
if (!$this->cls('Router')->esi_enabled()) {
// If cache logged-in, then init cacheable to private
if ($this->conf(Base::O_CACHE_PRIV)) {
add_action('wp_logout', __NAMESPACE__ . '\Purge::purge_on_logout');
$this->cls('Control')->init_cacheable();
Control::set_private('logged in user');
}
// No cache for logged-in user
else {
Control::set_nocache('logged in user');
}
}
// ESI is on, can be public cache
else {
// Need to make sure vary is using group id
$this->cls('Control')->init_cacheable();
}
// register logout hook to clear login status
add_action('clear_auth_cookie', array($this, 'remove_logged_in'));
} else {
// Only after vary init, can detect if is Guest mode or not
// Here need `self::$_vary_name` to be set first.
$this->_maybe_guest_mode();
// Set vary cookie for logging in user, otherwise the user will hit public with vary=0 (guest version)
add_action('set_logged_in_cookie', array($this, 'add_logged_in'), 10, 4);
add_action('wp_login', __NAMESPACE__ . '\Purge::purge_on_logout');
$this->cls('Control')->init_cacheable();
// Check `login page` cacheable setting because they don't go through main WP logic
add_action('login_init', array($this->cls('Tag'), 'check_login_cacheable'), 5);
if (!empty($_GET['litespeed_guest'])) {
add_action('wp_loaded', array($this, 'update_guest_vary'), 20);
}
}
// Add comment list ESI
add_filter('comments_array', array($this, 'check_commenter'));
// Set vary cookie for commenter.
add_action('set_comment_cookies', array($this, 'append_commenter'));
/**
* Don't change for REST call because they don't carry on user info usually
* @since 1.6.7
*/
add_action('rest_api_init', function () {
// this hook is fired in `init` hook
self::debug('Rest API init disabled vary change');
add_filter('litespeed_can_change_vary', '__return_false');
});
}
/**
* Check if is Guest mode or not
*
* @since 4.0
*/
private function _maybe_guest_mode()
{
if (defined('LITESPEED_GUEST')) {
self::debug('👒👒 Guest mode ' . (LITESPEED_GUEST ? 'predefined' : 'turned off'));
return;
}
if (!$this->conf(Base::O_GUEST)) {
return;
}
// If vary is set, then not a guest
if (self::has_vary()) {
return;
}
// If has admin QS, then no guest
if (!empty($_GET[Router::ACTION])) {
return;
}
if (defined('DOING_AJAX')) {
return;
}
if (defined('DOING_CRON')) {
return;
}
// If is the request to update vary, then no guest
// Don't need anymore as it is always ajax call
// Still keep it in case some WP blocked the lightweight guest vary update script, WP can still update the vary
if (!empty($_GET['litespeed_guest'])) {
return;
}
/* @ref https://wordpress.org/support/topic/checkout-add-to-cart-executed-twice/ */
if (!empty($_GET['litespeed_guest_off'])) {
return;
}
self::debug('👒👒 Guest mode');
!defined('LITESPEED_GUEST') && define('LITESPEED_GUEST', true);
if ($this->conf(Base::O_GUEST_OPTM)) {
!defined('LITESPEED_GUEST_OPTM') && define('LITESPEED_GUEST_OPTM', true);
}
}
/**
* Update Guest vary
*
* @since 4.0
* @deprecated 4.1 Use independent lightweight guest.vary.php as a replacement
*/
public function update_guest_vary()
{
// This process must not be cached
!defined('LSCACHE_NO_CACHE') && define('LSCACHE_NO_CACHE', true);
$_guest = new Lib\Guest();
if ($_guest->always_guest() || self::has_vary()) {
// If contains vary already, don't reload to avoid infinite loop when parent page having browser cache
!defined('LITESPEED_GUEST') && define('LITESPEED_GUEST', true); // Reuse this const to bypass set vary in vary finalize
self::debug('🤠🤠 Guest');
echo '[]';
exit();
}
self::debug('Will update guest vary in finalize');
// return json
echo \json_encode(array('reload' => 'yes'));
exit();
}
/**
* Hooked to the comments_array filter.
*
* Check if the user accessing the page has the commenter cookie.
*
* If the user does not want to cache commenters, just check if user is commenter.
* Otherwise if the vary cookie is set, unset it. This is so that when the page is cached, the page will appear as if the user was a normal user.
* Normal user is defined as not a logged in user and not a commenter.
*
* @since 1.0.4
* @access public
* @global type $post
* @param array $comments The current comments to output
* @return array The comments to output.
*/
public function check_commenter($comments)
{
/**
* Hook to bypass pending comment check for comment related plugins compatibility
* @since 2.9.5
*/
if (apply_filters('litespeed_vary_check_commenter_pending', true)) {
$pending = false;
foreach ($comments as $comment) {
if (!$comment->comment_approved) {
// current user has pending comment
$pending = true;
break;
}
}
// No pending comments, don't need to add private cache
if (!$pending) {
self::debug('No pending comment');
$this->remove_commenter();
// Remove commenter prefilled info if exists, for public cache
foreach ($_COOKIE as $cookie_name => $cookie_value) {
if (strlen($cookie_name) >= 15 && strpos($cookie_name, 'comment_author_') === 0) {
unset($_COOKIE[$cookie_name]);
}
}
return $comments;
}
}
// Current user/visitor has pending comments
// set vary=2 for next time vary lookup
$this->add_commenter();
if ($this->conf(Base::O_CACHE_COMMENTER)) {
Control::set_private('existing commenter');
} else {
Control::set_nocache('existing commenter');
}
return $comments;
}
/**
* Check if default vary has a value
*
* @since 1.1.3
* @access public
*/
public static function has_vary()
{
if (empty($_COOKIE[self::$_vary_name])) {
return false;
}
return $_COOKIE[self::$_vary_name];
}
/**
* Append user status with logged in
*
* @since 1.1.3
* @since 1.6.2 Removed static referral
* @access public
*/
public function add_logged_in($logged_in_cookie = false, $expire = false, $expiration = false, $uid = false)
{
self::debug('add_logged_in');
/**
* NOTE: Run before `$this->_update_default_vary()` to make vary changeable
* @since 2.2.2
*/
self::can_ajax_vary();
// If the cookie is lost somehow, set it
$this->_update_default_vary($uid, $expire);
}
/**
* Remove user logged in status
*
* @since 1.1.3
* @since 1.6.2 Removed static referral
* @access public
*/
public function remove_logged_in()
{
self::debug('remove_logged_in');
/**
* NOTE: Run before `$this->_update_default_vary()` to make vary changeable
* @since 2.2.2
*/
self::can_ajax_vary();
// Force update vary to remove login status
$this->_update_default_vary(-1);
}
/**
* Allow vary can be changed for ajax calls
*
* @since 2.2.2
* @since 2.6 Changed to static
* @access public
*/
public static function can_ajax_vary()
{
self::debug('_can_change_vary -> true');
self::$_can_change_vary = true;
}
/**
* Check if can change default vary
*
* @since 1.6.2
* @access private
*/
private function can_change_vary()
{
// Don't change for ajax due to ajax not sending webp header
if (Router::is_ajax()) {
if (!self::$_can_change_vary) {
self::debug('can_change_vary bypassed due to ajax call');
return false;
}
}
/**
* POST request can set vary to fix #820789 login "loop" guest cache issue
* @since 1.6.5
*/
if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] !== 'GET' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
self::debug('can_change_vary bypassed due to method not get/post');
return false;
}
/**
* Disable vary change if is from crawler
* @since 2.9.8 To enable woocommerce cart not empty warm up (@Taba)
*/
if (!empty($_SERVER['HTTP_USER_AGENT']) && strpos($_SERVER['HTTP_USER_AGENT'], Crawler::FAST_USER_AGENT) === 0) {
self::debug('can_change_vary bypassed due to crawler');
return false;
}
if (!apply_filters('litespeed_can_change_vary', true)) {
self::debug('can_change_vary bypassed due to litespeed_can_change_vary hook');
return false;
}
return true;
}
/**
* Update default vary
*
* @since 1.6.2
* @since 1.6.6.1 Add ran check to make it only run once ( No run multiple times due to login process doesn't have valid uid )
* @access private
*/
private function _update_default_vary($uid = false, $expire = false)
{
// Make sure header output only run once
if (!defined('LITESPEED_DID_' . __FUNCTION__)) {
define('LITESPEED_DID_' . __FUNCTION__, true);
} else {
self::debug2('_update_default_vary bypassed due to run already');
return;
}
// ESI shouldn't change vary (Let main page do only)
if (defined('LSCACHE_IS_ESI') && LSCACHE_IS_ESI) {
self::debug2('_update_default_vary bypassed due to ESI');
return;
}
// If the cookie is lost somehow, set it
$vary = $this->finalize_default_vary($uid);
$current_vary = self::has_vary();
if ($current_vary !== $vary && $current_vary !== 'commenter' && $this->can_change_vary()) {
// $_COOKIE[ self::$_vary_name ] = $vary; // not needed
// save it
if (!$expire) {
$expire = time() + 2 * DAY_IN_SECONDS;
}
$this->_cookie($vary, $expire);
// Control::set_nocache( 'changing default vary' . " $current_vary => $vary" );
}
}
/**
* Get vary name
*
* @since 1.9.1
* @access public
*/
public function get_vary_name()
{
return self::$_vary_name;
}
/**
* Check if one user role is in vary group settings
*
* @since 1.2.0
* @since 3.0 Moved here from conf.cls
* @access public
* @param string $role The user role
* @return int The set value if already set
*/
public function in_vary_group($role)
{
$group = 0;
$vary_groups = $this->conf(Base::O_CACHE_VARY_GROUP);
$roles = explode(',', $role);
if ($found = array_intersect($roles, array_keys($vary_groups))) {
$groups = array();
foreach ($found as $curr_role) {
$groups[] = $vary_groups[$curr_role];
}
$group = implode(',', array_unique($groups));
} elseif (in_array('administrator', $roles)) {
$group = 99;
}
if ($group) {
self::debug2('role in vary_group [group] ' . $group);
}
return $group;
}
/**
* Finalize default Vary Cookie
*
* Get user vary tag based on admin_bar & role
*
* NOTE: Login process will also call this because it does not call wp hook as normal page loading
*
* @since 1.6.2
* @access public
*/
public function finalize_default_vary($uid = false)
{
// Must check this to bypass vary generation for guests
// Must check this to avoid Guest page's CSS/JS/CCSS/UCSS get non-guest vary filename
if (defined('LITESPEED_GUEST') && LITESPEED_GUEST) {
return false;
}
$vary = array();
if ($this->conf(Base::O_GUEST)) {
$vary['guest_mode'] = 1;
}
if (!$uid) {
$uid = get_current_user_id();
} else {
self::debug('uid: ' . $uid);
}
// get user's group id
$role = Router::get_role($uid);
if ($uid > 0 && $role) {
$vary['logged-in'] = 1;
// parse role group from settings
if ($role_group = $this->in_vary_group($role)) {
$vary['role'] = $role_group;
}
// Get admin bar set
// see @_get_admin_bar_pref()
$pref = get_user_option('show_admin_bar_front', $uid);
self::debug2('show_admin_bar_front: ' . $pref);
$admin_bar = $pref === false || $pref === 'true';
if ($admin_bar) {
$vary['admin_bar'] = 1;
self::debug2('admin bar : true');
}
} else {
// Guest user
self::debug('role id: failed, guest');
}
/**
* Add filter
* @since 1.6 Added for Role Excludes for optimization cls
* @since 1.6.2 Hooked to webp (checked in v4, no webp anymore)
* @since 3.0 Used by 3rd hooks too
*/
$vary = apply_filters('litespeed_vary', $vary);
if (!$vary) {
return false;
}
ksort($vary);
$res = array();
foreach ($vary as $key => $val) {
$res[] = $key . ':' . $val;
}
$res = implode(';', $res);
if (defined('LSCWP_LOG')) {
return $res;
}
// Encrypt in production
return md5($this->conf(Base::HASH) . $res);
}
/**
* Get the hash of all vary related values
*
* @since 4.0
*/
public function finalize_full_varies()
{
$vary = $this->_finalize_curr_vary_cookies(true);
$vary .= $this->finalize_default_vary(get_current_user_id());
$vary .= $this->get_env_vary();
return $vary;
}
/**
* Get request environment Vary
*
* @since 4.0
*/
public function get_env_vary()
{
$env_vary = isset($_SERVER['LSCACHE_VARY_VALUE']) ? $_SERVER['LSCACHE_VARY_VALUE'] : false;
if (!$env_vary) {
$env_vary = isset($_SERVER['HTTP_X_LSCACHE_VARY_VALUE']) ? $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] : false;
}
return $env_vary;
}
/**
* Append user status with commenter
*
* This is ONLY used when submit a comment
*
* @since 1.1.6
* @access public
*/
public function append_commenter()
{
$this->add_commenter(true);
}
/**
* Correct user status with commenter
*
* @since 1.1.3
* @access private
* @param boolean $from_redirect If the request is from redirect page or not
*/
private function add_commenter($from_redirect = false)
{
// If the cookie is lost somehow, set it
if (self::has_vary() !== 'commenter') {
self::debug('Add commenter');
// $_COOKIE[ self::$_vary_name ] = 'commenter'; // not needed
// save it
// only set commenter status for current domain path
$this->_cookie('commenter', time() + apply_filters('comment_cookie_lifetime', 30000000), self::_relative_path($from_redirect));
// Control::set_nocache( 'adding commenter status' );
}
}
/**
* Remove user commenter status
*
* @since 1.1.3
* @access private
*/
private function remove_commenter()
{
if (self::has_vary() === 'commenter') {
self::debug('Remove commenter');
// remove logged in status from global var
// unset( $_COOKIE[ self::$_vary_name ] ); // not needed
// save it
$this->_cookie(false, false, self::_relative_path());
// Control::set_nocache( 'removing commenter status' );
}
}
/**
* Generate relative path for cookie
*
* @since 1.1.3
* @access private
* @param boolean $from_redirect If the request is from redirect page or not
*/
private static function _relative_path($from_redirect = false)
{
$path = false;
$tag = $from_redirect ? 'HTTP_REFERER' : 'SCRIPT_URL';
if (!empty($_SERVER[$tag])) {
$path = parse_url($_SERVER[$tag]);
$path = !empty($path['path']) ? $path['path'] : false;
self::debug('Cookie Vary path: ' . $path);
}
return $path;
}
/**
* Builds the vary header.
*
* NOTE: Non caccheable page can still set vary ( for logged in process )
*
* Currently, this only checks post passwords and 3rd party.
*
* @since 1.0.13
* @access public
* @global $post
* @return mixed false if the user has the postpass cookie. Empty string if the post is not password protected. Vary header otherwise.
*/
public function finalize()
{
// Finalize default vary
if (!defined('LITESPEED_GUEST') || !LITESPEED_GUEST) {
$this->_update_default_vary();
}
$tp_cookies = $this->_finalize_curr_vary_cookies();
if (!$tp_cookies) {
self::debug2('no custimzed vary');
return;
}
self::debug('finalized 3rd party cookies', $tp_cookies);
return self::X_HEADER . ': ' . implode(',', $tp_cookies);
}
/**
* Gets vary cookies or their values unique hash that are already added for the current page.
*
* @since 1.0.13
* @access private
* @return array List of all vary cookies currently added.
*/
private function _finalize_curr_vary_cookies($values_json = false)
{
global $post;
$cookies = array(); // No need to append default vary cookie name
if (!empty($post->post_password)) {
$postpass_key = 'wp-postpass_' . COOKIEHASH;
if ($this->_get_cookie_val($postpass_key)) {
self::debug('finalize bypassed due to password protected vary ');
// If user has password cookie, do not cache & ignore existing vary cookies
Control::set_nocache('password protected vary');
return false;
}
$cookies[] = $values_json ? $this->_get_cookie_val($postpass_key) : $postpass_key;
}
$cookies = apply_filters('litespeed_vary_curr_cookies', $cookies);
if ($cookies) {
$cookies = array_filter(array_unique($cookies));
self::debug('vary cookies changed by filter litespeed_vary_curr_cookies', $cookies);
}
if (!$cookies) {
return false;
}
// Format cookie name data or value data
sort($cookies); // This is to maintain the cookie val orders for $values_json=true case.
foreach ($cookies as $k => $v) {
$cookies[$k] = $values_json ? $this->_get_cookie_val($v) : 'cookie=' . $v;
}
return $values_json ? \json_encode($cookies) : $cookies;
}
/**
* Get one vary cookie value
*
* @since 4.0
*/
private function _get_cookie_val($key)
{
if (!empty($_COOKIE[$key])) {
return $_COOKIE[$key];
}
return false;
}
/**
* Set the vary cookie.
*
* If vary cookie changed, must set non cacheable.
*
* @since 1.0.4
* @access private
* @param integer $val The value to update.
* @param integer $expire Expire time.
* @param boolean $path False if use wp root path as cookie path
*/
private function _cookie($val = false, $expire = false, $path = false)
{
if (!$val) {
$expire = 1;
}
/**
* Add HTTPS bypass in case clients use both HTTP and HTTPS version of site
* @since 1.7
*/
$is_ssl = $this->conf(Base::O_UTIL_NO_HTTPS_VARY) ? false : is_ssl();
setcookie(self::$_vary_name, $val, $expire, $path ?: COOKIEPATH, COOKIE_DOMAIN, $is_ssl, true);
self::debug('set_cookie ---> [k] ' . self::$_vary_name . " [v] $val [ttl] " . ($expire - time()));
}
}
vpi.cls.php 0000644 00000016304 15153741267 0006652 0 ustar 00 <?php
/**
* The viewport image class.
*
* @since 4.7
*/
namespace LiteSpeed;
defined('WPINC') || exit();
class VPI extends Base
{
const LOG_TAG = '[VPI]';
const TYPE_GEN = 'gen';
const TYPE_CLEAR_Q = 'clear_q';
protected $_summary;
private $_queue;
/**
* Init
*
* @since 4.7
*/
public function __construct()
{
$this->_summary = self::get_summary();
}
/**
* The VPI content of the current page
*
* @since 4.7
*/
public function add_to_queue()
{
$is_mobile = $this->_separate_mobile();
global $wp;
$request_url = home_url($wp->request);
$ua = !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
// Store it to prepare for cron
$this->_queue = $this->load_queue('vpi');
if (count($this->_queue) > 500) {
self::debug('Queue is full - 500');
return;
}
$home_id = get_option('page_for_posts');
if (!is_singular() && !($home_id > 0 && is_home())) {
self::debug('not single post ID');
return;
}
$post_id = is_home() ? $home_id : get_the_ID();
$queue_k = ($is_mobile ? 'mobile' : '') . ' ' . $request_url;
if (!empty($this->_queue[$queue_k])) {
self::debug('queue k existed ' . $queue_k);
return;
}
$this->_queue[$queue_k] = array(
'url' => apply_filters('litespeed_vpi_url', $request_url),
'post_id' => $post_id,
'user_agent' => substr($ua, 0, 200),
'is_mobile' => $this->_separate_mobile(),
); // Current UA will be used to request
$this->save_queue('vpi', $this->_queue);
self::debug('Added queue_vpi [url] ' . $queue_k . ' [UA] ' . $ua);
// Prepare cache tag for later purge
Tag::add('VPI.' . md5($queue_k));
return null;
}
/**
* Notify finished from server
* @since 4.7
*/
public function notify()
{
$post_data = \json_decode(file_get_contents('php://input'), true);
if (is_null($post_data)) {
$post_data = $_POST;
}
self::debug('notify() data', $post_data);
$this->_queue = $this->load_queue('vpi');
list($post_data) = $this->cls('Cloud')->extract_msg($post_data, 'vpi');
$notified_data = $post_data['data'];
if (empty($notified_data) || !is_array($notified_data)) {
self::debug('❌ notify exit: no notified data');
return Cloud::err('no notified data');
}
// Check if its in queue or not
$valid_i = 0;
foreach ($notified_data as $v) {
if (empty($v['request_url'])) {
self::debug('❌ notify bypass: no request_url', $v);
continue;
}
if (empty($v['queue_k'])) {
self::debug('❌ notify bypass: no queue_k', $v);
continue;
}
// $queue_k = ( $is_mobile ? 'mobile' : '' ) . ' ' . $v[ 'request_url' ];
$queue_k = $v['queue_k'];
if (empty($this->_queue[$queue_k])) {
self::debug('❌ notify bypass: no this queue [q_k]' . $queue_k);
continue;
}
// Save data
if (!empty($v['data_vpi'])) {
$post_id = $this->_queue[$queue_k]['post_id'];
$name = !empty($v['is_mobile']) ? 'litespeed_vpi_list_mobile' : 'litespeed_vpi_list';
$urldecode = is_array($v['data_vpi']) ? array_map('urldecode', $v['data_vpi']) : urldecode($v['data_vpi']);
self::debug('save data_vpi', $urldecode);
$this->cls('Metabox')->save($post_id, $name, $urldecode);
$valid_i++;
}
unset($this->_queue[$queue_k]);
self::debug('notify data handled, unset queue [q_k] ' . $queue_k);
}
$this->save_queue('vpi', $this->_queue);
self::debug('notified');
return Cloud::ok(array('count' => $valid_i));
}
/**
* Cron
*
* @since 4.7
*/
public static function cron($continue = false)
{
$_instance = self::cls();
return $_instance->_cron_handler($continue);
}
/**
* Cron generation
*
* @since 4.7
*/
private function _cron_handler($continue = false)
{
self::debug('cron start');
$this->_queue = $this->load_queue('vpi');
if (empty($this->_queue)) {
return;
}
// For cron, need to check request interval too
if (!$continue) {
if (!empty($this->_summary['curr_request_vpi']) && time() - $this->_summary['curr_request_vpi'] < 300 && !$this->conf(self::O_DEBUG)) {
self::debug('Last request not done');
return;
}
}
$i = 0;
foreach ($this->_queue as $k => $v) {
if (!empty($v['_status'])) {
continue;
}
self::debug('cron job [tag] ' . $k . ' [url] ' . $v['url'] . ($v['is_mobile'] ? ' 📱 ' : '') . ' [UA] ' . $v['user_agent']);
$i++;
$res = $this->_send_req($v['url'], $k, $v['user_agent'], $v['is_mobile']);
if (!$res) {
// Status is wrong, drop this this->_queue
$this->_queue = $this->load_queue('vpi');
unset($this->_queue[$k]);
$this->save_queue('vpi', $this->_queue);
if (!$continue) {
return;
}
// if ( $i > 3 ) {
GUI::print_loading(count($this->_queue), 'VPI');
return Router::self_redirect(Router::ACTION_VPI, self::TYPE_GEN);
// }
continue;
}
// Exit queue if out of quota or service is hot
if ($res === 'out_of_quota' || $res === 'svc_hot') {
return;
}
$this->_queue = $this->load_queue('vpi');
$this->_queue[$k]['_status'] = 'requested';
$this->save_queue('vpi', $this->_queue);
self::debug('Saved to queue [k] ' . $k);
// only request first one
if (!$continue) {
return;
}
// if ( $i > 3 ) {
GUI::print_loading(count($this->_queue), 'VPI');
return Router::self_redirect(Router::ACTION_VPI, self::TYPE_GEN);
// }
}
}
/**
* Send to QC API to generate VPI
*
* @since 4.7
* @access private
*/
private function _send_req($request_url, $queue_k, $user_agent, $is_mobile)
{
$svc = Cloud::SVC_VPI;
// Check if has credit to push or not
$err = false;
$allowance = $this->cls('Cloud')->allowance($svc, $err);
if (!$allowance) {
self::debug('❌ No credit: ' . $err);
$err && Admin_Display::error(Error::msg($err));
return 'out_of_quota';
}
set_time_limit(120);
// Update css request status
self::save_summary(array('curr_request_vpi' => time()), true);
// Gather guest HTML to send
$html = $this->cls('CSS')->prepare_html($request_url, $user_agent);
if (!$html) {
return false;
}
// Parse HTML to gather all CSS content before requesting
$css = false;
list($css, $html) = $this->cls('CSS')->prepare_css($html);
if (!$css) {
self::debug('❌ No css');
return false;
}
$data = array(
'url' => $request_url,
'queue_k' => $queue_k,
'user_agent' => $user_agent,
'is_mobile' => $is_mobile ? 1 : 0, // todo:compatible w/ tablet
'html' => $html,
'css' => $css,
);
self::debug('Generating: ', $data);
$json = Cloud::post($svc, $data, 30);
if (!is_array($json)) {
return $json;
}
// Unknown status, remove this line
if ($json['status'] != 'queued') {
return false;
}
// Save summary data
self::reload_summary();
$this->_summary['last_spent_vpi'] = time() - $this->_summary['curr_request_vpi'];
$this->_summary['last_request_vpi'] = $this->_summary['curr_request_vpi'];
$this->_summary['curr_request_vpi'] = 0;
self::save_summary();
return true;
}
/**
* Handle all request actions from main cls
*
* @since 4.7
*/
public function handler()
{
$type = Router::verify_type();
switch ($type) {
case self::TYPE_GEN:
self::cron(true);
break;
case self::TYPE_CLEAR_Q:
$this->clear_q('vpi');
break;
default:
break;
}
Admin::redirect();
}
}
Assets/Api.php 0000644 00000026272 15154173073 0007247 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Assets;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Exception;
/**
* The Api class provides an interface to various asset registration helpers.
*
* Contains asset api methods
*
* @since 2.5.0
*/
class Api {
/**
* Stores inline scripts already enqueued.
*
* @var array
*/
private $inline_scripts = [];
/**
* Determines if caching is enabled for script data.
*
* @var boolean
*/
private $disable_cache = false;
/**
* Stores loaded script data for the current request
*
* @var array|null
*/
private $script_data = null;
/**
* Stores the hash for the script data, made up of the site url, plugin version and package path.
*
* @var string
*/
private $script_data_hash;
/**
* Stores the transient key used to cache the script data. This will change if the site is accessed via HTTPS or HTTP.
*
* @var string
*/
private $script_data_transient_key = 'woocommerce_blocks_asset_api_script_data';
/**
* Reference to the Package instance
*
* @var Package
*/
private $package;
/**
* Constructor for class
*
* @param Package $package An instance of Package.
*/
public function __construct( Package $package ) {
$this->package = $package;
$this->disable_cache = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) || ! $this->package->feature()->is_production_environment();
// If the site is accessed via HTTPS, change the transient key. This is to prevent the script URLs being cached
// with the first scheme they are accessed on after cache expiry.
if ( is_ssl() ) {
$this->script_data_transient_key .= '_ssl';
}
if ( ! $this->disable_cache ) {
$this->script_data_hash = $this->get_script_data_hash();
}
add_action( 'shutdown', array( $this, 'update_script_data_cache' ), 20 );
}
/**
* Get the file modified time as a cache buster if we're in dev mode.
*
* @param string $file Local path to the file (relative to the plugin
* directory).
* @return string The cache buster value to use for the given file.
*/
protected function get_file_version( $file ) {
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG && file_exists( $this->package->get_path() . $file ) ) {
return filemtime( $this->package->get_path( trim( $file, '/' ) ) );
}
return $this->package->get_version();
}
/**
* Retrieve the url to an asset for this plugin.
*
* @param string $relative_path An optional relative path appended to the
* returned url.
*
* @return string
*/
protected function get_asset_url( $relative_path = '' ) {
return $this->package->get_url( $relative_path );
}
/**
* Get the path to a block's metadata
*
* @param string $block_name The block to get metadata for.
* @param string $path Optional. The path to the metadata file inside the 'build' folder.
*
* @return string|boolean False if metadata file is not found for the block.
*/
public function get_block_metadata_path( $block_name, $path = '' ) {
$path_to_metadata_from_plugin_root = $this->package->get_path( 'build/' . $path . $block_name . '/block.json' );
if ( ! file_exists( $path_to_metadata_from_plugin_root ) ) {
return false;
}
return $path_to_metadata_from_plugin_root;
}
/**
* Generates a hash containing the site url, plugin version and package path.
*
* Moving the plugin, changing the version, or changing the site url will result in a new hash and the cache will be invalidated.
*
* @return string The generated hash.
*/
private function get_script_data_hash() {
return md5( get_option( 'siteurl', '' ) . $this->package->get_version() . $this->package->get_path() );
}
/**
* Initialize and load cached script data from the transient cache.
*
* @return array
*/
private function get_cached_script_data() {
if ( $this->disable_cache ) {
return [];
}
$transient_value = json_decode( (string) get_transient( $this->script_data_transient_key ), true );
if (
json_last_error() !== JSON_ERROR_NONE ||
empty( $transient_value ) ||
empty( $transient_value['script_data'] ) ||
empty( $transient_value['version'] ) ||
$transient_value['version'] !== $this->package->get_version() ||
empty( $transient_value['hash'] ) ||
$transient_value['hash'] !== $this->script_data_hash
) {
return [];
}
return (array) ( $transient_value['script_data'] ?? [] );
}
/**
* Store all cached script data in the transient cache.
*/
public function update_script_data_cache() {
if ( is_null( $this->script_data ) || $this->disable_cache ) {
return;
}
set_transient(
$this->script_data_transient_key,
wp_json_encode(
array(
'script_data' => $this->script_data,
'version' => $this->package->get_version(),
'hash' => $this->script_data_hash,
)
),
DAY_IN_SECONDS * 30
);
}
/**
* Get src, version and dependencies given a script relative src.
*
* @param string $relative_src Relative src to the script.
* @param array $dependencies Optional. An array of registered script handles this script depends on. Default empty array.
*
* @return array src, version and dependencies of the script.
*/
public function get_script_data( $relative_src, $dependencies = [] ) {
if ( ! $relative_src ) {
return array(
'src' => '',
'version' => '1',
'dependencies' => $dependencies,
);
}
if ( is_null( $this->script_data ) ) {
$this->script_data = $this->get_cached_script_data();
}
if ( empty( $this->script_data[ $relative_src ] ) ) {
$asset_path = $this->package->get_path( str_replace( '.js', '.asset.php', $relative_src ) );
// The following require is safe because we are checking if the file exists and it is not a user input.
// nosemgrep audit.php.lang.security.file.inclusion-arg.
$asset = file_exists( $asset_path ) ? require $asset_path : [];
$this->script_data[ $relative_src ] = array(
'src' => $this->get_asset_url( $relative_src ),
'version' => ! empty( $asset['version'] ) ? $asset['version'] : $this->get_file_version( $relative_src ),
'dependencies' => ! empty( $asset['dependencies'] ) ? $asset['dependencies'] : [],
);
}
// Return asset details as well as the requested dependencies array.
return [
'src' => $this->script_data[ $relative_src ]['src'],
'version' => $this->script_data[ $relative_src ]['version'],
'dependencies' => array_merge( $this->script_data[ $relative_src ]['dependencies'], $dependencies ),
];
}
/**
* Registers a script according to `wp_register_script`, adding the correct prefix, and additionally loading translations.
*
* When creating script assets, the following rules should be followed:
* 1. All asset handles should have a `wc-` prefix.
* 2. If the asset handle is for a Block (in editor context) use the `-block` suffix.
* 3. If the asset handle is for a Block (in frontend context) use the `-block-frontend` suffix.
* 4. If the asset is for any other script being consumed or enqueued by the blocks plugin, use the `wc-blocks-` prefix.
*
* @since 2.5.0
* @throws Exception If the registered script has a dependency on itself.
*
* @param string $handle Unique name of the script.
* @param string $relative_src Relative url for the script to the path from plugin root.
* @param array $dependencies Optional. An array of registered script handles this script depends on. Default empty array.
* @param bool $has_i18n Optional. Whether to add a script translation call to this file. Default: true.
*/
public function register_script( $handle, $relative_src, $dependencies = [], $has_i18n = true ) {
$script_data = $this->get_script_data( $relative_src, $dependencies );
if ( in_array( $handle, $script_data['dependencies'], true ) ) {
if ( $this->package->feature()->is_development_environment() ) {
$dependencies = array_diff( $script_data['dependencies'], [ $handle ] );
add_action(
'admin_notices',
function() use ( $handle ) {
echo '<div class="error"><p>';
/* translators: %s file handle name. */
printf( esc_html__( 'Script with handle %s had a dependency on itself which has been removed. This is an indicator that your JS code has a circular dependency that can cause bugs.', 'woocommerce' ), esc_html( $handle ) );
echo '</p></div>';
}
);
} else {
throw new Exception( sprintf( 'Script with handle %s had a dependency on itself. This is an indicator that your JS code has a circular dependency that can cause bugs.', $handle ) );
}
}
/**
* Filters the list of script dependencies.
*
* @since 3.0.0
*
* @param array $dependencies The list of script dependencies.
* @param string $handle The script's handle.
* @return array
*/
$script_dependencies = apply_filters( 'woocommerce_blocks_register_script_dependencies', $script_data['dependencies'], $handle );
wp_register_script( $handle, $script_data['src'], $script_dependencies, $script_data['version'], true );
if ( $has_i18n && function_exists( 'wp_set_script_translations' ) ) {
wp_set_script_translations( $handle, 'woocommerce', $this->package->get_path( 'languages' ) );
}
}
/**
* Registers a style according to `wp_register_style`.
*
* @since 2.5.0
* @since 2.6.0 Change src to be relative source.
*
* @param string $handle Name of the stylesheet. Should be unique.
* @param string $relative_src Relative source of the stylesheet to the plugin path.
* @param array $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string $media Optional. The media for which this stylesheet has been defined. Default 'all'. Accepts media types like
* 'all', 'print' and 'screen', or media queries like '(orientation: portrait)' and '(max-width: 640px)'.
* @param boolean $rtl Optional. Whether or not to register RTL styles.
*/
public function register_style( $handle, $relative_src, $deps = [], $media = 'all', $rtl = false ) {
$filename = str_replace( plugins_url( '/', __DIR__ ), '', $relative_src );
$src = $this->get_asset_url( $relative_src );
$ver = $this->get_file_version( $filename );
wp_register_style( $handle, $src, $deps, $ver, $media );
if ( $rtl ) {
wp_style_add_data( $handle, 'rtl', 'replace' );
}
}
/**
* Returns the appropriate asset path for current builds.
*
* @param string $filename Filename for asset path (without extension).
* @param string $type File type (.css or .js).
* @return string The generated path.
*/
public function get_block_asset_build_path( $filename, $type = 'js' ) {
return "build/$filename.$type";
}
/**
* Adds an inline script, once.
*
* @param string $handle Script handle.
* @param string $script Script contents.
*/
public function add_inline_script( $handle, $script ) {
if ( ! empty( $this->inline_scripts[ $handle ] ) && in_array( $script, $this->inline_scripts[ $handle ], true ) ) {
return;
}
wp_add_inline_script( $handle, $script );
if ( isset( $this->inline_scripts[ $handle ] ) ) {
$this->inline_scripts[ $handle ][] = $script;
} else {
$this->inline_scripts[ $handle ] = array( $script );
}
}
}
Assets/AssetDataRegistry.php 0000644 00000032541 15154173073 0012134 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Assets;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
use Exception;
use InvalidArgumentException;
/**
* Class instance for registering data used on the current view session by
* assets.
*
* Holds data registered for output on the current view session when
* `wc-settings` is enqueued( directly or via dependency )
*
* @since 2.5.0
*/
class AssetDataRegistry {
/**
* Contains registered data.
*
* @var array
*/
private $data = [];
/**
* Contains preloaded API data.
*
* @var array
*/
private $preloaded_api_requests = [];
/**
* Lazy data is an array of closures that will be invoked just before
* asset data is generated for the enqueued script.
*
* @var array
*/
private $lazy_data = [];
/**
* Asset handle for registered data.
*
* @var string
*/
private $handle = 'wc-settings';
/**
* Asset API interface for various asset registration.
*
* @var API
*/
private $api;
/**
* Constructor
*
* @param Api $asset_api Asset API interface for various asset registration.
*/
public function __construct( Api $asset_api ) {
$this->api = $asset_api;
$this->init();
}
/**
* Hook into WP asset registration for enqueueing asset data.
*/
protected function init() {
add_action( 'init', array( $this, 'register_data_script' ) );
add_action( is_admin() ? 'admin_print_footer_scripts' : 'wp_print_footer_scripts', array( $this, 'enqueue_asset_data' ), 1 );
}
/**
* Exposes core data via the wcSettings global. This data is shared throughout the client.
*
* Settings that are used by various components or multiple blocks should be added here. Note, that settings here are
* global so be sure not to add anything heavy if possible.
*
* @return array An array containing core data.
*/
protected function get_core_data() {
return [
'adminUrl' => admin_url(),
'countries' => WC()->countries->get_countries(),
'currency' => $this->get_currency_data(),
'currentUserId' => get_current_user_id(),
'currentUserIsAdmin' => current_user_can( 'manage_woocommerce' ),
'homeUrl' => esc_url( home_url( '/' ) ),
'locale' => $this->get_locale_data(),
'dashboardUrl' => wc_get_account_endpoint_url( 'dashboard' ),
'orderStatuses' => $this->get_order_statuses(),
'placeholderImgSrc' => wc_placeholder_img_src(),
'productsSettings' => $this->get_products_settings(),
'siteTitle' => get_bloginfo( 'name' ),
'storePages' => $this->get_store_pages(),
'wcAssetUrl' => plugins_url( 'assets/', WC_PLUGIN_FILE ),
'wcVersion' => defined( 'WC_VERSION' ) ? WC_VERSION : '',
'wpLoginUrl' => wp_login_url(),
'wpVersion' => get_bloginfo( 'version' ),
];
}
/**
* Get currency data to include in settings.
*
* @return array
*/
protected function get_currency_data() {
$currency = get_woocommerce_currency();
return [
'code' => $currency,
'precision' => wc_get_price_decimals(),
'symbol' => html_entity_decode( get_woocommerce_currency_symbol( $currency ) ),
'symbolPosition' => get_option( 'woocommerce_currency_pos' ),
'decimalSeparator' => wc_get_price_decimal_separator(),
'thousandSeparator' => wc_get_price_thousand_separator(),
'priceFormat' => html_entity_decode( get_woocommerce_price_format() ),
];
}
/**
* Get locale data to include in settings.
*
* @return array
*/
protected function get_locale_data() {
global $wp_locale;
return [
'siteLocale' => get_locale(),
'userLocale' => get_user_locale(),
'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ),
];
}
/**
* Get store pages to include in settings.
*
* @return array
*/
protected function get_store_pages() {
$store_pages = [
'myaccount' => wc_get_page_id( 'myaccount' ),
'shop' => wc_get_page_id( 'shop' ),
'cart' => wc_get_page_id( 'cart' ),
'checkout' => wc_get_page_id( 'checkout' ),
'privacy' => wc_privacy_policy_page_id(),
'terms' => wc_terms_and_conditions_page_id(),
];
if ( is_callable( '_prime_post_caches' ) ) {
_prime_post_caches( array_values( $store_pages ), false, false );
}
return array_map(
[ $this, 'format_page_resource' ],
$store_pages
);
}
/**
* Get product related settings.
*
* Note: For the time being we are exposing only the settings that are used by blocks.
*
* @return array
*/
protected function get_products_settings() {
return [
'cartRedirectAfterAdd' => get_option( 'woocommerce_cart_redirect_after_add' ) === 'yes',
];
}
/**
* Format a page object into a standard array of data.
*
* @param WP_Post|int $page Page object or ID.
* @return array
*/
protected function format_page_resource( $page ) {
if ( is_numeric( $page ) && $page > 0 ) {
$page = get_post( $page );
}
if ( ! is_a( $page, '\WP_Post' ) || 'publish' !== $page->post_status ) {
return [
'id' => 0,
'title' => '',
'permalink' => false,
];
}
return [
'id' => $page->ID,
'title' => $page->post_title,
'permalink' => get_permalink( $page->ID ),
];
}
/**
* Returns block-related data for enqueued wc-settings script.
* Format order statuses by removing a leading 'wc-' if present.
*
* @return array formatted statuses.
*/
protected function get_order_statuses() {
$formatted_statuses = array();
foreach ( wc_get_order_statuses() as $key => $value ) {
$formatted_key = preg_replace( '/^wc-/', '', $key );
$formatted_statuses[ $formatted_key ] = $value;
}
return $formatted_statuses;
}
/**
* Used for on demand initialization of asset data and registering it with
* the internal data registry.
*
* Note: core data will overwrite any externally registered data via the api.
*/
protected function initialize_core_data() {
/**
* Filters the array of shared settings.
*
* Low level hook for registration of new data late in the cycle. This is deprecated.
* Instead, use the data api:
*
* ```php
* Automattic\WooCommerce\Blocks\Package::container()->get( Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry::class )->add( $key, $value )
* ```
*
* @since 5.0.0
*
* @deprecated
* @param array $data Settings data.
* @return array
*/
$settings = apply_filters( 'woocommerce_shared_settings', $this->data );
// Surface a deprecation warning in the error console.
if ( has_filter( 'woocommerce_shared_settings' ) ) {
$error_handle = 'deprecated-shared-settings-error';
$error_message = '`woocommerce_shared_settings` filter in Blocks is deprecated. See https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/trunk/docs/contributors/block-assets.md';
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter,WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_register_script( $error_handle, '' );
wp_enqueue_script( $error_handle );
wp_add_inline_script(
$error_handle,
sprintf( 'console.warn( "%s" );', $error_message )
);
}
// note this WILL wipe any data already registered to these keys because they are protected.
$this->data = array_replace_recursive( $settings, $this->get_core_data() );
}
/**
* Loops through each registered lazy data callback and adds the returned
* value to the data array.
*
* This method is executed right before preparing the data for printing to
* the rendered screen.
*
* @return void
*/
protected function execute_lazy_data() {
foreach ( $this->lazy_data as $key => $callback ) {
$this->data[ $key ] = $callback();
}
}
/**
* Exposes private registered data to child classes.
*
* @return array The registered data on the private data property
*/
protected function get() {
return $this->data;
}
/**
* Allows checking whether a key exists.
*
* @param string $key The key to check if exists.
* @return bool Whether the key exists in the current data registry.
*/
public function exists( $key ) {
return array_key_exists( $key, $this->data );
}
/**
* Interface for adding data to the registry.
*
* You can only register data that is not already in the registry identified by the given key. If there is a
* duplicate found, unless $ignore_duplicates is true, an exception will be thrown.
*
* @param string $key The key used to reference the data being registered. This should use camelCase.
* @param mixed $data If not a function, registered to the registry as is. If a function, then the
* callback is invoked right before output to the screen.
* @param boolean $check_key_exists If set to true, duplicate data will be ignored if the key exists.
* If false, duplicate data will cause an exception.
*
* @throws InvalidArgumentException Only throws when site is in debug mode. Always logs the error.
*/
public function add( $key, $data, $check_key_exists = false ) {
if ( $check_key_exists && $this->exists( $key ) ) {
return;
}
try {
$this->add_data( $key, $data );
} catch ( Exception $e ) {
if ( $this->debug() ) {
// bubble up.
throw $e;
}
wc_caught_exception( $e, __METHOD__, [ $key, $data ] );
}
}
/**
* Hydrate from the API.
*
* @param string $path REST API path to preload.
*/
public function hydrate_api_request( $path ) {
if ( ! isset( $this->preloaded_api_requests[ $path ] ) ) {
$this->preloaded_api_requests[ $path ] = Package::container()->get( Hydration::class )->get_rest_api_response_data( $path );
}
}
/**
* Hydrate some data from the API.
*
* @param string $key The key used to reference the data being registered.
* @param string $path REST API path to preload.
* @param boolean $check_key_exists If set to true, duplicate data will be ignored if the key exists.
* If false, duplicate data will cause an exception.
*
* @throws InvalidArgumentException Only throws when site is in debug mode. Always logs the error.
*/
public function hydrate_data_from_api_request( $key, $path, $check_key_exists = false ) {
$this->add(
$key,
function() use ( $path ) {
if ( isset( $this->preloaded_api_requests[ $path ], $this->preloaded_api_requests[ $path ]['body'] ) ) {
return $this->preloaded_api_requests[ $path ]['body'];
}
$response = Package::container()->get( Hydration::class )->get_rest_api_response_data( $path );
return $response['body'] ?? '';
},
$check_key_exists
);
}
/**
* Adds a page permalink to the data registry.
*
* @param integer $page_id Page ID to add to the registry.
*/
public function register_page_id( $page_id ) {
$permalink = $page_id ? get_permalink( $page_id ) : false;
if ( $permalink ) {
$this->data[ 'page-' . $page_id ] = $permalink;
}
}
/**
* Callback for registering the data script via WordPress API.
*
* @return void
*/
public function register_data_script() {
$this->api->register_script(
$this->handle,
'build/wc-settings.js',
[ 'wp-api-fetch' ],
true
);
}
/**
* Callback for enqueuing asset data via the WP api.
*
* Note: while this is hooked into print/admin_print_scripts, it still only
* happens if the script attached to `wc-settings` handle is enqueued. This
* is done to allow for any potentially expensive data generation to only
* happen for routes that need it.
*/
public function enqueue_asset_data() {
if ( wp_script_is( $this->handle, 'enqueued' ) ) {
$this->initialize_core_data();
$this->execute_lazy_data();
$data = rawurlencode( wp_json_encode( $this->data ) );
$wc_settings_script = "var wcSettings = wcSettings || JSON.parse( decodeURIComponent( '" . esc_js( $data ) . "' ) );";
$preloaded_api_requests_script = '';
if ( count( $this->preloaded_api_requests ) > 0 ) {
$preloaded_api_requests = rawurlencode( wp_json_encode( $this->preloaded_api_requests ) );
$preloaded_api_requests_script = "wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( JSON.parse( decodeURIComponent( '" . esc_js( $preloaded_api_requests ) . "' ) ) ) );";
}
wp_add_inline_script(
$this->handle,
$wc_settings_script . $preloaded_api_requests_script,
'before'
);
}
}
/**
* See self::add() for docs.
*
* @param string $key Key for the data.
* @param mixed $data Value for the data.
*
* @throws InvalidArgumentException If key is not a string or already
* exists in internal data cache.
*/
protected function add_data( $key, $data ) {
if ( ! is_string( $key ) ) {
if ( $this->debug() ) {
throw new InvalidArgumentException(
'Key for the data being registered must be a string'
);
}
}
if ( isset( $this->data[ $key ] ) ) {
if ( $this->debug() ) {
throw new InvalidArgumentException(
'Overriding existing data with an already registered key is not allowed'
);
}
return;
}
if ( \is_callable( $data ) ) {
$this->lazy_data[ $key ] = $data;
return;
}
$this->data[ $key ] = $data;
}
/**
* Exposes whether the current site is in debug mode or not.
*
* @return boolean True means the site is in debug mode.
*/
protected function debug() {
return defined( 'WP_DEBUG' ) && WP_DEBUG;
}
}
Assets.php 0000644 00000005427 15154173073 0006535 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
/**
* Assets class.
*
* @deprecated 5.0.0 This class will be removed in a future release. This has been replaced by AssetsController.
* @internal
*/
class Assets {
/**
* Initialize class features on init.
*
* @since 2.5.0
* @deprecated 5.0.0
*/
public static function init() {
_deprecated_function( 'Assets::init', '5.0.0' );
}
/**
* Register block scripts & styles.
*
* @since 2.5.0
* @deprecated 5.0.0
*/
public static function register_assets() {
_deprecated_function( 'Assets::register_assets', '5.0.0' );
}
/**
* Register the vendors style file. We need to do it after the other files
* because we need to check if `wp-edit-post` has been enqueued.
*
* @deprecated 5.0.0
*/
public static function enqueue_scripts() {
_deprecated_function( 'Assets::enqueue_scripts', '5.0.0' );
}
/**
* Add body classes.
*
* @deprecated 5.0.0
* @param array $classes Array of CSS classnames.
* @return array Modified array of CSS classnames.
*/
public static function add_theme_body_class( $classes = [] ) {
_deprecated_function( 'Assets::add_theme_body_class', '5.0.0' );
return $classes;
}
/**
* Add theme class to admin body.
*
* @deprecated 5.0.0
* @param array $classes String with the CSS classnames.
* @return array Modified string of CSS classnames.
*/
public static function add_theme_admin_body_class( $classes = '' ) {
_deprecated_function( 'Assets::add_theme_admin_body_class', '5.0.0' );
return $classes;
}
/**
* Adds a redirect field to the login form so blocks can redirect users after login.
*
* @deprecated 5.0.0
*/
public static function redirect_to_field() {
_deprecated_function( 'Assets::redirect_to_field', '5.0.0' );
}
/**
* Queues a block script in the frontend.
*
* @since 2.3.0
* @since 2.6.0 Changed $name to $script_name and added $handle argument.
* @since 2.9.0 Made it so scripts are not loaded in admin pages.
* @deprecated 4.5.0 Block types register the scripts themselves.
*
* @param string $script_name Name of the script used to identify the file inside build folder.
* @param string $handle Optional. Provided if the handle should be different than the script name. `wc-` prefix automatically added.
* @param array $dependencies Optional. An array of registered script handles this script depends on. Default empty array.
*/
public static function register_block_script( $script_name, $handle = '', $dependencies = [] ) {
_deprecated_function( 'register_block_script', '4.5.0' );
$asset_api = Package::container()->get( AssetApi::class );
$asset_api->register_block_script( $script_name, $handle, $dependencies );
}
}
AssetsController.php 0000644 00000031377 15154173073 0010604 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
/**
* AssetsController class.
*
* @since 5.0.0
* @internal
*/
final class AssetsController {
/**
* Asset API interface for various asset registration.
*
* @var AssetApi
*/
private $api;
/**
* Constructor.
*
* @param AssetApi $asset_api Asset API interface for various asset registration.
*/
public function __construct( AssetApi $asset_api ) {
$this->api = $asset_api;
$this->init();
}
/**
* Initialize class features.
*/
protected function init() {
add_action( 'init', array( $this, 'register_assets' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'register_and_enqueue_site_editor_assets' ) );
add_filter( 'wp_resource_hints', array( $this, 'add_resource_hints' ), 10, 2 );
add_action( 'body_class', array( $this, 'add_theme_body_class' ), 1 );
add_action( 'admin_body_class', array( $this, 'add_theme_body_class' ), 1 );
add_action( 'admin_enqueue_scripts', array( $this, 'update_block_style_dependencies' ), 20 );
add_action( 'wp_enqueue_scripts', array( $this, 'update_block_settings_dependencies' ), 100 );
add_action( 'admin_enqueue_scripts', array( $this, 'update_block_settings_dependencies' ), 100 );
}
/**
* Register block scripts & styles.
*/
public function register_assets() {
$this->register_style( 'wc-blocks-packages-style', plugins_url( $this->api->get_block_asset_build_path( 'packages-style', 'css' ), __DIR__ ), [], 'all', true );
$this->register_style( 'wc-blocks-style', plugins_url( $this->api->get_block_asset_build_path( 'wc-blocks', 'css' ), __DIR__ ), [], 'all', true );
$this->register_style( 'wc-blocks-editor-style', plugins_url( $this->api->get_block_asset_build_path( 'wc-blocks-editor-style', 'css' ), __DIR__ ), [ 'wp-edit-blocks' ], 'all', true );
$this->api->register_script( 'wc-blocks-middleware', 'build/wc-blocks-middleware.js', [], false );
$this->api->register_script( 'wc-blocks-data-store', 'build/wc-blocks-data.js', [ 'wc-blocks-middleware' ] );
$this->api->register_script( 'wc-blocks-vendors', $this->api->get_block_asset_build_path( 'wc-blocks-vendors' ), [], false );
$this->api->register_script( 'wc-blocks-registry', 'build/wc-blocks-registry.js', [], false );
$this->api->register_script( 'wc-blocks', $this->api->get_block_asset_build_path( 'wc-blocks' ), [ 'wc-blocks-vendors' ], false );
$this->api->register_script( 'wc-blocks-shared-context', 'build/wc-blocks-shared-context.js' );
$this->api->register_script( 'wc-blocks-shared-hocs', 'build/wc-blocks-shared-hocs.js', [], false );
// The price package is shared externally so has no blocks prefix.
$this->api->register_script( 'wc-price-format', 'build/price-format.js', [], false );
$this->api->register_script( 'wc-blocks-checkout', 'build/blocks-checkout.js', [] );
wp_add_inline_script(
'wc-blocks-middleware',
"
var wcBlocksMiddlewareConfig = {
storeApiNonce: '" . esc_js( wp_create_nonce( 'wc_store_api' ) ) . "',
wcStoreApiNonceTimestamp: '" . esc_js( time() ) . "'
};
",
'before'
);
}
/**
* Register and enqueue assets for exclusive usage within the Site Editor.
*/
public function register_and_enqueue_site_editor_assets() {
$this->api->register_script( 'wc-blocks-classic-template-revert-button', 'build/wc-blocks-classic-template-revert-button.js' );
$this->api->register_style( 'wc-blocks-classic-template-revert-button-style', 'build/wc-blocks-classic-template-revert-button-style.css' );
$current_screen = get_current_screen();
if ( $current_screen instanceof \WP_Screen && 'site-editor' === $current_screen->base ) {
wp_enqueue_script( 'wc-blocks-classic-template-revert-button' );
wp_enqueue_style( 'wc-blocks-classic-template-revert-button-style' );
}
}
/**
* Defines resource hints to help speed up the loading of some critical blocks.
*
* These will not impact page loading times negatively because they are loaded once the current page is idle.
*
* @param array $urls URLs to print for resource hints. Each URL is an array of resource attributes, or a URL string.
* @param string $relation_type The relation type the URLs are printed. Possible values: preconnect, dns-prefetch, prefetch, prerender.
* @return array URLs to print for resource hints.
*/
public function add_resource_hints( $urls, $relation_type ) {
if ( ! in_array( $relation_type, [ 'prefetch', 'prerender' ], true ) || is_admin() ) {
return $urls;
}
// We only need to prefetch when the cart has contents.
$cart = wc()->cart;
if ( ! $cart instanceof \WC_Cart || 0 === $cart->get_cart_contents_count() ) {
return $urls;
}
if ( 'prefetch' === $relation_type ) {
$urls = array_merge(
$urls,
$this->get_prefetch_resource_hints()
);
}
if ( 'prerender' === $relation_type ) {
$urls = array_merge(
$urls,
$this->get_prerender_resource_hints()
);
}
return $urls;
}
/**
* Get resource hints during prefetch requests.
*
* @return array Array of URLs.
*/
private function get_prefetch_resource_hints() {
$urls = [];
// Core page IDs.
$cart_page_id = wc_get_page_id( 'cart' );
$checkout_page_id = wc_get_page_id( 'checkout' );
// Checks a specific page (by ID) to see if it contains the named block.
$has_block_cart = $cart_page_id && has_block( 'woocommerce/cart', $cart_page_id );
$has_block_checkout = $checkout_page_id && has_block( 'woocommerce/checkout', $checkout_page_id );
// Checks the current page to see if it contains the named block.
$is_block_cart = has_block( 'woocommerce/cart' );
$is_block_checkout = has_block( 'woocommerce/checkout' );
if ( $has_block_cart && ! $is_block_cart ) {
$urls = array_merge( $urls, $this->get_block_asset_resource_hints( 'cart-frontend' ) );
}
if ( $has_block_checkout && ! $is_block_checkout ) {
$urls = array_merge( $urls, $this->get_block_asset_resource_hints( 'checkout-frontend' ) );
}
return $urls;
}
/**
* Get resource hints during prerender requests.
*
* @return array Array of URLs.
*/
private function get_prerender_resource_hints() {
$urls = [];
$is_block_cart = has_block( 'woocommerce/cart' );
if ( ! $is_block_cart ) {
return $urls;
}
$checkout_page_id = wc_get_page_id( 'checkout' );
$checkout_page_url = $checkout_page_id ? get_permalink( $checkout_page_id ) : '';
if ( $checkout_page_url ) {
$urls[] = $checkout_page_url;
}
return $urls;
}
/**
* Get resource hint for a block by name.
*
* @param string $filename Block filename.
* @return array
*/
private function get_block_asset_resource_hints( $filename = '' ) {
if ( ! $filename ) {
return [];
}
$script_data = $this->api->get_script_data(
$this->api->get_block_asset_build_path( $filename )
);
$resources = array_merge(
[ esc_url( add_query_arg( 'ver', $script_data['version'], $script_data['src'] ) ) ],
$this->get_script_dependency_src_array( $script_data['dependencies'] )
);
return array_map(
function( $src ) {
return array(
'href' => $src,
'as' => 'script',
);
},
array_unique( array_filter( $resources ) )
);
}
/**
* Get the src of all script dependencies (handles).
*
* @param array $dependencies Array of dependency handles.
* @return string[] Array of src strings.
*/
private function get_script_dependency_src_array( array $dependencies ) {
$wp_scripts = wp_scripts();
return array_reduce(
$dependencies,
function( $src, $handle ) use ( $wp_scripts ) {
if ( isset( $wp_scripts->registered[ $handle ] ) ) {
$src[] = esc_url( add_query_arg( 'ver', $wp_scripts->registered[ $handle ]->ver, $this->get_absolute_url( $wp_scripts->registered[ $handle ]->src ) ) );
$src = array_merge( $src, $this->get_script_dependency_src_array( $wp_scripts->registered[ $handle ]->deps ) );
}
return $src;
},
[]
);
}
/**
* Returns an absolute url to relative links for WordPress core scripts.
*
* @param string $src Original src that can be relative.
* @return string Correct full path string.
*/
private function get_absolute_url( $src ) {
$wp_scripts = wp_scripts();
if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && 0 === strpos( $src, $wp_scripts->content_url ) ) ) {
$src = $wp_scripts->base_url . $src;
}
return $src;
}
/**
* Add body classes to the frontend and within admin.
*
* @param string|array $classes Array or string of CSS classnames.
* @return string|array Modified classnames.
*/
public function add_theme_body_class( $classes ) {
$class = 'theme-' . get_template();
if ( is_array( $classes ) ) {
$classes[] = $class;
} else {
$classes .= ' ' . $class . ' ';
}
return $classes;
}
/**
* Get the file modified time as a cache buster if we're in dev mode.
*
* @param string $file Local path to the file.
* @return string The cache buster value to use for the given file.
*/
protected function get_file_version( $file ) {
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG && file_exists( \Automattic\WooCommerce\Blocks\Package::get_path() . $file ) ) {
return filemtime( \Automattic\WooCommerce\Blocks\Package::get_path() . $file );
}
return \Automattic\WooCommerce\Blocks\Package::get_version();
}
/**
* Registers a style according to `wp_register_style`.
*
* @param string $handle Name of the stylesheet. Should be unique.
* @param string $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory.
* @param array $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string $media Optional. The media for which this stylesheet has been defined. Default 'all'. Accepts media types like
* 'all', 'print' and 'screen', or media queries like '(orientation: portrait)' and '(max-width: 640px)'.
* @param boolean $rtl Optional. Whether or not to register RTL styles.
*/
protected function register_style( $handle, $src, $deps = [], $media = 'all', $rtl = false ) {
$filename = str_replace( plugins_url( '/', __DIR__ ), '', $src );
$ver = self::get_file_version( $filename );
wp_register_style( $handle, $src, $deps, $ver, $media );
if ( $rtl ) {
wp_style_add_data( $handle, 'rtl', 'replace' );
}
}
/**
* Update block style dependencies after they have been registered.
*/
public function update_block_style_dependencies() {
$wp_styles = wp_styles();
$style = $wp_styles->query( 'wc-blocks-style', 'registered' );
if ( ! $style ) {
return;
}
// In WC < 5.5, `woocommerce-general` is not registered in block editor
// screens, so we don't add it as a dependency if it's not registered.
// In WC >= 5.5, `woocommerce-general` is registered on `admin_enqueue_scripts`,
// so we need to check if it's registered here instead of on `init`.
if (
wp_style_is( 'woocommerce-general', 'registered' ) &&
! in_array( 'woocommerce-general', $style->deps, true )
) {
$style->deps[] = 'woocommerce-general';
}
}
/**
* Fix scripts with wc-settings dependency.
*
* The wc-settings script only works correctly when enqueued in the footer. This is to give blocks etc time to
* register their settings data before it's printed.
*
* This code will look at registered scripts, and if they have a wc-settings dependency, force them to print in the
* footer instead of the header.
*
* This only supports packages known to require wc-settings!
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5052
*/
public function update_block_settings_dependencies() {
$wp_scripts = wp_scripts();
$known_packages = [ 'wc-settings', 'wc-blocks-checkout', 'wc-price-format' ];
foreach ( $wp_scripts->registered as $handle => $script ) {
// scripts that are loaded in the footer has extra->group = 1.
if ( array_intersect( $known_packages, $script->deps ) && ! isset( $script->extra['group'] ) ) {
// Append the script to footer.
$wp_scripts->add_data( $handle, 'group', 1 );
// Show a warning.
$error_handle = 'wc-settings-dep-in-header';
$used_deps = implode( ', ', array_intersect( $known_packages, $script->deps ) );
$error_message = "Scripts that have a dependency on [$used_deps] must be loaded in the footer, {$handle} was registered to load in the header, but has been switched to load in the footer instead. See https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5059";
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter,WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_register_script( $error_handle, '' );
wp_enqueue_script( $error_handle );
wp_add_inline_script(
$error_handle,
sprintf( 'console.warn( "%s" );', $error_message )
);
}
}
}
}
BlockPatterns.php 0000644 00000012723 15154173073 0010043 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\Domain\Package;
/**
* Registers patterns under the `./patterns/` directory.
* Each pattern is defined as a PHP file and defines its metadata using plugin-style headers.
* The minimum required definition is:
*
* /**
* * Title: My Pattern
* * Slug: my-theme/my-pattern
* *
*
* The output of the PHP source corresponds to the content of the pattern, e.g.:
*
* <main><p><?php echo "Hello"; ?></p></main>
*
* Other settable fields include:
*
* - Description
* - Viewport Width
* - Categories (comma-separated values)
* - Keywords (comma-separated values)
* - Block Types (comma-separated values)
* - Inserter (yes/no)
*
* @internal
*/
class BlockPatterns {
const SLUG_REGEX = '/^[A-z0-9\/_-]+$/';
const COMMA_SEPARATED_REGEX = '/[\s,]+/';
/**
* Path to the patterns directory.
*
* @var string $patterns_path
*/
private $patterns_path;
/**
* Constructor for class
*
* @param Package $package An instance of Package.
*/
public function __construct( Package $package ) {
$this->patterns_path = $package->get_path( 'patterns' );
add_action( 'init', array( $this, 'register_block_patterns' ) );
}
/**
* Registers the block patterns and categories under `./patterns/`.
*/
public function register_block_patterns() {
if ( ! class_exists( 'WP_Block_Patterns_Registry' ) ) {
return;
}
$default_headers = array(
'title' => 'Title',
'slug' => 'Slug',
'description' => 'Description',
'viewportWidth' => 'Viewport Width',
'categories' => 'Categories',
'keywords' => 'Keywords',
'blockTypes' => 'Block Types',
'inserter' => 'Inserter',
);
if ( ! file_exists( $this->patterns_path ) ) {
return;
}
$files = glob( $this->patterns_path . '/*.php' );
if ( ! $files ) {
return;
}
foreach ( $files as $file ) {
$pattern_data = get_file_data( $file, $default_headers );
if ( empty( $pattern_data['slug'] ) ) {
_doing_it_wrong(
'register_block_patterns',
esc_html(
sprintf(
/* translators: %s: file name. */
__( 'Could not register file "%s" as a block pattern ("Slug" field missing)', 'woocommerce' ),
$file
)
),
'6.0.0'
);
continue;
}
if ( ! preg_match( self::SLUG_REGEX, $pattern_data['slug'] ) ) {
_doing_it_wrong(
'register_block_patterns',
esc_html(
sprintf(
/* translators: %1s: file name; %2s: slug value found. */
__( 'Could not register file "%1$s" as a block pattern (invalid slug "%2$s")', 'woocommerce' ),
$file,
$pattern_data['slug']
)
),
'6.0.0'
);
continue;
}
if ( \WP_Block_Patterns_Registry::get_instance()->is_registered( $pattern_data['slug'] ) ) {
continue;
}
// Title is a required property.
if ( ! $pattern_data['title'] ) {
_doing_it_wrong(
'register_block_patterns',
esc_html(
sprintf(
/* translators: %1s: file name; %2s: slug value found. */
__( 'Could not register file "%s" as a block pattern ("Title" field missing)', 'woocommerce' ),
$file
)
),
'6.0.0'
);
continue;
}
// For properties of type array, parse data as comma-separated.
foreach ( array( 'categories', 'keywords', 'blockTypes' ) as $property ) {
if ( ! empty( $pattern_data[ $property ] ) ) {
$pattern_data[ $property ] = array_filter(
preg_split(
self::COMMA_SEPARATED_REGEX,
(string) $pattern_data[ $property ]
)
);
} else {
unset( $pattern_data[ $property ] );
}
}
// Parse properties of type int.
foreach ( array( 'viewportWidth' ) as $property ) {
if ( ! empty( $pattern_data[ $property ] ) ) {
$pattern_data[ $property ] = (int) $pattern_data[ $property ];
} else {
unset( $pattern_data[ $property ] );
}
}
// Parse properties of type bool.
foreach ( array( 'inserter' ) as $property ) {
if ( ! empty( $pattern_data[ $property ] ) ) {
$pattern_data[ $property ] = in_array(
strtolower( $pattern_data[ $property ] ),
array( 'yes', 'true' ),
true
);
} else {
unset( $pattern_data[ $property ] );
}
}
// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.LowLevelTranslationFunction
$pattern_data['title'] = translate_with_gettext_context( $pattern_data['title'], 'Pattern title', 'woo-gutenberg-products-block' );
if ( ! empty( $pattern_data['description'] ) ) {
// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.LowLevelTranslationFunction
$pattern_data['description'] = translate_with_gettext_context( $pattern_data['description'], 'Pattern description', 'woo-gutenberg-products-block' );
}
// The actual pattern content is the output of the file.
ob_start();
include $file;
$pattern_data['content'] = ob_get_clean();
if ( ! $pattern_data['content'] ) {
continue;
}
foreach ( $pattern_data['categories'] as $key => $category ) {
$category_slug = _wp_to_kebab_case( $category );
$pattern_data['categories'][ $key ] = $category_slug;
register_block_pattern_category(
$category_slug,
// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
array( 'label' => __( $category, 'woocommerce' ) )
);
}
register_block_pattern( $pattern_data['slug'], $pattern_data );
}
}
}
BlockTemplatesController.php 0000644 00000111401 15154173073 0012236 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Automattic\WooCommerce\Blocks\Templates\CartTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductAttributeTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
use Automattic\WooCommerce\Blocks\Templates\OrderConfirmationTemplate;
use Automattic\WooCommerce\Blocks\Utils\SettingsUtils;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateMigrationUtils;
use \WP_Post;
/**
* BlockTypesController class.
*
* @internal
*/
class BlockTemplatesController {
/**
* Holds the Package instance
*
* @var Package
*/
private $package;
/**
* Holds the path for the directory where the block templates will be kept.
*
* @var string
*/
private $templates_directory;
/**
* Holds the path for the directory where the block template parts will be kept.
*
* @var string
*/
private $template_parts_directory;
/**
* Directory which contains all templates
*
* @var string
*/
const TEMPLATES_ROOT_DIR = 'templates';
/**
* Constructor.
*
* @param Package $package An instance of Package.
*/
public function __construct( Package $package ) {
$this->package = $package;
// This feature is gated for WooCommerce versions 6.0.0 and above.
if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '6.0.0', '>=' ) ) {
$root_path = plugin_dir_path( __DIR__ ) . self::TEMPLATES_ROOT_DIR . DIRECTORY_SEPARATOR;
$this->templates_directory = $root_path . BlockTemplateUtils::DIRECTORY_NAMES['TEMPLATES'];
$this->template_parts_directory = $root_path . BlockTemplateUtils::DIRECTORY_NAMES['TEMPLATE_PARTS'];
$this->init();
}
}
/**
* Initialization method.
*/
protected function init() {
add_filter( 'default_wp_template_part_areas', array( $this, 'register_mini_cart_template_part_area' ), 10, 1 );
add_action( 'template_redirect', array( $this, 'render_block_template' ) );
add_filter( 'pre_get_block_template', array( $this, 'get_block_template_fallback' ), 10, 3 );
add_filter( 'pre_get_block_file_template', array( $this, 'get_block_file_template' ), 10, 3 );
add_filter( 'get_block_templates', array( $this, 'add_block_templates' ), 10, 3 );
add_filter( 'current_theme_supports-block-templates', array( $this, 'remove_block_template_support_for_shop_page' ) );
add_filter( 'taxonomy_template_hierarchy', array( $this, 'add_archive_product_to_eligible_for_fallback_templates' ), 10, 1 );
add_filter( 'post_type_archive_title', array( $this, 'update_product_archive_title' ), 10, 2 );
add_action( 'after_switch_theme', array( $this, 'check_should_use_blockified_product_grid_templates' ), 10, 2 );
if ( wc_current_theme_is_fse_theme() ) {
add_action( 'init', array( $this, 'maybe_migrate_content' ) );
add_filter( 'woocommerce_settings_pages', array( $this, 'template_permalink_settings' ) );
add_filter( 'pre_update_option', array( $this, 'update_template_permalink' ), 10, 2 );
add_action( 'woocommerce_admin_field_permalink', array( SettingsUtils::class, 'permalink_input_field' ) );
// By default, the Template Part Block only supports template parts that are in the current theme directory.
// This render_callback wrapper allows us to add support for plugin-housed template parts.
add_filter(
'block_type_metadata_settings',
function( $settings, $metadata ) {
if (
isset( $metadata['name'], $settings['render_callback'] ) &&
'core/template-part' === $metadata['name'] &&
in_array( $settings['render_callback'], [ 'render_block_core_template_part', 'gutenberg_render_block_core_template_part' ], true )
) {
$settings['render_callback'] = [ $this, 'render_woocommerce_template_part' ];
}
return $settings;
},
10,
2
);
// Prevents shortcodes in templates having their HTML content broken by wpautop.
// @see https://core.trac.wordpress.org/ticket/58366 for more info.
add_filter(
'block_type_metadata_settings',
function( $settings, $metadata ) {
if (
isset( $metadata['name'], $settings['render_callback'] ) &&
'core/shortcode' === $metadata['name']
) {
$settings['original_render_callback'] = $settings['render_callback'];
$settings['render_callback'] = function( $attributes, $content ) use ( $settings ) {
// The shortcode has already been rendered, so look for the cart/checkout HTML.
if ( strstr( $content, 'woocommerce-cart-form' ) || strstr( $content, 'wc-empty-cart-message' ) || strstr( $content, 'woocommerce-checkout-form' ) ) {
// Return early before wpautop runs again.
return $content;
}
$render_callback = $settings['original_render_callback'];
return $render_callback( $attributes, $content );
};
}
return $settings;
},
10,
2
);
}
}
/**
* Add Mini-Cart to the default template part areas.
*
* @param array $default_area_definitions An array of supported area objects.
* @return array The supported template part areas including the Mini-Cart one.
*/
public function register_mini_cart_template_part_area( $default_area_definitions ) {
$mini_cart_template_part_area = [
'area' => 'mini-cart',
'label' => __( 'Mini-Cart', 'woocommerce' ),
'description' => __( 'The Mini-Cart template allows shoppers to see their cart items and provides access to the Cart and Checkout pages.', 'woocommerce' ),
'icon' => 'mini-cart',
'area_tag' => 'mini-cart',
];
return array_merge( $default_area_definitions, [ $mini_cart_template_part_area ] );
}
/**
* Renders the `core/template-part` block on the server.
*
* @param array $attributes The block attributes.
* @return string The render.
*/
public function render_woocommerce_template_part( $attributes ) {
if ( 'woocommerce/woocommerce' === $attributes['theme'] ) {
$template_part = BlockTemplateUtils::get_block_template( $attributes['theme'] . '//' . $attributes['slug'], 'wp_template_part' );
if ( $template_part && ! empty( $template_part->content ) ) {
return do_blocks( $template_part->content );
}
}
return function_exists( '\gutenberg_render_block_core_template_part' ) ? \gutenberg_render_block_core_template_part( $attributes ) : \render_block_core_template_part( $attributes );
}
/**
* This function is used on the `pre_get_block_template` hook to return the fallback template from the db in case
* the template is eligible for it.
*
* @param \WP_Block_Template|null $template Block template object to short-circuit the default query,
* or null to allow WP to run its normal queries.
* @param string $id Template unique identifier (example: theme_slug//template_slug).
* @param string $template_type wp_template or wp_template_part.
*
* @return object|null
*/
public function get_block_template_fallback( $template, $id, $template_type ) {
$template_name_parts = explode( '//', $id );
list( $theme, $slug ) = $template_name_parts;
if ( ! BlockTemplateUtils::template_is_eligible_for_product_archive_fallback( $slug ) ) {
return null;
}
$wp_query_args = array(
'post_name__in' => array( 'archive-product', $slug ),
'post_type' => $template_type,
'post_status' => array( 'auto-draft', 'draft', 'publish', 'trash' ),
'no_found_rows' => true,
'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
array(
'taxonomy' => 'wp_theme',
'field' => 'name',
'terms' => $theme,
),
),
);
$template_query = new \WP_Query( $wp_query_args );
$posts = $template_query->posts;
// If we have more than one result from the query, it means that the current template is present in the db (has
// been customized by the user) and we should not return the `archive-product` template.
if ( count( $posts ) > 1 ) {
return null;
}
if ( count( $posts ) > 0 && 'archive-product' === $posts[0]->post_name ) {
$template = _build_block_template_result_from_post( $posts[0] );
if ( ! is_wp_error( $template ) ) {
$template->id = $theme . '//' . $slug;
$template->slug = $slug;
$template->title = BlockTemplateUtils::get_block_template_title( $slug );
$template->description = BlockTemplateUtils::get_block_template_description( $slug );
unset( $template->source );
return $template;
}
}
return $template;
}
/**
* Adds the `archive-product` template to the `taxonomy-product_cat`, `taxonomy-product_tag`, `taxonomy-attribute`
* templates to be able to fall back to it.
*
* @param array $template_hierarchy A list of template candidates, in descending order of priority.
*/
public function add_archive_product_to_eligible_for_fallback_templates( $template_hierarchy ) {
$template_slugs = array_map(
'_strip_template_file_suffix',
$template_hierarchy
);
$templates_eligible_for_fallback = array_filter(
$template_slugs,
array( BlockTemplateUtils::class, 'template_is_eligible_for_product_archive_fallback' )
);
if ( count( $templates_eligible_for_fallback ) > 0 ) {
$template_hierarchy[] = 'archive-product';
}
return $template_hierarchy;
}
/**
* Checks the old and current themes and determines if the "wc_blocks_use_blockified_product_grid_block_as_template"
* option need to be updated accordingly.
*
* @param string $old_name Old theme name.
* @param \WP_Theme $old_theme Instance of the old theme.
* @return void
*/
public function check_should_use_blockified_product_grid_templates( $old_name, $old_theme ) {
if ( ! wc_current_theme_is_fse_theme() ) {
update_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE, wc_bool_to_string( false ) );
return;
}
if ( ! $old_theme->is_block_theme() && wc_current_theme_is_fse_theme() ) {
update_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE, wc_bool_to_string( true ) );
return;
}
}
/**
* This function checks if there's a block template file in `woo-gutenberg-products-block/templates/templates/`
* to return to pre_get_posts short-circuiting the query in Gutenberg.
*
* @param \WP_Block_Template|null $template Return a block template object to short-circuit the default query,
* or null to allow WP to run its normal queries.
* @param string $id Template unique identifier (example: theme_slug//template_slug).
* @param string $template_type wp_template or wp_template_part.
*
* @return mixed|\WP_Block_Template|\WP_Error
*/
public function get_block_file_template( $template, $id, $template_type ) {
$template_name_parts = explode( '//', $id );
if ( count( $template_name_parts ) < 2 ) {
return $template;
}
list( $template_id, $template_slug ) = $template_name_parts;
// If the theme has an archive-product.html template, but not a taxonomy-product_cat/tag/attribute.html template let's use the themes archive-product.html template.
if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback_from_theme( $template_slug ) ) {
$template_path = BlockTemplateUtils::get_theme_template_path( 'archive-product' );
$template_object = BlockTemplateUtils::create_new_block_template_object( $template_path, $template_type, $template_slug, true );
return BlockTemplateUtils::build_template_result_from_file( $template_object, $template_type );
}
// This is a real edge-case, we are supporting users who have saved templates under the deprecated slug. See its definition for more information.
// You can likely ignore this code unless you're supporting/debugging early customised templates.
if ( BlockTemplateUtils::DEPRECATED_PLUGIN_SLUG === strtolower( $template_id ) ) {
// Because we are using get_block_templates we have to unhook this method to prevent a recursive loop where this filter is applied.
remove_filter( 'pre_get_block_file_template', array( $this, 'get_block_file_template' ), 10, 3 );
$template_with_deprecated_id = BlockTemplateUtils::get_block_template( $id, $template_type );
// Let's hook this method back now that we have used the function.
add_filter( 'pre_get_block_file_template', array( $this, 'get_block_file_template' ), 10, 3 );
if ( null !== $template_with_deprecated_id ) {
return $template_with_deprecated_id;
}
}
// If we are not dealing with a WooCommerce template let's return early and let it continue through the process.
if ( BlockTemplateUtils::PLUGIN_SLUG !== $template_id ) {
return $template;
}
// If we don't have a template let Gutenberg do its thing.
if ( ! $this->block_template_is_available( $template_slug, $template_type ) ) {
return $template;
}
$directory = $this->get_templates_directory( $template_type );
$template_file_path = $directory . '/' . $template_slug . '.html';
$template_object = BlockTemplateUtils::create_new_block_template_object( $template_file_path, $template_type, $template_slug );
$template_built = BlockTemplateUtils::build_template_result_from_file( $template_object, $template_type );
if ( null !== $template_built ) {
return $template_built;
}
// Hand back over to Gutenberg if we can't find a template.
return $template;
}
/**
* Add the block template objects to be used.
*
* @param array $query_result Array of template objects.
* @param array $query Optional. Arguments to retrieve templates.
* @param string $template_type wp_template or wp_template_part.
* @return array
*/
public function add_block_templates( $query_result, $query, $template_type ) {
if ( ! BlockTemplateUtils::supports_block_templates( $template_type ) ) {
return $query_result;
}
$post_type = isset( $query['post_type'] ) ? $query['post_type'] : '';
$slugs = isset( $query['slug__in'] ) ? $query['slug__in'] : array();
$template_files = $this->get_block_templates( $slugs, $template_type );
// @todo: Add apply_filters to _gutenberg_get_template_files() in Gutenberg to prevent duplication of logic.
foreach ( $template_files as $template_file ) {
// If we have a template which is eligible for a fallback, we need to explicitly tell Gutenberg that
// it has a theme file (because it is using the fallback template file). And then `continue` to avoid
// adding duplicates.
if ( BlockTemplateUtils::set_has_theme_file_if_fallback_is_available( $query_result, $template_file ) ) {
continue;
}
// If the current $post_type is set (e.g. on an Edit Post screen), and isn't included in the available post_types
// on the template file, then lets skip it so that it doesn't get added. This is typically used to hide templates
// in the template dropdown on the Edit Post page.
if ( $post_type &&
isset( $template_file->post_types ) &&
! in_array( $post_type, $template_file->post_types, true )
) {
continue;
}
// It would be custom if the template was modified in the editor, so if it's not custom we can load it from
// the filesystem.
if ( 'custom' !== $template_file->source ) {
$template = BlockTemplateUtils::build_template_result_from_file( $template_file, $template_type );
} else {
$template_file->title = BlockTemplateUtils::get_block_template_title( $template_file->slug );
$template_file->description = BlockTemplateUtils::get_block_template_description( $template_file->slug );
$query_result[] = $template_file;
continue;
}
$is_not_custom = false === array_search(
wp_get_theme()->get_stylesheet() . '//' . $template_file->slug,
array_column( $query_result, 'id' ),
true
);
$fits_slug_query =
! isset( $query['slug__in'] ) || in_array( $template_file->slug, $query['slug__in'], true );
$fits_area_query =
! isset( $query['area'] ) || ( property_exists( $template_file, 'area' ) && $template_file->area === $query['area'] );
$should_include = $is_not_custom && $fits_slug_query && $fits_area_query;
if ( $should_include ) {
$query_result[] = $template;
}
}
// We need to remove theme (i.e. filesystem) templates that have the same slug as a customised one.
// This only affects saved templates that were saved BEFORE a theme template with the same slug was added.
$query_result = BlockTemplateUtils::remove_theme_templates_with_custom_alternative( $query_result );
/**
* WC templates from theme aren't included in `$this->get_block_templates()` but are handled by Gutenberg.
* We need to do additional search through all templates file to update title and description for WC
* templates that aren't listed in theme.json.
*/
$query_result = array_map(
function( $template ) {
if ( str_contains( $template->slug, 'single-product' ) ) {
// We don't want to add the compatibility layer on the Editor Side.
// The second condition is necessary to not apply the compatibility layer on the REST API. Gutenberg uses the REST API to clone the template.
// More details: https://github.com/woocommerce/woocommerce-blocks/issues/9662.
if ( ( ! is_admin() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) && ! BlockTemplateUtils::template_has_legacy_template_block( $template ) ) {
// Add the product class to the body. We should move this to a more appropriate place.
add_filter(
'body_class',
function( $classes ) {
return array_merge( $classes, wc_get_product_class() );
}
);
global $product;
if ( ! $product instanceof \WC_Product ) {
$product_id = get_the_ID();
if ( $product_id ) {
wc_setup_product_data( $product_id );
}
}
$new_content = SingleProductTemplateCompatibility::add_compatibility_layer( $template->content );
$template->content = $new_content;
}
}
if ( 'theme' === $template->origin && BlockTemplateUtils::template_has_title( $template ) ) {
return $template;
}
if ( $template->title === $template->slug ) {
$template->title = BlockTemplateUtils::get_block_template_title( $template->slug );
}
if ( ! $template->description ) {
$template->description = BlockTemplateUtils::get_block_template_description( $template->slug );
}
return $template;
},
$query_result
);
return $query_result;
}
/**
* Gets the templates saved in the database.
*
* @param array $slugs An array of slugs to retrieve templates for.
* @param string $template_type wp_template or wp_template_part.
*
* @return int[]|\WP_Post[] An array of found templates.
*/
public function get_block_templates_from_db( $slugs = array(), $template_type = 'wp_template' ) {
wc_deprecated_function( 'BlockTemplatesController::get_block_templates_from_db()', '7.8', '\Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils::get_block_templates_from_db()' );
return BlockTemplateUtils::get_block_templates_from_db( $slugs, $template_type );
}
/**
* Gets the templates from the WooCommerce blocks directory, skipping those for which a template already exists
* in the theme directory.
*
* @param string[] $slugs An array of slugs to filter templates by. Templates whose slug does not match will not be returned.
* @param array $already_found_templates Templates that have already been found, these are customised templates that are loaded from the database.
* @param string $template_type wp_template or wp_template_part.
*
* @return array Templates from the WooCommerce blocks plugin directory.
*/
public function get_block_templates_from_woocommerce( $slugs, $already_found_templates, $template_type = 'wp_template' ) {
$directory = $this->get_templates_directory( $template_type );
$template_files = BlockTemplateUtils::get_template_paths( $directory );
$templates = array();
foreach ( $template_files as $template_file ) {
// Skip the template if it's blockified, and we should only use classic ones.
if ( ! BlockTemplateUtils::should_use_blockified_product_grid_templates() && strpos( $template_file, 'blockified' ) !== false ) {
continue;
}
$template_slug = BlockTemplateUtils::generate_template_slug_from_path( $template_file );
// This template does not have a slug we're looking for. Skip it.
if ( is_array( $slugs ) && count( $slugs ) > 0 && ! in_array( $template_slug, $slugs, true ) ) {
continue;
}
// If the theme already has a template, or the template is already in the list (i.e. it came from the
// database) then we should not overwrite it with the one from the filesystem.
if (
BlockTemplateUtils::theme_has_template( $template_slug ) ||
count(
array_filter(
$already_found_templates,
function ( $template ) use ( $template_slug ) {
$template_obj = (object) $template; //phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.Found
return $template_obj->slug === $template_slug;
}
)
) > 0 ) {
continue;
}
if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback_from_db( $template_slug, $already_found_templates ) ) {
$template = clone BlockTemplateUtils::get_fallback_template_from_db( $template_slug, $already_found_templates );
$template_id = explode( '//', $template->id );
$template->id = $template_id[0] . '//' . $template_slug;
$template->slug = $template_slug;
$template->title = BlockTemplateUtils::get_block_template_title( $template_slug );
$template->description = BlockTemplateUtils::get_block_template_description( $template_slug );
$templates[] = $template;
continue;
}
// If the theme has an archive-product.html template, but not a taxonomy-product_cat/tag/attribute.html template let's use the themes archive-product.html template.
if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback_from_theme( $template_slug ) ) {
$template_file = BlockTemplateUtils::get_theme_template_path( 'archive-product' );
$templates[] = BlockTemplateUtils::create_new_block_template_object( $template_file, $template_type, $template_slug, true );
continue;
}
// At this point the template only exists in the Blocks filesystem, if is a taxonomy-product_cat/tag/attribute.html template
// let's use the archive-product.html template from Blocks.
if ( BlockTemplateUtils::template_is_eligible_for_product_archive_fallback( $template_slug ) ) {
$template_file = $this->get_template_path_from_woocommerce( 'archive-product' );
$templates[] = BlockTemplateUtils::create_new_block_template_object( $template_file, $template_type, $template_slug, false );
continue;
}
// At this point the template only exists in the Blocks filesystem and has not been saved in the DB,
// or superseded by the theme.
$templates[] = BlockTemplateUtils::create_new_block_template_object( $template_file, $template_type, $template_slug );
}
return $templates;
}
/**
* Get and build the block template objects from the block template files.
*
* @param array $slugs An array of slugs to retrieve templates for.
* @param string $template_type wp_template or wp_template_part.
*
* @return array WP_Block_Template[] An array of block template objects.
*/
public function get_block_templates( $slugs = array(), $template_type = 'wp_template' ) {
$templates_from_db = BlockTemplateUtils::get_block_templates_from_db( $slugs, $template_type );
$templates_from_woo = $this->get_block_templates_from_woocommerce( $slugs, $templates_from_db, $template_type );
$templates = array_merge( $templates_from_db, $templates_from_woo );
return BlockTemplateUtils::filter_block_templates_by_feature_flag( $templates );
}
/**
* Gets the directory where templates of a specific template type can be found.
*
* @param string $template_type wp_template or wp_template_part.
*
* @return string
*/
protected function get_templates_directory( $template_type = 'wp_template' ) {
if ( 'wp_template_part' === $template_type ) {
return $this->template_parts_directory;
}
if ( BlockTemplateUtils::should_use_blockified_product_grid_templates() ) {
return $this->templates_directory . '/blockified';
}
return $this->templates_directory;
}
/**
* Returns the path of a template on the Blocks template folder.
*
* @param string $template_slug Block template slug e.g. single-product.
* @param string $template_type wp_template or wp_template_part.
*
* @return string
*/
public function get_template_path_from_woocommerce( $template_slug, $template_type = 'wp_template' ) {
return $this->get_templates_directory( $template_type ) . '/' . $template_slug . '.html';
}
/**
* Checks whether a block template with that name exists in Woo Blocks
*
* @param string $template_name Template to check.
* @param array $template_type wp_template or wp_template_part.
*
* @return boolean
*/
public function block_template_is_available( $template_name, $template_type = 'wp_template' ) {
if ( ! $template_name ) {
return false;
}
$directory = $this->get_templates_directory( $template_type ) . '/' . $template_name . '.html';
return is_readable(
$directory
) || $this->get_block_templates( array( $template_name ), $template_type );
}
/**
* Renders the default block template from Woo Blocks if no theme templates exist.
*/
public function render_block_template() {
if ( is_embed() || ! BlockTemplateUtils::supports_block_templates() ) {
return;
}
if (
is_singular( 'product' ) && $this->block_template_is_available( 'single-product' )
) {
global $post;
$valid_slugs = [ 'single-product' ];
if ( 'product' === $post->post_type && $post->post_name ) {
$valid_slugs[] = 'single-product-' . $post->post_name;
}
$templates = get_block_templates( array( 'slug__in' => $valid_slugs ) );
if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) {
add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' );
}
if ( ! BlockTemplateUtils::theme_has_template( 'single-product' ) ) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
} elseif (
( is_product_taxonomy() && is_tax( 'product_cat' ) ) && $this->block_template_is_available( 'taxonomy-product_cat' )
) {
$templates = get_block_templates( array( 'slug__in' => array( 'taxonomy-product_cat' ) ) );
if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) {
add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' );
}
if ( ! BlockTemplateUtils::theme_has_template( 'taxonomy-product_cat' ) ) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
} elseif (
( is_product_taxonomy() && is_tax( 'product_tag' ) ) && $this->block_template_is_available( 'taxonomy-product_tag' )
) {
$templates = get_block_templates( array( 'slug__in' => array( 'taxonomy-product_tag' ) ) );
if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) {
add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' );
}
if ( ! BlockTemplateUtils::theme_has_template( 'taxonomy-product_tag' ) ) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
} elseif ( is_post_type_archive( 'product' ) && is_search() ) {
$templates = get_block_templates( array( 'slug__in' => array( ProductSearchResultsTemplate::SLUG ) ) );
if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) {
add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' );
}
if ( ! BlockTemplateUtils::theme_has_template( ProductSearchResultsTemplate::SLUG ) ) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
} elseif (
( is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ) ) && $this->block_template_is_available( 'archive-product' )
) {
$templates = get_block_templates( array( 'slug__in' => array( 'archive-product' ) ) );
if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) {
add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' );
}
if ( ! BlockTemplateUtils::theme_has_template( 'archive-product' ) ) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
} elseif (
is_cart() &&
! BlockTemplateUtils::theme_has_template( CartTemplate::get_slug() ) && $this->block_template_is_available( CartTemplate::get_slug() )
) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
} elseif (
is_checkout() &&
! BlockTemplateUtils::theme_has_template( CheckoutTemplate::get_slug() ) && $this->block_template_is_available( CheckoutTemplate::get_slug() )
) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
} elseif (
is_wc_endpoint_url( 'order-received' )
&& ! BlockTemplateUtils::theme_has_template( OrderConfirmationTemplate::get_slug() )
&& $this->block_template_is_available( OrderConfirmationTemplate::get_slug() )
) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
} else {
$queried_object = get_queried_object();
if ( is_null( $queried_object ) ) {
return;
}
if ( isset( $queried_object->taxonomy ) && taxonomy_is_product_attribute( $queried_object->taxonomy ) && $this->block_template_is_available( ProductAttributeTemplate::SLUG )
) {
$templates = get_block_templates( array( 'slug__in' => array( ProductAttributeTemplate::SLUG ) ) );
if ( isset( $templates[0] ) && BlockTemplateUtils::template_has_legacy_template_block( $templates[0] ) ) {
add_filter( 'woocommerce_disable_compatibility_layer', '__return_true' );
}
if ( ! BlockTemplateUtils::theme_has_template( ProductAttributeTemplate::SLUG ) ) {
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
}
}
}
/**
* Remove the template panel from the Sidebar of the Shop page because
* the Site Editor handles it.
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/6278
*
* @param bool $is_support Whether the active theme supports block templates.
*
* @return bool
*/
public function remove_block_template_support_for_shop_page( $is_support ) {
global $pagenow, $post;
if (
is_admin() &&
'post.php' === $pagenow &&
function_exists( 'wc_get_page_id' ) &&
is_a( $post, 'WP_Post' ) &&
wc_get_page_id( 'shop' ) === $post->ID
) {
return false;
}
return $is_support;
}
/**
* Update the product archive title to "Shop".
*
* @param string $post_type_name Post type 'name' label.
* @param string $post_type Post type.
*
* @return string
*/
public function update_product_archive_title( $post_type_name, $post_type ) {
if (
function_exists( 'is_shop' ) &&
is_shop() &&
'product' === $post_type
) {
return __( 'Shop', 'woocommerce' );
}
return $post_type_name;
}
/**
* Migrates page content to templates if needed.
*/
public function maybe_migrate_content() {
// Migration should occur on a normal request to ensure every requirement is met.
// We are postponing it if WP is in maintenance mode, installing, WC installing or if the request is part of a WP-CLI command.
if ( wp_is_maintenance_mode() || ! get_option( 'woocommerce_db_version', false ) || Constants::is_defined( 'WP_SETUP_CONFIG' ) || Constants::is_defined( 'WC_INSTALLING' ) || Constants::is_defined( 'WP_CLI' ) ) {
return;
}
if ( ! BlockTemplateMigrationUtils::has_migrated_page( 'cart' ) ) {
BlockTemplateMigrationUtils::migrate_page( 'cart', CartTemplate::get_placeholder_page() );
}
if ( ! BlockTemplateMigrationUtils::has_migrated_page( 'checkout' ) ) {
BlockTemplateMigrationUtils::migrate_page( 'checkout', CheckoutTemplate::get_placeholder_page() );
}
}
/**
* Replaces page settings in WooCommerce with text based permalinks which point to a template.
*
* @param array $settings Settings pages.
* @return array
*/
public function template_permalink_settings( $settings ) {
foreach ( $settings as $key => $setting ) {
if ( 'woocommerce_checkout_page_id' === $setting['id'] ) {
$checkout_page = CheckoutTemplate::get_placeholder_page();
$settings[ $key ] = [
'title' => __( 'Checkout page', 'woocommerce' ),
'desc' => sprintf(
// translators: %1$s: opening anchor tag, %2$s: closing anchor tag.
__( 'The checkout template can be %1$s edited here%2$s.', 'woocommerce' ),
'<a href="' . esc_url( admin_url( 'site-editor.php?postType=wp_template&postId=woocommerce%2Fwoocommerce%2F%2F' . CheckoutTemplate::get_slug() ) ) . '" target="_blank">',
'</a>'
),
'desc_tip' => __( 'This is the URL to the checkout page.', 'woocommerce' ),
'id' => 'woocommerce_checkout_page_endpoint',
'type' => 'permalink',
'default' => $checkout_page ? $checkout_page->post_name : CheckoutTemplate::get_slug(),
'autoload' => false,
];
}
if ( 'woocommerce_cart_page_id' === $setting['id'] ) {
$cart_page = CartTemplate::get_placeholder_page();
$settings[ $key ] = [
'title' => __( 'Cart page', 'woocommerce' ),
'desc' => sprintf(
// translators: %1$s: opening anchor tag, %2$s: closing anchor tag.
__( 'The cart template can be %1$s edited here%2$s.', 'woocommerce' ),
'<a href="' . esc_url( admin_url( 'site-editor.php?postType=wp_template&postId=woocommerce%2Fwoocommerce%2F%2F' . CartTemplate::get_slug() ) ) . '" target="_blank">',
'</a>'
),
'desc_tip' => __( 'This is the URL to the cart page.', 'woocommerce' ),
'id' => 'woocommerce_cart_page_endpoint',
'type' => 'permalink',
'default' => $cart_page ? $cart_page->post_name : CartTemplate::get_slug(),
'autoload' => false,
];
}
}
return $settings;
}
/**
* Syncs entered permalink with the pages and returns the correct value.
*
* @param string $value Value of the option.
* @param string $option Name of the option.
* @return string
*/
public function update_template_permalink( $value, $option ) {
if ( 'woocommerce_checkout_page_endpoint' === $option ) {
return $this->sync_endpoint_with_page( CheckoutTemplate::get_placeholder_page(), 'checkout', $value );
}
if ( 'woocommerce_cart_page_endpoint' === $option ) {
return $this->sync_endpoint_with_page( CartTemplate::get_placeholder_page(), 'cart', $value );
}
return $value;
}
/**
* Syncs the provided permalink with the actual WP page.
*
* @param WP_Post|null $page The page object, or null if it does not exist.
* @param string $page_slug The identifier for the page e.g. cart, checkout.
* @param string $permalink The new permalink to use.
* @return string THe actual permalink assigned to the page. May differ from $permalink if it was already taken.
*/
protected function sync_endpoint_with_page( $page, $page_slug, $permalink ) {
$matching_page = get_page_by_path( $permalink, OBJECT, 'page' );
/**
* Filters whether to attempt to guess a redirect URL for a 404 request.
*
* Returning a false value from the filter will disable the URL guessing
* and return early without performing a redirect.
*
* @since 11.0.0
*
* @param bool $do_redirect_guess Whether to attempt to guess a redirect URL
* for a 404 request. Default true.
*/
if ( ! $matching_page instanceof WP_Post && apply_filters( 'do_redirect_guess_404_permalink', true ) ) {
// If it is a subpage and url guessing is on, then we will need to get it via post_name as path will not match.
$query = new \WP_Query(
[
'post_type' => 'page',
'name' => $permalink,
]
);
$matching_page = $query->have_posts() ? $query->posts[0] : null;
}
if ( $matching_page && 'publish' === $matching_page->post_status ) {
// Existing page matches given permalink; use its ID.
update_option( 'woocommerce_' . $page_slug . '_page_id', $matching_page->ID );
return get_page_uri( $matching_page );
}
// No matching page; either update current page (by ID stored in option table) or create a new page.
if ( ! $page ) {
$updated_page_id = wc_create_page(
esc_sql( $permalink ),
'woocommerce_' . $page_slug . '_page_id',
$page_slug,
'',
'',
'publish'
);
} else {
$updated_page_id = wp_update_post(
[
'ID' => $page->ID,
'post_name' => esc_sql( $permalink ),
]
);
}
// Get post again in case slug was updated with a suffix.
if ( $updated_page_id && ! is_wp_error( $updated_page_id ) ) {
return get_page_uri( get_post( $updated_page_id ) );
}
return $permalink;
}
}
BlockTypes/AbstractBlock.php 0000644 00000034755 15154173073 0012076 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use WP_Block;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
/**
* AbstractBlock class.
*/
abstract class AbstractBlock {
/**
* Block namespace.
*
* @var string
*/
protected $namespace = 'woocommerce';
/**
* Block name within this namespace.
*
* @var string
*/
protected $block_name = '';
/**
* Tracks if assets have been enqueued.
*
* @var boolean
*/
protected $enqueued_assets = false;
/**
* Instance of the asset API.
*
* @var AssetApi
*/
protected $asset_api;
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
protected $asset_data_registry;
/**
* Instance of the integration registry.
*
* @var IntegrationRegistry
*/
protected $integration_registry;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
* @param IntegrationRegistry $integration_registry Instance of the integration registry.
* @param string $block_name Optionally set block name during construct.
*/
public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_registry, IntegrationRegistry $integration_registry, $block_name = '' ) {
$this->asset_api = $asset_api;
$this->asset_data_registry = $asset_data_registry;
$this->integration_registry = $integration_registry;
$this->block_name = $block_name ? $block_name : $this->block_name;
$this->initialize();
}
/**
* The default render_callback for all blocks. This will ensure assets are enqueued just in time, then render
* the block (if applicable).
*
* @param array|WP_Block $attributes Block attributes, or an instance of a WP_Block. Defaults to an empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block|null $block Block instance.
* @return string Rendered block type output.
*/
public function render_callback( $attributes = [], $content = '', $block = null ) {
$render_callback_attributes = $this->parse_render_callback_attributes( $attributes );
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$this->enqueue_assets( $render_callback_attributes, $content, $block );
}
return $this->render( $render_callback_attributes, $content, $block );
}
/**
* Enqueue assets used for rendering the block in editor context.
*
* This is needed if a block is not yet within the post content--`render` and `enqueue_assets` may not have ran.
*/
public function enqueue_editor_assets() {
if ( $this->enqueued_assets ) {
return;
}
$this->enqueue_data();
}
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
*/
protected function initialize() {
if ( empty( $this->block_name ) ) {
_doing_it_wrong( __METHOD__, esc_html__( 'Block name is required.', 'woocommerce' ), '4.5.0' );
return false;
}
$this->integration_registry->initialize( $this->block_name . '_block' );
$this->register_block_type_assets();
$this->register_block_type();
add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_editor_assets' ] );
}
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
if ( null !== $this->get_block_type_editor_script() ) {
$data = $this->asset_api->get_script_data( $this->get_block_type_editor_script( 'path' ) );
$has_i18n = in_array( 'wp-i18n', $data['dependencies'], true );
$this->asset_api->register_script(
$this->get_block_type_editor_script( 'handle' ),
$this->get_block_type_editor_script( 'path' ),
array_merge(
$this->get_block_type_editor_script( 'dependencies' ),
$this->integration_registry->get_all_registered_editor_script_handles()
),
$has_i18n
);
}
if ( null !== $this->get_block_type_script() ) {
$data = $this->asset_api->get_script_data( $this->get_block_type_script( 'path' ) );
$has_i18n = in_array( 'wp-i18n', $data['dependencies'], true );
$this->asset_api->register_script(
$this->get_block_type_script( 'handle' ),
$this->get_block_type_script( 'path' ),
array_merge(
$this->get_block_type_script( 'dependencies' ),
$this->integration_registry->get_all_registered_script_handles()
),
$has_i18n
);
}
}
/**
* Injects Chunk Translations into the page so translations work for lazy loaded components.
*
* The chunk names are defined when creating lazy loaded components using webpackChunkName.
*
* @param string[] $chunks Array of chunk names.
*/
protected function register_chunk_translations( $chunks ) {
foreach ( $chunks as $chunk ) {
$handle = 'wc-blocks-' . $chunk . '-chunk';
$this->asset_api->register_script( $handle, $this->asset_api->get_block_asset_build_path( $chunk ), [], true );
wp_add_inline_script(
$this->get_block_type_script( 'handle' ),
wp_scripts()->print_translations( $handle, false ),
'before'
);
wp_deregister_script( $handle );
}
}
/**
* Generate an array of chunks paths for loading translation.
*
* @param string $chunks_folder The folder to iterate over.
* @return string[] $chunks list of chunks to load.
*/
protected function get_chunks_paths( $chunks_folder ) {
$build_path = \Automattic\WooCommerce\Blocks\Package::get_path() . 'build/';
$blocks = [];
if ( ! is_dir( $build_path . $chunks_folder ) ) {
return [];
}
foreach ( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $build_path . $chunks_folder ) ) as $block_name ) {
$blocks[] = str_replace( $build_path, '', $block_name );
}
$chunks = preg_filter( '/.js/', '', $blocks );
return $chunks;
}
/**
* Registers the block type with WordPress.
*
* @return string[] Chunks paths.
*/
protected function register_block_type() {
$block_settings = [
'render_callback' => $this->get_block_type_render_callback(),
'editor_script' => $this->get_block_type_editor_script( 'handle' ),
'editor_style' => $this->get_block_type_editor_style(),
'style' => $this->get_block_type_style(),
];
if ( isset( $this->api_version ) && '2' === $this->api_version ) {
$block_settings['api_version'] = 2;
}
$metadata_path = $this->asset_api->get_block_metadata_path( $this->block_name );
/**
* We always want to load block styles separately, for every theme.
* When the core assets are loaded separately, other blocks' styles get
* enqueued separately too. Thus we only need to handle the remaining
* case.
*/
if (
! is_admin() &&
! wc_current_theme_is_fse_theme() &&
$block_settings['style'] &&
(
! function_exists( 'wp_should_load_separate_core_block_assets' ) ||
! wp_should_load_separate_core_block_assets()
)
) {
$style_handles = $block_settings['style'];
$block_settings['style'] = null;
add_filter(
'render_block',
function( $html, $block ) use ( $style_handles ) {
if ( $block['blockName'] === $this->get_block_type() ) {
array_map( 'wp_enqueue_style', $style_handles );
}
return $html;
},
10,
2
);
}
// Prefer to register with metadata if the path is set in the block's class.
if ( ! empty( $metadata_path ) ) {
register_block_type_from_metadata(
$metadata_path,
$block_settings
);
return;
}
/*
* Insert attributes and supports if we're not registering the block using metadata.
* These are left unset until now and only added here because if they were set when registering with metadata,
* the attributes and supports from $block_settings would override the values from metadata.
*/
$block_settings['attributes'] = $this->get_block_type_attributes();
$block_settings['supports'] = $this->get_block_type_supports();
$block_settings['uses_context'] = $this->get_block_type_uses_context();
register_block_type(
$this->get_block_type(),
$block_settings
);
}
/**
* Get the block type.
*
* @return string
*/
protected function get_block_type() {
return $this->namespace . '/' . $this->block_name;
}
/**
* Get the render callback for this block type.
*
* Dynamic blocks should return a callback, for example, `return [ $this, 'render' ];`
*
* @see $this->register_block_type()
* @return callable|null;
*/
protected function get_block_type_render_callback() {
return [ $this, 'render_callback' ];
}
/**
* Get the editor script data for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the editor style handle for this block type.
*
* @see $this->register_block_type()
* @return string|null
*/
protected function get_block_type_editor_style() {
return 'wc-blocks-editor-style';
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string|null
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]|null
*/
protected function get_block_type_style() {
$this->asset_api->register_style( 'wc-blocks-style-' . $this->block_name, $this->asset_api->get_block_asset_build_path( $this->block_name, 'css' ), [], 'all', true );
return [ 'wc-blocks-style', 'wc-blocks-style-' . $this->block_name ];
}
/**
* Get the supports array for this block type.
*
* @see $this->register_block_type()
* @return string;
*/
protected function get_block_type_supports() {
return [];
}
/**
* Get block attributes.
*
* @return array;
*/
protected function get_block_type_attributes() {
return [];
}
/**
* Get block usesContext.
*
* @return array;
*/
protected function get_block_type_uses_context() {
return [];
}
/**
* Parses block attributes from the render_callback.
*
* @param array|WP_Block $attributes Block attributes, or an instance of a WP_Block. Defaults to an empty array.
* @return array
*/
protected function parse_render_callback_attributes( $attributes ) {
return is_a( $attributes, 'WP_Block' ) ? $attributes->attributes : $attributes;
}
/**
* Render the block. Extended by children.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
return $content;
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @internal This prevents the block script being enqueued on all pages. It is only enqueued as needed. Note that
* we intentionally do not pass 'script' to register_block_type.
*
* @param array $attributes Any attributes that currently are available from the block.
* @param string $content The block content.
* @param WP_Block $block The block object.
*/
protected function enqueue_assets( array $attributes, $content, $block ) {
if ( $this->enqueued_assets ) {
return;
}
$this->enqueue_data( $attributes );
$this->enqueue_scripts( $attributes );
$this->enqueued_assets = true;
}
/**
* Data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
$registered_script_data = $this->integration_registry->get_all_registered_script_data();
foreach ( $registered_script_data as $asset_data_key => $asset_data_value ) {
if ( ! $this->asset_data_registry->exists( $asset_data_key ) ) {
$this->asset_data_registry->add( $asset_data_key, $asset_data_value );
}
}
if ( ! $this->asset_data_registry->exists( 'wcBlocksConfig' ) ) {
$this->asset_data_registry->add(
'wcBlocksConfig',
[
'buildPhase' => Package::feature()->get_flag(),
'pluginUrl' => plugins_url( '/', dirname( __DIR__ ) ),
'productCount' => array_sum( (array) wp_count_posts( 'product' ) ),
'restApiRoutes' => [
'/wc/store/v1' => array_keys( $this->get_routes_from_namespace( 'wc/store/v1' ) ),
],
'defaultAvatar' => get_avatar_url( 0, [ 'force_default' => true ] ),
/*
* translators: If your word count is based on single characters (e.g. East Asian characters),
* enter 'characters_excluding_spaces' or 'characters_including_spaces'. Otherwise, enter 'words'.
* Do not translate into your own language.
*/
'wordCountType' => _x( 'words', 'Word count type. Do not translate!', 'woocommerce' ),
]
);
}
}
/**
* Get routes from a REST API namespace.
*
* @param string $namespace Namespace to retrieve.
* @return array
*/
protected function get_routes_from_namespace( $namespace ) {
$rest_server = rest_get_server();
$namespace_index = $rest_server->get_namespace_index(
[
'namespace' => $namespace,
'context' => 'view',
]
);
if ( is_wp_error( $namespace_index ) ) {
return [];
}
$response_data = $namespace_index->get_data();
return $response_data['routes'] ?? [];
}
/**
* Register/enqueue scripts used for this block on the frontend, during render.
*
* @param array $attributes Any attributes that currently are available from the block.
*/
protected function enqueue_scripts( array $attributes = [] ) {
if ( null !== $this->get_block_type_script() ) {
wp_enqueue_script( $this->get_block_type_script( 'handle' ) );
}
}
}
BlockTypes/AbstractDynamicBlock.php 0000644 00000003544 15154173073 0013373 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AbstractDynamicBlock class.
*/
abstract class AbstractDynamicBlock extends AbstractBlock {
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array();
}
/**
* Get the schema for the alignment property.
*
* @return array Property definition for align.
*/
protected function get_schema_align() {
return array(
'type' => 'string',
'enum' => array( 'left', 'center', 'right', 'wide', 'full' ),
);
}
/**
* Get the schema for a list of IDs.
*
* @return array Property definition for a list of numeric ids.
*/
protected function get_schema_list_ids() {
return array(
'type' => 'array',
'items' => array(
'type' => 'number',
),
'default' => array(),
);
}
/**
* Get the schema for a boolean value.
*
* @param string $default The default value.
* @return array Property definition.
*/
protected function get_schema_boolean( $default = true ) {
return array(
'type' => 'boolean',
'default' => $default,
);
}
/**
* Get the schema for a numeric value.
*
* @param string $default The default value.
* @return array Property definition.
*/
protected function get_schema_number( $default ) {
return array(
'type' => 'number',
'default' => $default,
);
}
/**
* Get the schema for a string value.
*
* @param string $default The default value.
* @return array Property definition.
*/
protected function get_schema_string( $default = '' ) {
return array(
'type' => 'string',
'default' => $default,
);
}
}
BlockTypes/AbstractInnerBlock.php 0000644 00000003374 15154173073 0013063 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AbstractInnerBlock class.
*/
abstract class AbstractInnerBlock extends AbstractBlock {
/**
* Is this inner block lazy loaded? this helps us know if we should load its frontend script ot not.
*
* @var boolean
*/
protected $is_lazy_loaded = true;
/**
* Registers the block type with WordPress using the metadata file.
*
* The registration using metadata is now recommended. And it's required for "Inner Blocks" to
* fix the issue of missing translations in the inspector (in the Editor mode)
*/
protected function register_block_type() {
$block_settings = [
'render_callback' => $this->get_block_type_render_callback(),
'editor_style' => $this->get_block_type_editor_style(),
'style' => $this->get_block_type_style(),
];
if ( isset( $this->api_version ) && '2' === $this->api_version ) {
$block_settings['api_version'] = 2;
}
$metadata_path = $this->asset_api->get_block_metadata_path( $this->block_name, 'inner-blocks/' );
// Prefer to register with metadata if the path is set in the block's class.
register_block_type_from_metadata(
$metadata_path,
$block_settings
);
}
/**
* For lazy loaded inner blocks, we don't want to enqueue the script but rather leave it for webpack to do that.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string|null
*/
protected function get_block_type_script( $key = null ) {
if ( $this->is_lazy_loaded ) {
return null;
}
return parent::get_block_type_script( $key );
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
}
BlockTypes/AbstractProductGrid.php 0000644 00000052351 15154173073 0013262 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\BlocksWpQuery;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\StoreApi;
/**
* AbstractProductGrid class.
*/
abstract class AbstractProductGrid extends AbstractDynamicBlock {
/**
* Attributes.
*
* @var array
*/
protected $attributes = array();
/**
* InnerBlocks content.
*
* @var string
*/
protected $content = '';
/**
* Query args.
*
* @var array
*/
protected $query_args = array();
/**
* Meta query args.
*
* @var array
*/
protected $meta_query = array();
/**
* Get a set of attributes shared across most of the grid blocks.
*
* @return array List of block attributes with type and defaults.
*/
protected function get_block_type_attributes() {
return array(
'className' => $this->get_schema_string(),
'columns' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_columns', 3 ) ),
'rows' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_rows', 3 ) ),
'categories' => $this->get_schema_list_ids(),
'catOperator' => array(
'type' => 'string',
'default' => 'any',
),
'contentVisibility' => $this->get_schema_content_visibility(),
'align' => $this->get_schema_align(),
'alignButtons' => $this->get_schema_boolean( false ),
'isPreview' => $this->get_schema_boolean( false ),
'stockStatus' => array(
'type' => 'array',
'default' => array_keys( wc_get_product_stock_status_options() ),
),
);
}
/**
* Include and render the dynamic block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block|null $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes = array(), $content = '', $block = null ) {
$this->attributes = $this->parse_attributes( $attributes );
$this->content = $content;
$this->query_args = $this->parse_query_args();
$products = array_filter( array_map( 'wc_get_product', $this->get_products() ) );
if ( ! $products ) {
return '';
}
/**
* Override product description to prevent infinite loop.
*
* @see https://github.com/woocommerce/woocommerce-blocks/pull/6849
*/
foreach ( $products as $product ) {
$product->set_description( '' );
}
/**
* Product List Render event.
*
* Fires a WP Hook named `experimental__woocommerce_blocks-product-list-render` on render so that the client
* can add event handling when certain products are displayed. This can be used by tracking extensions such
* as Google Analytics to track impressions.
*
* Provides the list of product data (shaped like the Store API responses) and the block name.
*/
$this->asset_api->add_inline_script(
'wp-hooks',
'
window.addEventListener( "DOMContentLoaded", () => {
wp.hooks.doAction(
"experimental__woocommerce_blocks-product-list-render",
{
products: JSON.parse( decodeURIComponent( "' . esc_js(
rawurlencode(
wp_json_encode(
array_map(
[ StoreApi::container()->get( SchemaController::class )->get( 'product' ), 'get_item_response' ],
$products
)
)
)
) . '" ) ),
listName: "' . esc_js( $this->block_name ) . '"
}
);
} );
',
'after'
);
return sprintf(
'<div class="%s"><ul class="wc-block-grid__products">%s</ul></div>',
esc_attr( $this->get_container_classes() ),
implode( '', array_map( array( $this, 'render_product' ), $products ) )
);
}
/**
* Get the schema for the contentVisibility attribute
*
* @return array List of block attributes with type and defaults.
*/
protected function get_schema_content_visibility() {
return array(
'type' => 'object',
'properties' => array(
'image' => $this->get_schema_boolean( true ),
'title' => $this->get_schema_boolean( true ),
'price' => $this->get_schema_boolean( true ),
'rating' => $this->get_schema_boolean( true ),
'button' => $this->get_schema_boolean( true ),
),
);
}
/**
* Get the schema for the orderby attribute.
*
* @return array Property definition of `orderby` attribute.
*/
protected function get_schema_orderby() {
return array(
'type' => 'string',
'enum' => array( 'date', 'popularity', 'price_asc', 'price_desc', 'rating', 'title', 'menu_order' ),
'default' => 'date',
);
}
/**
* Get the block's attributes.
*
* @param array $attributes Block attributes. Default empty array.
* @return array Block attributes merged with defaults.
*/
protected function parse_attributes( $attributes ) {
// These should match what's set in JS `registerBlockType`.
$defaults = array(
'columns' => wc_get_theme_support( 'product_blocks::default_columns', 3 ),
'rows' => wc_get_theme_support( 'product_blocks::default_rows', 3 ),
'alignButtons' => false,
'categories' => array(),
'catOperator' => 'any',
'contentVisibility' => array(
'image' => true,
'title' => true,
'price' => true,
'rating' => true,
'button' => true,
),
'stockStatus' => array_keys( wc_get_product_stock_status_options() ),
);
return wp_parse_args( $attributes, $defaults );
}
/**
* Parse query args.
*
* @return array
*/
protected function parse_query_args() {
// Store the original meta query.
$this->meta_query = WC()->query->get_meta_query();
$query_args = array(
'post_type' => 'product',
'post_status' => 'publish',
'fields' => 'ids',
'ignore_sticky_posts' => true,
'no_found_rows' => false,
'orderby' => '',
'order' => '',
'meta_query' => $this->meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery
'tax_query' => array(), // phpcs:ignore WordPress.DB.SlowDBQuery
'posts_per_page' => $this->get_products_limit(),
);
$this->set_block_query_args( $query_args );
$this->set_ordering_query_args( $query_args );
$this->set_categories_query_args( $query_args );
$this->set_visibility_query_args( $query_args );
$this->set_stock_status_query_args( $query_args );
return $query_args;
}
/**
* Parse query args.
*
* @param array $query_args Query args.
*/
protected function set_ordering_query_args( &$query_args ) {
if ( isset( $this->attributes['orderby'] ) ) {
if ( 'price_desc' === $this->attributes['orderby'] ) {
$query_args['orderby'] = 'price';
$query_args['order'] = 'DESC';
} elseif ( 'price_asc' === $this->attributes['orderby'] ) {
$query_args['orderby'] = 'price';
$query_args['order'] = 'ASC';
} elseif ( 'date' === $this->attributes['orderby'] ) {
$query_args['orderby'] = 'date';
$query_args['order'] = 'DESC';
} else {
$query_args['orderby'] = $this->attributes['orderby'];
}
}
$query_args = array_merge(
$query_args,
WC()->query->get_catalog_ordering_args( $query_args['orderby'], $query_args['order'] )
);
}
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
abstract protected function set_block_query_args( &$query_args );
/**
* Set categories query args.
*
* @param array $query_args Query args.
*/
protected function set_categories_query_args( &$query_args ) {
if ( ! empty( $this->attributes['categories'] ) ) {
$categories = array_map( 'absint', $this->attributes['categories'] );
$query_args['tax_query'][] = array(
'taxonomy' => 'product_cat',
'terms' => $categories,
'field' => 'term_id',
'operator' => 'all' === $this->attributes['catOperator'] ? 'AND' : 'IN',
/*
* When cat_operator is AND, the children categories should be excluded,
* as only products belonging to all the children categories would be selected.
*/
'include_children' => 'all' === $this->attributes['catOperator'] ? false : true,
);
}
}
/**
* Set visibility query args.
*
* @param array $query_args Query args.
*/
protected function set_visibility_query_args( &$query_args ) {
$product_visibility_terms = wc_get_product_visibility_term_ids();
$product_visibility_not_in = array( $product_visibility_terms['exclude-from-catalog'] );
if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$product_visibility_not_in[] = $product_visibility_terms['outofstock'];
}
$query_args['tax_query'][] = array(
'taxonomy' => 'product_visibility',
'field' => 'term_taxonomy_id',
'terms' => $product_visibility_not_in,
'operator' => 'NOT IN',
);
}
/**
* Set which stock status to use when displaying products.
*
* @param array $query_args Query args.
* @return void
*/
protected function set_stock_status_query_args( &$query_args ) {
$stock_statuses = array_keys( wc_get_product_stock_status_options() );
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
if ( isset( $this->attributes['stockStatus'] ) && $stock_statuses !== $this->attributes['stockStatus'] ) {
// Reset meta_query then update with our stock status.
$query_args['meta_query'] = $this->meta_query;
$query_args['meta_query'][] = array(
'key' => '_stock_status',
'value' => array_merge( [ '' ], $this->attributes['stockStatus'] ),
'compare' => 'IN',
);
} else {
$query_args['meta_query'] = $this->meta_query;
}
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
/**
* Works out the item limit based on rows and columns, or returns default.
*
* @return int
*/
protected function get_products_limit() {
if ( isset( $this->attributes['rows'], $this->attributes['columns'] ) && ! empty( $this->attributes['rows'] ) ) {
$this->attributes['limit'] = intval( $this->attributes['columns'] ) * intval( $this->attributes['rows'] );
}
return intval( $this->attributes['limit'] );
}
/**
* Run the query and return an array of product IDs
*
* @return array List of product IDs
*/
protected function get_products() {
/**
* Filters whether or not the product grid is cacheable.
*
* @param boolean $is_cacheable The list of script dependencies.
* @param array $query_args Query args for the products query passed to BlocksWpQuery.
* @return array True to enable cache, false to disable cache.
*
* @since 2.5.0
*/
$is_cacheable = (bool) apply_filters( 'woocommerce_blocks_product_grid_is_cacheable', true, $this->query_args );
$transient_version = \WC_Cache_Helper::get_transient_version( 'product_query' );
$query = new BlocksWpQuery( $this->query_args );
$results = wp_parse_id_list( $is_cacheable ? $query->get_cached_posts( $transient_version ) : $query->get_posts() );
// Remove ordering query arguments which may have been added by get_catalog_ordering_args.
WC()->query->remove_ordering_args();
// Prime caches to reduce future queries. Note _prime_post_caches is private--we could replace this with our own
// query if it becomes unavailable.
if ( is_callable( '_prime_post_caches' ) ) {
_prime_post_caches( $results );
}
$this->prime_product_variations( $results );
return $results;
}
/**
* Retrieve IDs that are not already present in the cache.
*
* Based on WordPress function: _get_non_cached_ids
*
* @param int[] $product_ids Array of IDs.
* @param string $cache_key The cache bucket to check against.
* @return int[] Array of IDs not present in the cache.
*/
protected function get_non_cached_ids( $product_ids, $cache_key ) {
$non_cached_ids = array();
$cache_values = wp_cache_get_multiple( $product_ids, $cache_key );
foreach ( $cache_values as $id => $value ) {
if ( ! $value ) {
$non_cached_ids[] = (int) $id;
}
}
return $non_cached_ids;
}
/**
* Prime query cache of product variation meta data.
*
* Prepares values in the product_ID_variation_meta_data cache for later use in the ProductSchema::get_variations()
* method. Doing so here reduces the total number of queries needed.
*
* @param int[] $product_ids Product ids to prime variation cache for.
*/
protected function prime_product_variations( $product_ids ) {
$cache_group = 'product_variation_meta_data';
$prime_product_ids = $this->get_non_cached_ids( wp_parse_id_list( $product_ids ), $cache_group );
if ( ! $prime_product_ids ) {
return;
}
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$product_variations = $wpdb->get_results( "SELECT ID as variation_id, post_parent as product_id from {$wpdb->posts} WHERE post_parent IN ( " . implode( ',', $prime_product_ids ) . ' )', ARRAY_A );
$prime_variation_ids = array_column( $product_variations, 'variation_id' );
$variation_ids_by_parent = array_column( $product_variations, 'product_id', 'variation_id' );
if ( empty( $prime_variation_ids ) ) {
return;
}
$all_variation_meta_data = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id as variation_id, meta_key as attribute_key, meta_value as attribute_value FROM {$wpdb->postmeta} WHERE post_id IN (" . implode( ',', array_map( 'esc_sql', $prime_variation_ids ) ) . ') AND meta_key LIKE %s',
$wpdb->esc_like( 'attribute_' ) . '%'
)
);
// phpcs:enable
// Prepare the data to cache by indexing by the parent product.
$primed_data = array_reduce(
$all_variation_meta_data,
function( $values, $data ) use ( $variation_ids_by_parent ) {
$values[ $variation_ids_by_parent[ $data->variation_id ] ?? 0 ][] = $data;
return $values;
},
array_fill_keys( $prime_product_ids, [] )
);
// Cache everything.
foreach ( $primed_data as $product_id => $variation_meta_data ) {
wp_cache_set(
$product_id,
[
'last_modified' => get_the_modified_date( 'U', $product_id ),
'data' => $variation_meta_data,
],
$cache_group
);
}
}
/**
* Get the list of classes to apply to this block.
*
* @return string space-separated list of classes.
*/
protected function get_container_classes() {
$classes = array(
'wc-block-grid',
"wp-block-{$this->block_name}",
"wc-block-{$this->block_name}",
"has-{$this->attributes['columns']}-columns",
);
if ( $this->attributes['rows'] > 1 ) {
$classes[] = 'has-multiple-rows';
}
if ( isset( $this->attributes['align'] ) ) {
$classes[] = "align{$this->attributes['align']}";
}
if ( ! empty( $this->attributes['alignButtons'] ) ) {
$classes[] = 'has-aligned-buttons';
}
if ( ! empty( $this->attributes['className'] ) ) {
$classes[] = $this->attributes['className'];
}
return implode( ' ', $classes );
}
/**
* Render a single products.
*
* @param \WC_Product $product Product object.
* @return string Rendered product output.
*/
protected function render_product( $product ) {
$data = (object) array(
'permalink' => esc_url( $product->get_permalink() ),
'image' => $this->get_image_html( $product ),
'title' => $this->get_title_html( $product ),
'rating' => $this->get_rating_html( $product ),
'price' => $this->get_price_html( $product ),
'badge' => $this->get_sale_badge_html( $product ),
'button' => $this->get_button_html( $product ),
);
/**
* Filters the HTML for products in the grid.
*
* @param string $html Product grid item HTML.
* @param array $data Product data passed to the template.
* @param \WC_Product $product Product object.
* @return string Updated product grid item HTML.
*
* @since 2.2.0
*/
return apply_filters(
'woocommerce_blocks_product_grid_item_html',
"<li class=\"wc-block-grid__product\">
<a href=\"{$data->permalink}\" class=\"wc-block-grid__product-link\">
{$data->badge}
{$data->image}
{$data->title}
</a>
{$data->price}
{$data->rating}
{$data->button}
</li>",
$data,
$product
);
}
/**
* Get the product image.
*
* @param \WC_Product $product Product.
* @return string
*/
protected function get_image_html( $product ) {
if ( array_key_exists( 'image', $this->attributes['contentVisibility'] ) && false === $this->attributes['contentVisibility']['image'] ) {
return '';
}
$attr = array(
'alt' => '',
);
if ( $product->get_image_id() ) {
$image_alt = get_post_meta( $product->get_image_id(), '_wp_attachment_image_alt', true );
$attr = array(
'alt' => ( $image_alt ? $image_alt : $product->get_name() ),
);
}
return '<div class="wc-block-grid__product-image">' . $product->get_image( 'woocommerce_thumbnail', $attr ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Get the product title.
*
* @param \WC_Product $product Product.
* @return string
*/
protected function get_title_html( $product ) {
if ( empty( $this->attributes['contentVisibility']['title'] ) ) {
return '';
}
return '<div class="wc-block-grid__product-title">' . wp_kses_post( $product->get_title() ) . '</div>';
}
/**
* Render the rating icons.
*
* @param WC_Product $product Product.
* @return string Rendered product output.
*/
protected function get_rating_html( $product ) {
if ( empty( $this->attributes['contentVisibility']['rating'] ) ) {
return '';
}
$rating_count = $product->get_rating_count();
$average = $product->get_average_rating();
if ( $rating_count > 0 ) {
return sprintf(
'<div class="wc-block-grid__product-rating">%s</div>',
wc_get_rating_html( $average, $rating_count ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
);
}
return '';
}
/**
* Get the price.
*
* @param \WC_Product $product Product.
* @return string Rendered product output.
*/
protected function get_price_html( $product ) {
if ( empty( $this->attributes['contentVisibility']['price'] ) ) {
return '';
}
return sprintf(
'<div class="wc-block-grid__product-price price">%s</div>',
wp_kses_post( $product->get_price_html() )
);
}
/**
* Get the sale badge.
*
* @param \WC_Product $product Product.
* @return string Rendered product output.
*/
protected function get_sale_badge_html( $product ) {
if ( empty( $this->attributes['contentVisibility']['price'] ) ) {
return '';
}
if ( ! $product->is_on_sale() ) {
return;
}
return '<div class="wc-block-grid__product-onsale">
<span aria-hidden="true">' . esc_html__( 'Sale', 'woocommerce' ) . '</span>
<span class="screen-reader-text">' . esc_html__( 'Product on sale', 'woocommerce' ) . '</span>
</div>';
}
/**
* Get the button.
*
* @param \WC_Product $product Product.
* @return string Rendered product output.
*/
protected function get_button_html( $product ) {
if ( empty( $this->attributes['contentVisibility']['button'] ) ) {
return '';
}
return '<div class="wp-block-button wc-block-grid__product-add-to-cart">' . $this->get_add_to_cart( $product ) . '</div>';
}
/**
* Get the "add to cart" button.
*
* @param \WC_Product $product Product.
* @return string Rendered product output.
*/
protected function get_add_to_cart( $product ) {
$attributes = array(
'aria-label' => $product->add_to_cart_description(),
'data-quantity' => '1',
'data-product_id' => $product->get_id(),
'data-product_sku' => $product->get_sku(),
'rel' => 'nofollow',
'class' => 'wp-block-button__link ' . ( function_exists( 'wc_wp_theme_get_element_class_name' ) ? wc_wp_theme_get_element_class_name( 'button' ) : '' ) . ' add_to_cart_button',
);
if (
$product->supports( 'ajax_add_to_cart' ) &&
$product->is_purchasable() &&
( $product->is_in_stock() || $product->backorders_allowed() )
) {
$attributes['class'] .= ' ajax_add_to_cart';
}
return sprintf(
'<a href="%s" %s>%s</a>',
esc_url( $product->add_to_cart_url() ),
wc_implode_html_attributes( $attributes ),
esc_html( $product->add_to_cart_text() )
);
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'minColumns', wc_get_theme_support( 'product_blocks::min_columns', 1 ), true );
$this->asset_data_registry->add( 'maxColumns', wc_get_theme_support( 'product_blocks::max_columns', 6 ), true );
$this->asset_data_registry->add( 'defaultColumns', wc_get_theme_support( 'product_blocks::default_columns', 3 ), true );
$this->asset_data_registry->add( 'minRows', wc_get_theme_support( 'product_blocks::min_rows', 1 ), true );
$this->asset_data_registry->add( 'maxRows', wc_get_theme_support( 'product_blocks::max_rows', 6 ), true );
$this->asset_data_registry->add( 'defaultRows', wc_get_theme_support( 'product_blocks::default_rows', 3 ), true );
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
// Currently these blocks rely on the styles from the All Products block.
return [ 'wc-blocks-style', 'wc-blocks-style-all-products' ];
}
}
BlockTypes/ActiveFilters.php 0000644 00000000342 15154173073 0012105 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ActiveFilters class.
*/
class ActiveFilters extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'active-filters';
}
BlockTypes/AddToCartForm.php 0000644 00000013730 15154173073 0011777 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* CatalogSorting class.
*/
class AddToCartForm extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'add-to-cart-form';
/**
* Initializes the AddToCartForm block and hooks into the `wc_add_to_cart_message_html` filter
* to prevent displaying the Cart Notice when the block is inside the Single Product block
* and the Add to Cart button is clicked.
*
* It also hooks into the `woocommerce_add_to_cart_redirect` filter to prevent redirecting
* to another page when the block is inside the Single Product block and the Add to Cart button
* is clicked.
*
* @return void
*/
protected function initialize() {
parent::initialize();
add_filter( 'wc_add_to_cart_message_html', array( $this, 'add_to_cart_message_html_filter' ), 10, 2 );
add_filter( 'woocommerce_add_to_cart_redirect', array( $this, 'add_to_cart_redirect_filter' ), 10, 1 );
}
/**
* Get the block's attributes.
*
* @param array $attributes Block attributes. Default empty array.
* @return array Block attributes merged with defaults.
*/
private function parse_attributes( $attributes ) {
// These should match what's set in JS `registerBlockType`.
$defaults = array(
'isDescendentOfSingleProductBlock' => false,
);
return wp_parse_args( $attributes, $defaults );
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string | void Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
global $product;
$post_id = $block->context['postId'];
if ( ! isset( $post_id ) ) {
return '';
}
$previous_product = $product;
$product = wc_get_product( $post_id );
if ( ! $product instanceof \WC_Product ) {
$product = $previous_product;
return '';
}
ob_start();
/**
* Trigger the single product add to cart action for each product type.
*
* @since 9.7.0
*/
do_action( 'woocommerce_' . $product->get_type() . '_add_to_cart' );
$product = ob_get_clean();
if ( ! $product ) {
$product = $previous_product;
return '';
}
$parsed_attributes = $this->parse_attributes( $attributes );
$is_descendent_of_single_product_block = $parsed_attributes['isDescendentOfSingleProductBlock'];
$product = $this->add_is_descendent_of_single_product_block_hidden_input_to_product_form( $product, $is_descendent_of_single_product_block );
$classname = $attributes['className'] ?? '';
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$product_classname = $is_descendent_of_single_product_block ? 'product' : '';
$form = sprintf(
'<div class="wp-block-add-to-cart-form %1$s %2$s %3$s" style="%4$s">%5$s</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
esc_attr( $product_classname ),
esc_attr( $classes_and_styles['styles'] ),
$product
);
$product = $previous_product;
return $form;
}
/**
* Add a hidden input to the Add to Cart form to indicate that it is a descendent of a Single Product block.
*
* @param string $product The Add to Cart Form HTML.
* @param string $is_descendent_of_single_product_block Indicates if block is descendent of Single Product block.
*
* @return string The Add to Cart Form HTML with the hidden input.
*/
protected function add_is_descendent_of_single_product_block_hidden_input_to_product_form( $product, $is_descendent_of_single_product_block ) {
$hidden_is_descendent_of_single_product_block_input = sprintf(
'<input type="hidden" name="is-descendent-of-single-product-block" value="%1$s">',
$is_descendent_of_single_product_block ? 'true' : 'false'
);
$regex_pattern = '/<button\s+type="submit"[^>]*>.*?<\/button>/i';
preg_match( $regex_pattern, $product, $input_matches );
if ( ! empty( $input_matches ) ) {
$product = preg_replace( $regex_pattern, $hidden_is_descendent_of_single_product_block_input . $input_matches[0], $product );
}
return $product;
}
/**
* Filter the add to cart message to prevent the Notice from being displayed when the Add to Cart form is a descendent of a Single Product block
* and the Add to Cart button is clicked.
*
* @param string $message Message to be displayed when product is added to the cart.
*/
public function add_to_cart_message_html_filter( $message ) {
// phpcs:ignore
if ( isset( $_POST['is-descendent-of-single-product-block'] ) && 'true' === $_POST['is-descendent-of-single-product-block'] ) {
return false;
}
return $message;
}
/**
* Hooks into the `woocommerce_add_to_cart_redirect` filter to prevent redirecting
* to another page when the block is inside the Single Product block and the Add to Cart button
* is clicked.
*
* @param string $url The URL to redirect to after the product is added to the cart.
* @return string The filtered redirect URL.
*/
public function add_to_cart_redirect_filter( $url ) {
// phpcs:ignore
if ( isset( $_POST['is-descendent-of-single-product-block'] ) && 'true' == $_POST['is-descendent-of-single-product-block'] ) {
return wp_validate_redirect( wp_get_referer(), $url );
}
return $url;
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
}
BlockTypes/AllProducts.php 0000644 00000004042 15154173073 0011576 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AllProducts class.
*/
class AllProducts extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'all-products';
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
// Set this so filter blocks being used as widgets know when to render.
$this->asset_data_registry->add( 'hasFilterableProducts', true, true );
$this->asset_data_registry->add( 'minColumns', wc_get_theme_support( 'product_blocks::min_columns', 1 ), true );
$this->asset_data_registry->add( 'maxColumns', wc_get_theme_support( 'product_blocks::max_columns', 6 ), true );
$this->asset_data_registry->add( 'defaultColumns', wc_get_theme_support( 'product_blocks::default_columns', 3 ), true );
$this->asset_data_registry->add( 'minRows', wc_get_theme_support( 'product_blocks::min_rows', 1 ), true );
$this->asset_data_registry->add( 'maxRows', wc_get_theme_support( 'product_blocks::max_rows', 6 ), true );
$this->asset_data_registry->add( 'defaultRows', wc_get_theme_support( 'product_blocks::default_rows', 3 ), true );
// Hydrate the All Product block with data from the API. This is for the add to cart buttons which show current quantity in cart, and events.
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$this->asset_data_registry->hydrate_api_request( '/wc/store/v1/cart' );
}
}
/**
* It is necessary to register and enqueue assets during the render phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
}
}
BlockTypes/AllReviews.php 0000644 00000002461 15154173073 0011422 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AllReviews class.
*/
class AllReviews extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'all-reviews';
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-reviews-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( 'reviews-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'reviewRatingsEnabled', wc_review_ratings_enabled(), true );
$this->asset_data_registry->add( 'showAvatars', '1' === get_option( 'show_avatars' ), true );
}
}
BlockTypes/AtomicBlock.php 0000644 00000001616 15154173073 0011535 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AtomicBlock class.
*
* @internal
*/
class AtomicBlock extends AbstractBlock {
/**
* Get the editor script data for this block type.
*
* @param string $key Data to get, or default to everything.
* @return null
*/
protected function get_block_type_editor_script( $key = null ) {
return null;
}
/**
* Get the editor style handle for this block type.
*
* @return null
*/
protected function get_block_type_editor_style() {
return null;
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
}
BlockTypes/AttributeFilter.php 0000644 00000002127 15154173073 0012455 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AttributeFilter class.
*/
class AttributeFilter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'attribute-filter';
const FILTER_QUERY_VAR_PREFIX = 'filter_';
const QUERY_TYPE_QUERY_VAR_PREFIX = 'query_type_';
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'attributes', array_values( wc_get_attribute_taxonomies() ), true );
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
}
BlockTypes/Breadcrumbs.php 0000644 00000002431 15154173073 0011573 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* CatalogSorting class.
*/
class Breadcrumbs extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'breadcrumbs';
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string | void Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
ob_start();
woocommerce_breadcrumb();
$breadcrumb = ob_get_clean();
if ( ! $breadcrumb ) {
return;
}
$classname = $attributes['className'] ?? '';
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
return sprintf(
'<div class="woocommerce wc-block-breadcrumbs %1$s %2$s" style="%3$s">%4$s</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
esc_attr( $classes_and_styles['styles'] ),
$breadcrumb
);
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
}
BlockTypes/Cart.php 0000644 00000030545 15154173073 0010242 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
/**
* Cart class.
*
* @internal
*/
class Cart extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart';
/**
* Chunks build folder.
*
* @var string
*/
protected $chunks_folder = 'cart-blocks';
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
*/
protected function initialize() {
parent::initialize();
add_action( 'wp_loaded', array( $this, 'register_patterns' ) );
}
/**
* Dequeues the scripts added by WC Core to the Cart page.
*
* @return void
*/
public function dequeue_woocommerce_core_scripts() {
wp_dequeue_script( 'wc-cart' );
wp_dequeue_script( 'wc-password-strength-meter' );
wp_dequeue_script( 'selectWoo' );
wp_dequeue_style( 'select2' );
}
/**
* Register block pattern for Empty Cart Message to make it translatable.
*/
public function register_patterns() {
$shop_permalink = wc_get_page_id( 'shop' ) ? get_permalink( wc_get_page_id( 'shop' ) ) : '';
register_block_pattern(
'woocommerce/cart-heading',
array(
'title' => '',
'inserter' => false,
'content' => '<!-- wp:heading {"align":"wide", "level":1} --><h1 class="wp-block-heading alignwide">' . esc_html__( 'Cart', 'woocommerce' ) . '</h1><!-- /wp:heading -->',
)
);
register_block_pattern(
'woocommerce/cart-cross-sells-message',
array(
'title' => '',
'inserter' => false,
'content' => '<!-- wp:heading {"fontSize":"large"} --><h2 class="wp-block-heading has-large-font-size">' . esc_html__( 'You may be interested in…', 'woocommerce' ) . '</h2><!-- /wp:heading -->',
)
);
register_block_pattern(
'woocommerce/cart-empty-message',
array(
'title' => '',
'inserter' => false,
'content' => '
<!-- wp:heading {"textAlign":"center","className":"with-empty-cart-icon wc-block-cart__empty-cart__title"} --><h2 class="wp-block-heading has-text-align-center with-empty-cart-icon wc-block-cart__empty-cart__title">' . esc_html__( 'Your cart is currently empty!', 'woocommerce' ) . '</h2><!-- /wp:heading -->
<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center"><a href="' . esc_attr( esc_url( $shop_permalink ) ) . '">' . esc_html__( 'Browse store', 'woocommerce' ) . '</a></p><!-- /wp:paragraph -->
',
)
);
register_block_pattern(
'woocommerce/cart-new-in-store-message',
array(
'title' => '',
'inserter' => false,
'content' => '<!-- wp:heading {"textAlign":"center"} --><h2 class="wp-block-heading has-text-align-center">' . esc_html__( 'New in store', 'woocommerce' ) . '</h2><!-- /wp:heading -->',
)
);
}
/**
* Get the editor script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return array|string;
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @param array $attributes Any attributes that currently are available from the block.
* @param string $content The block content.
* @param WP_Block $block The block object.
*/
protected function enqueue_assets( array $attributes, $content, $block ) {
/**
* Fires before cart block scripts are enqueued.
*
* @since 2.6.0
*/
do_action( 'woocommerce_blocks_enqueue_cart_block_scripts_before' );
parent::enqueue_assets( $attributes, $content, $block );
/**
* Fires after cart block scripts are enqueued.
*
* @since 2.6.0
*/
do_action( 'woocommerce_blocks_enqueue_cart_block_scripts_after' );
}
/**
* Append frontend scripts when rendering the Cart block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
// Dequeue the core scripts when rendering this block.
add_action( 'wp_enqueue_scripts', array( $this, 'dequeue_woocommerce_core_scripts' ), 20 );
/**
* We need to check if $content has any templates from prior iterations of the block, in order to update to the latest iteration.
* We test the iteration version by searching for new blocks brought in by it.
* The blocks used for testing should be always available in the block (not removable by the user).
*/
$regex_for_filled_cart_block = '/<div[^<]*?data-block-name="woocommerce\/filled-cart-block"[^>]*?>/mi';
// Filled Cart block was added in i2, so we search for it to see if we have a Cart i1 template.
$has_i1_template = ! preg_match( $regex_for_filled_cart_block, $content );
if ( $has_i1_template ) {
/**
* This fallback structure needs to match the defaultTemplate variables defined in the block's edit.tsx files,
* starting from the parent block and going down each inner block, in the order the blocks were registered.
*/
$inner_blocks_html = '$0
<div data-block-name="woocommerce/filled-cart-block" class="wp-block-woocommerce-filled-cart-block">
<div data-block-name="woocommerce/cart-items-block" class="wp-block-woocommerce-cart-items-block">
<div data-block-name="woocommerce/cart-line-items-block" class="wp-block-woocommerce-cart-line-items-block"></div>
</div>
<div data-block-name="woocommerce/cart-totals-block" class="wp-block-woocommerce-cart-totals-block">
<div data-block-name="woocommerce/cart-order-summary-block" class="wp-block-woocommerce-cart-order-summary-block"></div>
<div data-block-name="woocommerce/cart-express-payment-block" class="wp-block-woocommerce-cart-express-payment-block"></div>
<div data-block-name="woocommerce/proceed-to-checkout-block" class="wp-block-woocommerce-proceed-to-checkout-block"></div>
<div data-block-name="woocommerce/cart-accepted-payment-methods-block" class="wp-block-woocommerce-cart-accepted-payment-methods-block"></div>
</div>
</div>
<div data-block-name="woocommerce/empty-cart-block" class="wp-block-woocommerce-empty-cart-block">
';
$content = preg_replace( '/<div class="[a-zA-Z0-9_\- ]*wp-block-woocommerce-cart[a-zA-Z0-9_\- ]*">/mi', $inner_blocks_html, $content );
$content = $content . '</div>';
}
/**
* Cart i3 added inner blocks for Order summary. We need to add them to Cart i2 templates.
* The order needs to match the order in which these blocks were registered.
*/
$order_summary_with_inner_blocks = '$0
<div data-block-name="woocommerce/cart-order-summary-heading-block" class="wp-block-woocommerce-cart-order-summary-heading-block"></div>
<div data-block-name="woocommerce/cart-order-summary-subtotal-block" class="wp-block-woocommerce-cart-order-summary-subtotal-block"></div>
<div data-block-name="woocommerce/cart-order-summary-fee-block" class="wp-block-woocommerce-cart-order-summary-fee-block"></div>
<div data-block-name="woocommerce/cart-order-summary-discount-block" class="wp-block-woocommerce-cart-order-summary-discount-block"></div>
<div data-block-name="woocommerce/cart-order-summary-coupon-form-block" class="wp-block-woocommerce-cart-order-summary-coupon-form-block"></div>
<div data-block-name="woocommerce/cart-order-summary-shipping-form-block" class="wp-block-woocommerce-cart-order-summary-shipping-block"></div>
<div data-block-name="woocommerce/cart-order-summary-taxes-block" class="wp-block-woocommerce-cart-order-summary-taxes-block"></div>
';
// Order summary subtotal block was added in i3, so we search for it to see if we have a Cart i2 template.
$regex_for_order_summary_subtotal = '/<div[^<]*?data-block-name="woocommerce\/cart-order-summary-subtotal-block"[^>]*?>/mi';
$regex_for_order_summary = '/<div[^<]*?data-block-name="woocommerce\/cart-order-summary-block"[^>]*?>/mi';
$has_i2_template = ! preg_match( $regex_for_order_summary_subtotal, $content );
if ( $has_i2_template ) {
$content = preg_replace( $regex_for_order_summary, $order_summary_with_inner_blocks, $content );
}
return $content;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'countryData', CartCheckoutUtils::get_country_data(), true );
$this->asset_data_registry->add( 'baseLocation', wc_get_base_location(), true );
$this->asset_data_registry->add( 'isShippingCalculatorEnabled', filter_var( get_option( 'woocommerce_enable_shipping_calc' ), FILTER_VALIDATE_BOOLEAN ), true );
$this->asset_data_registry->add( 'displayItemizedTaxes', 'itemized' === get_option( 'woocommerce_tax_total_display' ), true );
$this->asset_data_registry->add( 'displayCartPricesIncludingTax', 'incl' === get_option( 'woocommerce_tax_display_cart' ), true );
$this->asset_data_registry->add( 'taxesEnabled', wc_tax_enabled(), true );
$this->asset_data_registry->add( 'couponsEnabled', wc_coupons_enabled(), true );
$this->asset_data_registry->add( 'shippingEnabled', wc_shipping_enabled(), true );
$this->asset_data_registry->add( 'hasDarkEditorStyleSupport', current_theme_supports( 'dark-editor-style' ), true );
$this->asset_data_registry->register_page_id( isset( $attributes['checkoutPageId'] ) ? $attributes['checkoutPageId'] : 0 );
$this->asset_data_registry->add( 'isBlockTheme', wc_current_theme_is_fse_theme(), true );
$pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [] );
$this->asset_data_registry->add( 'localPickupEnabled', wc_string_to_bool( $pickup_location_settings['enabled'] ?? 'no' ), true );
// Hydrate the following data depending on admin or frontend context.
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$this->asset_data_registry->hydrate_api_request( '/wc/store/v1/cart' );
}
/**
* Fires after cart block data is registered.
*
* @since 2.6.0
*/
do_action( 'woocommerce_blocks_cart_enqueue_data' );
}
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
parent::register_block_type_assets();
$chunks = $this->get_chunks_paths( $this->chunks_folder );
$vendor_chunks = $this->get_chunks_paths( 'vendors--cart-blocks' );
$shared_chunks = [];
$this->register_chunk_translations( array_merge( $chunks, $vendor_chunks, $shared_chunks ) );
}
/**
* Get list of Cart block & its inner-block types.
*
* @return array;
*/
public static function get_cart_block_types() {
return [
'Cart',
'CartOrderSummaryTaxesBlock',
'CartOrderSummarySubtotalBlock',
'FilledCartBlock',
'EmptyCartBlock',
'CartTotalsBlock',
'CartItemsBlock',
'CartLineItemsBlock',
'CartOrderSummaryBlock',
'CartExpressPaymentBlock',
'ProceedToCheckoutBlock',
'CartAcceptedPaymentMethodsBlock',
'CartOrderSummaryCouponFormBlock',
'CartOrderSummaryDiscountBlock',
'CartOrderSummaryFeeBlock',
'CartOrderSummaryHeadingBlock',
'CartOrderSummaryShippingBlock',
'CartCrossSellsBlock',
'CartCrossSellsProductsBlock',
];
}
}
BlockTypes/CartAcceptedPaymentMethodsBlock.php 0000644 00000000440 15154173073 0015517 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartAcceptedPaymentMethodsBlock class.
*/
class CartAcceptedPaymentMethodsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-accepted-payment-methods-block';
}
BlockTypes/CartCrossSellsBlock.php 0000644 00000000373 15154173073 0013226 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartCrossSellsBlock class.
*/
class CartCrossSellsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-cross-sells-block';
}
BlockTypes/CartCrossSellsProductsBlock.php 0000644 00000000424 15154173073 0014747 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartCrossSellsProductsBlock class.
*/
class CartCrossSellsProductsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-cross-sells-products-block';
}
BlockTypes/CartExpressPaymentBlock.php 0000644 00000000407 15154173073 0014117 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartExpressPaymentBlock class.
*/
class CartExpressPaymentBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-express-payment-block';
}
BlockTypes/CartItemsBlock.php 0000644 00000000353 15154173073 0012211 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartItemsBlock class.
*/
class CartItemsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-items-block';
}
BlockTypes/CartLineItemsBlock.php 0000644 00000000370 15154173073 0013020 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartLineItemsBlock class.
*/
class CartLineItemsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-line-items-block';
}
BlockTypes/CartOrderSummaryBlock.php 0000644 00000000401 15154173073 0013553 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryBlock class.
*/
class CartOrderSummaryBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-block';
}
BlockTypes/CartOrderSummaryCouponFormBlock.php 0000644 00000000441 15154173073 0015567 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryCouponFormBlock class.
*/
class CartOrderSummaryCouponFormBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-coupon-form-block';
}
BlockTypes/CartOrderSummaryDiscountBlock.php 0000644 00000000432 15154173073 0015270 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryDiscountBlock class.
*/
class CartOrderSummaryDiscountBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-discount-block';
}
BlockTypes/CartOrderSummaryFeeBlock.php 0000644 00000000413 15154173073 0014176 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryFeeBlock class.
*/
class CartOrderSummaryFeeBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-fee-block';
}
BlockTypes/CartOrderSummaryHeadingBlock.php 0000644 00000000427 15154173073 0015043 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryHeadingBlock class.
*/
class CartOrderSummaryHeadingBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-heading-block';
}
BlockTypes/CartOrderSummaryShippingBlock.php 0000644 00000000432 15154173073 0015261 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryShippingBlock class.
*/
class CartOrderSummaryShippingBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-shipping-block';
}
BlockTypes/CartOrderSummarySubtotalBlock.php 0000644 00000000432 15154173073 0015275 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummarySubtotalBlock class.
*/
class CartOrderSummarySubtotalBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-subtotal-block';
}
BlockTypes/CartOrderSummaryTaxesBlock.php 0000644 00000000421 15154173073 0014562 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartOrderSummaryTaxesBlock class.
*/
class CartOrderSummaryTaxesBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-order-summary-taxes-block';
}
BlockTypes/CartTotalsBlock.php 0000644 00000000356 15154173073 0012401 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CartTotalsBlock class.
*/
class CartTotalsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'cart-totals-block';
}
BlockTypes/CatalogSorting.php 0000644 00000002534 15154173073 0012266 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* CatalogSorting class.
*/
class CatalogSorting extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'catalog-sorting';
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string | void Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
ob_start();
woocommerce_catalog_ordering();
$catalog_sorting = ob_get_clean();
if ( ! $catalog_sorting ) {
return;
}
$classname = isset( $attributes['className'] ) ? $attributes['className'] : '';
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
return sprintf(
'<div class="woocommerce wc-block-catalog-sorting %1$s %2$s" style="%3$s">%4$s</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
esc_attr( $classes_and_styles['styles'] ),
$catalog_sorting
);
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
}
BlockTypes/Checkout.php 0000644 00000046071 15154173073 0011117 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
/**
* Checkout class.
*
* @internal
*/
class Checkout extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout';
/**
* Chunks build folder.
*
* @var string
*/
protected $chunks_folder = 'checkout-blocks';
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
*/
protected function initialize() {
parent::initialize();
add_action( 'wp_loaded', array( $this, 'register_patterns' ) );
}
/**
* Dequeues the scripts added by WC Core to the Checkout page.
*
* @return void
*/
public function dequeue_woocommerce_core_scripts() {
wp_dequeue_script( 'wc-checkout' );
wp_dequeue_script( 'wc-password-strength-meter' );
wp_dequeue_script( 'selectWoo' );
wp_dequeue_style( 'select2' );
}
/**
* Register block pattern for Empty Cart Message to make it translatable.
*/
public function register_patterns() {
register_block_pattern(
'woocommerce/checkout-heading',
array(
'title' => '',
'inserter' => false,
'content' => '<!-- wp:heading {"align":"wide", "level":1} --><h1 class="wp-block-heading alignwide">' . esc_html__( 'Checkout', 'woocommerce' ) . '</h1><!-- /wp:heading -->',
)
);
}
/**
* Get the editor script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return array|string;
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @param array $attributes Any attributes that currently are available from the block.
* @param string $content The block content.
* @param WP_Block $block The block object.
*/
protected function enqueue_assets( array $attributes, $content, $block ) {
/**
* Fires before checkout block scripts are enqueued.
*
* @since 4.6.0
*/
do_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_before' );
parent::enqueue_assets( $attributes, $content, $block );
/**
* Fires after checkout block scripts are enqueued.
*
* @since 4.6.0
*/
do_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after' );
}
/**
* Append frontend scripts when rendering the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( $this->is_checkout_endpoint() ) {
// Note: Currently the block only takes care of the main checkout form -- if an endpoint is set, refer to the
// legacy shortcode instead and do not render block.
return wc_current_theme_is_fse_theme() ? do_shortcode( '[woocommerce_checkout]' ) : '[woocommerce_checkout]';
}
// Dequeue the core scripts when rendering this block.
add_action( 'wp_enqueue_scripts', array( $this, 'dequeue_woocommerce_core_scripts' ), 20 );
/**
* We need to check if $content has any templates from prior iterations of the block, in order to update to the latest iteration.
* We test the iteration version by searching for new blocks brought in by it.
* The blocks used for testing should be always available in the block (not removable by the user).
* Checkout i1's content was returning an empty div, with no data-block-name attribute
*/
$regex_for_empty_block = '/<div class="[a-zA-Z0-9_\- ]*wp-block-woocommerce-checkout[a-zA-Z0-9_\- ]*"><\/div>/mi';
$has_i1_template = preg_match( $regex_for_empty_block, $content );
if ( $has_i1_template ) {
// This fallback needs to match the default templates defined in our Blocks.
$inner_blocks_html = '
<div data-block-name="woocommerce/checkout-fields-block" class="wp-block-woocommerce-checkout-fields-block">
<div data-block-name="woocommerce/checkout-express-payment-block" class="wp-block-woocommerce-checkout-express-payment-block"></div>
<div data-block-name="woocommerce/checkout-contact-information-block" class="wp-block-woocommerce-checkout-contact-information-block"></div>
<div data-block-name="woocommerce/checkout-shipping-address-block" class="wp-block-woocommerce-checkout-shipping-address-block"></div>
<div data-block-name="woocommerce/checkout-billing-address-block" class="wp-block-woocommerce-checkout-billing-address-block"></div>
<div data-block-name="woocommerce/checkout-shipping-methods-block" class="wp-block-woocommerce-checkout-shipping-methods-block"></div>
<div data-block-name="woocommerce/checkout-payment-block" class="wp-block-woocommerce-checkout-payment-block"></div>' .
( isset( $attributes['showOrderNotes'] ) && false === $attributes['showOrderNotes'] ? '' : '<div data-block-name="woocommerce/checkout-order-note-block" class="wp-block-woocommerce-checkout-order-note-block"></div>' ) .
( isset( $attributes['showPolicyLinks'] ) && false === $attributes['showPolicyLinks'] ? '' : '<div data-block-name="woocommerce/checkout-terms-block" class="wp-block-woocommerce-checkout-terms-block"></div>' ) .
'<div data-block-name="woocommerce/checkout-actions-block" class="wp-block-woocommerce-checkout-actions-block"></div>
</div>
<div data-block-name="woocommerce/checkout-totals-block" class="wp-block-woocommerce-checkout-totals-block">
<div data-block-name="woocommerce/checkout-order-summary-block" class="wp-block-woocommerce-checkout-order-summary-block"></div>
</div>
';
$content = str_replace( '</div>', $inner_blocks_html . '</div>', $content );
}
/**
* Checkout i3 added inner blocks for Order summary.
* We need to add them to Checkout i2 templates.
* The order needs to match the order in which these blocks were registered.
*/
$order_summary_with_inner_blocks = '$0
<div data-block-name="woocommerce/checkout-order-summary-cart-items-block" class="wp-block-woocommerce-checkout-order-summary-cart-items-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-subtotal-block" class="wp-block-woocommerce-checkout-order-summary-subtotal-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-fee-block" class="wp-block-woocommerce-checkout-order-summary-fee-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-discount-block" class="wp-block-woocommerce-checkout-order-summary-discount-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-coupon-form-block" class="wp-block-woocommerce-checkout-order-summary-coupon-form-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-shipping-block" class="wp-block-woocommerce-checkout-order-summary-shipping-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-taxes-block" class="wp-block-woocommerce-checkout-order-summary-taxes-block"></div>
';
// Order summary subtotal block was added in i3, so we search for it to see if we have a Checkout i2 template.
$regex_for_order_summary_subtotal = '/<div[^<]*?data-block-name="woocommerce\/checkout-order-summary-subtotal-block"[^>]*?>/mi';
$regex_for_order_summary = '/<div[^<]*?data-block-name="woocommerce\/checkout-order-summary-block"[^>]*?>/mi';
$has_i2_template = ! preg_match( $regex_for_order_summary_subtotal, $content );
if ( $has_i2_template ) {
$content = preg_replace( $regex_for_order_summary, $order_summary_with_inner_blocks, $content );
}
/**
* Add the Local Pickup toggle to checkouts missing this forced template.
*/
$local_pickup_inner_blocks = '<div data-block-name="woocommerce/checkout-shipping-method-block" class="wp-block-woocommerce-checkout-shipping-method-block"></div>' . PHP_EOL . PHP_EOL . '<div data-block-name="woocommerce/checkout-pickup-options-block" class="wp-block-woocommerce-checkout-pickup-options-block"></div>' . PHP_EOL . PHP_EOL . '$0';
$has_local_pickup_regex = '/<div[^<]*?data-block-name="woocommerce\/checkout-shipping-method-block"[^>]*?>/mi';
$has_local_pickup = preg_match( $has_local_pickup_regex, $content );
if ( ! $has_local_pickup ) {
$shipping_address_block_regex = '/<div[^<]*?data-block-name="woocommerce\/checkout-shipping-address-block" class="wp-block-woocommerce-checkout-shipping-address-block"[^>]*?><\/div>/mi';
$content = preg_replace( $shipping_address_block_regex, $local_pickup_inner_blocks, $content );
}
return $content;
}
/**
* Check if we're viewing a checkout page endpoint, rather than the main checkout page itself.
*
* @return boolean
*/
protected function is_checkout_endpoint() {
return is_wc_endpoint_url( 'order-pay' ) || is_wc_endpoint_url( 'order-received' );
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'countryData', CartCheckoutUtils::get_country_data(), true );
$this->asset_data_registry->add( 'baseLocation', wc_get_base_location(), true );
$this->asset_data_registry->add(
'checkoutAllowsGuest',
false === filter_var(
wc()->checkout()->is_registration_required(),
FILTER_VALIDATE_BOOLEAN
),
true
);
$this->asset_data_registry->add(
'checkoutAllowsSignup',
filter_var(
wc()->checkout()->is_registration_enabled(),
FILTER_VALIDATE_BOOLEAN
),
true
);
$this->asset_data_registry->add( 'checkoutShowLoginReminder', filter_var( get_option( 'woocommerce_enable_checkout_login_reminder' ), FILTER_VALIDATE_BOOLEAN ), true );
$this->asset_data_registry->add( 'displayCartPricesIncludingTax', 'incl' === get_option( 'woocommerce_tax_display_cart' ), true );
$this->asset_data_registry->add( 'displayItemizedTaxes', 'itemized' === get_option( 'woocommerce_tax_total_display' ), true );
$this->asset_data_registry->add( 'forcedBillingAddress', 'billing_only' === get_option( 'woocommerce_ship_to_destination' ), true );
$this->asset_data_registry->add( 'taxesEnabled', wc_tax_enabled(), true );
$this->asset_data_registry->add( 'couponsEnabled', wc_coupons_enabled(), true );
$this->asset_data_registry->add( 'shippingEnabled', wc_shipping_enabled(), true );
$this->asset_data_registry->add( 'hasDarkEditorStyleSupport', current_theme_supports( 'dark-editor-style' ), true );
$this->asset_data_registry->register_page_id( isset( $attributes['cartPageId'] ) ? $attributes['cartPageId'] : 0 );
$this->asset_data_registry->add( 'isBlockTheme', wc_current_theme_is_fse_theme(), true );
$pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [] );
$this->asset_data_registry->add( 'localPickupEnabled', wc_string_to_bool( $pickup_location_settings['enabled'] ?? 'no' ), true );
$is_block_editor = $this->is_block_editor();
// Hydrate the following data depending on admin or frontend context.
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'shippingMethodsExist' ) ) {
$methods_exist = wc_get_shipping_method_count( false, true ) > 0;
$this->asset_data_registry->add( 'shippingMethodsExist', $methods_exist );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'globalShippingMethods' ) ) {
$shipping_methods = WC()->shipping()->get_shipping_methods();
$formatted_shipping_methods = array_reduce(
$shipping_methods,
function( $acc, $method ) {
if ( in_array( $method->id, LocalPickupUtils::get_local_pickup_method_ids(), true ) ) {
return $acc;
}
if ( $method->supports( 'settings' ) ) {
$acc[] = [
'id' => $method->id,
'title' => $method->method_title,
'description' => $method->method_description,
];
}
return $acc;
},
[]
);
$this->asset_data_registry->add( 'globalShippingMethods', $formatted_shipping_methods );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'activeShippingZones' ) && class_exists( '\WC_Shipping_Zones' ) ) {
$shipping_zones = \WC_Shipping_Zones::get_zones();
$formatted_shipping_zones = array_reduce(
$shipping_zones,
function( $acc, $zone ) {
$acc[] = [
'id' => $zone['id'],
'title' => $zone['zone_name'],
'description' => $zone['formatted_zone_location'],
];
return $acc;
},
[]
);
$formatted_shipping_zones[] = [
'id' => 0,
'title' => __( 'International', 'woocommerce' ),
'description' => __( 'Locations outside all other zones', 'woocommerce' ),
];
$this->asset_data_registry->add( 'activeShippingZones', $formatted_shipping_zones );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'globalPaymentMethods' ) ) {
// These are used to show options in the sidebar. We want to get the full list of enabled payment methods,
// not just the ones that are available for the current cart (which may not exist yet).
$payment_methods = $this->get_enabled_payment_gateways();
$formatted_payment_methods = array_reduce(
$payment_methods,
function( $acc, $method ) {
$acc[] = [
'id' => $method->id,
'title' => $method->method_title,
'description' => $method->method_description,
];
return $acc;
},
[]
);
$this->asset_data_registry->add( 'globalPaymentMethods', $formatted_payment_methods );
}
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$this->asset_data_registry->hydrate_api_request( '/wc/store/v1/cart' );
$this->asset_data_registry->hydrate_data_from_api_request( 'checkoutData', '/wc/store/v1/checkout' );
$this->hydrate_customer_payment_methods();
}
/**
* Fires after checkout block data is registered.
*
* @since 2.6.0
*/
do_action( 'woocommerce_blocks_checkout_enqueue_data' );
}
/**
* Get payment methods that are enabled in settings.
*
* @return array
*/
protected function get_enabled_payment_gateways() {
$payment_gateways = WC()->payment_gateways->payment_gateways();
return array_filter(
$payment_gateways,
function( $payment_gateway ) {
return 'yes' === $payment_gateway->enabled;
}
);
}
/**
* Are we currently on the admin block editor screen?
*/
protected function is_block_editor() {
if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
return false;
}
$screen = get_current_screen();
return $screen && $screen->is_block_editor();
}
/**
* Get saved customer payment methods for use in checkout.
*/
protected function hydrate_customer_payment_methods() {
if ( ! is_user_logged_in() || $this->asset_data_registry->exists( 'customerPaymentMethods' ) ) {
return;
}
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
$payment_gateways = $this->get_enabled_payment_gateways();
$payment_methods = wc_get_customer_saved_methods_list( get_current_user_id() );
// Filter out payment methods that are not enabled.
foreach ( $payment_methods as $payment_method_group => $saved_payment_methods ) {
$payment_methods[ $payment_method_group ] = array_filter(
$saved_payment_methods,
function( $saved_payment_method ) use ( $payment_gateways ) {
return in_array( $saved_payment_method['method']['gateway'], array_keys( $payment_gateways ), true );
}
);
}
$this->asset_data_registry->add(
'customerPaymentMethods',
$payment_methods
);
remove_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
}
/**
* Callback for woocommerce_payment_methods_list_item filter to add token id
* to the generated list.
*
* @param array $list_item The current list item for the saved payment method.
* @param \WC_Token $token The token for the current list item.
*
* @return array The list item with the token id added.
*/
public static function include_token_id_with_payment_methods( $list_item, $token ) {
$list_item['tokenId'] = $token->get_id();
$brand = ! empty( $list_item['method']['brand'] ) ?
strtolower( $list_item['method']['brand'] ) :
'';
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- need to match on translated value from core.
if ( ! empty( $brand ) && esc_html__( 'Credit card', 'woocommerce' ) !== $brand ) {
$list_item['method']['brand'] = wc_get_credit_card_type_label( $brand );
}
return $list_item;
}
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
parent::register_block_type_assets();
$chunks = $this->get_chunks_paths( $this->chunks_folder );
$vendor_chunks = $this->get_chunks_paths( 'vendors--checkout-blocks' );
$shared_chunks = [ 'cart-blocks/cart-express-payment--checkout-blocks/express-payment-frontend' ];
$this->register_chunk_translations( array_merge( $chunks, $vendor_chunks, $shared_chunks ) );
}
/**
* Get list of Checkout block & its inner-block types.
*
* @return array;
*/
public static function get_checkout_block_types() {
return [
'Checkout',
'CheckoutActionsBlock',
'CheckoutBillingAddressBlock',
'CheckoutContactInformationBlock',
'CheckoutExpressPaymentBlock',
'CheckoutFieldsBlock',
'CheckoutOrderNoteBlock',
'CheckoutOrderSummaryBlock',
'CheckoutOrderSummaryCartItemsBlock',
'CheckoutOrderSummaryCouponFormBlock',
'CheckoutOrderSummaryDiscountBlock',
'CheckoutOrderSummaryFeeBlock',
'CheckoutOrderSummaryShippingBlock',
'CheckoutOrderSummarySubtotalBlock',
'CheckoutOrderSummaryTaxesBlock',
'CheckoutPaymentBlock',
'CheckoutShippingAddressBlock',
'CheckoutShippingMethodsBlock',
'CheckoutShippingMethodBlock',
'CheckoutPickupOptionsBlock',
'CheckoutTermsBlock',
'CheckoutTotalsBlock',
];
}
}
BlockTypes/CheckoutActionsBlock.php 0000644 00000000375 15154173073 0013410 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutActionsBlock class.
*/
class CheckoutActionsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-actions-block';
}
BlockTypes/CheckoutBillingAddressBlock.php 0000644 00000000423 15154173073 0014670 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutBillingAddressBlock class.
*/
class CheckoutBillingAddressBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-billing-address-block';
}
BlockTypes/CheckoutContactInformationBlock.php 0000644 00000000437 15154173073 0015610 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutContactInformationBlock class.
*/
class CheckoutContactInformationBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-contact-information-block';
}
BlockTypes/CheckoutExpressPaymentBlock.php 0000644 00000000423 15154173073 0014771 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutExpressPaymentBlock class.
*/
class CheckoutExpressPaymentBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-express-payment-block';
}
BlockTypes/CheckoutFieldsBlock.php 0000644 00000000372 15154173073 0013213 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutFieldsBlock class.
*/
class CheckoutFieldsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-fields-block';
}
BlockTypes/CheckoutOrderNoteBlock.php 0000644 00000000404 15154173073 0013702 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderNoteBlock class.
*/
class CheckoutOrderNoteBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-note-block';
}
BlockTypes/CheckoutOrderSummaryBlock.php 0000644 00000000415 15154173073 0014434 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryBlock class.
*/
class CheckoutOrderSummaryBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-block';
}
BlockTypes/CheckoutOrderSummaryCartItemsBlock.php 0000644 00000000452 15154173073 0016251 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryCartItemsBlock class.
*/
class CheckoutOrderSummaryCartItemsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-cart-items-block';
}
BlockTypes/CheckoutOrderSummaryCouponFormBlock.php 0000644 00000000455 15154173073 0016450 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryCouponFormBlock class.
*/
class CheckoutOrderSummaryCouponFormBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-coupon-form-block';
}
BlockTypes/CheckoutOrderSummaryDiscountBlock.php 0000644 00000000446 15154173073 0016151 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryDiscountBlock class.
*/
class CheckoutOrderSummaryDiscountBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-discount-block';
}
BlockTypes/CheckoutOrderSummaryFeeBlock.php 0000644 00000000427 15154173073 0015057 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryFeeBlock class.
*/
class CheckoutOrderSummaryFeeBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-fee-block';
}
BlockTypes/CheckoutOrderSummaryShippingBlock.php 0000644 00000000446 15154173073 0016142 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryShippingBlock class.
*/
class CheckoutOrderSummaryShippingBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-shipping-block';
}
BlockTypes/CheckoutOrderSummarySubtotalBlock.php 0000644 00000000446 15154173073 0016156 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummarySubtotalBlock class.
*/
class CheckoutOrderSummarySubtotalBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-subtotal-block';
}
BlockTypes/CheckoutOrderSummaryTaxesBlock.php 0000644 00000000435 15154173073 0015443 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutOrderSummaryTaxesBlock class.
*/
class CheckoutOrderSummaryTaxesBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-order-summary-taxes-block';
}
BlockTypes/CheckoutPaymentBlock.php 0000644 00000000375 15154173073 0013425 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutPaymentBlock class.
*/
class CheckoutPaymentBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-payment-block';
}
BlockTypes/CheckoutPickupOptionsBlock.php 0000644 00000000420 15154173073 0014606 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutPickupOptionsBlock class.
*/
class CheckoutPickupOptionsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-pickup-options-block';
}
BlockTypes/CheckoutShippingAddressBlock.php 0000644 00000000426 15154173073 0015074 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutShippingAddressBlock class.
*/
class CheckoutShippingAddressBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-shipping-address-block';
}
BlockTypes/CheckoutShippingMethodBlock.php 0000644 00000000423 15154173073 0014724 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutShippingMethodBlock class.
*/
class CheckoutShippingMethodBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-shipping-method-block';
}
BlockTypes/CheckoutShippingMethodsBlock.php 0000644 00000000426 15154173073 0015112 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutShippingMethodsBlock class.
*/
class CheckoutShippingMethodsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-shipping-methods-block';
}
BlockTypes/CheckoutTermsBlock.php 0000644 00000000367 15154173073 0013103 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutTermsBlock class.
*/
class CheckoutTermsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-terms-block';
}
BlockTypes/CheckoutTotalsBlock.php 0000644 00000000372 15154173073 0013253 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutTotalsBlock class.
*/
class CheckoutTotalsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-totals-block';
}
BlockTypes/ClassicTemplate.php 0000644 00000026052 15154173073 0012424 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Templates\ProductAttributeTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
use Automattic\WooCommerce\Blocks\Templates\OrderConfirmationTemplate;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
use WC_Shortcode_Checkout;
use WC_Frontend_Scripts;
/**
* Classic Template class
*
* @internal
*/
class ClassicTemplate extends AbstractDynamicBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'legacy-template';
/**
* API version.
*
* @var string
*/
protected $api_version = '2';
const FILTER_PRODUCTS_BY_STOCK_QUERY_PARAM = 'filter_stock_status';
/**
* Initialize this block.
*/
protected function initialize() {
parent::initialize();
add_filter( 'render_block', array( $this, 'add_alignment_class_to_wrapper' ), 10, 2 );
add_filter( 'woocommerce_product_query_meta_query', array( $this, 'filter_products_by_stock' ) );
add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) );
}
/**
* Enqueue assets used for rendering the block in editor context.
*
* This is needed if a block is not yet within the post content--`render` and `enqueue_assets` may not have ran.
*/
public function enqueue_block_assets() {
// Ensures frontend styles for blocks exist in the site editor iframe.
if ( class_exists( 'WC_Frontend_Scripts' ) && is_admin() ) {
$frontend_scripts = new WC_Frontend_Scripts();
$styles = $frontend_scripts::get_styles();
foreach ( $styles as $handle => $style ) {
wp_enqueue_style(
$handle,
set_url_scheme( $style['src'] ),
$style['deps'],
$style['version'],
$style['media']
);
}
}
}
/**
* Render method for the Classic Template block. This method will determine which template to render.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string | void Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! isset( $attributes['template'] ) ) {
return;
}
/**
* We need to load the scripts here because when using block templates wp_head() gets run after the block
* template. As a result we are trying to enqueue required scripts before we have even registered them.
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5328#issuecomment-989013447
*/
if ( class_exists( 'WC_Frontend_Scripts' ) ) {
$frontend_scripts = new WC_Frontend_Scripts();
$frontend_scripts::load_scripts();
}
if ( OrderConfirmationTemplate::get_slug() === $attributes['template'] ) {
return $this->render_order_received();
}
if ( is_product() ) {
return $this->render_single_product();
}
$archive_templates = array(
'archive-product',
'taxonomy-product_cat',
'taxonomy-product_tag',
ProductAttributeTemplate::SLUG,
ProductSearchResultsTemplate::SLUG,
);
if ( in_array( $attributes['template'], $archive_templates, true ) ) {
// Set this so that our product filters can detect if it's a PHP template.
$this->asset_data_registry->add( 'isRenderingPhpTemplate', true, true );
// Set this so filter blocks being used as widgets know when to render.
$this->asset_data_registry->add( 'hasFilterableProducts', true, true );
$this->asset_data_registry->add(
'pageUrl',
html_entity_decode( get_pagenum_link() ),
''
);
return $this->render_archive_product();
}
ob_start();
echo "You're using the ClassicTemplate block";
wp_reset_postdata();
return ob_get_clean();
}
/**
* Render method for rendering the order confirmation template.
*
* @return string Rendered block type output.
*/
protected function render_order_received() {
ob_start();
echo '<div class="wp-block-group">';
echo sprintf(
'<%1$s %2$s>%3$s</%1$s>',
'h1',
get_block_wrapper_attributes(), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
esc_html__( 'Order confirmation', 'woocommerce' )
);
WC_Shortcode_Checkout::output( array() );
echo '</div>';
return ob_get_clean();
}
/**
* Render method for the single product template and parts.
*
* @return string Rendered block type output.
*/
protected function render_single_product() {
ob_start();
/**
* Hook: woocommerce_before_main_content
*
* Called before rendering the main content for a product.
*
* @see woocommerce_output_content_wrapper() Outputs opening DIV for the content (priority 10)
* @see woocommerce_breadcrumb() Outputs breadcrumb trail to the current product (priority 20)
* @see WC_Structured_Data::generate_website_data() Outputs schema markup (priority 30)
*
* @since 6.3.0
*/
do_action( 'woocommerce_before_main_content' );
while ( have_posts() ) :
the_post();
wc_get_template_part( 'content', 'single-product' );
endwhile;
/**
* Hook: woocommerce_after_main_content
*
* Called after rendering the main content for a product.
*
* @see woocommerce_output_content_wrapper_end() Outputs closing DIV for the content (priority 10)
*
* @since 6.3.0
*/
do_action( 'woocommerce_after_main_content' );
wp_reset_postdata();
return ob_get_clean();
}
/**
* Render method for the archive product template and parts.
*
* @return string Rendered block type output.
*/
protected function render_archive_product() {
ob_start();
/**
* Hook: woocommerce_before_main_content
*
* Called before rendering the main content for a product.
*
* @see woocommerce_output_content_wrapper() Outputs opening DIV for the content (priority 10)
* @see woocommerce_breadcrumb() Outputs breadcrumb trail to the current product (priority 20)
* @see WC_Structured_Data::generate_website_data() Outputs schema markup (priority 30)
*
* @since 6.3.0
*/
do_action( 'woocommerce_before_main_content' );
?>
<header class="woocommerce-products-header">
<?php
/**
* Hook: woocommerce_show_page_title
*
* Allows controlling the display of the page title.
*
* @since 6.3.0
*/
if ( apply_filters( 'woocommerce_show_page_title', true ) ) {
?>
<h1 class="woocommerce-products-header__title page-title">
<?php
woocommerce_page_title();
?>
</h1>
<?php
}
/**
* Hook: woocommerce_archive_description.
*
* @see woocommerce_taxonomy_archive_description() Renders the taxonomy archive description (priority 10)
* @see woocommerce_product_archive_description() Renders the product archive description (priority 10)
*
* @since 6.3.0
*/
do_action( 'woocommerce_archive_description' );
?>
</header>
<?php
if ( woocommerce_product_loop() ) {
/**
* Hook: woocommerce_before_shop_loop.
*
* @see woocommerce_output_all_notices() Render error notices (priority 10)
* @see woocommerce_result_count() Show number of results found (priority 20)
* @see woocommerce_catalog_ordering() Show form to control sort order (priority 30)
*
* @since 6.3.0
*/
do_action( 'woocommerce_before_shop_loop' );
woocommerce_product_loop_start();
if ( wc_get_loop_prop( 'total' ) ) {
while ( have_posts() ) {
the_post();
/**
* Hook: woocommerce_shop_loop.
*
* @since 6.3.0
*/
do_action( 'woocommerce_shop_loop' );
wc_get_template_part( 'content', 'product' );
}
}
woocommerce_product_loop_end();
/**
* Hook: woocommerce_after_shop_loop.
*
* @see woocommerce_pagination() Renders pagination (priority 10)
*
* @since 6.3.0
*/
do_action( 'woocommerce_after_shop_loop' );
} else {
/**
* Hook: woocommerce_no_products_found.
*
* @see wc_no_products_found() Default no products found content (priority 10)
*
* @since 6.3.0
*/
do_action( 'woocommerce_no_products_found' );
}
/**
* Hook: woocommerce_after_main_content
*
* Called after rendering the main content for a product.
*
* @see woocommerce_output_content_wrapper_end() Outputs closing DIV for the content (priority 10)
*
* @since 6.3.0
*/
do_action( 'woocommerce_after_main_content' );
wp_reset_postdata();
return ob_get_clean();
}
/**
* Get HTML markup with the right classes by attributes.
* This function appends the classname at the first element that have the class attribute.
* Based on the experience, all the wrapper elements have a class attribute.
*
* @param string $content Block content.
* @param array $block Parsed block data.
* @return string Rendered block type output.
*/
public function add_alignment_class_to_wrapper( string $content, array $block ) {
if ( ( 'woocommerce/' . $this->block_name ) !== $block['blockName'] ) {
return $content;
}
$attributes = (array) $block['attrs'];
// Set the default alignment to wide.
if ( ! isset( $attributes['align'] ) ) {
$attributes['align'] = 'wide';
}
$align_class_and_style = StyleAttributesUtils::get_align_class_and_style( $attributes );
if ( ! isset( $align_class_and_style['class'] ) ) {
return $content;
}
// Find the first tag.
$first_tag = '<[^<>]+>';
$matches = array();
preg_match( $first_tag, $content, $matches );
// If there is a tag, but it doesn't have a class attribute, add the class attribute.
if ( isset( $matches[0] ) && strpos( $matches[0], ' class=' ) === false ) {
$pattern_before_tag_closing = '/.+?(?=>)/';
return preg_replace( $pattern_before_tag_closing, '$0 class="' . $align_class_and_style['class'] . '"', $content, 1 );
}
// If there is a tag, and it has a class already, add the class attribute.
$pattern_get_class = '/(?<=class=\"|\')[^"|\']+(?=\"|\')/';
return preg_replace( $pattern_get_class, '$0 ' . $align_class_and_style['class'], $content, 1 );
}
/**
* Filter products by stock status when as query param there is "filter_stock_status"
*
* @param array $meta_query Meta query.
* @return array
*/
public function filter_products_by_stock( $meta_query ) {
global $wp_query;
if (
is_admin() ||
! $wp_query->is_main_query() ||
! isset( $_GET[ self::FILTER_PRODUCTS_BY_STOCK_QUERY_PARAM ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
) {
return $meta_query;
}
$stock_status = array_keys( wc_get_product_stock_status_options() );
$values = sanitize_text_field( wp_unslash( $_GET[ self::FILTER_PRODUCTS_BY_STOCK_QUERY_PARAM ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$values_to_array = explode( ',', $values );
$filtered_values = array_filter(
$values_to_array,
function( $value ) use ( $stock_status ) {
return in_array( $value, $stock_status, true );
}
);
if ( ! empty( $filtered_values ) ) {
$meta_query[] = array(
'key' => '_stock_status',
'value' => $filtered_values,
'compare' => 'IN',
);
}
return $meta_query;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
}
BlockTypes/CustomerAccount.php 0000644 00000007547 15154173073 0012475 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* CustomerAccount class.
*/
class CustomerAccount extends AbstractBlock {
const TEXT_ONLY = 'text_only';
const ICON_ONLY = 'icon_only';
const DISPLAY_ALT = 'alt';
/**
* Block name.
*
* @var string
*/
protected $block_name = 'customer-account';
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$account_link = get_option( 'woocommerce_myaccount_page_id' ) ? wc_get_account_endpoint_url( 'dashboard' ) : wp_login_url();
$allowed_svg = array(
'svg' => array(
'class' => true,
'xmlns' => true,
'width' => true,
'height' => true,
'viewbox' => true,
),
'path' => array(
'd' => true,
'fill' => true,
),
);
return "<div class='wp-block-woocommerce-customer-account " . esc_attr( $classes_and_styles['classes'] ) . "' style='" . esc_attr( $classes_and_styles['styles'] ) . "'>
<a href='" . esc_attr( $account_link ) . "'>
" . wp_kses( $this->render_icon( $attributes ), $allowed_svg ) . "<span class='label'>" . wp_kses( $this->render_label( $attributes ), array() ) . '</span>
</a>
</div>';
}
/**
* Gets the icon to render depending on the iconStyle and displayStyle.
*
* @param array $attributes Block attributes.
*
* @return string Label to render on the block
*/
private function render_icon( $attributes ) {
if ( self::TEXT_ONLY === $attributes['displayStyle'] ) {
return '';
}
if ( self::DISPLAY_ALT === $attributes['iconStyle'] ) {
return '<svg class="' . $attributes['iconClass'] . '" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18">
<path
d="M9 0C4.03579 0 0 4.03579 0 9C0 13.9642 4.03579 18 9 18C13.9642 18 18 13.9642 18 9C18 4.03579 13.9642 0 9
0ZM9 4.32C10.5347 4.32 11.7664 5.57056 11.7664 7.08638C11.7664 8.62109 10.5158 9.85277 9 9.85277C7.4653
9.85277 6.23362 8.60221 6.23362 7.08638C6.23362 5.57056 7.46526 4.32 9 4.32ZM9 10.7242C11.1221 10.7242
12.96 12.2021 13.7937 14.4189C12.5242 15.5559 10.8379 16.238 9 16.238C7.16207 16.238 5.49474 15.5369
4.20632 14.4189C5.05891 12.2021 6.87793 10.7242 9 10.7242Z"
fill="currentColor"
/>
</svg>';
}
return '<svg class="' . $attributes['iconClass'] . '" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<path
d="M8.00009 8.34785C10.3096 8.34785 12.1819 6.47909 12.1819 4.17393C12.1819 1.86876 10.3096 0 8.00009 0C5.69055
0 3.81824 1.86876 3.81824 4.17393C3.81824 6.47909 5.69055 8.34785 8.00009 8.34785ZM0.333496 15.6522C0.333496
15.8444 0.489412 16 0.681933 16H15.3184C15.5109 16 15.6668 15.8444 15.6668 15.6522V14.9565C15.6668 12.1428
13.7821 9.73911 10.0912 9.73911H5.90931C2.21828 9.73911 0.333645 12.1428 0.333645 14.9565L0.333496 15.6522Z"
fill="currentColor"
/>
</svg>';
}
/**
* Gets the label to render depending on the displayStyle.
*
* @param array $attributes Block attributes.
*
* @return string Label to render on the block.
*/
private function render_label( $attributes ) {
if ( self::ICON_ONLY === $attributes['displayStyle'] ) {
return '';
}
return get_current_user_id()
? __( 'My Account', 'woocommerce' )
: __( 'Login', 'woocommerce' );
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*
* @return null This block has no frontend script.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
}
BlockTypes/EmptyCartBlock.php 0000644 00000000353 15154173073 0012226 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* EmptyCartBlock class.
*/
class EmptyCartBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'empty-cart-block';
}
BlockTypes/EmptyMiniCartContentsBlock.php 0000644 00000000421 15154173073 0014555 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* EmptyMiniCartContentsBlock class.
*/
class EmptyMiniCartContentsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'empty-mini-cart-contents-block';
}
BlockTypes/FeaturedCategory.php 0000644 00000004400 15154173073 0012575 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* FeaturedCategory class.
*/
class FeaturedCategory extends FeaturedItem {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'featured-category';
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array_merge(
parent::get_block_type_attributes(),
array(
'textColor' => $this->get_schema_string(),
'fontSize' => $this->get_schema_string(),
'lineHeight' => $this->get_schema_string(),
'style' => array( 'type' => 'object' ),
)
);
}
/**
* Returns the featured category.
*
* @param array $attributes Block attributes. Default empty array.
* @return \WP_Term|null
*/
protected function get_item( $attributes ) {
$id = absint( $attributes['categoryId'] ?? 0 );
$category = get_term( $id, 'product_cat' );
if ( ! $category || is_wp_error( $category ) ) {
return null;
}
return $category;
}
/**
* Returns the name of the featured category.
*
* @param \WP_Term $category Featured category.
* @return string
*/
protected function get_item_title( $category ) {
return $category->name;
}
/**
* Returns the featured category image URL.
*
* @param \WP_Term $category Term object.
* @param string $size Image size, defaults to 'full'.
* @return string
*/
protected function get_item_image( $category, $size = 'full' ) {
$image = '';
$image_id = get_term_meta( $category->term_id, 'thumbnail_id', true );
if ( $image_id ) {
$image = wp_get_attachment_image_url( $image_id, $size );
}
return $image;
}
/**
* Renders the featured category attributes.
*
* @param \WP_Term $category Term object.
* @param array $attributes Block attributes. Default empty array.
* @return string
*/
protected function render_attributes( $category, $attributes ) {
$title = sprintf(
'<h2 class="wc-block-featured-category__title">%s</h2>',
wp_kses_post( $category->name )
);
$desc_str = sprintf(
'<div class="wc-block-featured-category__description">%s</div>',
wc_format_content( wp_kses_post( $category->description ) )
);
$output = $title;
if ( $attributes['showDesc'] ) {
$output .= $desc_str;
}
return $output;
}
}
BlockTypes/FeaturedItem.php 0000644 00000022303 15154173073 0011720 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* FeaturedItem class.
*/
abstract class FeaturedItem extends AbstractDynamicBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name;
/**
* Default attribute values.
*
* @var array
*/
protected $defaults = array(
'align' => 'none',
);
/**
* Global style enabled for this block.
*
* @var array
*/
protected $global_style_wrapper = array(
'background_color',
'border_color',
'border_radius',
'border_width',
'font_size',
'padding',
'text_color',
);
/**
* Returns the featured item.
*
* @param array $attributes Block attributes. Default empty array.
* @return \WP_Term|\WC_Product|null
*/
abstract protected function get_item( $attributes );
/**
* Returns the name of the featured item.
*
* @param \WP_Term|\WC_Product $item Item object.
* @return string
*/
abstract protected function get_item_title( $item );
/**
* Returns the featured item image URL.
*
* @param \WP_Term|\WC_Product $item Item object.
* @param string $size Image size, defaults to 'full'.
* @return string
*/
abstract protected function get_item_image( $item, $size = 'full' );
/**
* Renders the featured item attributes.
*
* @param \WP_Term|\WC_Product $item Item object.
* @param array $attributes Block attributes. Default empty array.
* @return string
*/
abstract protected function render_attributes( $item, $attributes );
/**
* Render the featured item block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$item = $this->get_item( $attributes );
if ( ! $item ) {
return '';
}
$attributes = wp_parse_args( $attributes, $this->defaults );
$attributes['height'] = $attributes['height'] ?? wc_get_theme_support( 'featured_block::default_height', 500 );
$image_url = esc_url( $this->get_image_url( $attributes, $item ) );
$styles = $this->get_styles( $attributes );
$classes = $this->get_classes( $attributes );
$output = sprintf( '<div class="%1$s wp-block-woocommerce-%2$s" style="%3$s">', esc_attr( trim( $classes ) ), $this->block_name, esc_attr( $styles ) );
$output .= sprintf( '<div class="wc-block-%s__wrapper">', $this->block_name );
$output .= $this->render_overlay( $attributes );
if ( ! $attributes['isRepeated'] && ! $attributes['hasParallax'] ) {
$output .= $this->render_image( $attributes, $item, $image_url );
} else {
$output .= $this->render_bg_image( $attributes, $image_url );
}
$output .= $this->render_attributes( $item, $attributes );
$output .= sprintf( '<div class="wc-block-%s__link">%s</div>', $this->block_name, $content );
$output .= '</div>';
$output .= '</div>';
return $output;
}
/**
* Returns the url the item's image
*
* @param array $attributes Block attributes. Default empty array.
* @param \WP_Term|\WC_Product $item Item object.
*
* @return string
*/
private function get_image_url( $attributes, $item ) {
$image_size = 'large';
if ( 'none' !== $attributes['align'] || $attributes['height'] > 800 ) {
$image_size = 'full';
}
if ( $attributes['mediaId'] ) {
return wp_get_attachment_image_url( $attributes['mediaId'], $image_size );
}
return $this->get_item_image( $item, $image_size );
}
/**
* Renders the featured image as a div background.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $image_url Item image url.
*
* @return string
*/
private function render_bg_image( $attributes, $image_url ) {
$styles = $this->get_bg_styles( $attributes, $image_url );
$classes = [ "wc-block-{$this->block_name}__background-image" ];
if ( $attributes['hasParallax'] ) {
$classes[] = ' has-parallax';
}
return sprintf( '<div class="%1$s" style="%2$s" /></div>', esc_attr( implode( ' ', $classes ) ), esc_attr( $styles ) );
}
/**
* Get the styles for the wrapper element (background image, color).
*
* @param array $attributes Block attributes. Default empty array.
* @param string $image_url Item image url.
*
* @return string
*/
public function get_bg_styles( $attributes, $image_url ) {
$style = '';
if ( $attributes['isRepeated'] || $attributes['hasParallax'] ) {
$style .= "background-image: url($image_url);";
}
if ( ! $attributes['isRepeated'] ) {
$style .= 'background-repeat: no-repeat;';
$bg_size = 'cover' === $attributes['imageFit'] ? $attributes['imageFit'] : 'auto';
$style .= 'background-size: ' . $bg_size . ';';
}
if ( $this->hasFocalPoint( $attributes ) ) {
$style .= sprintf(
'background-position: %s%% %s%%;',
$attributes['focalPoint']['x'] * 100,
$attributes['focalPoint']['y'] * 100
);
}
$global_style_style = StyleAttributesUtils::get_styles_by_attributes( $attributes, $this->global_style_wrapper );
$style .= $global_style_style;
return $style;
}
/**
* Renders the featured image
*
* @param array $attributes Block attributes. Default empty array.
* @param \WC_Product|\WP_Term $item Item object.
* @param string $image_url Item image url.
*
* @return string
*/
private function render_image( $attributes, $item, string $image_url ) {
$style = sprintf( 'object-fit: %s;', esc_attr( $attributes['imageFit'] ) );
$img_alt = $attributes['alt'] ?: $this->get_item_title( $item );
if ( $this->hasFocalPoint( $attributes ) ) {
$style .= sprintf(
'object-position: %s%% %s%%;',
$attributes['focalPoint']['x'] * 100,
$attributes['focalPoint']['y'] * 100
);
}
if ( ! empty( $image_url ) ) {
return sprintf(
'<img alt="%1$s" class="wc-block-%2$s__background-image" src="%3$s" style="%4$s" />',
esc_attr( $img_alt ),
$this->block_name,
esc_url( $image_url ),
esc_attr( $style )
);
}
return '';
}
/**
* Get the styles for the wrapper element (background image, color).
*
* @param array $attributes Block attributes. Default empty array.
* @return string
*/
public function get_styles( $attributes ) {
$style = '';
$min_height = $attributes['minHeight'] ?? wc_get_theme_support( 'featured_block::default_height', 500 );
if ( isset( $attributes['minHeight'] ) ) {
$style .= sprintf( 'min-height:%dpx;', intval( $min_height ) );
}
$global_style_style = StyleAttributesUtils::get_styles_by_attributes( $attributes, $this->global_style_wrapper );
$style .= $global_style_style;
return $style;
}
/**
* Get class names for the block container.
*
* @param array $attributes Block attributes. Default empty array.
* @return string
*/
public function get_classes( $attributes ) {
$classes = array( 'wc-block-' . $this->block_name );
if ( isset( $attributes['align'] ) ) {
$classes[] = "align{$attributes['align']}";
}
if ( isset( $attributes['dimRatio'] ) && ( 0 !== $attributes['dimRatio'] ) ) {
$classes[] = 'has-background-dim';
if ( 50 !== $attributes['dimRatio'] ) {
$classes[] = 'has-background-dim-' . 10 * round( $attributes['dimRatio'] / 10 );
}
}
if ( isset( $attributes['contentAlign'] ) && 'center' !== $attributes['contentAlign'] ) {
$classes[] = "has-{$attributes['contentAlign']}-content";
}
if ( isset( $attributes['className'] ) ) {
$classes[] = $attributes['className'];
}
$global_style_classes = StyleAttributesUtils::get_classes_by_attributes( $attributes, $this->global_style_wrapper );
$classes[] = $global_style_classes;
return implode( ' ', $classes );
}
/**
* Renders the block overlay
*
* @param array $attributes Block attributes. Default empty array.
*
* @return string
*/
private function render_overlay( $attributes ) {
if ( isset( $attributes['overlayGradient'] ) ) {
$overlay_styles = sprintf( 'background-image: %s', $attributes['overlayGradient'] );
} elseif ( isset( $attributes['overlayColor'] ) ) {
$overlay_styles = sprintf( 'background-color: %s', $attributes['overlayColor'] );
} else {
$overlay_styles = 'background-color: #000000';
}
return sprintf( '<div class="background-dim__overlay" style="%s"></div>', esc_attr( $overlay_styles ) );
}
/**
* Returns whether the focal point is defined for the block.
*
* @param array $attributes Block attributes. Default empty array.
*
* @return bool
*/
private function hasFocalPoint( $attributes ): bool {
return is_array( $attributes['focalPoint'] ) && 2 === count( $attributes['focalPoint'] );
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'defaultHeight', wc_get_theme_support( 'featured_block::default_height', 500 ), true );
}
}
BlockTypes/FeaturedProduct.php 0000644 00000005043 15154173073 0012444 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* FeaturedProduct class.
*/
class FeaturedProduct extends FeaturedItem {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'featured-product';
/**
* Returns the featured product.
*
* @param array $attributes Block attributes. Default empty array.
* @return \WP_Term|null
*/
protected function get_item( $attributes ) {
$id = absint( $attributes['productId'] ?? 0 );
$product = wc_get_product( $id );
if ( ! $product ) {
return null;
}
return $product;
}
/**
* Returns the name of the featured product.
*
* @param \WC_Product $product Product object.
* @return string
*/
protected function get_item_title( $product ) {
return $product->get_title();
}
/**
* Returns the featured product image URL.
*
* @param \WC_Product $product Product object.
* @param string $size Image size, defaults to 'full'.
* @return string
*/
protected function get_item_image( $product, $size = 'full' ) {
$image = '';
if ( $product->get_image_id() ) {
$image = wp_get_attachment_image_url( $product->get_image_id(), $size );
} elseif ( $product->get_parent_id() ) {
$parent_product = wc_get_product( $product->get_parent_id() );
if ( $parent_product ) {
$image = wp_get_attachment_image_url( $parent_product->get_image_id(), $size );
}
}
return $image;
}
/**
* Renders the featured product attributes.
*
* @param \WC_Product $product Product object.
* @param array $attributes Block attributes. Default empty array.
* @return string
*/
protected function render_attributes( $product, $attributes ) {
$title = sprintf(
'<h2 class="wc-block-featured-product__title">%s</h2>',
wp_kses_post( $product->get_title() )
);
if ( $product->is_type( 'variation' ) ) {
$title .= sprintf(
'<h3 class="wc-block-featured-product__variation">%s</h3>',
wp_kses_post( wc_get_formatted_variation( $product, true, true, false ) )
);
}
$desc_str = sprintf(
'<div class="wc-block-featured-product__description">%s</div>',
wc_format_content( wp_kses_post( $product->get_short_description() ? $product->get_short_description() : wc_trim_string( $product->get_description(), 400 ) ) )
);
$price_str = sprintf(
'<div class="wc-block-featured-product__price">%s</div>',
wp_kses_post( $product->get_price_html() )
);
$output = $title;
if ( $attributes['showDesc'] ) {
$output .= $desc_str;
}
if ( $attributes['showPrice'] ) {
$output .= $price_str;
}
return $output;
}
}
BlockTypes/FilledCartBlock.php 0000644 00000000356 15154173073 0012332 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* FilledCartBlock class.
*/
class FilledCartBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'filled-cart-block';
}
BlockTypes/FilledMiniCartContentsBlock.php 0000644 00000000424 15154173073 0014661 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* FilledMiniCartContentsBlock class.
*/
class FilledMiniCartContentsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'filled-mini-cart-contents-block';
}
BlockTypes/FilterWrapper.php 0000644 00000000572 15154173073 0012134 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* FilledCartBlock class.
*/
class FilterWrapper extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'filter-wrapper';
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
}
BlockTypes/HandpickedProducts.php 0000644 00000003504 15154173073 0013122 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* HandpickedProducts class.
*/
class HandpickedProducts extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'handpicked-products';
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
$ids = array_map( 'absint', $this->attributes['products'] );
$query_args['post__in'] = $ids;
$query_args['posts_per_page'] = count( $ids ); // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
}
/**
* Set visibility query args. Handpicked products will show hidden products if chosen.
*
* @param array $query_args Query args.
*/
protected function set_visibility_query_args( &$query_args ) {
if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$product_visibility_terms = wc_get_product_visibility_term_ids();
$query_args['tax_query'][] = array(
'taxonomy' => 'product_visibility',
'field' => 'term_taxonomy_id',
'terms' => array( $product_visibility_terms['outofstock'] ),
'operator' => 'NOT IN',
);
}
}
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array(
'align' => $this->get_schema_align(),
'alignButtons' => $this->get_schema_boolean( false ),
'className' => $this->get_schema_string(),
'columns' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_columns', 3 ) ),
'orderby' => $this->get_schema_orderby(),
'products' => $this->get_schema_list_ids(),
'contentVisibility' => $this->get_schema_content_visibility(),
'isPreview' => $this->get_schema_boolean( false ),
);
}
}
BlockTypes/MiniCart.php 0000644 00000054164 15154173073 0011062 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
use Automattic\WooCommerce\Blocks\Utils\Utils;
use Automattic\WooCommerce\Blocks\Utils\MiniCartUtils;
/**
* Mini-Cart class.
*
* @internal
*/
class MiniCart extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart';
/**
* Chunks build folder.
*
* @var string
*/
protected $chunks_folder = 'mini-cart-contents-block';
/**
* Array of scripts that will be lazy loaded when interacting with the block.
*
* @var string[]
*/
protected $scripts_to_lazy_load = array();
/**
* Inc Tax label.
*
* @var string
*/
protected $tax_label = '';
/**
* Visibility of price including tax.
*
* @var string
*/
protected $display_cart_prices_including_tax = false;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
* @param IntegrationRegistry $integration_registry Instance of the integration registry.
*/
public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_registry, IntegrationRegistry $integration_registry ) {
parent::__construct( $asset_api, $asset_data_registry, $integration_registry, $this->block_name );
}
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
*/
protected function initialize() {
parent::initialize();
add_action( 'wp_loaded', array( $this, 'register_empty_cart_message_block_pattern' ) );
add_action( 'wp_print_footer_scripts', array( $this, 'print_lazy_load_scripts' ), 2 );
}
/**
* Get the editor script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return array|string;
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
if ( is_cart() || is_checkout() ) {
return;
}
$script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
if ( is_cart() || is_checkout() ) {
return;
}
parent::enqueue_data( $attributes );
// Hydrate the following data depending on admin or frontend context.
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$label_info = $this->get_tax_label();
$this->tax_label = $label_info['tax_label'];
$this->display_cart_prices_including_tax = $label_info['display_cart_prices_including_tax'];
$this->asset_data_registry->add(
'taxLabel',
$this->tax_label,
''
);
}
$this->asset_data_registry->add(
'displayCartPricesIncludingTax',
$this->display_cart_prices_including_tax,
true
);
$template_part_edit_uri = '';
if (
current_user_can( 'edit_theme_options' ) &&
( wc_current_theme_is_fse_theme() || current_theme_supports( 'block-template-parts' ) )
) {
$theme_slug = BlockTemplateUtils::theme_has_template_part( 'mini-cart' ) ? wp_get_theme()->get_stylesheet() : BlockTemplateUtils::PLUGIN_SLUG;
if ( version_compare( get_bloginfo( 'version' ), '5.9', '<' ) ) {
$site_editor_uri = add_query_arg(
array( 'page' => 'gutenberg-edit-site' ),
admin_url( 'themes.php' )
);
} else {
$site_editor_uri = add_query_arg(
array(
'canvas' => 'edit',
'path' => '/template-parts/single',
),
admin_url( 'site-editor.php' )
);
}
$template_part_edit_uri = esc_url_raw(
add_query_arg(
array(
'postId' => sprintf( '%s//%s', $theme_slug, 'mini-cart' ),
'postType' => 'wp_template_part',
),
$site_editor_uri
)
);
}
$this->asset_data_registry->add(
'templatePartEditUri',
$template_part_edit_uri,
''
);
/**
* Fires after cart block data is registered.
*
* @since 5.8.0
*/
do_action( 'woocommerce_blocks_cart_enqueue_data' );
}
/**
* Prints the variable containing information about the scripts to lazy load.
*/
public function print_lazy_load_scripts() {
$script_data = $this->asset_api->get_script_data( 'build/mini-cart-component-frontend.js' );
$num_dependencies = count( $script_data['dependencies'] );
$wp_scripts = wp_scripts();
for ( $i = 0; $i < $num_dependencies; $i++ ) {
$dependency = $script_data['dependencies'][ $i ];
foreach ( $wp_scripts->registered as $script ) {
if ( $script->handle === $dependency ) {
$this->append_script_and_deps_src( $script );
break;
}
}
}
$payment_method_registry = Package::container()->get( PaymentMethodRegistry::class );
$payment_methods = $payment_method_registry->get_all_active_payment_method_script_dependencies();
foreach ( $payment_methods as $payment_method ) {
$payment_method_script = $this->get_script_from_handle( $payment_method );
if ( ! is_null( $payment_method_script ) ) {
$this->append_script_and_deps_src( $payment_method_script );
}
}
$this->scripts_to_lazy_load['wc-block-mini-cart-component-frontend'] = array(
'src' => $script_data['src'],
'version' => $script_data['version'],
'translations' => $this->get_inner_blocks_translations(),
);
$inner_blocks_frontend_scripts = array();
$cart = $this->get_cart_instance();
if ( $cart ) {
// Preload inner blocks frontend scripts.
$inner_blocks_frontend_scripts = $cart->is_empty() ? array(
'empty-cart-frontend',
'filled-cart-frontend',
'shopping-button-frontend',
) : array(
'empty-cart-frontend',
'filled-cart-frontend',
'title-frontend',
'items-frontend',
'footer-frontend',
'products-table-frontend',
'cart-button-frontend',
'checkout-button-frontend',
'title-label-frontend',
'title-items-counter-frontend',
);
}
foreach ( $inner_blocks_frontend_scripts as $inner_block_frontend_script ) {
$script_data = $this->asset_api->get_script_data( 'build/mini-cart-contents-block/' . $inner_block_frontend_script . '.js' );
$this->scripts_to_lazy_load[ 'wc-block-' . $inner_block_frontend_script ] = array(
'src' => $script_data['src'],
'version' => $script_data['version'],
);
}
$data = rawurlencode( wp_json_encode( $this->scripts_to_lazy_load ) );
$mini_cart_dependencies_script = "var wcBlocksMiniCartFrontendDependencies = JSON.parse( decodeURIComponent( '" . esc_js( $data ) . "' ) );";
wp_add_inline_script(
'wc-mini-cart-block-frontend',
$mini_cart_dependencies_script,
'before'
);
}
/**
* Returns the script data given its handle.
*
* @param string $handle Handle of the script.
*
* @return \_WP_Dependency|null Object containing the script data if found, or null.
*/
protected function get_script_from_handle( $handle ) {
$wp_scripts = wp_scripts();
foreach ( $wp_scripts->registered as $script ) {
if ( $script->handle === $handle ) {
return $script;
}
}
return null;
}
/**
* Recursively appends a scripts and its dependencies into the scripts_to_lazy_load array.
*
* @param \_WP_Dependency $script Object containing script data.
*/
protected function append_script_and_deps_src( $script ) {
$wp_scripts = wp_scripts();
// This script and its dependencies have already been appended.
if ( ! $script || array_key_exists( $script->handle, $this->scripts_to_lazy_load ) || wp_script_is( $script->handle, 'enqueued' ) ) {
return;
}
if ( count( $script->deps ) ) {
foreach ( $script->deps as $dep ) {
if ( ! array_key_exists( $dep, $this->scripts_to_lazy_load ) ) {
$dep_script = $this->get_script_from_handle( $dep );
if ( ! is_null( $dep_script ) ) {
$this->append_script_and_deps_src( $dep_script );
}
}
}
}
if ( ! $script->src ) {
return;
}
$site_url = site_url() ?? wp_guess_url();
if ( Utils::wp_version_compare( '6.3', '>=' ) ) {
$script_before = $wp_scripts->get_inline_script_data( $script->handle, 'before' );
$script_after = $wp_scripts->get_inline_script_data( $script->handle, 'after' );
} else {
$script_before = $wp_scripts->print_inline_script( $script->handle, 'before', false );
$script_after = $wp_scripts->print_inline_script( $script->handle, 'after', false );
}
$this->scripts_to_lazy_load[ $script->handle ] = array(
'src' => preg_match( '|^(https?:)?//|', $script->src ) ? $script->src : $site_url . $script->src,
'version' => $script->ver,
'before' => $script_before,
'after' => $script_after,
'translations' => $wp_scripts->print_translations( $script->handle, false ),
);
}
/**
* Returns the markup for the cart price.
*
* @param array $attributes Block attributes.
*
* @return string
*/
protected function get_cart_price_markup( $attributes ) {
if ( isset( $attributes['hasHiddenPrice'] ) && false !== $attributes['hasHiddenPrice'] ) {
return;
}
$price_color = array_key_exists( 'priceColor', $attributes ) ? $attributes['priceColor']['color'] : '';
return '<span class="wc-block-mini-cart__amount" style="color:' . esc_attr( $price_color ) . ' "></span>' . $this->get_include_tax_label_markup( $attributes );
}
/**
* Returns the markup for render the tax label.
*
* @param array $attributes Block attributes.
*
* @return string
*/
protected function get_include_tax_label_markup( $attributes ) {
if ( empty( $this->tax_label ) ) {
return '';
}
$price_color = array_key_exists( 'priceColor', $attributes ) ? $attributes['priceColor']['color'] : '';
return '<small class="wc-block-mini-cart__tax-label" style="color:' . esc_attr( $price_color ) . ' " hidden>' . esc_html( $this->tax_label ) . '</small>';
}
/**
* Append frontend scripts when rendering the Mini-Cart block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
return $content . $this->get_markup( MiniCartUtils::migrate_attributes_to_color_panel( $attributes ) );
}
/**
* Render the markup for the Mini-Cart block.
*
* @param array $attributes Block attributes.
*
* @return string The HTML markup.
*/
protected function get_markup( $attributes ) {
if ( is_admin() || WC()->is_rest_api_request() ) {
// In the editor we will display the placeholder, so no need to load
// real cart data and to print the markup.
return '';
}
$classes_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array( 'text_color', 'background_color', 'font_size', 'font_weight', 'font_family' ) );
$wrapper_classes = sprintf( 'wc-block-mini-cart wp-block-woocommerce-mini-cart %s', $classes_styles['classes'] );
if ( ! empty( $attributes['className'] ) ) {
$wrapper_classes .= ' ' . $attributes['className'];
}
$wrapper_styles = $classes_styles['styles'];
$icon_color = array_key_exists( 'iconColor', $attributes ) ? esc_attr( $attributes['iconColor']['color'] ) : 'currentColor';
$product_count_color = array_key_exists( 'productCountColor', $attributes ) ? esc_attr( $attributes['productCountColor']['color'] ) : '';
// Default "Cart" icon.
$icon = '<svg class="wc-block-mini-cart__icon" width="32" height="32" viewBox="0 0 32 32" fill="' . $icon_color . '" xmlns="http://www.w3.org/2000/svg">
<circle cx="12.6667" cy="24.6667" r="2" fill="' . $icon_color . '"/>
<circle cx="23.3333" cy="24.6667" r="2" fill="' . $icon_color . '"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.28491 10.0356C9.47481 9.80216 9.75971 9.66667 10.0606 9.66667H25.3333C25.6232 9.66667 25.8989 9.79247 26.0888 10.0115C26.2787 10.2305 26.3643 10.5211 26.3233 10.8081L24.99 20.1414C24.9196 20.6341 24.4977 21 24 21H12C11.5261 21 11.1173 20.6674 11.0209 20.2034L9.08153 10.8701C9.02031 10.5755 9.09501 10.269 9.28491 10.0356ZM11.2898 11.6667L12.8136 19H23.1327L24.1803 11.6667H11.2898Z" fill="' . $icon_color . '"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.66669 6.66667C5.66669 6.11438 6.1144 5.66667 6.66669 5.66667H9.33335C9.81664 5.66667 10.2308 6.01229 10.3172 6.48778L11.0445 10.4878C11.1433 11.0312 10.7829 11.5517 10.2395 11.6505C9.69614 11.7493 9.17555 11.3889 9.07676 10.8456L8.49878 7.66667H6.66669C6.1144 7.66667 5.66669 7.21895 5.66669 6.66667Z" fill="' . $icon_color . '"/>
</svg>';
if ( isset( $attributes['miniCartIcon'] ) ) {
if ( 'bag' === $attributes['miniCartIcon'] ) {
$icon = '<svg class="wc-block-mini-cart__icon" width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4444 14.2222C12.9354 14.2222 13.3333 14.6202 13.3333 15.1111C13.3333 15.8183 13.6143 16.4966 14.1144 16.9967C14.6145 17.4968 15.2927 17.7778 16 17.7778C16.7072 17.7778 17.3855 17.4968 17.8856 16.9967C18.3857 16.4966 18.6667 15.8183 18.6667 15.1111C18.6667 14.6202 19.0646 14.2222 19.5555 14.2222C20.0465 14.2222 20.4444 14.6202 20.4444 15.1111C20.4444 16.2898 19.9762 17.4203 19.1427 18.2538C18.3092 19.0873 17.1787 19.5555 16 19.5555C14.8212 19.5555 13.6908 19.0873 12.8573 18.2538C12.0238 17.4203 11.5555 16.2898 11.5555 15.1111C11.5555 14.6202 11.9535 14.2222 12.4444 14.2222Z" fill="' . $icon_color . '""/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.2408 6.68254C11.4307 6.46089 11.7081 6.33333 12 6.33333H20C20.2919 6.33333 20.5693 6.46089 20.7593 6.68254L24.7593 11.3492C25.0134 11.6457 25.0717 12.0631 24.9085 12.4179C24.7453 12.7727 24.3905 13 24 13H8.00001C7.60948 13 7.25469 12.7727 7.0915 12.4179C6.92832 12.0631 6.9866 11.6457 7.24076 11.3492L11.2408 6.68254ZM12.4599 8.33333L10.1742 11H21.8258L19.5401 8.33333H12.4599Z" fill="' . $icon_color . '"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 12C7 11.4477 7.44772 11 8 11H24C24.5523 11 25 11.4477 25 12V25.3333C25 25.8856 24.5523 26.3333 24 26.3333H8C7.44772 26.3333 7 25.8856 7 25.3333V12ZM9 13V24.3333H23V13H9Z" fill="' . $icon_color . '"/>
</svg>';
} elseif ( 'bag-alt' === $attributes['miniCartIcon'] ) {
$icon = '<svg class="wc-block-mini-cart__icon" width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5556 12.3333C19.0646 12.3333 18.6667 11.9354 18.6667 11.4444C18.6667 10.7372 18.3857 8.05893 17.8856 7.55883C17.3855 7.05873 16.7073 6.77778 16 6.77778C15.2928 6.77778 14.6145 7.05873 14.1144 7.55883C13.6143 8.05893 13.3333 10.7372 13.3333 11.4444C13.3333 11.9354 12.9354 12.3333 12.4445 12.3333C11.9535 12.3333 11.5556 11.9354 11.5556 11.4444C11.5556 10.2657 12.0238 7.13524 12.8573 6.30175C13.6908 5.46825 14.8213 5 16 5C17.1788 5 18.3092 5.46825 19.1427 6.30175C19.9762 7.13524 20.4445 10.2657 20.4445 11.4444C20.4445 11.9354 20.0465 12.3333 19.5556 12.3333Z" fill="' . $icon_color . '"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.5 12C7.5 11.4477 7.94772 11 8.5 11H23.5C24.0523 11 24.5 11.4477 24.5 12V25.3333C24.5 25.8856 24.0523 26.3333 23.5 26.3333H8.5C7.94772 26.3333 7.5 25.8856 7.5 25.3333V12ZM9.5 13V24.3333H22.5V13H9.5Z" fill="' . $icon_color . '" />
</svg>';
}
}
$button_html = $this->get_cart_price_markup( $attributes ) . '
<span class="wc-block-mini-cart__quantity-badge">
' . $icon . '
<span class="wc-block-mini-cart__badge" style="background:' . $product_count_color . '"></span>
</span>';
if ( is_cart() || is_checkout() ) {
if ( $this->should_not_render_mini_cart( $attributes ) ) {
return '';
}
// It is not necessary to load the Mini-Cart Block on Cart and Checkout page.
return '<div class="' . esc_attr( $wrapper_classes ) . '" style="visibility:hidden" aria-hidden="true">
<button class="wc-block-mini-cart__button" disabled>' . $button_html . '</button>
</div>';
}
$template_part_contents = '';
// Determine if we need to load the template part from the DB, the theme or WooCommerce in that order.
$templates_from_db = BlockTemplateUtils::get_block_templates_from_db( array( 'mini-cart' ), 'wp_template_part' );
if ( count( $templates_from_db ) > 0 ) {
$template_slug_to_load = $templates_from_db[0]->theme;
} else {
$theme_has_mini_cart = BlockTemplateUtils::theme_has_template_part( 'mini-cart' );
$template_slug_to_load = $theme_has_mini_cart ? get_stylesheet() : BlockTemplateUtils::PLUGIN_SLUG;
}
$template_part = BlockTemplateUtils::get_block_template( $template_slug_to_load . '//mini-cart', 'wp_template_part' );
if ( $template_part && ! empty( $template_part->content ) ) {
$template_part_contents = do_blocks( $template_part->content );
}
if ( '' === $template_part_contents ) {
$template_part_contents = do_blocks(
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
file_get_contents( Package::get_path() . 'templates/' . BlockTemplateUtils::DIRECTORY_NAMES['TEMPLATE_PARTS'] . '/mini-cart.html' )
);
}
return '<div class="' . esc_attr( $wrapper_classes ) . '" style="' . esc_attr( $wrapper_styles ) . '">
<button class="wc-block-mini-cart__button">' . $button_html . '</button>
<div class="is-loading wc-block-components-drawer__screen-overlay wc-block-components-drawer__screen-overlay--is-hidden" aria-hidden="true">
<div class="wc-block-mini-cart__drawer wc-block-components-drawer">
<div class="wc-block-components-drawer__content">
<div class="wc-block-mini-cart__template-part">'
. wp_kses_post( $template_part_contents ) .
'</div>
</div>
</div>
</div>
</div>';
}
/**
* Return the main instance of WC_Cart class.
*
* @return \WC_Cart CartController class instance.
*/
protected function get_cart_instance() {
$cart = WC()->cart;
if ( $cart && $cart instanceof \WC_Cart ) {
return $cart;
}
return null;
}
/**
* Get array with data for handle the tax label.
* the entire logic of this function is was taken from:
* https://github.com/woocommerce/woocommerce/blob/e730f7463c25b50258e97bf56e31e9d7d3bc7ae7/includes/class-wc-cart.php#L1582
*
* @return array;
*/
protected function get_tax_label() {
$cart = $this->get_cart_instance();
if ( $cart && $cart->display_prices_including_tax() ) {
if ( ! wc_prices_include_tax() ) {
$tax_label = WC()->countries->inc_tax_or_vat();
$display_cart_prices_including_tax = true;
return array(
'tax_label' => $tax_label,
'display_cart_prices_including_tax' => $display_cart_prices_including_tax,
);
}
return array(
'tax_label' => '',
'display_cart_prices_including_tax' => true,
);
}
if ( wc_prices_include_tax() ) {
$tax_label = WC()->countries->ex_tax_or_vat();
return array(
'tax_label' => $tax_label,
'display_cart_prices_including_tax' => false,
);
};
return array(
'tax_label' => '',
'display_cart_prices_including_tax' => false,
);
}
/**
* Prepare translations for inner blocks and dependencies.
*/
protected function get_inner_blocks_translations() {
$wp_scripts = wp_scripts();
$translations = array();
$chunks = $this->get_chunks_paths( $this->chunks_folder );
$vendor_chunks = $this->get_chunks_paths( 'vendors--mini-cart-contents-block' );
$shared_chunks = [ 'cart-blocks/cart-line-items--mini-cart-contents-block/products-table-frontend' ];
foreach ( array_merge( $chunks, $vendor_chunks, $shared_chunks ) as $chunk ) {
$handle = 'wc-blocks-' . $chunk . '-chunk';
$this->asset_api->register_script( $handle, $this->asset_api->get_block_asset_build_path( $chunk ), [], true );
$translations[] = $wp_scripts->print_translations( $handle, false );
wp_deregister_script( $handle );
}
$translations = array_filter( $translations );
return implode( '', $translations );
}
/**
* Register block pattern for Empty Cart Message to make it translatable.
*/
public function register_empty_cart_message_block_pattern() {
register_block_pattern(
'woocommerce/mini-cart-empty-cart-message',
array(
'title' => __( 'Empty Mini-Cart Message', 'woocommerce' ),
'inserter' => false,
'content' => '<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center"><strong>' . __( 'Your cart is currently empty!', 'woocommerce' ) . '</strong></p><!-- /wp:paragraph -->',
)
);
}
/**
* Returns whether the Mini-Cart should be rendered or not.
*
* @param array $attributes Block attributes.
*
* @return bool
*/
public function should_not_render_mini_cart( array $attributes ) {
return isset( $attributes['cartAndCheckoutRenderStyle'] ) && 'hidden' !== $attributes['cartAndCheckoutRenderStyle'];
}
}
BlockTypes/MiniCartCartButtonBlock.php 0000644 00000000410 15154173073 0014024 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartCartButtonBlock class.
*/
class MiniCartCartButtonBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-cart-button-block';
}
BlockTypes/MiniCartCheckoutButtonBlock.php 0000644 00000000424 15154173073 0014705 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartCheckoutButtonBlock class.
*/
class MiniCartCheckoutButtonBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-checkout-button-block';
}
BlockTypes/MiniCartContents.php 0000644 00000012034 15154173073 0012566 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* Mini-Cart Contents class.
*
* @internal
*/
class MiniCartContents extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-contents';
/**
* Get the editor script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*
* @return array|string;
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*
* @return null
*/
protected function get_block_type_script( $key = null ) {
// The frontend script is a dependency of the Mini-Cart block so it's
// already lazy-loaded.
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* Render the markup for the Mini-Cart Contents block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( is_admin() || WC()->is_rest_api_request() ) {
// In the editor we will display the placeholder, so no need to
// print the markup.
return '';
}
return $content;
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @param array $attributes Any attributes that currently are available from the block.
* @param string $content The block content.
* @param WP_Block $block The block object.
*/
protected function enqueue_assets( array $attributes, $content, $block ) {
parent::enqueue_assets( $attributes, $content, $block );
$text_color = StyleAttributesUtils::get_text_color_class_and_style( $attributes );
$bg_color = StyleAttributesUtils::get_background_color_class_and_style( $attributes );
$styles = array(
array(
'selector' => array(
'.wc-block-mini-cart__footer .wc-block-mini-cart__footer-actions .wc-block-mini-cart__footer-checkout',
'.wc-block-mini-cart__footer .wc-block-mini-cart__footer-actions .wc-block-mini-cart__footer-checkout:hover',
'.wc-block-mini-cart__footer .wc-block-mini-cart__footer-actions .wc-block-mini-cart__footer-checkout:focus',
'.wc-block-mini-cart__footer .wc-block-mini-cart__footer-actions .wc-block-mini-cart__footer-cart.wc-block-components-button:hover',
'.wc-block-mini-cart__footer .wc-block-mini-cart__footer-actions .wc-block-mini-cart__footer-cart.wc-block-components-button:focus',
'.wc-block-mini-cart__shopping-button a:hover',
'.wc-block-mini-cart__shopping-button a:focus',
),
'properties' => array(
array(
'property' => 'color',
'value' => $bg_color ? $bg_color['value'] : false,
),
array(
'property' => 'border-color',
'value' => $text_color ? $text_color['value'] : false,
),
array(
'property' => 'background-color',
'value' => $text_color ? $text_color['value'] : false,
),
),
),
);
$parsed_style = '';
if ( array_key_exists( 'width', $attributes ) ) {
$parsed_style .= ':root{--drawer-width: ' . esc_html( $attributes['width'] ) . '}';
}
foreach ( $styles as $style ) {
$selector = is_array( $style['selector'] ) ? implode( ',', $style['selector'] ) : $style['selector'];
$properties = array_filter(
$style['properties'],
function( $property ) {
return $property['value'];
}
);
if ( ! empty( $properties ) ) {
$parsed_style .= $selector . '{';
foreach ( $properties as $property ) {
$parsed_style .= sprintf( '%1$s:%2$s;', $property['property'], $property['value'] );
}
$parsed_style .= '}';
}
}
wp_add_inline_style(
'wc-blocks-style',
$parsed_style
);
}
/**
* Get list of Mini-Cart Contents block & its inner-block types.
*
* @return array;
*/
public static function get_mini_cart_block_types() {
$block_types = [];
$block_types[] = 'MiniCartContents';
$block_types[] = 'EmptyMiniCartContentsBlock';
$block_types[] = 'FilledMiniCartContentsBlock';
$block_types[] = 'MiniCartFooterBlock';
$block_types[] = 'MiniCartItemsBlock';
$block_types[] = 'MiniCartProductsTableBlock';
$block_types[] = 'MiniCartShoppingButtonBlock';
$block_types[] = 'MiniCartCartButtonBlock';
$block_types[] = 'MiniCartCheckoutButtonBlock';
$block_types[] = 'MiniCartTitleBlock';
$block_types[] = 'MiniCartTitleItemsCounterBlock';
$block_types[] = 'MiniCartTitleLabelBlock';
return $block_types;
}
}
BlockTypes/MiniCartFooterBlock.php 0000644 00000000373 15154173073 0013205 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartFooterBlock class.
*/
class MiniCartFooterBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-footer-block';
}
BlockTypes/MiniCartItemsBlock.php 0000644 00000000370 15154173073 0013025 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartItemsBlock class.
*/
class MiniCartItemsBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-items-block';
}
BlockTypes/MiniCartProductsTableBlock.php 0000644 00000000421 15154173073 0014514 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartProductsTableBlock class.
*/
class MiniCartProductsTableBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-products-table-block';
}
BlockTypes/MiniCartShoppingButtonBlock.php 0000644 00000000424 15154173073 0014727 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartShoppingButtonBlock class.
*/
class MiniCartShoppingButtonBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-shopping-button-block';
}
BlockTypes/MiniCartTitleBlock.php 0000644 00000000370 15154173073 0013025 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartTitleBlock class.
*/
class MiniCartTitleBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-title-block';
}
BlockTypes/MiniCartTitleItemsCounterBlock.php 0000644 00000000436 15154173073 0015372 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartTitleItemsCounterBlock class.
*/
class MiniCartTitleItemsCounterBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-title-items-counter-block';
}
BlockTypes/MiniCartTitleLabelBlock.php 0000644 00000000410 15154173073 0013760 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* MiniCartTitleLabelBlock class.
*/
class MiniCartTitleLabelBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart-title-label-block';
}
BlockTypes/PriceFilter.php 0000644 00000000465 15154173073 0011557 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* PriceFilter class.
*/
class PriceFilter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'price-filter';
const MIN_PRICE_QUERY_VAR = 'min_price';
const MAX_PRICE_QUERY_VAR = 'max_price';
}
BlockTypes/ProceedToCheckoutBlock.php 0000644 00000001417 15154173073 0013672 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProceedToCheckoutBlock class.
*/
class ProceedToCheckoutBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'proceed-to-checkout-block';
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
$this->asset_data_registry->register_page_id( isset( $attributes['checkoutPageId'] ) ? $attributes['checkoutPageId'] : 0 );
}
}
BlockTypes/ProductAddToCart.php 0000644 00000001171 15154173073 0012510 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductAddToCart class.
*/
class ProductAddToCart extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-add-to-cart';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
}
}
BlockTypes/ProductAverageRating.php 0000644 00000005326 15154173073 0013430 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductAverageRating class.
*/
class ProductAverageRating extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-average-rating';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'text' => true,
'background' => true,
'__experimentalSkipSerialization' => true,
),
'spacing' =>
array(
'margin' => true,
'padding' => true,
'__experimentalSkipSerialization' => true,
),
'typography' =>
array(
'fontSize' => true,
'__experimentalFontWeight' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-average-rating',
);
}
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
if ( ! $product || ! $product->get_review_count() ) {
return '';
}
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$text_align_styles_and_classes = StyleAttributesUtils::get_text_align_class_and_style( $attributes );
return sprintf(
'<div class="wc-block-components-product-average-rating-counter %1$s %2$s" style="%3$s">%4$s</div>',
esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ),
$product->get_average_rating()
);
}
}
BlockTypes/ProductBestSellers.php 0000644 00000000674 15154173073 0013141 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductBestSellers class.
*/
class ProductBestSellers extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-best-sellers';
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
$query_args['orderby'] = 'popularity';
}
}
BlockTypes/ProductButton.php 0000644 00000021523 15154173073 0012161 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductButton class.
*/
class ProductButton extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-button';
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-interactivity-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-interactivity-frontend' ),
'dependencies' => [ 'wc-interactivity' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @param array $attributes Any attributes that currently are available from the block.
* @param string $content The block content.
* @param WP_Block $block The block object.
*/
protected function enqueue_assets( array $attributes, $content, $block ) {
parent::enqueue_assets( $attributes, $content, $block );
if ( wc_current_theme_is_fse_theme() ) {
add_action(
'wp_enqueue_scripts',
array( $this, 'dequeue_add_to_cart_scripts' )
);
} else {
$this->dequeue_add_to_cart_scripts();
}
}
/**
* Dequeue the add-to-cart script.
* The block uses Interactivity API, it isn't necessary enqueue the add-to-cart script.
*/
public function dequeue_add_to_cart_scripts() {
wp_dequeue_script( 'wc-add-to-cart' );
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
$product = wc_get_product( $post_id );
if ( $product ) {
$number_of_items_in_cart = $this->get_cart_item_quantities_by_product_id( $product->get_id() );
$more_than_one_item = $number_of_items_in_cart > 0;
$initial_product_text = $more_than_one_item ? sprintf(
/* translators: %s: product number. */
__( '%s in cart', 'woocommerce' ),
$number_of_items_in_cart
) : $product->add_to_cart_text();
$cart_redirect_after_add = get_option( 'woocommerce_cart_redirect_after_add' ) === 'yes';
$ajax_add_to_cart_enabled = get_option( 'woocommerce_enable_ajax_add_to_cart' ) === 'yes';
$is_ajax_button = $ajax_add_to_cart_enabled && ! $cart_redirect_after_add && $product->supports( 'ajax_add_to_cart' ) && $product->is_purchasable() && $product->is_in_stock();
$html_element = $is_ajax_button ? 'button' : 'a';
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$classname = $attributes['className'] ?? '';
$custom_width_classes = isset( $attributes['width'] ) ? 'has-custom-width wp-block-button__width-' . $attributes['width'] : '';
$custom_align_classes = isset( $attributes['textAlign'] ) ? 'align-' . $attributes['textAlign'] : '';
$html_classes = implode(
' ',
array_filter(
array(
'wp-block-button__link',
'wp-element-button',
'wc-block-components-product-button__button',
$product->is_purchasable() && $product->is_in_stock() ? 'add_to_cart_button' : '',
$is_ajax_button ? 'ajax_add_to_cart' : '',
'product_type_' . $product->get_type(),
esc_attr( $styles_and_classes['classes'] ),
)
)
);
wc_store(
array(
'state' => array(
'woocommerce' => array(
'inTheCartText' => sprintf(
/* translators: %s: product number. */
__( '%s in cart', 'woocommerce' ),
'###'
),
),
),
)
);
$default_quantity = 1;
$context = array(
'woocommerce' => array(
/**
* Filters the change the quantity to add to cart.
*
* @since 10.9.0
* @param number $default_quantity The default quantity.
* @param number $product_id The product id.
*/
'quantityToAdd' => apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() ),
'productId' => $product->get_id(),
'addToCartText' => null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woocommerce' ),
'temporaryNumberOfItems' => $number_of_items_in_cart,
'animationStatus' => 'IDLE',
),
);
/**
* Allow filtering of the add to cart button arguments.
*
* @since 9.7.0
*/
$args = apply_filters(
'woocommerce_loop_add_to_cart_args',
array(
'class' => $html_classes,
'attributes' => array(
'data-product_id' => $product->get_id(),
'data-product_sku' => $product->get_sku(),
'aria-label' => $product->add_to_cart_description(),
'rel' => 'nofollow',
),
),
$product
);
if ( isset( $args['attributes']['aria-label'] ) ) {
$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
}
if ( isset( WC()->cart ) && ! WC()->cart->is_empty() ) {
$this->prevent_cache();
}
$div_directives = 'data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\'';
$button_directives = '
data-wc-on--click="actions.woocommerce.addToCart"
data-wc-class--loading="context.woocommerce.isLoading"
';
$span_button_directives = '
data-wc-text="selectors.woocommerce.addToCartText"
data-wc-class--wc-block-slide-in="selectors.woocommerce.slideInAnimation"
data-wc-class--wc-block-slide-out="selectors.woocommerce.slideOutAnimation"
data-wc-layout-init="init.woocommerce.syncTemporaryNumberOfItemsOnLoad"
data-wc-effect="effects.woocommerce.startAnimation"
data-wc-on--animationend="actions.woocommerce.handleAnimationEnd"
';
/**
* Filters the add to cart button class.
*
* @since 8.7.0
*
* @param string $class The class.
*/
return apply_filters(
'woocommerce_loop_add_to_cart_link',
strtr(
'<div data-wc-interactive class="wp-block-button wc-block-components-product-button {classes} {custom_classes}"
{div_directives}
>
<{html_element}
href="{add_to_cart_url}"
class="{button_classes}"
style="{button_styles}"
{attributes}
{button_directives}
>
<span {span_button_directives}> {add_to_cart_text} </span>
</{html_element}>
{view_cart_html}
</div>',
array(
'{classes}' => esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
'{custom_classes}' => esc_attr( $classname . ' ' . $custom_width_classes . ' ' . $custom_align_classes ),
'{html_element}' => $html_element,
'{add_to_cart_url}' => esc_url( $product->add_to_cart_url() ),
'{button_classes}' => isset( $args['class'] ) ? esc_attr( $args['class'] ) : '',
'{button_styles}' => esc_attr( $styles_and_classes['styles'] ),
'{attributes}' => isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '',
'{add_to_cart_text}' => esc_html( $initial_product_text ),
'{div_directives}' => $is_ajax_button ? $div_directives : '',
'{button_directives}' => $is_ajax_button ? $button_directives : '',
'{span_button_directives}' => $is_ajax_button ? $span_button_directives : '',
'{view_cart_html}' => $is_ajax_button ? $this->get_view_cart_html() : '',
)
),
$product,
$args
);
}
}
/**
* Get the number of items in the cart for a given product id.
*
* @param number $product_id The product id.
* @return number The number of items in the cart.
*/
private function get_cart_item_quantities_by_product_id( $product_id ) {
if ( ! isset( WC()->cart ) ) {
return 0;
}
$cart = WC()->cart->get_cart_item_quantities();
return isset( $cart[ $product_id ] ) ? $cart[ $product_id ] : 0;
}
/**
* Prevent caching on certain pages
*/
private function prevent_cache() {
\WC_Cache_Helper::set_nocache_constants();
nocache_headers();
}
/**
* Get the view cart link html.
*
* @return string The view cart html.
*/
private function get_view_cart_html() {
return sprintf(
'<span hidden data-wc-bind--hidden="!selectors.woocommerce.displayViewCart">
<a
href="%1$s"
class="added_to_cart wc_forward"
title="%2$s"
>
%2$s
</a>
</span>',
wc_get_cart_url(),
__( 'View cart', 'woocommerce' )
);
}
}
BlockTypes/ProductCategories.php 0000644 00000031520 15154173073 0012771 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductCategories class.
*/
class ProductCategories extends AbstractDynamicBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-categories';
/**
* Default attribute values, should match what's set in JS `registerBlockType`.
*
* @var array
*/
protected $defaults = array(
'hasCount' => true,
'hasImage' => false,
'hasEmpty' => false,
'isDropdown' => false,
'isHierarchical' => true,
'showChildrenOnly' => false,
);
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array_merge(
parent::get_block_type_attributes(),
array(
'align' => $this->get_schema_align(),
'className' => $this->get_schema_string(),
'hasCount' => $this->get_schema_boolean( true ),
'hasImage' => $this->get_schema_boolean( false ),
'hasEmpty' => $this->get_schema_boolean( false ),
'isDropdown' => $this->get_schema_boolean( false ),
'isHierarchical' => $this->get_schema_boolean( true ),
'showChildrenOnly' => $this->get_schema_boolean( false ),
'textColor' => $this->get_schema_string(),
'fontSize' => $this->get_schema_string(),
'lineHeight' => $this->get_schema_string(),
'style' => array( 'type' => 'object' ),
)
);
}
/**
* Render the Product Categories List block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$uid = uniqid( 'product-categories-' );
$categories = $this->get_categories( $attributes );
if ( empty( $categories ) ) {
return '';
}
if ( ! empty( $content ) ) {
// Deal with legacy attributes (before this was an SSR block) that differ from defaults.
if ( strstr( $content, 'data-has-count="false"' ) ) {
$attributes['hasCount'] = false;
}
if ( strstr( $content, 'data-is-dropdown="true"' ) ) {
$attributes['isDropdown'] = true;
}
if ( strstr( $content, 'data-is-hierarchical="false"' ) ) {
$attributes['isHierarchical'] = false;
}
if ( strstr( $content, 'data-has-empty="true"' ) ) {
$attributes['hasEmpty'] = true;
}
}
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes(
$attributes,
array( 'line_height', 'text_color', 'font_size' )
);
$classes = $this->get_container_classes( $attributes ) . ' ' . $classes_and_styles['classes'];
$styles = $classes_and_styles['styles'];
$output = '<div class="wp-block-woocommerce-product-categories ' . esc_attr( $classes ) . '" style="' . esc_attr( $styles ) . '">';
$output .= ! empty( $attributes['isDropdown'] ) ? $this->renderDropdown( $categories, $attributes, $uid ) : $this->renderList( $categories, $attributes, $uid );
$output .= '</div>';
return $output;
}
/**
* Get the list of classes to apply to this block.
*
* @param array $attributes Block attributes. Default empty array.
* @return string space-separated list of classes.
*/
protected function get_container_classes( $attributes = array() ) {
$classes = array( 'wc-block-product-categories' );
if ( isset( $attributes['align'] ) ) {
$classes[] = "align{$attributes['align']}";
}
if ( ! empty( $attributes['className'] ) ) {
$classes[] = $attributes['className'];
}
if ( $attributes['isDropdown'] ) {
$classes[] = 'is-dropdown';
} else {
$classes[] = 'is-list';
}
return implode( ' ', $classes );
}
/**
* Get categories (terms) from the db.
*
* @param array $attributes Block attributes. Default empty array.
* @return array
*/
protected function get_categories( $attributes ) {
$hierarchical = wc_string_to_bool( $attributes['isHierarchical'] );
$children_only = wc_string_to_bool( $attributes['showChildrenOnly'] ) && is_product_category();
if ( $children_only ) {
$term_id = get_queried_object_id();
$categories = get_terms(
'product_cat',
[
'hide_empty' => ! $attributes['hasEmpty'],
'pad_counts' => true,
'hierarchical' => true,
'child_of' => $term_id,
]
);
} else {
$categories = get_terms(
'product_cat',
[
'hide_empty' => ! $attributes['hasEmpty'],
'pad_counts' => true,
'hierarchical' => true,
]
);
}
if ( ! is_array( $categories ) || empty( $categories ) ) {
return [];
}
// This ensures that no categories with a product count of 0 is rendered.
if ( ! $attributes['hasEmpty'] ) {
$categories = array_filter(
$categories,
function( $category ) {
return 0 !== $category->count;
}
);
}
return $hierarchical ? $this->build_category_tree( $categories, $children_only ) : $categories;
}
/**
* Build hierarchical tree of categories.
*
* @param array $categories List of terms.
* @param bool $children_only Is the block rendering only the children of the current category.
* @return array
*/
protected function build_category_tree( $categories, $children_only ) {
$categories_by_parent = [];
foreach ( $categories as $category ) {
if ( ! isset( $categories_by_parent[ 'cat-' . $category->parent ] ) ) {
$categories_by_parent[ 'cat-' . $category->parent ] = [];
}
$categories_by_parent[ 'cat-' . $category->parent ][] = $category;
}
$parent_id = $children_only ? get_queried_object_id() : 0;
$tree = $categories_by_parent[ 'cat-' . $parent_id ]; // these are top level categories. So all parents.
unset( $categories_by_parent[ 'cat-' . $parent_id ] );
foreach ( $tree as $category ) {
if ( ! empty( $categories_by_parent[ 'cat-' . $category->term_id ] ) ) {
$category->children = $this->fill_category_children( $categories_by_parent[ 'cat-' . $category->term_id ], $categories_by_parent );
}
}
return $tree;
}
/**
* Build hierarchical tree of categories by appending children in the tree.
*
* @param array $categories List of terms.
* @param array $categories_by_parent List of terms grouped by parent.
* @return array
*/
protected function fill_category_children( $categories, $categories_by_parent ) {
foreach ( $categories as $category ) {
if ( ! empty( $categories_by_parent[ 'cat-' . $category->term_id ] ) ) {
$category->children = $this->fill_category_children( $categories_by_parent[ 'cat-' . $category->term_id ], $categories_by_parent );
}
}
return $categories;
}
/**
* Render the category list as a dropdown.
*
* @param array $categories List of terms.
* @param array $attributes Block attributes. Default empty array.
* @param int $uid Unique ID for the rendered block, used for HTML IDs.
* @return string Rendered output.
*/
protected function renderDropdown( $categories, $attributes, $uid ) {
$aria_label = empty( $attributes['hasCount'] ) ?
__( 'List of categories', 'woocommerce' ) :
__( 'List of categories with their product counts', 'woocommerce' );
$output = '
<div class="wc-block-product-categories__dropdown">
<label
class="screen-reader-text"
for="' . esc_attr( $uid ) . '-select"
>
' . esc_html__( 'Select a category', 'woocommerce' ) . '
</label>
<select aria-label="' . esc_attr( $aria_label ) . '" id="' . esc_attr( $uid ) . '-select">
<option value="false" hidden>
' . esc_html__( 'Select a category', 'woocommerce' ) . '
</option>
' . $this->renderDropdownOptions( $categories, $attributes, $uid ) . '
</select>
</div>
<button
type="button"
class="wc-block-product-categories__button"
aria-label="' . esc_html__( 'Go to category', 'woocommerce' ) . '"
onclick="const url = document.getElementById( \'' . esc_attr( $uid ) . '-select\' ).value; if ( \'false\' !== url ) document.location.href = url;"
>
<svg
aria-hidden="true"
role="img"
focusable="false"
class="dashicon dashicons-arrow-right-alt2"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path d="M6 15l5-5-5-5 1-2 7 7-7 7z" />
</svg>
</button>
';
return $output;
}
/**
* Render dropdown options list.
*
* @param array $categories List of terms.
* @param array $attributes Block attributes. Default empty array.
* @param int $uid Unique ID for the rendered block, used for HTML IDs.
* @param int $depth Current depth.
* @return string Rendered output.
*/
protected function renderDropdownOptions( $categories, $attributes, $uid, $depth = 0 ) {
$output = '';
foreach ( $categories as $category ) {
$output .= '
<option value="' . esc_attr( get_term_link( $category->term_id, 'product_cat' ) ) . '">
' . str_repeat( '−', $depth ) . '
' . esc_html( $category->name ) . '
' . $this->getCount( $category, $attributes ) . '
</option>
' . ( ! empty( $category->children ) ? $this->renderDropdownOptions( $category->children, $attributes, $uid, $depth + 1 ) : '' ) . '
';
}
return $output;
}
/**
* Render the category list as a list.
*
* @param array $categories List of terms.
* @param array $attributes Block attributes. Default empty array.
* @param int $uid Unique ID for the rendered block, used for HTML IDs.
* @param int $depth Current depth.
* @return string Rendered output.
*/
protected function renderList( $categories, $attributes, $uid, $depth = 0 ) {
$classes = [
'wc-block-product-categories-list',
'wc-block-product-categories-list--depth-' . absint( $depth ),
];
if ( ! empty( $attributes['hasImage'] ) ) {
$classes[] = 'wc-block-product-categories-list--has-images';
}
$output = '<ul class="' . esc_attr( implode( ' ', $classes ) ) . '">' . $this->renderListItems( $categories, $attributes, $uid, $depth ) . '</ul>';
return $output;
}
/**
* Render a list of terms.
*
* @param array $categories List of terms.
* @param array $attributes Block attributes. Default empty array.
* @param int $uid Unique ID for the rendered block, used for HTML IDs.
* @param int $depth Current depth.
* @return string Rendered output.
*/
protected function renderListItems( $categories, $attributes, $uid, $depth = 0 ) {
$output = '';
$link_color_class_and_style = StyleAttributesUtils::get_link_color_class_and_style( $attributes );
$link_color_style = isset( $link_color_class_and_style['style'] ) ? $link_color_class_and_style['style'] : '';
foreach ( $categories as $category ) {
$output .= '
<li class="wc-block-product-categories-list-item">
<a style="' . esc_attr( $link_color_style ) . '" href="' . esc_attr( get_term_link( $category->term_id, 'product_cat' ) ) . '">'
. $this->get_image_html( $category, $attributes )
. '<span class="wc-block-product-categories-list-item__name">' . esc_html( $category->name ) . '</span>'
. '</a>'
. $this->getCount( $category, $attributes )
. ( ! empty( $category->children ) ? $this->renderList( $category->children, $attributes, $uid, $depth + 1 ) : '' ) . '
</li>
';
}
return preg_replace( '/\r|\n/', '', $output );
}
/**
* Returns the category image html
*
* @param \WP_Term $category Term object.
* @param array $attributes Block attributes. Default empty array.
* @param string $size Image size, defaults to 'woocommerce_thumbnail'.
* @return string
*/
public function get_image_html( $category, $attributes, $size = 'woocommerce_thumbnail' ) {
if ( empty( $attributes['hasImage'] ) ) {
return '';
}
$image_id = get_term_meta( $category->term_id, 'thumbnail_id', true );
if ( ! $image_id ) {
return '<span class="wc-block-product-categories-list-item__image wc-block-product-categories-list-item__image--placeholder">' . wc_placeholder_img( 'woocommerce_thumbnail' ) . '</span>';
}
return '<span class="wc-block-product-categories-list-item__image">' . wp_get_attachment_image( $image_id, 'woocommerce_thumbnail' ) . '</span>';
}
/**
* Get the count, if displaying.
*
* @param object $category Term object.
* @param array $attributes Block attributes. Default empty array.
* @return string
*/
protected function getCount( $category, $attributes ) {
if ( empty( $attributes['hasCount'] ) ) {
return '';
}
if ( $attributes['isDropdown'] ) {
return '(' . absint( $category->count ) . ')';
}
$screen_reader_text = sprintf(
/* translators: %s number of products in cart. */
_n( '%d product', '%d products', absint( $category->count ), 'woocommerce' ),
absint( $category->count )
);
return '<span class="wc-block-product-categories-list-item-count">'
. '<span aria-hidden="true">' . absint( $category->count ) . '</span>'
. '<span class="screen-reader-text">' . esc_html( $screen_reader_text ) . '</span>'
. '</span>';
}
}
BlockTypes/ProductCategory.php 0000644 00000001335 15154173073 0012462 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductCategory class.
*/
class ProductCategory extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-category';
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {}
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array_merge(
parent::get_block_type_attributes(),
array(
'className' => $this->get_schema_string(),
'orderby' => $this->get_schema_orderby(),
'editMode' => $this->get_schema_boolean( true ),
)
);
}
}
BlockTypes/ProductCollection.php 0000644 00000067603 15154173073 0013012 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use WP_Query;
/**
* ProductCollection class.
*/
class ProductCollection extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-collection';
/**
* The Block with its attributes before it gets rendered
*
* @var array
*/
protected $parsed_block;
/**
* All query args from WP_Query.
*
* @var array
*/
protected $valid_query_vars;
/**
* All the query args related to the filter by attributes block.
*
* @var array
*/
protected $attributes_filter_query_args = array();
/**
* Orderby options not natively supported by WordPress REST API
*
* @var array
*/
protected $custom_order_opts = array( 'popularity', 'rating' );
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
* - Hook into pre_render_block to update the query.
*/
protected function initialize() {
parent::initialize();
// Update query for frontend rendering.
add_filter(
'query_loop_block_query_vars',
array( $this, 'build_frontend_query' ),
10,
3
);
add_filter(
'pre_render_block',
array( $this, 'add_support_for_filter_blocks' ),
10,
2
);
// Update the query for Editor.
add_filter( 'rest_product_query', array( $this, 'update_rest_query_in_editor' ), 10, 2 );
// Extend allowed `collection_params` for the REST API.
add_filter( 'rest_product_collection_params', array( $this, 'extend_rest_query_allowed_params' ), 10, 1 );
// Interactivity API: Add navigation directives to the product collection block.
add_filter( 'render_block_woocommerce/product-collection', array( $this, 'add_navigation_id_directive' ), 10, 3 );
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
}
/**
* Mark the Product Collection as an interactive region so it can be updated
* during client-side navigation.
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
* @param \WP_Block $instance The block instance.
*/
public function add_navigation_id_directive( $block_content, $block, $instance ) {
$is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false;
if ( $is_product_collection_block ) {
// Enqueue the Interactivity API runtime.
wp_enqueue_script( 'wc-interactivity' );
$p = new \WP_HTML_Tag_Processor( $block_content );
// Add `data-wc-navigation-id to the query block.
if ( $p->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) ) ) {
$p->set_attribute(
'data-wc-navigation-id',
'wc-product-collection-' . $this->parsed_block['attrs']['queryId']
);
$p->set_attribute( 'data-wc-interactive', true );
$block_content = $p->get_updated_html();
}
}
return $block_content;
}
/**
* Add interactive links to all anchors inside the Query Pagination block.
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
* @param \WP_Block $instance The block instance.
*/
public function add_navigation_link_directives( $block_content, $block, $instance ) {
$is_product_collection_block = $instance->context['query']['isProductCollectionBlock'] ?? false;
if (
$is_product_collection_block &&
$instance->context['queryId'] === $this->parsed_block['attrs']['queryId']
) {
$p = new \WP_HTML_Tag_Processor( $block_content );
$p->next_tag( array( 'class_name' => 'wp-block-query-pagination' ) );
while ( $p->next_tag( 'a' ) ) {
$class_attr = $p->get_attribute( 'class' );
$class_list = preg_split( '/\s+/', $class_attr );
$is_previous = in_array( 'wp-block-query-pagination-previous', $class_list, true );
$is_next = in_array( 'wp-block-query-pagination-next', $class_list, true );
$is_previous_or_next = $is_previous || $is_next;
$navigation_link_payload = array(
'prefetch' => $is_previous_or_next,
'scroll' => false,
);
$p->set_attribute(
'data-wc-navigation-link',
wp_json_encode( $navigation_link_payload )
);
if ( $is_previous ) {
$p->set_attribute( 'key', 'pagination-previous' );
} elseif ( $is_next ) {
$p->set_attribute( 'key', 'pagination-next' );
}
}
$block_content = $p->get_updated_html();
}
return $block_content;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
// The `loop_shop_per_page` filter can be found in WC_Query::product_query().
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$this->asset_data_registry->add( 'loopShopPerPage', apply_filters( 'loop_shop_per_page', wc_get_default_products_per_row() * wc_get_default_product_rows_per_page() ), true );
}
/**
* Update the query for the product query block in Editor.
*
* @param array $args Query args.
* @param WP_REST_Request $request Request.
*/
public function update_rest_query_in_editor( $args, $request ): array {
// Only update the query if this is a product collection block.
$is_product_collection_block = $request->get_param( 'isProductCollectionBlock' );
if ( ! $is_product_collection_block ) {
return $args;
}
$orderby = $request->get_param( 'orderBy' );
$on_sale = $request->get_param( 'woocommerceOnSale' ) === 'true';
$stock_status = $request->get_param( 'woocommerceStockStatus' );
$product_attributes = $request->get_param( 'woocommerceAttributes' );
$handpicked_products = $request->get_param( 'woocommerceHandPickedProducts' );
$args['author'] = $request->get_param( 'author' ) ?? '';
return $this->get_final_query_args(
$args,
array(
'orderby' => $orderby,
'on_sale' => $on_sale,
'stock_status' => $stock_status,
'product_attributes' => $product_attributes,
'handpicked_products' => $handpicked_products,
)
);
}
/**
* Add support for filter blocks:
* - Price filter block
* - Attributes filter block
* - Rating filter block
* - In stock filter block etc.
*
* @param array $pre_render The pre-rendered block.
* @param array $parsed_block The parsed block.
*/
public function add_support_for_filter_blocks( $pre_render, $parsed_block ) {
$is_product_collection_block = $parsed_block['attrs']['query']['isProductCollectionBlock'] ?? false;
if ( ! $is_product_collection_block ) {
return;
}
$this->parsed_block = $parsed_block;
$this->asset_data_registry->add( 'hasFilterableProducts', true, true );
/**
* It enables the page to refresh when a filter is applied, ensuring that the product collection block,
* which is a server-side rendered (SSR) block, retrieves the products that match the filters.
*/
$this->asset_data_registry->add( 'isRenderingPhpTemplate', true, true );
}
/**
* Return a custom query based on attributes, filters and global WP_Query.
*
* @param WP_Query $query The WordPress Query.
* @param WP_Block $block The block being rendered.
* @param int $page The page number.
*
* @return array
*/
public function build_frontend_query( $query, $block, $page ) {
// If not in context of product collection block, return the query as is.
$is_product_collection_block = $block->context['query']['isProductCollectionBlock'] ?? false;
if ( ! $is_product_collection_block ) {
return $query;
}
$block_context_query = $block->context['query'];
// phpcs:ignore WordPress.DB.SlowDBQuery
$block_context_query['tax_query'] = ! empty( $query['tax_query'] ) ? $query['tax_query'] : array();
return $this->get_final_frontend_query( $block_context_query, $page );
}
/**
* Get the final query arguments for the frontend.
*
* @param array $query The query arguments.
* @param int $page The page number.
* @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not.
*/
private function get_final_frontend_query( $query, $page = 1, $is_exclude_applied_filters = false ) {
$offset = $query['offset'] ?? 0;
$per_page = $query['perPage'] ?? 9;
$common_query_values = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(),
'posts_per_page' => $query['perPage'],
'order' => $query['order'],
'offset' => ( $per_page * ( $page - 1 ) ) + $offset,
'post__in' => array(),
'post_status' => 'publish',
'post_type' => 'product',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
'tax_query' => array(),
'paged' => $page,
's' => $query['search'],
'author' => $query['author'] ?? '',
);
$is_on_sale = $query['woocommerceOnSale'] ?? false;
$taxonomies_query = $this->get_filter_by_taxonomies_query( $query['tax_query'] ?? [] );
$handpicked_products = $query['woocommerceHandPickedProducts'] ?? [];
$final_query = $this->get_final_query_args(
$common_query_values,
array(
'on_sale' => $is_on_sale,
'stock_status' => $query['woocommerceStockStatus'],
'orderby' => $query['orderBy'],
'product_attributes' => $query['woocommerceAttributes'],
'taxonomies_query' => $taxonomies_query,
'handpicked_products' => $handpicked_products,
),
$is_exclude_applied_filters
);
return $final_query;
}
/**
* Get final query args based on provided values
*
* @param array $common_query_values Common query values.
* @param array $query Query from block context.
* @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not.
*/
private function get_final_query_args( $common_query_values, $query, $is_exclude_applied_filters = false ) {
$handpicked_products = $query['handpicked_products'] ?? [];
$orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : [];
$on_sale_query = $this->get_on_sale_products_query( $query['on_sale'] );
$stock_query = $this->get_stock_status_query( $query['stock_status'] );
$visibility_query = is_array( $query['stock_status'] ) ? $this->get_product_visibility_query( $stock_query ) : [];
$attributes_query = $this->get_product_attributes_query( $query['product_attributes'] );
$taxonomies_query = $query['taxonomies_query'] ?? [];
$tax_query = $this->merge_tax_queries( $visibility_query, $attributes_query, $taxonomies_query );
// We exclude applied filters to generate product ids for the filter blocks.
$applied_filters_query = $is_exclude_applied_filters ? [] : $this->get_queries_by_applied_filters();
$merged_query = $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, $stock_query, $tax_query, $applied_filters_query );
return $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products );
}
/**
* Extends allowed `collection_params` for the REST API
*
* By itself, the REST API doesn't accept custom `orderby` values,
* even if they are supported by a custom post type.
*
* @param array $params A list of allowed `orderby` values.
*
* @return array
*/
public function extend_rest_query_allowed_params( $params ) {
$original_enum = isset( $params['orderby']['enum'] ) ? $params['orderby']['enum'] : array();
$params['orderby']['enum'] = array_unique( array_merge( $original_enum, $this->custom_order_opts ) );
return $params;
}
/**
* Merge in the first parameter the keys "post_in", "meta_query" and "tax_query" of the second parameter.
*
* @param array[] ...$queries Query arrays to be merged.
* @return array
*/
private function merge_queries( ...$queries ) {
$merged_query = array_reduce(
$queries,
function( $acc, $query ) {
if ( ! is_array( $query ) ) {
return $acc;
}
// If the $query doesn't contain any valid query keys, we unpack/spread it then merge.
if ( empty( array_intersect( $this->get_valid_query_vars(), array_keys( $query ) ) ) ) {
return $this->merge_queries( $acc, ...array_values( $query ) );
}
return $this->array_merge_recursive_replace_non_array_properties( $acc, $query );
},
array()
);
/**
* If there are duplicated items in post__in, it means that we need to
* use the intersection of the results, which in this case, are the
* duplicated items.
*/
if (
! empty( $merged_query['post__in'] ) &&
count( $merged_query['post__in'] ) > count( array_unique( $merged_query['post__in'] ) )
) {
$merged_query['post__in'] = array_unique(
array_diff(
$merged_query['post__in'],
array_unique( $merged_query['post__in'] )
)
);
}
return $merged_query;
}
/**
* Return query params to support custom sort values
*
* @param string $orderby Sort order option.
*
* @return array
*/
private function get_custom_orderby_query( $orderby ) {
if ( ! in_array( $orderby, $this->custom_order_opts, true ) ) {
return array( 'orderby' => $orderby );
}
$meta_keys = array(
'popularity' => 'total_sales',
'rating' => '_wc_average_rating',
);
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_key' => $meta_keys[ $orderby ],
'orderby' => 'meta_value_num',
);
}
/**
* Return a query for on sale products.
*
* @param bool $is_on_sale Whether to query for on sale products.
*
* @return array
*/
private function get_on_sale_products_query( $is_on_sale ) {
if ( ! $is_on_sale ) {
return array();
}
return array(
'post__in' => wc_get_product_ids_on_sale(),
);
}
/**
* Return or initialize $valid_query_vars.
*
* @return array
*/
private function get_valid_query_vars() {
if ( ! empty( $this->valid_query_vars ) ) {
return $this->valid_query_vars;
}
$valid_query_vars = array_keys( ( new WP_Query() )->fill_query_vars( array() ) );
$this->valid_query_vars = array_merge(
$valid_query_vars,
// fill_query_vars doesn't include these vars so we need to add them manually.
array(
'date_query',
'exact',
'ignore_sticky_posts',
'lazy_load_term_meta',
'meta_compare_key',
'meta_compare',
'meta_query',
'meta_type_key',
'meta_type',
'nopaging',
'offset',
'order',
'orderby',
'page',
'post_type',
'posts_per_page',
'suppress_filters',
'tax_query',
)
);
return $this->valid_query_vars;
}
/**
* Merge two array recursively but replace the non-array values instead of
* merging them. The merging strategy:
*
* - If keys from merge array doesn't exist in the base array, create them.
* - For array items with numeric keys, we merge them as normal.
* - For array items with string keys:
*
* - If the value isn't array, we'll use the value comming from the merge array.
* $base = ['orderby' => 'date']
* $new = ['orderby' => 'meta_value_num']
* Result: ['orderby' => 'meta_value_num']
*
* - If the value is array, we'll use recursion to merge each key.
* $base = ['meta_query' => [
* [
* 'key' => '_stock_status',
* 'compare' => 'IN'
* 'value' => ['instock', 'onbackorder']
* ]
* ]]
* $new = ['meta_query' => [
* [
* 'relation' => 'AND',
* [...<max_price_query>],
* [...<min_price_query>],
* ]
* ]]
* Result: ['meta_query' => [
* [
* 'key' => '_stock_status',
* 'compare' => 'IN'
* 'value' => ['instock', 'onbackorder']
* ],
* [
* 'relation' => 'AND',
* [...<max_price_query>],
* [...<min_price_query>],
* ]
* ]]
*
* $base = ['post__in' => [1, 2, 3, 4, 5]]
* $new = ['post__in' => [3, 4, 5, 6, 7]]
* Result: ['post__in' => [1, 2, 3, 4, 5, 3, 4, 5, 6, 7]]
*
* @param array $base First array.
* @param array $new Second array.
*/
private function array_merge_recursive_replace_non_array_properties( $base, $new ) {
foreach ( $new as $key => $value ) {
if ( is_numeric( $key ) ) {
$base[] = $value;
} else {
if ( is_array( $value ) ) {
if ( ! isset( $base[ $key ] ) ) {
$base[ $key ] = array();
}
$base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value );
} else {
$base[ $key ] = $value;
}
}
}
return $base;
}
/**
* Return a query for products depending on their stock status.
*
* @param array $stock_statuses An array of acceptable stock statuses.
* @return array
*/
private function get_stock_status_query( $stock_statuses ) {
if ( ! is_array( $stock_statuses ) ) {
return array();
}
$stock_status_options = array_keys( wc_get_product_stock_status_options() );
/**
* If all available stock status are selected, we don't need to add the
* meta query for stock status.
*/
if (
count( $stock_statuses ) === count( $stock_status_options ) &&
array_diff( $stock_statuses, $stock_status_options ) === array_diff( $stock_status_options, $stock_statuses )
) {
return array();
}
/**
* If all stock statuses are selected except 'outofstock', we use the
* product visibility query to filter out out of stock products.
*
* @see get_product_visibility_query()
*/
$diff = array_diff( $stock_status_options, $stock_statuses );
if ( count( $diff ) === 1 && in_array( 'outofstock', $diff, true ) ) {
return array();
}
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_stock_status',
'value' => (array) $stock_statuses,
'compare' => 'IN',
),
),
);
}
/**
* Return a query for product visibility depending on their stock status.
*
* @param array $stock_query Stock status query.
*
* @return array Tax query for product visibility.
*/
private function get_product_visibility_query( $stock_query ) {
$product_visibility_terms = wc_get_product_visibility_term_ids();
$product_visibility_not_in = array( is_search() ? $product_visibility_terms['exclude-from-search'] : $product_visibility_terms['exclude-from-catalog'] );
// Hide out of stock products.
if ( empty( $stock_query ) && 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$product_visibility_not_in[] = $product_visibility_terms['outofstock'];
}
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
'tax_query' => array(
array(
'taxonomy' => 'product_visibility',
'field' => 'term_taxonomy_id',
'terms' => $product_visibility_not_in,
'operator' => 'NOT IN',
),
),
);
}
/**
* Merge tax_queries from various queries.
*
* @param array ...$queries Query arrays to be merged.
* @return array
*/
private function merge_tax_queries( ...$queries ) {
$tax_query = [];
foreach ( $queries as $query ) {
if ( ! empty( $query['tax_query'] ) ) {
$tax_query = array_merge( $tax_query, $query['tax_query'] );
}
}
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
return [ 'tax_query' => $tax_query ];
}
/**
* Return the `tax_query` for the requested attributes
*
* @param array $attributes Attributes and their terms.
*
* @return array
*/
private function get_product_attributes_query( $attributes = array() ) {
if ( empty( $attributes ) ) {
return array();
}
$grouped_attributes = array_reduce(
$attributes,
function ( $carry, $item ) {
$taxonomy = sanitize_title( $item['taxonomy'] );
if ( ! key_exists( $taxonomy, $carry ) ) {
$carry[ $taxonomy ] = array(
'field' => 'term_id',
'operator' => 'IN',
'taxonomy' => $taxonomy,
'terms' => array( $item['termId'] ),
);
} else {
$carry[ $taxonomy ]['terms'][] = $item['termId'];
}
return $carry;
},
array()
);
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
'tax_query' => array_values( $grouped_attributes ),
);
}
/**
* Return a query to filter products by taxonomies (product categories, product tags, etc.)
*
* For example:
* User could provide "Product Categories" using "Filters" ToolsPanel available in Inspector Controls.
* We use this function to extract its query from $tax_query.
*
* For example, this is how the query for product categories will look like in $tax_query array:
* Array
* (
* [taxonomy] => product_cat
* [terms] => Array
* (
* [0] => 36
* )
* )
*
* For product tags, taxonomy would be "product_tag"
*
* @param array $tax_query Query to filter products by taxonomies.
* @return array Query to filter products by taxonomies.
*/
private function get_filter_by_taxonomies_query( $tax_query ): array {
if ( ! is_array( $tax_query ) ) {
return [];
}
/**
* Get an array of taxonomy names associated with the "product" post type because
* we also want to include custom taxonomies associated with the "product" post type.
*/
$product_taxonomies = get_taxonomies( [ 'object_type' => [ 'product' ] ], 'names' );
$result = array_filter(
$tax_query,
function( $item ) use ( $product_taxonomies ) {
return isset( $item['taxonomy'] ) && in_array( $item['taxonomy'], $product_taxonomies, true );
}
);
// phpcs:ignore WordPress.DB.SlowDBQuery
return ! empty( $result ) ? [ 'tax_query' => $result ] : [];
}
/**
* Apply the query only to a subset of products
*
* @param array $query The query.
* @param array $ids Array of selected product ids.
*
* @return array
*/
private function filter_query_to_only_include_ids( $query, $ids ) {
if ( ! empty( $ids ) ) {
$query['post__in'] = empty( $query['post__in'] ) ?
$ids : array_intersect( $ids, $query['post__in'] );
}
return $query;
}
/**
* Return queries that are generated by query args.
*
* @return array
*/
private function get_queries_by_applied_filters() {
return array(
'price_filter' => $this->get_filter_by_price_query(),
'attributes_filter' => $this->get_filter_by_attributes_query(),
'stock_status_filter' => $this->get_filter_by_stock_status_query(),
'rating_filter' => $this->get_filter_by_rating_query(),
);
}
/**
* Return a query that filters products by price.
*
* @return array
*/
private function get_filter_by_price_query() {
$min_price = get_query_var( PriceFilter::MIN_PRICE_QUERY_VAR );
$max_price = get_query_var( PriceFilter::MAX_PRICE_QUERY_VAR );
$max_price_query = empty( $max_price ) ? array() : [
'key' => '_price',
'value' => $max_price,
'compare' => '<',
'type' => 'numeric',
];
$min_price_query = empty( $min_price ) ? array() : [
'key' => '_price',
'value' => $min_price,
'compare' => '>=',
'type' => 'numeric',
];
if ( empty( $min_price_query ) && empty( $max_price_query ) ) {
return array();
}
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'relation' => 'AND',
$max_price_query,
$min_price_query,
),
),
);
}
/**
* Return a query that filters products by attributes.
*
* @return array
*/
private function get_filter_by_attributes_query() {
$attributes_filter_query_args = $this->get_filter_by_attributes_query_vars();
$queries = array_reduce(
$attributes_filter_query_args,
function( $acc, $query_args ) {
$attribute_name = $query_args['filter'];
$attribute_query_type = $query_args['query_type'];
$attribute_value = get_query_var( $attribute_name );
$attribute_query = get_query_var( $attribute_query_type );
if ( empty( $attribute_value ) ) {
return $acc;
}
// It is necessary explode the value because $attribute_value can be a string with multiple values (e.g. "red,blue").
$attribute_value = explode( ',', $attribute_value );
$acc[] = array(
'taxonomy' => str_replace( AttributeFilter::FILTER_QUERY_VAR_PREFIX, 'pa_', $attribute_name ),
'field' => 'slug',
'terms' => $attribute_value,
'operator' => 'and' === $attribute_query ? 'AND' : 'IN',
);
return $acc;
},
array()
);
if ( empty( $queries ) ) {
return array();
}
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery
'tax_query' => array(
array(
'relation' => 'AND',
$queries,
),
),
);
}
/**
* Get all the query args related to the filter by attributes block.
*
* @return array
* [color] => Array
* (
* [filter] => filter_color
* [query_type] => query_type_color
* )
*
* [size] => Array
* (
* [filter] => filter_size
* [query_type] => query_type_size
* )
* )
*/
private function get_filter_by_attributes_query_vars() {
if ( ! empty( $this->attributes_filter_query_args ) ) {
return $this->attributes_filter_query_args;
}
$this->attributes_filter_query_args = array_reduce(
wc_get_attribute_taxonomies(),
function( $acc, $attribute ) {
$acc[ $attribute->attribute_name ] = array(
'filter' => AttributeFilter::FILTER_QUERY_VAR_PREFIX . $attribute->attribute_name,
'query_type' => AttributeFilter::QUERY_TYPE_QUERY_VAR_PREFIX . $attribute->attribute_name,
);
return $acc;
},
array()
);
return $this->attributes_filter_query_args;
}
/**
* Return a query that filters products by stock status.
*
* @return array
*/
private function get_filter_by_stock_status_query() {
$filter_stock_status_values = get_query_var( StockFilter::STOCK_STATUS_QUERY_VAR );
if ( empty( $filter_stock_status_values ) ) {
return array();
}
$filtered_stock_status_values = array_filter(
explode( ',', $filter_stock_status_values ),
function( $stock_status ) {
return in_array( $stock_status, StockFilter::get_stock_status_query_var_values(), true );
}
);
if ( empty( $filtered_stock_status_values ) ) {
return array();
}
return array(
// Ignoring the warning of not using meta queries.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_stock_status',
'value' => $filtered_stock_status_values,
'operator' => 'IN',
),
),
);
}
/**
* Return a query that filters products by rating.
*
* @return array
*/
private function get_filter_by_rating_query() {
$filter_rating_values = get_query_var( RatingFilter::RATING_QUERY_VAR );
if ( empty( $filter_rating_values ) ) {
return array();
}
$parsed_filter_rating_values = explode( ',', $filter_rating_values );
$product_visibility_terms = wc_get_product_visibility_term_ids();
if ( empty( $parsed_filter_rating_values ) || empty( $product_visibility_terms ) ) {
return array();
}
$rating_terms = array_map(
function( $rating ) use ( $product_visibility_terms ) {
return $product_visibility_terms[ 'rated-' . $rating ];
},
$parsed_filter_rating_values
);
return array(
// phpcs:ignore WordPress.DB.SlowDBQuery
'tax_query' => array(
array(
'field' => 'term_taxonomy_id',
'taxonomy' => 'product_visibility',
'terms' => $rating_terms,
'operator' => 'IN',
'rating_filter' => true,
),
),
);
}
}
BlockTypes/ProductDetails.php 0000644 00000003001 15154173074 0012263 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductDetails class.
*/
class ProductDetails extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-details';
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
$tabs = $this->render_tabs();
$classname = $attributes['className'] ?? '';
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
return sprintf(
'<div class="wp-block-woocommerce-product-details %1$s %2$s">
<div style="%3$s">
%4$s
</div>
</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
esc_attr( $classes_and_styles['styles'] ),
$tabs
);
}
/**
* Gets the tabs with their content to be rendered by the block.
*
* @return string The tabs html to be rendered by the block
*/
protected function render_tabs() {
ob_start();
rewind_posts();
while ( have_posts() ) {
the_post();
woocommerce_output_product_data_tabs();
}
$tabs = ob_get_clean();
return $tabs;
}
}
BlockTypes/ProductGallery.php 0000644 00000003537 15154173074 0012313 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductGallery class.
*/
class ProductGallery extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-gallery';
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$classname = $attributes['className'] ?? '';
$wrapper_attributes = get_block_wrapper_attributes( array( 'class' => trim( sprintf( 'woocommerce %1$s', $classname ) ) ) );
$html = sprintf(
'<div data-wc-interactive %1$s>
%2$s
</div>',
$wrapper_attributes,
$content
);
$p = new \WP_HTML_Tag_Processor( $content );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-interactive', true );
$p->set_attribute(
'data-wc-context',
wp_json_encode( array( 'woocommerce' => array( 'productGallery' => array( 'numberOfThumbnails' => 0 ) ) ) )
);
$html = $p->get_updated_html();
}
return $html;
}
/**
* Get the Interactivity API's view script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [ 'wc-interactivity' ],
];
return $key ? $script[ $key ] : $script;
}
}
BlockTypes/ProductGalleryLargeImage.php 0000644 00000007346 15154173074 0014233 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductGalleryLargeImage class.
*/
class ProductGalleryLargeImage extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-gallery-large-image';
/**
* Register the context
*
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'postId', 'hoverZoom' ];
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @param array $attributes Any attributes that currently are available from the block.
* @param string $content The block content.
* @param WP_Block $block The block object.
*/
protected function enqueue_assets( array $attributes, $content, $block ) {
if ( $block->context['hoverZoom'] ) {
parent::enqueue_assets( $attributes, $content, $block );
}
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$post_id = $block->context['postId'];
if ( ! isset( $post_id ) ) {
return '';
}
global $product;
$previous_product = $product;
$product = wc_get_product( $post_id );
if ( ! $product instanceof \WC_Product ) {
$product = $previous_product;
return '';
}
if ( class_exists( 'WC_Frontend_Scripts' ) ) {
$frontend_scripts = new \WC_Frontend_Scripts();
$frontend_scripts::load_scripts();
}
$processor = new \WP_HTML_Tag_Processor( $content );
$processor->next_tag();
$processor->remove_class( 'wp-block-woocommerce-product-gallery-large-image' );
$content = $processor->get_updated_html();
$image_html = wp_get_attachment_image(
$product->get_image_id(),
'full',
false,
array(
'class' => 'wc-block-woocommerce-product-gallery-large-image__image',
)
);
[$directives, $image_html] = $block->context['hoverZoom'] ? $this->get_html_with_interactivity( $image_html ) : array( array(), $image_html );
return strtr(
'<div class="wp-block-woocommerce-product-gallery-large-image" {directives}>
{image}
<div class="wc-block-woocommerce-product-gallery-large-image__content">
{content}
</div>
</div>',
array(
'{image}' => $image_html,
'{content}' => $content,
'{directives}' => array_reduce(
array_keys( $directives ),
function( $carry, $key ) use ( $directives ) {
return $carry . ' ' . $key . '="' . esc_attr( $directives[ $key ] ) . '"';
},
''
),
)
);
}
/**
* Get the HTML that adds interactivity to the image. This is used for the hover zoom effect.
*
* @param string $image_html The image HTML.
* @return array
*/
private function get_html_with_interactivity( $image_html ) {
$context = array(
'woocommerce' => array(
'styles' => array(
'transform' => 'scale(1.0)',
'transform-origin' => '',
),
),
);
$directives = array(
'data-wc-on--mousemove' => 'actions.woocommerce.handleMouseMove',
'data-wc-on--mouseleave' => 'actions.woocommerce.handleMouseLeave',
'data-wc-context' => wp_json_encode( $context, JSON_NUMERIC_CHECK ),
);
$image_html_processor = new \WP_HTML_Tag_Processor( $image_html );
$image_html_processor->next_tag( 'img' );
$image_html_processor->add_class( 'wc-block-woocommerce-product-gallery-large-image__image--hoverZoom' );
$image_html_processor->set_attribute( 'data-wc-bind--style', 'selectors.woocommerce.styles' );
$image_html = $image_html_processor->get_updated_html();
return array(
$directives,
$image_html,
);
}
}
BlockTypes/ProductGalleryLargeImageNextPrevious.php 0000644 00000010450 15154173074 0016615 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductGalleryLargeImage class.
*/
class ProductGalleryLargeImageNextPrevious extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-gallery-large-image-next-previous';
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context
*
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'nextPreviousButtonsPosition', 'productGalleryClientId' ];
}
/**
* Return class suffix
*
* @param array $context Block context.
* @return string
*/
private function get_class_suffix( $context ) {
switch ( $context['nextPreviousButtonsPosition'] ) {
case 'insideTheImage':
return 'inside-image';
case 'outsideTheImage':
return 'outside-image';
case 'off':
return 'off';
default:
return 'off';
} }
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$post_id = $block->context['postId'];
if ( ! isset( $post_id ) ) {
return '';
}
$product = wc_get_product( $post_id );
$product_gallery = $product->get_gallery_image_ids();
if ( empty( $product_gallery ) ) {
return null;
}
$context = $block->context;
$prev_button = sprintf(
'
<svg class="wc-block-product-gallery-large-image-next-previous-left--%1$s" xmlns="http://www.w3.org/2000/svg" width="49" height="48" viewBox="0 0 49 48" fill="none">
<g filter="url(#filter0_b_397_11356)">
<rect x="0.5" width="48" height="48" rx="5" fill="black" fill-opacity="0.5"/>
<path d="M28.1 12L30.5 14L21.3 24L30.5 34L28.1 36L17.3 24L28.1 12Z" fill="white"/>
</g>
<defs>
<filter id="filter0_b_397_11356" x="-9.5" y="-10" width="68" height="68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="5"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_397_11356"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_397_11356" result="shape"/>
</filter>
</defs>
</svg>',
$this->get_class_suffix( $context )
);
$next_button = sprintf(
'
<svg class="wc-block-product-gallery-large-image-next-previous-right--%1$s" xmlns="http://www.w3.org/2000/svg" width="49" height="48" viewBox="0 0 49 48" fill="none">
<g filter="url(#filter0_b_397_11354)">
<rect x="0.5" width="48" height="48" rx="5" fill="black" fill-opacity="0.5"/>
<path d="M21.7001 12L19.3 14L28.5 24L19.3 34L21.7001 36L32.5 24L21.7001 12Z" fill="white"/>
</g>
<defs>
<filter id="filter0_b_397_11354" x="-9.5" y="-10" width="68" height="68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="5"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_397_11354"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_397_11354" result="shape"/>
</filter>
</defs>
</svg>
',
$this->get_class_suffix( $context )
);
$alignment_class = isset( $attributes['layout']['verticalAlignment'] ) ? 'is-vertically-aligned-' . esc_attr( $attributes['layout']['verticalAlignment'] ) : '';
$position_class = 'wc-block-product-gallery-large-image-next-previous--' . $this->get_class_suffix( $context );
return strtr(
'<div class="wp-block-woocommerce-product-gallery-large-image-next-previous {alignment_class}">
<div class="wc-block-product-gallery-large-image-next-previous-container {position_class}">
{prev_button}
{next_button}
</div>
</div>',
array(
'{prev_button}' => $prev_button,
'{next_button}' => $next_button,
'{alignment_class}' => $alignment_class,
'{position_class}' => $position_class,
)
);
}
}
BlockTypes/ProductGalleryPager.php 0000644 00000007114 15154173074 0013265 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductGalleryPager class.
*/
class ProductGalleryPager extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-gallery-pager';
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context
*
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'productGalleryClientId', 'pagerDisplayMode' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$pager_display_mode = $block->context['pagerDisplayMode'] ?? '';
$classname = $attributes['className'] ?? '';
$wrapper_attributes = get_block_wrapper_attributes( array( 'class' => trim( sprintf( 'woocommerce %1$s', $classname ) ) ) );
$html = $this->render_pager( $pager_display_mode );
return sprintf(
'<div %1$s>
%2$s
</div>',
$wrapper_attributes,
$html
);
}
/**
* Renders the pager based on the given display mode.
*
* @param string $pager_display_mode The display mode for the pager. Possible values are 'dots', 'digits', and 'off'.
*
* @return string|null The rendered pager HTML, or null if the pager is disabled.
*/
private function render_pager( $pager_display_mode ) {
switch ( $pager_display_mode ) {
case 'dots':
return $this->render_dots_pager();
case 'digits':
return $this->render_digits_pager();
case 'off':
return null;
default:
return $this->render_dots_pager();
}
}
/**
* Renders the digits pager HTML.
*
* @return string The rendered digits pager HTML.
*/
private function render_digits_pager() {
return sprintf(
'<ul class="wp-block-woocommerce-product-gallery-pager__pager">
<li class="wp-block-woocommerce-product-gallery__pager-item is-active">1</li>
<li class="wp-block-woocommerce-product-gallery__pager-item">2</li>
<li class="wp-block-woocommerce-product-gallery__pager-item">3</li>
<li class="wp-block-woocommerce-product-gallery__pager-item">4</li>
</ul>'
);
}
/**
* Renders the dots pager HTML.
*
* @return string The rendered dots pager HTML.
*/
private function render_dots_pager() {
return sprintf(
'<ul class="wp-block-woocommerce-product-gallery-pager__pager">
<li class="wp-block-woocommerce-product-gallery__pager-item is-active">%1$s</li>
<li class="wp-block-woocommerce-product-gallery__pager-item">%2$s</li>
<li class="wp-block-woocommerce-product-gallery__pager-item">%2$s</li>
</ul>',
$this->get_selected_dot_icon(),
$this->get_dot_icon()
);
}
/**
* Returns the dot icon SVG code.
*
* @return string The dot icon SVG code.
*/
private function get_dot_icon() {
return sprintf(
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="6" fill="black" fill-opacity="0.2"/>
</svg>'
);
}
/**
* Returns the selected dot icon SVG code.
*
* @return string The selected dot icon SVG code.
*/
private function get_selected_dot_icon() {
return sprintf(
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="6" fill="black"/>
</svg>'
);
}
}
BlockTypes/ProductGalleryThumbnails.php 0000644 00000005710 15154173074 0014335 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductGalleryLargeImage class.
*/
class ProductGalleryThumbnails extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-gallery-thumbnails';
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context
*
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'productGalleryClientId', 'postId', 'thumbnailsNumberOfThumbnails', 'thumbnailsPosition' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( isset( $block->context['thumbnailsPosition'] ) && '' !== $block->context['thumbnailsPosition'] && 'off' !== $block->context['thumbnailsPosition'] ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
$product = wc_get_product( $post_id );
$post_thumbnail_id = $product->get_image_id();
$html = '';
if ( $product ) {
$attachment_ids = $product->get_gallery_image_ids();
if ( $attachment_ids && $post_thumbnail_id ) {
$html .= wc_get_gallery_image_html( $post_thumbnail_id, true );
$number_of_thumbnails = isset( $block->context['thumbnailsNumberOfThumbnails'] ) ? $block->context['thumbnailsNumberOfThumbnails'] : 3;
$thumbnails_count = 1;
foreach ( $attachment_ids as $attachment_id ) {
if ( $thumbnails_count >= $number_of_thumbnails ) {
break;
}
/**
* Filter the HTML markup for a single product image thumbnail in the gallery.
*
* @param string $thumbnail_html The HTML markup for the thumbnail.
* @param int $attachment_id The attachment ID of the thumbnail.
*
* @since 7.9.0
*/
$html .= apply_filters( 'woocommerce_single_product_image_thumbnail_html', wc_get_gallery_image_html( $attachment_id ), $attachment_id ); // phpcs:disable WordPress.XSS.EscapeOutput.OutputNotEscaped
$thumbnails_count++;
}
}
return sprintf(
'<div class="wc-block-components-product-gallery-thumbnails %1$s" style="%2$s">
%3$s
</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classes_and_styles['styles'] ),
$html
);
}
}
}
}
BlockTypes/ProductImage.php 0000644 00000015310 15154173074 0011726 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductImage class.
*/
class ProductImage extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-image';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'__experimentalBorder' =>
array(
'radius' => true,
'__experimentalSkipSerialization' => true,
),
'typography' =>
array(
'fontSize' => true,
'__experimentalSkipSerialization' => true,
),
'spacing' =>
array(
'margin' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-image',
);
}
/**
* It is necessary to register and enqueues assets during the render phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Get the block's attributes.
*
* @param array $attributes Block attributes. Default empty array.
* @return array Block attributes merged with defaults.
*/
private function parse_attributes( $attributes ) {
// These should match what's set in JS `registerBlockType`.
$defaults = array(
'showProductLink' => true,
'showSaleBadge' => true,
'saleBadgeAlign' => 'right',
'imageSizing' => 'single',
'productId' => 'number',
'isDescendentOfQueryLoop' => 'false',
'scale' => 'cover',
);
return wp_parse_args( $attributes, $defaults );
}
/**
* Render on Sale Badge.
*
* @param \WC_Product $product Product object.
* @param array $attributes Attributes.
* @return string
*/
private function render_on_sale_badge( $product, $attributes ) {
if ( ! $product->is_on_sale() || false === $attributes['showSaleBadge'] ) {
return '';
}
$font_size = StyleAttributesUtils::get_font_size_class_and_style( $attributes );
$on_sale_badge = sprintf(
'
<div class="wc-block-components-product-sale-badge wc-block-components-product-sale-badge--align-%s wc-block-grid__product-onsale %s" style="%s">
<span aria-hidden="true">%s</span>
<span class="screen-reader-text">Product on sale</span>
</div>
',
esc_attr( $attributes['saleBadgeAlign'] ),
isset( $font_size['class'] ) ? esc_attr( $font_size['class'] ) : '',
isset( $font_size['style'] ) ? esc_attr( $font_size['style'] ) : '',
esc_html__( 'Sale', 'woocommerce' )
);
return $on_sale_badge;
}
/**
* Render anchor.
*
* @param \WC_Product $product Product object.
* @param string $on_sale_badge Return value from $render_image.
* @param string $product_image Return value from $render_on_sale_badge.
* @param array $attributes Attributes.
* @return string
*/
private function render_anchor( $product, $on_sale_badge, $product_image, $attributes ) {
$product_permalink = $product->get_permalink();
$pointer_events = false === $attributes['showProductLink'] ? 'pointer-events: none;' : '';
return sprintf(
'<a href="%1$s" style="%2$s">%3$s %4$s</a>',
$product_permalink,
$pointer_events,
$on_sale_badge,
$product_image
);
}
/**
* Render Image.
*
* @param \WC_Product $product Product object.
* @param array $attributes Parsed attributes.
* @return string
*/
private function render_image( $product, $attributes ) {
$image_size = 'single' === $attributes['imageSizing'] ? 'woocommerce_single' : 'woocommerce_thumbnail';
$image_style = 'max-width:none;';
if ( ! empty( $attributes['height'] ) ) {
$image_style .= sprintf( 'height:%s;', $attributes['height'] );
}
if ( ! empty( $attributes['width'] ) ) {
$image_style .= sprintf( 'width:%s;', $attributes['width'] );
}
if ( ! empty( $attributes['scale'] ) ) {
$image_style .= sprintf( 'object-fit:%s;', $attributes['scale'] );
}
if ( ! $product->get_image_id() ) {
// The alt text is left empty on purpose, as it's considered a decorative image.
// More can be found here: https://www.w3.org/WAI/tutorials/images/decorative/.
// Github discussion for a context: https://github.com/woocommerce/woocommerce-blocks/pull/7651#discussion_r1019560494.
return wc_placeholder_img(
$image_size,
array(
'alt' => '',
'style' => $image_style,
)
);
}
return $product->get_image(
$image_size,
array(
'alt' => $product->get_title(),
'data-testid' => 'product-image',
'style' => $image_style,
)
);
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
$this->asset_data_registry->add( 'isBlockThemeEnabled', wc_current_theme_is_fse_theme(), false );
}
/**
* Include and render the block
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$parsed_attributes = $this->parse_attributes( $attributes );
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
$product = wc_get_product( $post_id );
if ( $product ) {
return sprintf(
'<div class="wc-block-components-product-image wc-block-grid__product-image %1$s" style="%2$s">
%3$s
</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classes_and_styles['styles'] ),
$this->render_anchor(
$product,
$this->render_on_sale_badge( $product, $parsed_attributes ),
$this->render_image( $product, $parsed_attributes ),
$parsed_attributes
)
);
}
}
}
BlockTypes/ProductImageGallery.php 0000644 00000003417 15154173074 0013253 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductImageGallery class.
*/
class ProductImageGallery extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-image-gallery';
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context
*
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$post_id = $block->context['postId'];
if ( ! isset( $post_id ) ) {
return '';
}
global $product;
$previous_product = $product;
$product = wc_get_product( $post_id );
if ( ! $product instanceof \WC_Product ) {
$product = $previous_product;
return '';
}
if ( class_exists( 'WC_Frontend_Scripts' ) ) {
$frontend_scripts = new \WC_Frontend_Scripts();
$frontend_scripts::load_scripts();
}
ob_start();
woocommerce_show_product_sale_flash();
$sale_badge_html = ob_get_clean();
ob_start();
woocommerce_show_product_images();
$product_image_gallery_html = ob_get_clean();
$product = $previous_product;
$classname = $attributes['className'] ?? '';
return sprintf(
'<div class="wp-block-woocommerce-product-image-gallery %1$s">%2$s %3$s</div>',
esc_attr( $classname ),
$sale_badge_html,
$product_image_gallery_html
);
}
}
BlockTypes/ProductNew.php 0000644 00000000700 15154173074 0011432 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductNew class.
*/
class ProductNew extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-new';
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
$query_args['orderby'] = 'date';
$query_args['order'] = 'DESC';
}
}
BlockTypes/ProductOnSale.php 0000644 00000001371 15154173074 0012067 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductOnSale class.
*/
class ProductOnSale extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-on-sale';
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
$query_args['post__in'] = array_merge( array( 0 ), wc_get_product_ids_on_sale() );
}
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array_merge(
parent::get_block_type_attributes(),
array(
'className' => $this->get_schema_string(),
'orderby' => $this->get_schema_orderby(),
)
);
}
}
BlockTypes/ProductPrice.php 0000644 00000005451 15154173074 0011753 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductPrice class.
*/
class ProductPrice extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-price';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'text' => true,
'background' => true,
'link' => false,
),
'typography' =>
array(
'fontSize' => true,
'__experimentalFontWeight' => true,
'__experimentalFontStyle' => true,
),
'__experimentalSelector' => '.wp-block-woocommerce-product-price .wc-block-components-product-price',
);
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
$product = wc_get_product( $post_id );
if ( $product ) {
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$text_align_styles_and_classes = StyleAttributesUtils::get_text_align_class_and_style( $attributes );
return sprintf(
'<div class="wp-block-woocommerce-product-price"><div class="wc-block-components-product-price wc-block-grid__product-price %1$s %2$s" style="%3$s">
%4$s
</div></div>',
esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ),
$product->get_price_html()
);
}
}
}
BlockTypes/ProductQuery.php 0000644 00000066167 15154173074 0012031 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use WP_Query;
use Automattic\WooCommerce\Blocks\Utils\Utils;
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
/**
* ProductQuery class.
*/
class ProductQuery extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-query';
/**
* The Block with its attributes before it gets rendered
*
* @var array
*/
protected $parsed_block;
/**
* Orderby options not natively supported by WordPress REST API
*
* @var array
*/
protected $custom_order_opts = array( 'popularity', 'rating' );
/**
* All the query args related to the filter by attributes block.
*
* @var array
*/
protected $attributes_filter_query_args = array();
/** This is a feature flag to enable the custom inherit Global Query implementation.
* This is not intended to be a permanent feature flag, but rather a temporary.
* It is also necessary to enable this feature flag on the PHP side: `assets/js/blocks/product-query/utils.tsx:83`.
* https://github.com/woocommerce/woocommerce-blocks/pull/7382
*
* @var boolean
*/
protected $is_custom_inherit_global_query_implementation_enabled = false;
/**
* All query args from WP_Query.
*
* @var array
*/
protected $valid_query_vars;
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
* - Hook into pre_render_block to update the query.
*/
protected function initialize() {
add_filter( 'query_vars', array( $this, 'set_query_vars' ) );
parent::initialize();
add_filter(
'pre_render_block',
array( $this, 'update_query' ),
10,
2
);
add_filter(
'render_block',
array( $this, 'enqueue_styles' ),
10,
2
);
add_filter( 'rest_product_query', array( $this, 'update_rest_query' ), 10, 2 );
add_filter( 'rest_product_collection_params', array( $this, 'extend_rest_query_allowed_params' ), 10, 1 );
}
/**
* Post Template support for grid view was introduced in Gutenberg 16 / WordPress 6.3
* Fixed in:
* - https://github.com/woocommerce/woocommerce-blocks/pull/9916
* - https://github.com/woocommerce/woocommerce-blocks/pull/10360
*/
private function check_if_post_template_has_support_for_grid_view() {
if ( Utils::wp_version_compare( '6.3', '>=' ) ) {
return true;
}
if ( is_plugin_active( 'gutenberg/gutenberg.php' ) ) {
$gutenberg_version = '';
if ( defined( 'GUTENBERG_VERSION' ) ) {
$gutenberg_version = GUTENBERG_VERSION;
}
if ( ! $gutenberg_version ) {
$gutenberg_data = get_file_data(
WP_PLUGIN_DIR . '/gutenberg/gutenberg.php',
array( 'Version' => 'Version' )
);
$gutenberg_version = $gutenberg_data['Version'];
}
return version_compare( $gutenberg_version, '16.0', '>=' );
}
return false;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$post_template_has_support_for_grid_view = $this->check_if_post_template_has_support_for_grid_view();
$this->asset_data_registry->add(
'postTemplateHasSupportForGridView',
$post_template_has_support_for_grid_view
);
// The `loop_shop_per_page` filter can be found in WC_Query::product_query().
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$this->asset_data_registry->add( 'loopShopPerPage', apply_filters( 'loop_shop_per_page', wc_get_default_products_per_row() * wc_get_default_product_rows_per_page() ), true );
}
/**
* Check if a given block
*
* @param array $parsed_block The block being rendered.
* @return boolean
*/
public static function is_woocommerce_variation( $parsed_block ) {
return isset( $parsed_block['attrs']['namespace'] )
&& substr( $parsed_block['attrs']['namespace'], 0, 11 ) === 'woocommerce';
}
/**
* Enqueues the variation styles when rendering the Product Query variation.
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
*
* @return string The block content.
*/
public function enqueue_styles( string $block_content, array $block ) {
if ( 'core/query' === $block['blockName'] && self::is_woocommerce_variation( $block ) ) {
wp_enqueue_style( 'wc-blocks-style-product-query' );
}
return $block_content;
}
/**
* Update the query for the product query block.
*
* @param string|null $pre_render The pre-rendered content. Default null.
* @param array $parsed_block The block being rendered.
*/
public function update_query( $pre_render, $parsed_block ) {
if ( 'core/query' !== $parsed_block['blockName'] ) {
return;
}
$this->parsed_block = $parsed_block;
if ( self::is_woocommerce_variation( $parsed_block ) ) {
// Set this so that our product filters can detect if it's a PHP template.
$this->asset_data_registry->add( 'hasFilterableProducts', true, true );
$this->asset_data_registry->add( 'isRenderingPhpTemplate', true, true );
add_filter(
'query_loop_block_query_vars',
array( $this, 'build_query' ),
10,
1
);
}
}
/**
* Merge tax_queries from various queries.
*
* @param array ...$queries Query arrays to be merged.
* @return array
*/
private function merge_tax_queries( ...$queries ) {
$tax_query = [];
foreach ( $queries as $query ) {
if ( ! empty( $query['tax_query'] ) ) {
$tax_query = array_merge( $tax_query, $query['tax_query'] );
}
}
return [ 'tax_query' => $tax_query ];
}
/**
* Update the query for the product query block in Editor.
*
* @param array $args Query args.
* @param WP_REST_Request $request Request.
*/
public function update_rest_query( $args, $request ): array {
$woo_attributes = $request->get_param( '__woocommerceAttributes' );
$is_valid_attributes = is_array( $woo_attributes );
$orderby = $request->get_param( 'orderby' );
$woo_stock_status = $request->get_param( '__woocommerceStockStatus' );
$on_sale = $request->get_param( '__woocommerceOnSale' ) === 'true';
$on_sale_query = $on_sale ? $this->get_on_sale_products_query() : [];
$orderby_query = $orderby ? $this->get_custom_orderby_query( $orderby ) : [];
$attributes_query = $is_valid_attributes ? $this->get_product_attributes_query( $woo_attributes ) : [];
$stock_query = is_array( $woo_stock_status ) ? $this->get_stock_status_query( $woo_stock_status ) : [];
$visibility_query = is_array( $woo_stock_status ) ? $this->get_product_visibility_query( $stock_query ) : [];
$tax_query = $is_valid_attributes ? $this->merge_tax_queries( $attributes_query, $visibility_query ) : [];
return array_merge( $args, $on_sale_query, $orderby_query, $stock_query, $tax_query );
}
/**
* Return a custom query based on attributes, filters and global WP_Query.
*
* @param WP_Query $query The WordPress Query.
* @return array
*/
public function build_query( $query ) {
$parsed_block = $this->parsed_block;
if ( ! $this->is_woocommerce_variation( $parsed_block ) ) {
return $query;
}
$common_query_values = array(
'meta_query' => array(),
'posts_per_page' => $query['posts_per_page'],
'orderby' => $query['orderby'],
'order' => $query['order'],
'offset' => $query['offset'],
'post__in' => array(),
'post_status' => 'publish',
'post_type' => 'product',
'tax_query' => array(),
);
$handpicked_products = isset( $parsed_block['attrs']['query']['include'] ) ?
$parsed_block['attrs']['query']['include'] : $common_query_values['post__in'];
$merged_query = $this->merge_queries(
$common_query_values,
$this->get_global_query( $parsed_block ),
$this->get_custom_orderby_query( $query['orderby'] ),
$this->get_queries_by_custom_attributes( $parsed_block ),
$this->get_queries_by_applied_filters(),
$this->get_filter_by_taxonomies_query( $query ),
$this->get_filter_by_keyword_query( $query )
);
return $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products );
}
/**
* Merge in the first parameter the keys "post_in", "meta_query" and "tax_query" of the second parameter.
*
* @param array[] ...$queries Query arrays to be merged.
* @return array
*/
private function merge_queries( ...$queries ) {
$merged_query = array_reduce(
$queries,
function( $acc, $query ) {
if ( ! is_array( $query ) ) {
return $acc;
}
// If the $query doesn't contain any valid query keys, we unpack/spread it then merge.
if ( empty( array_intersect( $this->get_valid_query_vars(), array_keys( $query ) ) ) ) {
return $this->merge_queries( $acc, ...array_values( $query ) );
}
return $this->array_merge_recursive_replace_non_array_properties( $acc, $query );
},
array()
);
/**
* If there are duplicated items in post__in, it means that we need to
* use the intersection of the results, which in this case, are the
* duplicated items.
*/
if (
! empty( $merged_query['post__in'] ) &&
count( $merged_query['post__in'] ) > count( array_unique( $merged_query['post__in'] ) )
) {
$merged_query['post__in'] = array_unique(
array_diff(
$merged_query['post__in'],
array_unique( $merged_query['post__in'] )
)
);
}
return $merged_query;
}
/**
* Extends allowed `collection_params` for the REST API
*
* By itself, the REST API doesn't accept custom `orderby` values,
* even if they are supported by a custom post type.
*
* @param array $params A list of allowed `orderby` values.
*
* @return array
*/
public function extend_rest_query_allowed_params( $params ) {
$original_enum = isset( $params['orderby']['enum'] ) ? $params['orderby']['enum'] : array();
$params['orderby']['enum'] = array_merge( $original_enum, $this->custom_order_opts );
return $params;
}
/**
* Return a query for on sale products.
*
* @return array
*/
private function get_on_sale_products_query() {
return array(
'post__in' => wc_get_product_ids_on_sale(),
);
}
/**
* Return query params to support custom sort values
*
* @param string $orderby Sort order option.
*
* @return array
*/
private function get_custom_orderby_query( $orderby ) {
if ( ! in_array( $orderby, $this->custom_order_opts, true ) ) {
return array( 'orderby' => $orderby );
}
$meta_keys = array(
'popularity' => 'total_sales',
'rating' => '_wc_average_rating',
);
return array(
'meta_key' => $meta_keys[ $orderby ],
'orderby' => 'meta_value_num',
);
}
/**
* Apply the query only to a subset of products
*
* @param array $query The query.
* @param array $ids Array of selected product ids.
*
* @return array
*/
private function filter_query_to_only_include_ids( $query, $ids ) {
if ( ! empty( $ids ) ) {
$query['post__in'] = empty( $query['post__in'] ) ?
$ids : array_intersect( $ids, $query['post__in'] );
}
return $query;
}
/**
* Return the `tax_query` for the requested attributes
*
* @param array $attributes Attributes and their terms.
*
* @return array
*/
private function get_product_attributes_query( $attributes = array() ) {
$grouped_attributes = array_reduce(
$attributes,
function ( $carry, $item ) {
$taxonomy = sanitize_title( $item['taxonomy'] );
if ( ! key_exists( $taxonomy, $carry ) ) {
$carry[ $taxonomy ] = array(
'field' => 'term_id',
'operator' => 'IN',
'taxonomy' => $taxonomy,
'terms' => array( $item['termId'] ),
);
} else {
$carry[ $taxonomy ]['terms'][] = $item['termId'];
}
return $carry;
},
array()
);
return array(
'tax_query' => array_values( $grouped_attributes ),
);
}
/**
* Return a query for products depending on their stock status.
*
* @param array $stock_statii An array of acceptable stock statii.
* @return array
*/
private function get_stock_status_query( $stock_statii ) {
if ( ! is_array( $stock_statii ) ) {
return array();
}
$stock_status_options = array_keys( wc_get_product_stock_status_options() );
/**
* If all available stock status are selected, we don't need to add the
* meta query for stock status.
*/
if (
count( $stock_statii ) === count( $stock_status_options ) &&
array_diff( $stock_statii, $stock_status_options ) === array_diff( $stock_status_options, $stock_statii )
) {
return array();
}
/**
* If all stock statuses are selected except 'outofstock', we use the
* product visibility query to filter out out of stock products.
*
* @see get_product_visibility_query()
*/
$diff = array_diff( $stock_status_options, $stock_statii );
if ( count( $diff ) === 1 && in_array( 'outofstock', $diff, true ) ) {
return array();
}
return array(
'meta_query' => array(
array(
'key' => '_stock_status',
'value' => (array) $stock_statii,
'compare' => 'IN',
),
),
);
}
/**
* Return a query for product visibility depending on their stock status.
*
* @param array $stock_query Stock status query.
*
* @return array Tax query for product visibility.
*/
private function get_product_visibility_query( $stock_query ) {
$product_visibility_terms = wc_get_product_visibility_term_ids();
$product_visibility_not_in = array( is_search() ? $product_visibility_terms['exclude-from-search'] : $product_visibility_terms['exclude-from-catalog'] );
// Hide out of stock products.
if ( empty( $stock_query ) && 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$product_visibility_not_in[] = $product_visibility_terms['outofstock'];
}
return array(
'tax_query' => array(
array(
'taxonomy' => 'product_visibility',
'field' => 'term_taxonomy_id',
'terms' => $product_visibility_not_in,
'operator' => 'NOT IN',
),
),
);
}
/**
* Set the query vars that are used by filter blocks.
*
* @return array
*/
private function get_query_vars_from_filter_blocks() {
$attributes_filter_query_args = array_reduce(
array_values( $this->get_filter_by_attributes_query_vars() ),
function( $acc, $array ) {
return array_merge( array_values( $array ), $acc );
},
array()
);
return array(
'price_filter_query_args' => array( PriceFilter::MIN_PRICE_QUERY_VAR, PriceFilter::MAX_PRICE_QUERY_VAR ),
'stock_filter_query_args' => array( StockFilter::STOCK_STATUS_QUERY_VAR ),
'attributes_filter_query_args' => $attributes_filter_query_args,
'rating_filter_query_args' => array( RatingFilter::RATING_QUERY_VAR ),
);
}
/**
* Set the query vars that are used by filter blocks.
*
* @param array $public_query_vars Public query vars.
* @return array
*/
public function set_query_vars( $public_query_vars ) {
$query_vars = $this->get_query_vars_from_filter_blocks();
return array_reduce(
array_values( $query_vars ),
function( $acc, $query_vars_filter_block ) {
return array_merge( $query_vars_filter_block, $acc );
},
$public_query_vars
);
}
/**
* Get all the query args related to the filter by attributes block.
*
* @return array
* [color] => Array
* (
* [filter] => filter_color
* [query_type] => query_type_color
* )
*
* [size] => Array
* (
* [filter] => filter_size
* [query_type] => query_type_size
* )
* )
*/
private function get_filter_by_attributes_query_vars() {
if ( ! empty( $this->attributes_filter_query_args ) ) {
return $this->attributes_filter_query_args;
}
$this->attributes_filter_query_args = array_reduce(
wc_get_attribute_taxonomies(),
function( $acc, $attribute ) {
$acc[ $attribute->attribute_name ] = array(
'filter' => AttributeFilter::FILTER_QUERY_VAR_PREFIX . $attribute->attribute_name,
'query_type' => AttributeFilter::QUERY_TYPE_QUERY_VAR_PREFIX . $attribute->attribute_name,
);
return $acc;
},
array()
);
return $this->attributes_filter_query_args;
}
/**
* Return queries that are generated by query args.
*
* @return array
*/
private function get_queries_by_applied_filters() {
return array(
'price_filter' => $this->get_filter_by_price_query(),
'attributes_filter' => $this->get_filter_by_attributes_query(),
'stock_status_filter' => $this->get_filter_by_stock_status_query(),
'rating_filter' => $this->get_filter_by_rating_query(),
);
}
/**
* Return queries that are generated by attributes
*
* @param array $parsed_block The Product Query that being rendered.
* @return array
*/
private function get_queries_by_custom_attributes( $parsed_block ) {
$query = $parsed_block['attrs']['query'];
$on_sale_enabled = isset( $query['__woocommerceOnSale'] ) && true === $query['__woocommerceOnSale'];
$attributes_query = isset( $query['__woocommerceAttributes'] ) ? $this->get_product_attributes_query( $query['__woocommerceAttributes'] ) : array();
$stock_query = isset( $query['__woocommerceStockStatus'] ) ? $this->get_stock_status_query( $query['__woocommerceStockStatus'] ) : array();
$visibility_query = $this->get_product_visibility_query( $stock_query );
return array(
'on_sale' => ( $on_sale_enabled ? $this->get_on_sale_products_query() : array() ),
'attributes' => $attributes_query,
'stock_status' => $stock_query,
'visibility' => $visibility_query,
);
}
/**
* Return a query that filters products by price.
*
* @return array
*/
private function get_filter_by_price_query() {
$min_price = get_query_var( PriceFilter::MIN_PRICE_QUERY_VAR );
$max_price = get_query_var( PriceFilter::MAX_PRICE_QUERY_VAR );
$max_price_query = empty( $max_price ) ? array() : [
'key' => '_price',
'value' => $max_price,
'compare' => '<',
'type' => 'numeric',
];
$min_price_query = empty( $min_price ) ? array() : [
'key' => '_price',
'value' => $min_price,
'compare' => '>=',
'type' => 'numeric',
];
if ( empty( $min_price_query ) && empty( $max_price_query ) ) {
return array();
}
return array(
'meta_query' => array(
array(
'relation' => 'AND',
$max_price_query,
$min_price_query,
),
),
);
}
/**
* Return a query that filters products by attributes.
*
* @return array
*/
private function get_filter_by_attributes_query() {
$attributes_filter_query_args = $this->get_filter_by_attributes_query_vars();
$queries = array_reduce(
$attributes_filter_query_args,
function( $acc, $query_args ) {
$attribute_name = $query_args['filter'];
$attribute_query_type = $query_args['query_type'];
$attribute_value = get_query_var( $attribute_name );
$attribute_query = get_query_var( $attribute_query_type );
if ( empty( $attribute_value ) ) {
return $acc;
}
// It is necessary explode the value because $attribute_value can be a string with multiple values (e.g. "red,blue").
$attribute_value = explode( ',', $attribute_value );
$acc[] = array(
'taxonomy' => str_replace( AttributeFilter::FILTER_QUERY_VAR_PREFIX, 'pa_', $attribute_name ),
'field' => 'slug',
'terms' => $attribute_value,
'operator' => 'and' === $attribute_query ? 'AND' : 'IN',
);
return $acc;
},
array()
);
if ( empty( $queries ) ) {
return array();
}
return array(
'tax_query' => array(
array(
'relation' => 'AND',
$queries,
),
),
);
}
/**
* Return a query that filters products by stock status.
*
* @return array
*/
private function get_filter_by_stock_status_query() {
$filter_stock_status_values = get_query_var( StockFilter::STOCK_STATUS_QUERY_VAR );
if ( empty( $filter_stock_status_values ) ) {
return array();
}
$filtered_stock_status_values = array_filter(
explode( ',', $filter_stock_status_values ),
function( $stock_status ) {
return in_array( $stock_status, StockFilter::get_stock_status_query_var_values(), true );
}
);
if ( empty( $filtered_stock_status_values ) ) {
return array();
}
return array(
// Ignoring the warning of not using meta queries.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_stock_status',
'value' => $filtered_stock_status_values,
'operator' => 'IN',
),
),
);
}
/**
* Return or initialize $valid_query_vars.
*
* @return array
*/
private function get_valid_query_vars() {
if ( ! empty( $this->valid_query_vars ) ) {
return $this->valid_query_vars;
}
$valid_query_vars = array_keys( ( new WP_Query() )->fill_query_vars( array() ) );
$this->valid_query_vars = array_merge(
$valid_query_vars,
// fill_query_vars doesn't include these vars so we need to add them manually.
array(
'date_query',
'exact',
'ignore_sticky_posts',
'lazy_load_term_meta',
'meta_compare_key',
'meta_compare',
'meta_query',
'meta_type_key',
'meta_type',
'nopaging',
'offset',
'order',
'orderby',
'page',
'post_type',
'posts_per_page',
'suppress_filters',
'tax_query',
)
);
return $this->valid_query_vars;
}
/**
* Merge two array recursively but replace the non-array values instead of
* merging them. The merging strategy:
*
* - If keys from merge array doesn't exist in the base array, create them.
* - For array items with numeric keys, we merge them as normal.
* - For array items with string keys:
*
* - If the value isn't array, we'll use the value comming from the merge array.
* $base = ['orderby' => 'date']
* $new = ['orderby' => 'meta_value_num']
* Result: ['orderby' => 'meta_value_num']
*
* - If the value is array, we'll use recursion to merge each key.
* $base = ['meta_query' => [
* [
* 'key' => '_stock_status',
* 'compare' => 'IN'
* 'value' => ['instock', 'onbackorder']
* ]
* ]]
* $new = ['meta_query' => [
* [
* 'relation' => 'AND',
* [...<max_price_query>],
* [...<min_price_query>],
* ]
* ]]
* Result: ['meta_query' => [
* [
* 'key' => '_stock_status',
* 'compare' => 'IN'
* 'value' => ['instock', 'onbackorder']
* ],
* [
* 'relation' => 'AND',
* [...<max_price_query>],
* [...<min_price_query>],
* ]
* ]]
*
* $base = ['post__in' => [1, 2, 3, 4, 5]]
* $new = ['post__in' => [3, 4, 5, 6, 7]]
* Result: ['post__in' => [1, 2, 3, 4, 5, 3, 4, 5, 6, 7]]
*
* @param array $base First array.
* @param array $new Second array.
*/
private function array_merge_recursive_replace_non_array_properties( $base, $new ) {
foreach ( $new as $key => $value ) {
if ( is_numeric( $key ) ) {
$base[] = $value;
} else {
if ( is_array( $value ) ) {
if ( ! isset( $base[ $key ] ) ) {
$base[ $key ] = array();
}
$base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value );
} else {
$base[ $key ] = $value;
}
}
}
return $base;
}
/**
* Get product-related query variables from the global query.
*
* @param array $parsed_block The Product Query that being rendered.
*
* @return array
*/
private function get_global_query( $parsed_block ) {
if ( ! $this->is_custom_inherit_global_query_implementation_enabled ) {
return array();
}
global $wp_query;
$inherit_enabled = isset( $parsed_block['attrs']['query']['__woocommerceInherit'] ) && true === $parsed_block['attrs']['query']['__woocommerceInherit'];
if ( ! $inherit_enabled ) {
return array();
}
$query = array();
if ( isset( $wp_query->query_vars['taxonomy'] ) && isset( $wp_query->query_vars['term'] ) ) {
$query['tax_query'] = array(
array(
'taxonomy' => $wp_query->query_vars['taxonomy'],
'field' => 'slug',
'terms' => $wp_query->query_vars['term'],
),
);
}
if ( isset( $wp_query->query_vars['s'] ) ) {
$query['s'] = $wp_query->query_vars['s'];
}
return $query;
}
/**
* Return a query that filters products by rating.
*
* @return array
*/
private function get_filter_by_rating_query() {
$filter_rating_values = get_query_var( RatingFilter::RATING_QUERY_VAR );
if ( empty( $filter_rating_values ) ) {
return array();
}
$parsed_filter_rating_values = explode( ',', $filter_rating_values );
$product_visibility_terms = wc_get_product_visibility_term_ids();
if ( empty( $parsed_filter_rating_values ) || empty( $product_visibility_terms ) ) {
return array();
}
$rating_terms = array_map(
function( $rating ) use ( $product_visibility_terms ) {
return $product_visibility_terms[ 'rated-' . $rating ];
},
$parsed_filter_rating_values
);
return array(
'tax_query' => array(
array(
'field' => 'term_taxonomy_id',
'taxonomy' => 'product_visibility',
'terms' => $rating_terms,
'operator' => 'IN',
'rating_filter' => true,
),
),
);
}
/**
* Return a query to filter products by taxonomies (product categories, product tags, etc.)
*
* For example:
* User could provide "Product Categories" using "Filters" ToolsPanel available in Inspector Controls.
* We use this function to extract it's query from $tax_query.
*
* For example, this is how the query for product categories will look like in $tax_query array:
* Array
* (
* [taxonomy] => product_cat
* [terms] => Array
* (
* [0] => 36
* )
* )
*
* For product categories, taxonomy would be "product_tag"
*
* @param array $query WP_Query.
* @return array Query to filter products by taxonomies.
*/
private function get_filter_by_taxonomies_query( $query ): array {
if ( ! isset( $query['tax_query'] ) || ! is_array( $query['tax_query'] ) ) {
return [];
}
$tax_query = $query['tax_query'];
/**
* Get an array of taxonomy names associated with the "product" post type because
* we also want to include custom taxonomies associated with the "product" post type.
*/
$product_taxonomies = get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' );
$result = array_filter(
$tax_query,
function( $item ) use ( $product_taxonomies ) {
return isset( $item['taxonomy'] ) && in_array( $item['taxonomy'], $product_taxonomies, true );
}
);
return ! empty( $result ) ? [ 'tax_query' => $result ] : [];
}
/**
* Returns the keyword filter from the given query.
*
* @param WP_Query $query The query to extract the keyword filter from.
* @return array The keyword filter, or an empty array if none is found.
*/
private function get_filter_by_keyword_query( $query ): array {
if ( ! is_array( $query ) ) {
return [];
}
if ( isset( $query['s'] ) ) {
return [ 's' => $query['s'] ];
}
return [];
}
}
BlockTypes/ProductRating.php 0000644 00000015212 15154173074 0012131 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductRating class.
*/
class ProductRating extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-rating';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'text' => true,
'background' => false,
'link' => false,
'__experimentalSkipSerialization' => true,
),
'typography' =>
array(
'fontSize' => true,
'__experimentalSkipSerialization' => true,
),
'spacing' =>
array(
'margin' => true,
'padding' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-rating',
);
}
/**
* Get the block's attributes.
*
* @param array $attributes Block attributes. Default empty array.
* @return array Block attributes merged with defaults.
*/
private function parse_attributes( $attributes ) {
// These should match what's set in JS `registerBlockType`.
$defaults = array(
'productId' => 0,
'isDescendentOfQueryLoop' => false,
'textAlign' => '',
'isDescendentOfSingleProductBlock' => false,
'isDescendentOfSingleProductTemplate' => false,
);
return wp_parse_args( $attributes, $defaults );
}
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
if ( $product && $product->get_review_count() > 0 ) {
$product_reviews_count = $product->get_review_count();
$product_rating = $product->get_average_rating();
$parsed_attributes = $this->parse_attributes( $attributes );
$is_descendent_of_single_product_block = $parsed_attributes['isDescendentOfSingleProductBlock'];
$is_descendent_of_single_product_template = $parsed_attributes['isDescendentOfSingleProductTemplate'];
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$text_align_styles_and_classes = StyleAttributesUtils::get_text_align_class_and_style( $attributes );
/**
* Filter the output from wc_get_rating_html.
*
* @param string $html Star rating markup. Default empty string.
* @param float $rating Rating being shown.
* @param int $count Total number of ratings.
* @return string
*/
$filter_rating_html = function( $html, $rating, $count ) use ( $post_id, $product_rating, $product_reviews_count, $is_descendent_of_single_product_block, $is_descendent_of_single_product_template ) {
$product_permalink = get_permalink( $post_id );
$reviews_count = $count;
$average_rating = $rating;
if ( $product_rating ) {
$average_rating = $product_rating;
}
if ( $product_reviews_count ) {
$reviews_count = $product_reviews_count;
}
if ( 0 < $average_rating || false === $product_permalink ) {
/* translators: %s: rating */
$label = sprintf( __( 'Rated %s out of 5', 'woocommerce' ), $average_rating );
$customer_reviews_count = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
$reviews_count,
'woocommerce'
),
esc_html( $reviews_count )
);
if ( $is_descendent_of_single_product_block ) {
$customer_reviews_count = '<a href="' . esc_url( $product_permalink ) . '#reviews">' . $customer_reviews_count . '</a>';
} elseif ( $is_descendent_of_single_product_template ) {
$customer_reviews_count = '<a class="woocommerce-review-link" rel="nofollow" href="#reviews">' . $customer_reviews_count . '</a>';
}
$reviews_count_html = sprintf( '<span class="wc-block-components-product-rating__reviews_count">%1$s</span>', $customer_reviews_count );
$html = sprintf(
'<div class="wc-block-components-product-rating__container">
<div class="wc-block-components-product-rating__stars wc-block-grid__product-rating__stars" role="img" aria-label="%1$s">
%2$s
</div>
%3$s
</div>
',
esc_attr( $label ),
wc_get_star_rating_html( $average_rating, $reviews_count ),
$is_descendent_of_single_product_block || $is_descendent_of_single_product_template ? $reviews_count_html : ''
);
} else {
$html = '';
}
return $html;
};
add_filter(
'woocommerce_product_get_rating_html',
$filter_rating_html,
10,
3
);
$rating_html = wc_get_rating_html( $product->get_average_rating() );
remove_filter(
'woocommerce_product_get_rating_html',
$filter_rating_html,
10
);
return sprintf(
'<div class="wc-block-components-product-rating wc-block-grid__product-rating %1$s %2$s" style="%3$s">
%4$s
</div>',
esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ),
$rating_html
);
}
return '';
}
}
BlockTypes/ProductRatingCounter.php 0000644 00000014205 15154173074 0013472 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductRatingCounter class.
*/
class ProductRatingCounter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-rating-counter';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'text' => true,
'background' => false,
'link' => false,
'__experimentalSkipSerialization' => true,
),
'typography' =>
array(
'fontSize' => true,
'__experimentalSkipSerialization' => true,
),
'spacing' =>
array(
'margin' => true,
'padding' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-rating-counter',
);
}
/**
* Get the block's attributes.
*
* @param array $attributes Block attributes. Default empty array.
* @return array Block attributes merged with defaults.
*/
private function parse_attributes( $attributes ) {
// These should match what's set in JS `registerBlockType`.
$defaults = array(
'productId' => 0,
'isDescendentOfQueryLoop' => false,
'textAlign' => '',
'isDescendentOfSingleProductBlock' => false,
'isDescendentOfSingleProductTemplate' => false,
);
return wp_parse_args( $attributes, $defaults );
}
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
if ( $product && $product->get_review_count() > 0 ) {
$product_reviews_count = $product->get_review_count();
$product_rating = $product->get_average_rating();
$parsed_attributes = $this->parse_attributes( $attributes );
$is_descendent_of_single_product_block = $parsed_attributes['isDescendentOfSingleProductBlock'];
$is_descendent_of_single_product_template = $parsed_attributes['isDescendentOfSingleProductTemplate'];
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$text_align_styles_and_classes = StyleAttributesUtils::get_text_align_class_and_style( $attributes );
/**
* Filter the output from wc_get_rating_html.
*
* @param string $html Star rating markup. Default empty string.
* @param float $rating Rating being shown.
* @param int $count Total number of ratings.
* @return string
*/
$filter_rating_html = function( $html, $rating, $count ) use ( $post_id, $product_rating, $product_reviews_count, $is_descendent_of_single_product_block, $is_descendent_of_single_product_template ) {
$product_permalink = get_permalink( $post_id );
$reviews_count = $count;
$average_rating = $rating;
if ( $product_rating ) {
$average_rating = $product_rating;
}
if ( $product_reviews_count ) {
$reviews_count = $product_reviews_count;
}
if ( 0 < $average_rating || false === $product_permalink ) {
/* translators: %s: rating */
$customer_reviews_count = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
$reviews_count,
'woocommerce'
),
esc_html( $reviews_count )
);
if ( $is_descendent_of_single_product_block ) {
$customer_reviews_count = '<a href="' . esc_url( $product_permalink ) . '#reviews">' . $customer_reviews_count . '</a>';
} elseif ( $is_descendent_of_single_product_template ) {
$customer_reviews_count = '<a class="woocommerce-review-link" rel="nofollow" href="#reviews">' . $customer_reviews_count . '</a>';
}
$html = sprintf(
'<div class="wc-block-components-product-rating-counter__container">
<span class="wc-block-components-product-rating-counter__reviews_count">%1$s</span>
</div>
',
$customer_reviews_count
);
} else {
$html = '';
}
return $html;
};
add_filter(
'woocommerce_product_get_rating_html',
$filter_rating_html,
10,
3
);
$rating_html = wc_get_rating_html( $product->get_average_rating() );
remove_filter(
'woocommerce_product_get_rating_html',
$filter_rating_html,
10
);
return sprintf(
'<div class="wc-block-components-product-rating-counter wc-block-grid__product-rating-counter %1$s %2$s" style="%3$s">
%4$s
</div>',
esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ),
$rating_html
);
}
return '';
}
}
BlockTypes/ProductRatingStars.php 0000644 00000010652 15154173074 0013151 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductRatingStars class.
*/
class ProductRatingStars extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-rating-stars';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'text' => true,
'background' => false,
'link' => false,
'__experimentalSkipSerialization' => true,
),
'typography' =>
array(
'fontSize' => true,
'__experimentalSkipSerialization' => true,
),
'spacing' =>
array(
'margin' => true,
'padding' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-rating-stars',
);
}
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
if ( $product ) {
$product_reviews_count = $product->get_review_count();
$product_rating = $product->get_average_rating();
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$text_align_styles_and_classes = StyleAttributesUtils::get_text_align_class_and_style( $attributes );
/**
* Filter the output from wc_get_rating_html.
*
* @param string $html Star rating markup. Default empty string.
* @param float $rating Rating being shown.
* @param int $count Total number of ratings.
* @return string
*/
$filter_rating_html = function( $html, $rating, $count ) use ( $product_rating, $product_reviews_count ) {
$product_permalink = get_permalink();
$reviews_count = $count;
$average_rating = $rating;
if ( $product_rating ) {
$average_rating = $product_rating;
}
if ( $product_reviews_count ) {
$reviews_count = $product_reviews_count;
}
if ( 0 < $average_rating || false === $product_permalink ) {
/* translators: %s: rating */
$label = sprintf( __( 'Rated %s out of 5', 'woocommerce' ), $average_rating );
$html = sprintf(
'<div class="wc-block-components-product-rating-stars__container">
<div class="wc-block-components-product-rating__stars wc-block-grid__product-rating__stars" role="img" aria-label="%1$s">
%2$s
</div>
</div>
',
esc_attr( $label ),
wc_get_star_rating_html( $average_rating, $reviews_count )
);
} else {
$html = '';
}
return $html;
};
add_filter(
'woocommerce_product_get_rating_html',
$filter_rating_html,
10,
3
);
$rating_html = wc_get_rating_html( $product->get_average_rating() );
remove_filter(
'woocommerce_product_get_rating_html',
$filter_rating_html,
10
);
return sprintf(
'<div class="wc-block-components-product-rating wc-block-grid__product-rating %1$s %2$s" style="%3$s">
%4$s
</div>',
esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ),
$rating_html
);
}
}
}
BlockTypes/ProductResultsCount.php 0000644 00000002560 15154173074 0013361 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductResultsCount class.
*/
class ProductResultsCount extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-results-count';
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
ob_start();
woocommerce_result_count();
$product_results_count = ob_get_clean();
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$classname = isset( $attributes['className'] ) ? $attributes['className'] : '';
return sprintf(
'<div class="woocommerce wc-block-product-results-count wp-block-woocommerce-product-results-count %1$s %2$s" style="%3$s">%4$s</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
esc_attr( $classes_and_styles['styles'] ),
$product_results_count
);
}
}
BlockTypes/ProductReviews.php 0000644 00000001750 15154173074 0012333 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductReviews class.
*/
class ProductReviews extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-reviews';
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
ob_start();
rewind_posts();
while ( have_posts() ) {
the_post();
comments_template();
}
$reviews = ob_get_clean();
$classname = $attributes['className'] ?? '';
return sprintf(
'<div class="wp-block-woocommerce-product-reviews %1$s">
%2$s
</div>',
esc_attr( $classname ),
$reviews
);
}
}
BlockTypes/ProductSKU.php 0000644 00000003631 15154173074 0011351 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductSKU class.
*/
class ProductSKU extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-sku';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
if ( ! $product ) {
return '';
}
$product_sku = $product->get_sku();
if ( ! $product_sku ) {
return '';
}
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
return sprintf(
'<div class="wc-block-components-product-sku wc-block-grid__product-sku wp-block-woocommerce-product-sku product_meta %1$s" style="%2$s">
SKU:
<strong class="sku">%3$s</strong>
</div>',
esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ),
$product_sku
);
}
}
BlockTypes/ProductSaleBadge.php 0000644 00000006672 15154173074 0012526 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductSaleBadge class.
*/
class ProductSaleBadge extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-sale-badge';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'gradients' => true,
'background' => true,
'link' => true,
),
'typography' =>
array(
'fontSize' => true,
'lineHeight' => true,
'__experimentalFontFamily' => true,
'__experimentalFontWeight' => true,
'__experimentalFontStyle' => true,
'__experimentalLetterSpacing' => true,
'__experimentalTextTransform' => true,
'__experimentalTextDecoration' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalBorder' =>
array(
'color' => true,
'radius' => true,
'width' => true,
),
'spacing' =>
array(
'margin' => true,
'padding' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-sale-badge',
);
}
/**
* Overwrite parent method to prevent script registration.
*
* It is necessary to register and enqueues assets during the render
* phase because we want to load assets only if the block has the content.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
$is_on_sale = $product->is_on_sale();
if ( ! $is_on_sale ) {
return null;
}
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$classname = isset( $attributes['className'] ) ? $attributes['className'] : '';
$align = isset( $attributes['align'] ) ? $attributes['align'] : '';
$output = '<div class="wp-block-woocommerce-product-sale-badge ' . esc_attr( $classname ) . '">';
$output .= sprintf( '<div class="wc-block-components-product-sale-badge %1$s wc-block-components-product-sale-badge--align-%2$s" style="%3$s">', esc_attr( $classes_and_styles['classes'] ), esc_attr( $align ), esc_attr( $classes_and_styles['styles'] ) );
$output .= '<span class="wc-block-components-product-sale-badge__text" aria-hidden="true">' . __( 'Sale', 'woocommerce' ) . '</span>';
$output .= '<span class="screen-reader-text">'
. __( 'Product on sale', 'woocommerce' )
. '</span>';
$output .= '</div></div>';
return $output;
}
}
BlockTypes/ProductSearch.php 0000644 00000010771 15154173074 0012117 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductSearch class.
*/
class ProductSearch extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-search';
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
static $instance_id = 0;
$attributes = wp_parse_args(
$attributes,
array(
'hasLabel' => true,
'align' => '',
'className' => '',
'label' => __( 'Search', 'woocommerce' ),
'placeholder' => __( 'Search products…', 'woocommerce' ),
)
);
/**
* Product Search event.
*
* Listens for product search form submission, and on submission fires a WP Hook named
* `experimental__woocommerce_blocks-product-search`. This can be used by tracking extensions such as Google
* Analytics to track searches.
*/
$this->asset_api->add_inline_script(
'wp-hooks',
"
window.addEventListener( 'DOMContentLoaded', () => {
const forms = document.querySelectorAll( '.wc-block-product-search form' );
for ( const form of forms ) {
form.addEventListener( 'submit', ( event ) => {
const field = form.querySelector( '.wc-block-product-search__field' );
if ( field && field.value ) {
wp.hooks.doAction( 'experimental__woocommerce_blocks-product-search', { event: event, searchTerm: field.value } );
}
} );
}
} );
",
'after'
);
$input_id = 'wc-block-search__input-' . ( ++$instance_id );
$wrapper_attributes = get_block_wrapper_attributes(
array(
'class' => implode(
' ',
array_filter(
[
'wc-block-product-search',
$attributes['align'] ? 'align' . $attributes['align'] : '',
]
)
),
)
);
$label_markup = $attributes['hasLabel'] ? sprintf(
'<label for="%s" class="wc-block-product-search__label">%s</label>',
esc_attr( $input_id ),
esc_html( $attributes['label'] )
) : sprintf(
'<label for="%s" class="wc-block-product-search__label screen-reader-text">%s</label>',
esc_attr( $input_id ),
esc_html( $attributes['label'] )
);
$input_markup = sprintf(
'<input type="search" id="%s" class="wc-block-product-search__field" placeholder="%s" name="s" />',
esc_attr( $input_id ),
esc_attr( $attributes['placeholder'] )
);
$button_markup = sprintf(
'<button type="submit" class="wc-block-product-search__button" aria-label="%s">
<svg aria-hidden="true" role="img" focusable="false" class="dashicon dashicons-arrow-right-alt2" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M6 15l5-5-5-5 1-2 7 7-7 7z" />
</svg>
</button>',
esc_attr__( 'Search', 'woocommerce' )
);
$field_markup = '
<div class="wc-block-product-search__fields">
' . $input_markup . $button_markup . '
<input type="hidden" name="post_type" value="product" />
</div>
';
return sprintf(
'<div %s><form role="search" method="get" action="%s">%s</form></div>',
$wrapper_attributes,
esc_url( home_url( '/' ) ),
$label_markup . $field_markup
);
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$gutenberg_version = '';
if ( is_plugin_active( 'gutenberg/gutenberg.php' ) ) {
if ( defined( 'GUTENBERG_VERSION' ) ) {
$gutenberg_version = GUTENBERG_VERSION;
}
if ( ! $gutenberg_version ) {
$gutenberg_data = get_file_data(
WP_PLUGIN_DIR . '/gutenberg/gutenberg.php',
array( 'Version' => 'Version' )
);
$gutenberg_version = $gutenberg_data['Version'];
}
}
$this->asset_data_registry->add(
'isBlockVariationAvailable',
version_compare( get_bloginfo( 'version' ), '6.1', '>=' ) || version_compare( $gutenberg_version, '13.4', '>=' )
);
}
}
BlockTypes/ProductStockIndicator.php 0000644 00000007411 15154173074 0013627 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* ProductStockIndicator class.
*/
class ProductStockIndicator extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-stock-indicator';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Register the context.
*/
protected function get_block_type_uses_context() {
return [ 'query', 'queryId', 'postId' ];
}
/**
* Get stock text based on stock. For example:
* - In stock
* - Out of stock
* - Available on backorder
* - 2 left in stock
*
* @param [bool] $is_in_stock Whether the product is in stock.
* @param [bool] $is_low_stock Whether the product is low in stock.
* @param [int|null] $low_stock_amount The amount of stock that is considered low.
* @param [bool] $is_on_backorder Whether the product is on backorder.
* @return string Stock text.
*/
protected static function getTextBasedOnStock( $is_in_stock, $is_low_stock, $low_stock_amount, $is_on_backorder ) {
if ( $is_low_stock ) {
return sprintf(
/* translators: %d is number of items in stock for product */
__( '%d left in stock', 'woocommerce' ),
$low_stock_amount
);
} elseif ( $is_on_backorder ) {
return __( 'Available on backorder', 'woocommerce' );
} elseif ( $is_in_stock ) {
return __( 'In stock', 'woocommerce' );
} else {
return __( 'Out of stock', 'woocommerce' );
}
}
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( ! empty( $content ) ) {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
return $content;
}
$post_id = $block->context['postId'];
$product = wc_get_product( $post_id );
$is_in_stock = $product->is_in_stock();
$is_on_backorder = $product->is_on_backorder();
$low_stock_amount = $product->get_low_stock_amount();
$total_stock = $product->get_stock_quantity();
$is_low_stock = $low_stock_amount && $total_stock <= $low_stock_amount;
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$classnames = isset( $classes_and_styles['classes'] ) ? ' ' . $classes_and_styles['classes'] . ' ' : '';
$classnames .= isset( $attributes['className'] ) ? ' ' . $attributes['className'] . ' ' : '';
$classnames .= ! $is_in_stock ? ' wc-block-components-product-stock-indicator--out-of-stock ' : '';
$classnames .= $is_in_stock ? ' wc-block-components-product-stock-indicator--in-stock ' : '';
$classnames .= $is_low_stock ? ' wc-block-components-product-stock-indicator--low-stock ' : '';
$classnames .= $is_on_backorder ? ' wc-block-components-product-stock-indicator--available-on-backorder ' : '';
$output = '';
$output .= '<div class="wc-block-components-product-stock-indicator wp-block-woocommerce-product-stock-indicator ' . esc_attr( $classnames ) . '"';
$output .= isset( $classes_and_styles['styles'] ) ? ' style="' . esc_attr( $classes_and_styles['styles'] ) . '"' : '';
$output .= '>';
$output .= wp_kses_post( self::getTextBasedOnStock( $is_in_stock, $is_low_stock, $low_stock_amount, $is_on_backorder ) );
$output .= '</div>';
return $output;
}
}
BlockTypes/ProductSummary.php 0000644 00000002102 15154173074 0012334 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductSummary class.
*/
class ProductSummary extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-summary';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'link' => true,
'background' => false,
'text' => true,
),
'typography' =>
array(
'fontSize' => true,
),
'__experimentalSelector' => '.wc-block-components-product-summary',
);
}
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
$this->register_chunk_translations( [ $this->block_name ] );
}
}
BlockTypes/ProductTag.php 0000644 00000004370 15154173074 0011423 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductTag class.
*/
class ProductTag extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-tag';
/**
* Set args specific to this block.
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
if ( ! empty( $this->attributes['tags'] ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => 'product_tag',
'terms' => array_map( 'absint', $this->attributes['tags'] ),
'field' => 'term_id',
'operator' => isset( $this->attributes['tagOperator'] ) && 'any' === $this->attributes['tagOperator'] ? 'IN' : 'AND',
);
}
}
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array(
'className' => $this->get_schema_string(),
'columns' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_columns', 3 ) ),
'rows' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_rows', 3 ) ),
'contentVisibility' => $this->get_schema_content_visibility(),
'align' => $this->get_schema_align(),
'alignButtons' => $this->get_schema_boolean( false ),
'orderby' => $this->get_schema_orderby(),
'tags' => $this->get_schema_list_ids(),
'tagOperator' => array(
'type' => 'string',
'default' => 'any',
),
'isPreview' => $this->get_schema_boolean( false ),
'stockStatus' => array_keys( wc_get_product_stock_status_options() ),
);
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$tag_count = wp_count_terms( 'product_tag' );
$this->asset_data_registry->add( 'hasTags', $tag_count > 0, true );
$this->asset_data_registry->add( 'limitTags', $tag_count > 100, true );
}
}
BlockTypes/ProductTemplate.php 0000644 00000010310 15154173074 0012452 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use WP_Block;
use WP_Query;
/**
* ProductTemplate class.
*/
class ProductTemplate extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-template';
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string | void Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
$page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page';
// phpcs:ignore WordPress.Security.NonceVerification
$page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ];
// Use global query if needed.
$use_global_query = ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] );
if ( $use_global_query ) {
global $wp_query;
$query = clone $wp_query;
} else {
$query_args = build_query_vars_from_query_block( $block, $page );
$query = new WP_Query( $query_args );
}
if ( ! $query->have_posts() ) {
return '';
}
if ( $this->block_core_post_template_uses_featured_image( $block->inner_blocks ) ) {
update_post_thumbnail_cache( $query );
}
$classnames = '';
if ( isset( $block->context['displayLayout'] ) && isset( $block->context['query'] ) ) {
if ( isset( $block->context['displayLayout']['type'] ) && 'flex' === $block->context['displayLayout']['type'] ) {
$classnames = "is-flex-container columns-{$block->context['displayLayout']['columns']}";
}
}
if ( isset( $attributes['style']['elements']['link']['color']['text'] ) ) {
$classnames .= ' has-link-color';
}
$classnames .= ' wc-block-product-template';
$wrapper_attributes = get_block_wrapper_attributes( array( 'class' => trim( $classnames ) ) );
$content = '';
while ( $query->have_posts() ) {
$query->the_post();
// Get an instance of the current Post Template block.
$block_instance = $block->parsed_block;
// Set the block name to one that does not correspond to an existing registered block.
// This ensures that for the inner instances of the Post Template block, we do not render any block supports.
$block_instance['blockName'] = 'core/null';
// Render the inner blocks of the Post Template block with `dynamic` set to `false` to prevent calling
// `render_callback` and ensure that no wrapper markup is included.
$block_content = (
new WP_Block(
$block_instance,
array(
'postType' => get_post_type(),
'postId' => get_the_ID(),
)
)
)->render( array( 'dynamic' => false ) );
// Wrap the render inner blocks in a `li` element with the appropriate post classes.
$post_classes = implode( ' ', get_post_class( 'wc-block-product' ) );
$content .= '<li data-wc-key="product-item-' . get_the_ID() . '" class="' . esc_attr( $post_classes ) . '">' . $block_content . '</li>';
}
/*
* Use this function to restore the context of the template tags
* from a secondary query loop back to the main query loop.
* Since we use two custom loops, it's safest to always restore.
*/
wp_reset_postdata();
return sprintf(
'<ul %1$s>%2$s</ul>',
$wrapper_attributes,
$content
);
}
/**
* Determines whether a block list contains a block that uses the featured image.
*
* @param WP_Block_List $inner_blocks Inner block instance.
*
* @return bool Whether the block list contains a block that uses the featured image.
*/
protected function block_core_post_template_uses_featured_image( $inner_blocks ) {
foreach ( $inner_blocks as $block ) {
if ( 'core/post-featured-image' === $block->name ) {
return true;
}
if (
'core/cover' === $block->name &&
! empty( $block->attributes['useFeaturedImage'] )
) {
return true;
}
if ( $block->inner_blocks && block_core_post_template_uses_featured_image( $block->inner_blocks ) ) {
return true;
}
}
return false;
}
}
BlockTypes/ProductTitle.php 0000644 00000003112 15154173074 0011762 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductTitle class.
*/
class ProductTitle extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-title';
/**
* API version name.
*
* @var string
*/
protected $api_version = '2';
/**
* Get block supports. Shared with the frontend.
* IMPORTANT: If you change anything here, make sure to update the JS file too.
*
* @return array
*/
protected function get_block_type_supports() {
return array(
'color' =>
array(
'gradients' => true,
'background' => true,
'link' => false,
'text' => true,
'__experimentalSkipSerialization' => true,
),
'typography' =>
array(
'fontSize' => true,
'lineHeight' => true,
'__experimentalFontWeight' => true,
'__experimentalTextTransform' => true,
'__experimentalFontFamily' => true,
),
'spacing' =>
array(
'margin' => true,
'__experimentalSkipSerialization' => true,
),
'__experimentalSelector' => '.wc-block-components-product-title',
);
}
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
parent::register_block_type_assets();
$this->register_chunk_translations( [ $this->block_name ] );
}
}
BlockTypes/ProductTopRated.php 0000644 00000000650 15154173074 0012427 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductTopRated class.
*/
class ProductTopRated extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-top-rated';
/**
* Force orderby to rating.
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
$query_args['orderby'] = 'rating';
}
}
BlockTypes/ProductsByAttribute.php 0000644 00000004070 15154173074 0013326 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ProductsByAttribute class.
*/
class ProductsByAttribute extends AbstractProductGrid {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'products-by-attribute';
/**
* Set args specific to this block
*
* @param array $query_args Query args.
*/
protected function set_block_query_args( &$query_args ) {
if ( ! empty( $this->attributes['attributes'] ) ) {
$taxonomy = sanitize_title( $this->attributes['attributes'][0]['attr_slug'] );
$terms = wp_list_pluck( $this->attributes['attributes'], 'id' );
$query_args['tax_query'][] = array(
'taxonomy' => $taxonomy,
'terms' => array_map( 'absint', $terms ),
'field' => 'term_id',
'operator' => 'all' === $this->attributes['attrOperator'] ? 'AND' : 'IN',
);
}
}
/**
* Get block attributes.
*
* @return array
*/
protected function get_block_type_attributes() {
return array(
'align' => $this->get_schema_align(),
'alignButtons' => $this->get_schema_boolean( false ),
'attributes' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'number',
),
'attr_slug' => array(
'type' => 'string',
),
),
),
'default' => array(),
),
'attrOperator' => array(
'type' => 'string',
'default' => 'any',
),
'className' => $this->get_schema_string(),
'columns' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_columns', 3 ) ),
'contentVisibility' => $this->get_schema_content_visibility(),
'orderby' => $this->get_schema_orderby(),
'rows' => $this->get_schema_number( wc_get_theme_support( 'product_blocks::default_rows', 3 ) ),
'isPreview' => $this->get_schema_boolean( false ),
'stockStatus' => array(
'type' => 'array',
'default' => array_keys( wc_get_product_stock_status_options() ),
),
);
}
}
BlockTypes/RatingFilter.php 0000644 00000001300 15154173074 0011727 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* PriceFilter class.
*/
class RatingFilter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'rating-filter';
const RATING_QUERY_VAR = 'rating_filter';
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
}
BlockTypes/RelatedProducts.php 0000644 00000010433 15154173074 0012450 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* RelatedProducts class.
*/
class RelatedProducts extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'related-products';
/**
* The Block with its attributes before it gets rendered
*
* @var array
*/
protected $parsed_block;
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
* - Hook into pre_render_block to update the query.
*/
protected function initialize() {
parent::initialize();
add_filter(
'pre_render_block',
array( $this, 'update_query' ),
10,
2
);
add_filter(
'render_block',
array( $this, 'render_block' ),
10,
2
);
}
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Update the query for the product query block.
*
* @param string|null $pre_render The pre-rendered content. Default null.
* @param array $parsed_block The block being rendered.
*/
public function update_query( $pre_render, $parsed_block ) {
if ( 'core/query' !== $parsed_block['blockName'] ) {
return;
}
$this->parsed_block = $parsed_block;
if ( ProductQuery::is_woocommerce_variation( $parsed_block ) && 'woocommerce/related-products' === $parsed_block['attrs']['namespace'] ) {
// Set this so that our product filters can detect if it's a PHP template.
add_filter(
'query_loop_block_query_vars',
array( $this, 'build_query' ),
10,
1
);
}
}
/**
* Return a custom query based on attributes, filters and global WP_Query.
*
* @param WP_Query $query The WordPress Query.
* @return array
*/
public function build_query( $query ) {
$parsed_block = $this->parsed_block;
if ( ! $this->is_related_products_block( $parsed_block ) ) {
return $query;
}
$related_products_ids = $this->get_related_products_ids( $query['posts_per_page'] );
if ( count( $related_products_ids ) < 1 ) {
return array();
}
return array(
'post_type' => 'product',
'post__in' => $related_products_ids,
'post_status' => 'publish',
'posts_per_page' => $query['posts_per_page'],
);
}
/**
* If there are no related products, return an empty string.
*
* @param string $content The block content.
* @param array $block The block.
*
* @return string The block content.
*/
public function render_block( string $content, array $block ) {
if ( ! $this->is_related_products_block( $block ) ) {
return $content;
}
// If there are no related products, render nothing.
$related_products_ids = $this->get_related_products_ids();
if ( count( $related_products_ids ) < 1 ) {
return '';
}
return $content;
}
/**
* Determines whether the block is a related products block.
*
* @param array $block The block.
*
* @return bool Whether the block is a related products block.
*/
private function is_related_products_block( $block ) {
if ( ProductQuery::is_woocommerce_variation( $block ) && isset( $block['attrs']['namespace'] ) && 'woocommerce/related-products' === $block['attrs']['namespace'] ) {
return true;
}
return false;
}
/**
* Get related products ids.
* The logic is copied from the core function woocommerce_related_products. https://github.com/woocommerce/woocommerce/blob/ca49caabcba84ce9f60a03c6d3534ec14b350b80/plugins/woocommerce/includes/wc-template-functions.php/#L2039-L2074
*
* @param number $product_per_page Products per page.
* @return array Products ids.
*/
private function get_related_products_ids( $product_per_page = 5 ) {
global $post;
$product = wc_get_product( $post->ID );
$related_products = array_filter( array_map( 'wc_get_product', wc_get_related_products( $product->get_id(), $product_per_page, $product->get_upsell_ids() ) ), 'wc_products_array_filter_visible' );
$related_products = wc_products_array_orderby( $related_products, 'rand', 'desc' );
$related_product_ids = array_map(
function( $product ) {
return $product->get_id();
},
$related_products
);
return $related_product_ids;
}
}
BlockTypes/ReviewsByCategory.php 0000644 00000002507 15154173074 0012764 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ReviewsByCategory class.
*/
class ReviewsByCategory extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'reviews-by-category';
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-reviews-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( 'reviews-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'reviewRatingsEnabled', wc_review_ratings_enabled(), true );
$this->asset_data_registry->add( 'showAvatars', '1' === get_option( 'show_avatars' ), true );
}
}
BlockTypes/ReviewsByProduct.php 0000644 00000002504 15154173074 0012624 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* ReviewsByProduct class.
*/
class ReviewsByProduct extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'reviews-by-product';
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-reviews-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( 'reviews-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'reviewRatingsEnabled', wc_review_ratings_enabled(), true );
$this->asset_data_registry->add( 'showAvatars', '1' === get_option( 'show_avatars' ), true );
}
}
BlockTypes/SingleProduct.php 0000644 00000013322 15154173074 0012126 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* SingleProduct class.
*/
class SingleProduct extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'single-product';
/**
* Product ID of the current product to be displayed in the Single Product block.
* This is used to replace the global post for the Single Product inner blocks.
*
* @var int
*/
protected $product_id = 0;
/**
* Single Product inner blocks names.
* This is used to map all the inner blocks for a Single Product block.
*
* @var array
*/
protected $single_product_inner_blocks_names = [];
/**
* Initialize the block and Hook into the `render_block_context` filter
* to update the context with the correct data.
*
* @var string
*/
protected function initialize() {
parent::initialize();
add_filter( 'render_block_context', [ $this, 'update_context' ], 10, 3 );
add_filter( 'render_block_core/post-excerpt', [ $this, 'restore_global_post' ], 10, 3 );
add_filter( 'render_block_core/post-title', [ $this, 'restore_global_post' ], 10, 3 );
}
/**
* Restore the global post variable right before generating the render output for the post title and/or post excerpt blocks.
*
* This is required due to the changes made via the replace_post_for_single_product_inner_block method.
* It is a temporary fix to ensure these blocks work as expected until Gutenberg versions 15.2 and 15.6 are part of the core of WordPress.
*
* @see https://github.com/WordPress/gutenberg/pull/48001
* @see https://github.com/WordPress/gutenberg/pull/49495
*
* @param string $block_content The block content.
* @param array $parsed_block The full block, including name and attributes.
* @param \WP_Block $block_instance The block instance.
*
* @return mixed
*/
public function restore_global_post( $block_content, $parsed_block, $block_instance ) {
if ( isset( $block_instance->context['singleProduct'] ) && $block_instance->context['singleProduct'] ) {
wp_reset_postdata();
}
return $block_content;
}
/**
* Render the Single Product block
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
$html = sprintf(
'<div class="woocommerce">
%1$s
</div>',
$content
);
return $html;
}
/**
* Update the context by injecting the correct post data
* for each one of the Single Product inner blocks.
*
* @param array $context Block context.
* @param array $block Block attributes.
* @param WP_Block $parent_block Block instance.
*
* @return array Updated block context.
*/
public function update_context( $context, $block, $parent_block ) {
if ( 'woocommerce/single-product' === $block['blockName']
&& isset( $block['attrs']['productId'] ) ) {
$this->product_id = $block['attrs']['productId'];
$this->single_product_inner_blocks_names = array_reverse(
$this->extract_single_product_inner_block_names( $block )
);
}
$this->replace_post_for_single_product_inner_block( $block, $context );
return $context;
}
/**
* Extract the inner block names for the Single Product block. This way it's possible
* to map all the inner blocks for a Single Product block and manipulate the data as needed.
*
* @param array $block The Single Product block or its inner blocks.
* @param array $result Array of inner block names.
*
* @return array Array containing all the inner block names of a Single Product block.
*/
protected function extract_single_product_inner_block_names( $block, &$result = [] ) {
if ( isset( $block['blockName'] ) ) {
$result[] = $block['blockName'];
}
if ( isset( $block['innerBlocks'] ) ) {
foreach ( $block['innerBlocks'] as $inner_block ) {
$this->extract_single_product_inner_block_names( $inner_block, $result );
}
}
return $result;
}
/**
* Replace the global post for the Single Product inner blocks and reset it after.
*
* This is needed because some of the inner blocks may use the global post
* instead of fetching the product through the `productId` attribute, so even if the
* `productId` is passed to the inner block, it will still use the global post.
*
* @param array $block Block attributes.
* @param array $context Block context.
*/
protected function replace_post_for_single_product_inner_block( $block, &$context ) {
if ( $this->single_product_inner_blocks_names ) {
$block_name = array_pop( $this->single_product_inner_blocks_names );
if ( $block_name === $block['blockName'] ) {
/**
* This is a temporary fix to ensure the Post Title and Excerpt blocks work as expected
* until Gutenberg versions 15.2 and 15.6 are included in the core of WordPress.
*
* Important: the original post data is restored in the restore_global_post method.
*
* @see https://github.com/WordPress/gutenberg/pull/48001
* @see https://github.com/WordPress/gutenberg/pull/49495
*/
if ( 'core/post-excerpt' === $block_name || 'core/post-title' === $block_name ) {
global $post;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = get_post( $this->product_id );
if ( $post instanceof \WP_Post ) {
setup_postdata( $post );
}
}
$context['postId'] = $this->product_id;
$context['singleProduct'] = true;
}
}
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*
* @return null This block has no frontend script.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
}
BlockTypes/StockFilter.php 0000644 00000002546 15154173074 0011603 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* AttributeFilter class.
*/
class StockFilter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'stock-filter';
const STOCK_STATUS_QUERY_VAR = 'filter_stock_status';
/**
* Extra data passed through from server to client for block.
*
* @param array $stock_statuses Any stock statuses that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $stock_statuses = [] ) {
parent::enqueue_data( $stock_statuses );
$this->asset_data_registry->add( 'stockStatusOptions', wc_get_product_stock_status_options(), true );
$this->asset_data_registry->add( 'hideOutOfStockItems', 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ), true );
}
/**
* Get Stock status query variables values.
*/
public static function get_stock_status_query_var_values() {
return array_keys( wc_get_product_stock_status_options() );
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
}
BlockTypes/StoreNotices.php 0000644 00000003012 15154173074 0011760 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* StoreNotices class.
*/
class StoreNotices extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'store-notices';
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string | void Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
ob_start();
woocommerce_output_all_notices();
$notices = ob_get_clean();
if ( ! $notices ) {
return;
}
$classname = isset( $attributes['className'] ) ? $attributes['className'] : '';
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
if ( isset( $attributes['align'] ) ) {
$classname .= " align{$attributes['align']}";
}
return sprintf(
'<div class="woocommerce wc-block-store-notices %1$s %2$s">%3$s</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
wc_kses_notice( $notices )
);
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
}
BlockTypesController.php 0000644 00000017162 15154173074 0011416 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
use Automattic\WooCommerce\Blocks\BlockTypes\Cart;
use Automattic\WooCommerce\Blocks\BlockTypes\Checkout;
use Automattic\WooCommerce\Blocks\BlockTypes\MiniCartContents;
/**
* BlockTypesController class.
*
* @since 5.0.0
* @internal
*/
final class BlockTypesController {
/**
* Instance of the asset API.
*
* @var AssetApi
*/
protected $asset_api;
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
protected $asset_data_registry;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
*/
public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_registry ) {
$this->asset_api = $asset_api;
$this->asset_data_registry = $asset_data_registry;
$this->init();
}
/**
* Initialize class features.
*/
protected function init() {
add_action( 'init', array( $this, 'register_blocks' ) );
add_filter( 'render_block', array( $this, 'add_data_attributes' ), 10, 2 );
add_action( 'woocommerce_login_form_end', array( $this, 'redirect_to_field' ) );
add_filter( 'widget_types_to_hide_from_legacy_widget_block', array( $this, 'hide_legacy_widgets_with_block_equivalent' ) );
}
/**
* Register blocks, hooking up assets and render functions as needed.
*/
public function register_blocks() {
$block_types = $this->get_block_types();
foreach ( $block_types as $block_type ) {
$block_type_class = __NAMESPACE__ . '\\BlockTypes\\' . $block_type;
new $block_type_class( $this->asset_api, $this->asset_data_registry, new IntegrationRegistry() );
}
}
/**
* Add data- attributes to blocks when rendered if the block is under the woocommerce/ namespace.
*
* @param string $content Block content.
* @param array $block Parsed block data.
* @return string
*/
public function add_data_attributes( $content, $block ) {
$block_name = $block['blockName'];
$block_namespace = strtok( $block_name ?? '', '/' );
/**
* Filters the list of allowed block namespaces.
*
* This hook defines which block namespaces should have block name and attribute `data-` attributes appended on render.
*
* @since 5.9.0
*
* @param array $allowed_namespaces List of namespaces.
*/
$allowed_namespaces = array_merge( [ 'woocommerce', 'woocommerce-checkout' ], (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_namespace', [] ) );
/**
* Filters the list of allowed Block Names
*
* This hook defines which block names should have block name and attribute data- attributes appended on render.
*
* @since 5.9.0
*
* @param array $allowed_namespaces List of namespaces.
*/
$allowed_blocks = (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_block', [] );
if ( ! in_array( $block_namespace, $allowed_namespaces, true ) && ! in_array( $block_name, $allowed_blocks, true ) ) {
return $content;
}
$attributes = (array) $block['attrs'];
$exclude_attributes = [ 'className', 'align' ];
$escaped_data_attributes = [
'data-block-name="' . esc_attr( $block['blockName'] ) . '"',
];
foreach ( $attributes as $key => $value ) {
if ( in_array( $key, $exclude_attributes, true ) ) {
continue;
}
if ( is_bool( $value ) ) {
$value = $value ? 'true' : 'false';
}
if ( ! is_scalar( $value ) ) {
$value = wp_json_encode( $value );
}
$escaped_data_attributes[] = 'data-' . esc_attr( strtolower( preg_replace( '/(?<!\ )[A-Z]/', '-$0', $key ) ) ) . '="' . esc_attr( $value ) . '"';
}
return preg_replace( '/^<div /', '<div ' . implode( ' ', $escaped_data_attributes ) . ' ', trim( $content ) );
}
/**
* Adds a redirect field to the login form so blocks can redirect users after login.
*/
public function redirect_to_field() {
// phpcs:ignore WordPress.Security.NonceVerification
if ( empty( $_GET['redirect_to'] ) ) {
return;
}
echo '<input type="hidden" name="redirect" value="' . esc_attr( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) ) . '" />'; // phpcs:ignore WordPress.Security.NonceVerification
}
/**
* Hide legacy widgets with a feature complete block equivalent in the inserter
* and prevent them from showing as an option in the Legacy Widget block.
*
* @param array $widget_types An array of widgets hidden in core.
* @return array $widget_types An array inluding the WooCommerce widgets to hide.
*/
public function hide_legacy_widgets_with_block_equivalent( $widget_types ) {
array_push(
$widget_types,
'woocommerce_product_search',
'woocommerce_product_categories',
'woocommerce_recent_reviews',
'woocommerce_product_tag_cloud',
'woocommerce_price_filter',
'woocommerce_layered_nav',
'woocommerce_layered_nav_filters',
'woocommerce_rating_filter'
);
return $widget_types;
}
/**
* Get list of block types.
*
* @return array
*/
protected function get_block_types() {
global $pagenow;
$block_types = [
'ActiveFilters',
'AddToCartForm',
'AllProducts',
'AllReviews',
'AttributeFilter',
'Breadcrumbs',
'CatalogSorting',
'ClassicTemplate',
'CustomerAccount',
'FeaturedCategory',
'FeaturedProduct',
'FilterWrapper',
'HandpickedProducts',
'MiniCart',
'StoreNotices',
'PriceFilter',
'ProductAddToCart',
'ProductBestSellers',
'ProductButton',
'ProductCategories',
'ProductCategory',
'ProductCollection',
'ProductImage',
'ProductImageGallery',
'ProductNew',
'ProductOnSale',
'ProductPrice',
'ProductTemplate',
'ProductQuery',
'ProductAverageRating',
'ProductRating',
'ProductRatingCounter',
'ProductRatingStars',
'ProductResultsCount',
'ProductReviews',
'ProductSaleBadge',
'ProductSearch',
'ProductSKU',
'ProductStockIndicator',
'ProductSummary',
'ProductTag',
'ProductTitle',
'ProductTopRated',
'ProductsByAttribute',
'RatingFilter',
'ReviewsByCategory',
'ReviewsByProduct',
'RelatedProducts',
'ProductDetails',
'SingleProduct',
'StockFilter',
];
$block_types = array_merge(
$block_types,
Cart::get_cart_block_types(),
Checkout::get_checkout_block_types(),
MiniCartContents::get_mini_cart_block_types()
);
if ( Package::feature()->is_experimental_build() ) {
$block_types[] = 'ProductGallery';
$block_types[] = 'ProductGalleryLargeImage';
$block_types[] = 'ProductGalleryLargeImageNextPrevious';
$block_types[] = 'ProductGalleryPager';
$block_types[] = 'ProductGalleryThumbnails';
}
/**
* This disables specific blocks in Widget Areas by not registering them.
*/
if ( in_array( $pagenow, [ 'widgets.php', 'themes.php', 'customize.php' ], true ) && ( empty( $_GET['page'] ) || 'gutenberg-edit-site' !== $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
$block_types = array_diff(
$block_types,
[
'AllProducts',
'Cart',
'Checkout',
]
);
}
/**
* This disables specific blocks in Post and Page editor by not registering them.
*/
if ( in_array( $pagenow, [ 'post.php', 'post-new.php' ], true ) ) {
$block_types = array_diff(
$block_types,
[
'AddToCartForm',
'Breadcrumbs',
'CatalogSorting',
'ClassicTemplate',
'ProductResultsCount',
'ProductDetails',
'StoreNotices',
]
);
}
return $block_types;
}
}
Domain/Bootstrap.php 0000644 00000043132 15154173074 0010453 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\AssetsController;
use Automattic\WooCommerce\Blocks\BlockPatterns;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\BlockTypesController;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\Domain\Services\Notices;
use Automattic\WooCommerce\Blocks\Domain\Services\DraftOrders;
use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating;
use Automattic\WooCommerce\Blocks\Domain\Services\GoogleAnalytics;
use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
use Automattic\WooCommerce\Blocks\InboxNotifications;
use Automattic\WooCommerce\Blocks\Installer;
use Automattic\WooCommerce\Blocks\Migration;
use Automattic\WooCommerce\Blocks\Payments\Api as PaymentsApi;
use Automattic\WooCommerce\Blocks\Payments\Integrations\BankTransfer;
use Automattic\WooCommerce\Blocks\Payments\Integrations\CashOnDelivery;
use Automattic\WooCommerce\Blocks\Payments\Integrations\Cheque;
use Automattic\WooCommerce\Blocks\Payments\Integrations\PayPal;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use Automattic\WooCommerce\Blocks\Registry\Container;
use Automattic\WooCommerce\Blocks\Templates\CartTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutHeaderTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutTemplate;
use Automattic\WooCommerce\Blocks\Templates\ClassicTemplatesCompatibility;
use Automattic\WooCommerce\Blocks\Templates\OrderConfirmationTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductAttributeTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
use Automattic\WooCommerce\StoreApi\RoutesController;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\Blocks\Shipping\ShippingController;
use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
/**
* Takes care of bootstrapping the plugin.
*
* @since 2.5.0
*/
class Bootstrap {
/**
* Holds the Dependency Injection Container
*
* @var Container
*/
private $container;
/**
* Holds the Package instance
*
* @var Package
*/
private $package;
/**
* Holds the Migration instance
*
* @var Migration
*/
private $migration;
/**
* Constructor
*
* @param Container $container The Dependency Injection Container.
*/
public function __construct( Container $container ) {
$this->container = $container;
$this->package = $container->get( Package::class );
$this->migration = $container->get( Migration::class );
if ( $this->has_core_dependencies() ) {
$this->init();
/**
* Fires when the woocommerce blocks are loaded and ready to use.
*
* This hook is intended to be used as a safe event hook for when the plugin
* has been loaded, and all dependency requirements have been met.
*
* To ensure blocks are initialized, you must use the `woocommerce_blocks_loaded`
* hook instead of the `plugins_loaded` hook. This is because the functions
* hooked into plugins_loaded on the same priority load in an inconsistent and unpredictable manner.
*
* @since 2.5.0
*/
do_action( 'woocommerce_blocks_loaded' );
}
}
/**
* Init the package - load the blocks library and define constants.
*/
protected function init() {
$this->register_dependencies();
$this->register_payment_methods();
$this->load_interactivity_api();
// This is just a temporary solution to make sure the migrations are run. We have to refactor this. More details: https://github.com/woocommerce/woocommerce-blocks/issues/10196.
if ( $this->package->get_version() !== $this->package->get_version_stored_on_db() ) {
$this->migration->run_migrations();
$this->package->set_version_stored_on_db();
}
add_action(
'admin_init',
function() {
// Delete this notification because the blocks are included in WC Core now. This will handle any sites
// with lingering notices.
InboxNotifications::delete_surface_cart_checkout_blocks_notification();
},
10,
0
);
$is_rest = wc()->is_rest_api_request();
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$is_store_api_request = $is_rest && ! empty( $_SERVER['REQUEST_URI'] ) && ( false !== strpos( $_SERVER['REQUEST_URI'], trailingslashit( rest_get_url_prefix() ) . 'wc/store/' ) );
// Load and init assets.
$this->container->get( StoreApi::class )->init();
$this->container->get( PaymentsApi::class )->init();
$this->container->get( DraftOrders::class )->init();
$this->container->get( CreateAccount::class )->init();
$this->container->get( ShippingController::class )->init();
// Load assets in admin and on the frontend.
if ( ! $is_rest ) {
$this->add_build_notice();
$this->container->get( AssetDataRegistry::class );
$this->container->get( AssetsController::class );
$this->container->get( Installer::class )->init();
$this->container->get( GoogleAnalytics::class )->init();
}
// Load assets unless this is a request specifically for the store API.
if ( ! $is_store_api_request ) {
// Template related functionality. These won't be loaded for store API requests, but may be loaded for
// regular rest requests to maintain compatibility with the store editor.
$this->container->get( BlockPatterns::class );
$this->container->get( BlockTypesController::class );
$this->container->get( BlockTemplatesController::class );
$this->container->get( ProductSearchResultsTemplate::class );
$this->container->get( ProductAttributeTemplate::class );
$this->container->get( CartTemplate::class );
$this->container->get( CheckoutTemplate::class );
$this->container->get( CheckoutHeaderTemplate::class );
$this->container->get( OrderConfirmationTemplate::class );
$this->container->get( ClassicTemplatesCompatibility::class );
$this->container->get( ArchiveProductTemplatesCompatibility::class )->init();
$this->container->get( SingleProductTemplateCompatibility::class )->init();
$this->container->get( Notices::class )->init();
}
}
/**
* Check core dependencies exist.
*
* @return boolean
*/
protected function has_core_dependencies() {
$has_needed_dependencies = class_exists( 'WooCommerce', false );
if ( $has_needed_dependencies ) {
$plugin_data = \get_file_data(
$this->package->get_path( 'woocommerce-gutenberg-products-block.php' ),
[
'RequiredWCVersion' => 'WC requires at least',
]
);
if ( isset( $plugin_data['RequiredWCVersion'] ) && version_compare( \WC()->version, $plugin_data['RequiredWCVersion'], '<' ) ) {
$has_needed_dependencies = false;
add_action(
'admin_notices',
function() {
if ( should_display_compatibility_notices() ) {
?>
<div class="notice notice-error">
<p><?php esc_html_e( 'The WooCommerce Blocks plugin requires a more recent version of WooCommerce and has been deactivated. Please update to the latest version of WooCommerce.', 'woocommerce' ); ?></p>
</div>
<?php
}
}
);
}
}
return $has_needed_dependencies;
}
/**
* See if files have been built or not.
*
* @return bool
*/
protected function is_built() {
return file_exists(
$this->package->get_path( 'build/featured-product.js' )
);
}
/**
* Add a notice stating that the build has not been done yet.
*/
protected function add_build_notice() {
if ( $this->is_built() ) {
return;
}
add_action(
'admin_notices',
function() {
echo '<div class="error"><p>';
printf(
/* translators: %1$s is the install command, %2$s is the build command, %3$s is the watch command. */
esc_html__( 'WooCommerce Blocks development mode requires files to be built. From the plugin directory, run %1$s to install dependencies, %2$s to build the files or %3$s to build the files and watch for changes.', 'woocommerce' ),
'<code>npm install</code>',
'<code>npm run build</code>',
'<code>npm start</code>'
);
echo '</p></div>';
}
);
}
/**
* Load and set up the Interactivity API if enabled.
*/
protected function load_interactivity_api() {
require_once __DIR__ . '/../Interactivity/load.php';
}
/**
* Register core dependencies with the container.
*/
protected function register_dependencies() {
$this->container->register(
FeatureGating::class,
function () {
return new FeatureGating();
}
);
$this->container->register(
AssetApi::class,
function ( Container $container ) {
return new AssetApi( $container->get( Package::class ) );
}
);
$this->container->register(
AssetDataRegistry::class,
function( Container $container ) {
return new AssetDataRegistry( $container->get( AssetApi::class ) );
}
);
$this->container->register(
AssetsController::class,
function( Container $container ) {
return new AssetsController( $container->get( AssetApi::class ) );
}
);
$this->container->register(
PaymentMethodRegistry::class,
function() {
return new PaymentMethodRegistry();
}
);
$this->container->register(
Installer::class,
function () {
return new Installer();
}
);
$this->container->register(
BlockTypesController::class,
function ( Container $container ) {
$asset_api = $container->get( AssetApi::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new BlockTypesController( $asset_api, $asset_data_registry );
}
);
$this->container->register(
BlockTemplatesController::class,
function ( Container $container ) {
return new BlockTemplatesController( $container->get( Package::class ) );
}
);
$this->container->register(
ProductSearchResultsTemplate::class,
function () {
return new ProductSearchResultsTemplate();
}
);
$this->container->register(
ProductAttributeTemplate::class,
function () {
return new ProductAttributeTemplate();
}
);
$this->container->register(
CartTemplate::class,
function () {
return new CartTemplate();
}
);
$this->container->register(
CheckoutTemplate::class,
function () {
return new CheckoutTemplate();
}
);
$this->container->register(
CheckoutHeaderTemplate::class,
function () {
return new CheckoutHeaderTemplate();
}
);
$this->container->register(
OrderConfirmationTemplate::class,
function () {
return new OrderConfirmationTemplate();
}
);
$this->container->register(
ClassicTemplatesCompatibility::class,
function ( Container $container ) {
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new ClassicTemplatesCompatibility( $asset_data_registry );
}
);
$this->container->register(
ArchiveProductTemplatesCompatibility::class,
function () {
return new ArchiveProductTemplatesCompatibility();
}
);
$this->container->register(
SingleProductTemplateCompatibility::class,
function () {
return new SingleProductTemplateCompatibility();
}
);
$this->container->register(
DraftOrders::class,
function( Container $container ) {
return new DraftOrders( $container->get( Package::class ) );
}
);
$this->container->register(
CreateAccount::class,
function( Container $container ) {
return new CreateAccount( $container->get( Package::class ) );
}
);
$this->container->register(
GoogleAnalytics::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new GoogleAnalytics( $asset_api );
}
);
$this->container->register(
Notices::class,
function( Container $container ) {
return new Notices( $container->get( Package::class ) );
}
);
$this->container->register(
Hydration::class,
function( Container $container ) {
return new Hydration( $container->get( AssetDataRegistry::class ) );
}
);
$this->container->register(
PaymentsApi::class,
function ( Container $container ) {
$payment_method_registry = $container->get( PaymentMethodRegistry::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new PaymentsApi( $payment_method_registry, $asset_data_registry );
}
);
$this->container->register(
StoreApi::class,
function () {
return new StoreApi();
}
);
// Maintains backwards compatibility with previous Store API namespace.
$this->container->register(
'Automattic\WooCommerce\Blocks\StoreApi\Formatters',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\StoreApi\Formatters', '7.2.0', 'Automattic\WooCommerce\StoreApi\Formatters', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( \Automattic\WooCommerce\StoreApi\Formatters::class );
}
);
$this->container->register(
'Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi', '7.2.0', 'Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( \Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::class );
}
);
$this->container->register(
'Automattic\WooCommerce\Blocks\StoreApi\SchemaController',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\StoreApi\SchemaController', '7.2.0', 'Automattic\WooCommerce\StoreApi\SchemaController', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( SchemaController::class );
}
);
$this->container->register(
'Automattic\WooCommerce\Blocks\StoreApi\RoutesController',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\StoreApi\RoutesController', '7.2.0', 'Automattic\WooCommerce\StoreApi\RoutesController', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( RoutesController::class );
}
);
$this->container->register(
BlockPatterns::class,
function () {
return new BlockPatterns( $this->package );
}
);
$this->container->register(
ShippingController::class,
function ( $container ) {
$asset_api = $container->get( AssetApi::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new ShippingController( $asset_api, $asset_data_registry );
}
);
}
/**
* Throws a deprecation notice for a dependency without breaking requests.
*
* @param string $function Class or function being deprecated.
* @param string $version Version in which it was deprecated.
* @param string $replacement Replacement class or function, if applicable.
* @param string $trigger_error_version Optional version to start surfacing this as a PHP error rather than a log. Defaults to $version.
*/
protected function deprecated_dependency( $function, $version, $replacement = '', $trigger_error_version = '' ) {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
$trigger_error_version = $trigger_error_version ? $trigger_error_version : $version;
$error_message = $replacement ? sprintf(
'%1$s is <strong>deprecated</strong> since version %2$s! Use %3$s instead.',
$function,
$version,
$replacement
) : sprintf(
'%1$s is <strong>deprecated</strong> since version %2$s with no alternative available.',
$function,
$version
);
/**
* Fires when a deprecated function is called.
*
* @since 7.3.0
*/
do_action( 'deprecated_function_run', $function, $replacement, $version );
$log_error = false;
// If headers have not been sent yet, log to avoid breaking the request.
if ( ! headers_sent() ) {
$log_error = true;
}
// If the $trigger_error_version was not yet reached, only log the error.
if ( version_compare( $this->package->get_version(), $trigger_error_version, '<' ) ) {
$log_error = true;
}
/**
* Filters whether to trigger an error for deprecated functions. (Same as WP core)
*
* @since 7.3.0
*
* @param bool $trigger Whether to trigger the error for deprecated functions. Default true.
*/
if ( ! apply_filters( 'deprecated_function_trigger_error', true ) ) {
$log_error = true;
}
if ( $log_error ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $error_message );
} else {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error( $error_message, E_USER_DEPRECATED );
}
}
/**
* Register payment method integrations with the container.
*/
protected function register_payment_methods() {
$this->container->register(
Cheque::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new Cheque( $asset_api );
}
);
$this->container->register(
PayPal::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new PayPal( $asset_api );
}
);
$this->container->register(
BankTransfer::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new BankTransfer( $asset_api );
}
);
$this->container->register(
CashOnDelivery::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new CashOnDelivery( $asset_api );
}
);
}
}
Domain/Package.php 0000644 00000006174 15154173074 0010036 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain;
use Automattic\WooCommerce\Blocks\Options;
use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating;
/**
* Main package class.
*
* Returns information about the package and handles init.
*
* @since 2.5.0
*/
class Package {
/**
* Holds the current version of the blocks plugin.
*
* @var string
*/
private $version;
/**
* Holds the main path to the blocks plugin directory.
*
* @var string
*/
private $path;
/**
* Holds locally the plugin_dir_url to avoid recomputing it.
*
* @var string
*/
private $plugin_dir_url;
/**
* Holds the feature gating class instance.
*
* @var FeatureGating
*/
private $feature_gating;
/**
* Constructor
*
* @param string $version Version of the plugin.
* @param string $plugin_path Path to the main plugin file.
* @param FeatureGating $feature_gating Feature gating class instance.
*/
public function __construct( $version, $plugin_path, FeatureGating $feature_gating ) {
$this->version = $version;
$this->path = $plugin_path;
$this->feature_gating = $feature_gating;
}
/**
* Returns the version of the plugin.
*
* @return string
*/
public function get_version() {
return $this->version;
}
/**
* Returns the version of the plugin stored in the database.
*
* @return string
*/
public function get_version_stored_on_db() {
return get_option( Options::WC_BLOCK_VERSION, '' );
}
/**
* Set the version of the plugin stored in the database.
* This is useful during the first installation or after the upgrade process.
*/
public function set_version_stored_on_db() {
update_option( Options::WC_BLOCK_VERSION, $this->get_version() );
}
/**
* Returns the path to the plugin directory.
*
* @param string $relative_path If provided, the relative path will be
* appended to the plugin path.
*
* @return string
*/
public function get_path( $relative_path = '' ) {
return trailingslashit( $this->path ) . $relative_path;
}
/**
* Returns the url to the blocks plugin directory.
*
* @param string $relative_url If provided, the relative url will be
* appended to the plugin url.
*
* @return string
*/
public function get_url( $relative_url = '' ) {
if ( ! $this->plugin_dir_url ) {
// Append index.php so WP does not return the parent directory.
$this->plugin_dir_url = plugin_dir_url( $this->path . '/index.php' );
}
return $this->plugin_dir_url . $relative_url;
}
/**
* Returns an instance of the the FeatureGating class.
*
* @return FeatureGating
*/
public function feature() {
return $this->feature_gating;
}
/**
* Checks if we're executing the code in an experimental build mode.
*
* @return boolean
*/
public function is_experimental_build() {
return $this->feature()->is_experimental_build();
}
/**
* Checks if we're executing the code in an feature plugin or experimental build mode.
*
* @return boolean
*/
public function is_feature_plugin_build() {
return $this->feature()->is_feature_plugin_build();
}
}
Domain/Services/CreateAccount.php 0000644 00000004551 15154173074 0013003 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\Email\CustomerNewAccount;
/**
* Service class implementing new create account emails used for order processing via the Block Based Checkout.
*/
class CreateAccount {
/**
* Reference to the Package instance
*
* @var Package
*/
private $package;
/**
* Constructor.
*
* @param Package $package An instance of (Woo Blocks) Package.
*/
public function __construct( Package $package ) {
$this->package = $package;
}
/**
* Init - register handlers for WooCommerce core email hooks.
*/
public function init() {
// Override core email handlers to add our new improved "new account" email.
add_action(
'woocommerce_email',
function ( $wc_emails_instance ) {
// Remove core "new account" handler; we are going to replace it.
remove_action( 'woocommerce_created_customer_notification', array( $wc_emails_instance, 'customer_new_account' ), 10, 3 );
// Add custom "new account" handler.
add_action(
'woocommerce_created_customer_notification',
function( $customer_id, $new_customer_data = array(), $password_generated = false ) use ( $wc_emails_instance ) {
// If this is a block-based signup, send a new email with password reset link (no password in email).
if ( isset( $new_customer_data['source'] ) && 'store-api' === $new_customer_data['source'] ) {
$this->customer_new_account( $customer_id, $new_customer_data );
return;
}
// Otherwise, trigger the existing legacy email (with new password inline).
$wc_emails_instance->customer_new_account( $customer_id, $new_customer_data, $password_generated );
},
10,
3
);
}
);
}
/**
* Trigger new account email.
* This is intended as a replacement to WC_Emails::customer_new_account(),
* with a set password link instead of emailing the new password in email
* content.
*
* @param int $customer_id The ID of the new customer account.
* @param array $new_customer_data Assoc array of data for the new account.
*/
public function customer_new_account( $customer_id = 0, array $new_customer_data = array() ) {
$new_account_email = new CustomerNewAccount( $this->package );
$new_account_email->trigger( $customer_id, $new_customer_data );
}
}
Domain/Services/DraftOrders.php 0000644 00000016437 15154173074 0012510 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Exception;
use WC_Order;
/**
* Service class for adding DraftOrder functionality to WooCommerce core.
*
* Sets up all logic related to the Checkout Draft Orders service
*
* @internal
*/
class DraftOrders {
const DB_STATUS = 'wc-checkout-draft';
const STATUS = 'checkout-draft';
/**
* Holds the Package instance
*
* @var Package
*/
private $package;
/**
* Constructor
*
* @param Package $package An instance of the package class.
*/
public function __construct( Package $package ) {
$this->package = $package;
}
/**
* Set all hooks related to adding Checkout Draft order functionality to Woo Core.
*/
public function init() {
add_filter( 'wc_order_statuses', [ $this, 'register_draft_order_status' ] );
add_filter( 'woocommerce_register_shop_order_post_statuses', [ $this, 'register_draft_order_post_status' ] );
add_filter( 'woocommerce_analytics_excluded_order_statuses', [ $this, 'append_draft_order_post_status' ] );
add_filter( 'woocommerce_valid_order_statuses_for_payment', [ $this, 'append_draft_order_post_status' ] );
add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', [ $this, 'append_draft_order_post_status' ] );
// Hook into the query to retrieve My Account orders so draft status is excluded.
add_action( 'woocommerce_my_account_my_orders_query', [ $this, 'delete_draft_order_post_status_from_args' ] );
add_action( 'woocommerce_cleanup_draft_orders', [ $this, 'delete_expired_draft_orders' ] );
add_action( 'admin_init', [ $this, 'install' ] );
}
/**
* Installation related logic for Draft order functionality.
*
* @internal
*/
public function install() {
$this->maybe_create_cronjobs();
}
/**
* Maybe create cron events.
*/
protected function maybe_create_cronjobs() {
if ( function_exists( 'as_next_scheduled_action' ) && false === as_next_scheduled_action( 'woocommerce_cleanup_draft_orders' ) ) {
as_schedule_recurring_action( strtotime( 'midnight tonight' ), DAY_IN_SECONDS, 'woocommerce_cleanup_draft_orders' );
}
}
/**
* Register custom order status for orders created via the API during checkout.
*
* Draft order status is used before payment is attempted, during checkout, when a cart is converted to an order.
*
* @param array $statuses Array of statuses.
* @internal
* @return array
*/
public function register_draft_order_status( array $statuses ) {
$statuses[ self::DB_STATUS ] = _x( 'Draft', 'Order status', 'woocommerce' );
return $statuses;
}
/**
* Register custom order post status for orders created via the API during checkout.
*
* @param array $statuses Array of statuses.
* @internal
* @return array
*/
public function register_draft_order_post_status( array $statuses ) {
$statuses[ self::DB_STATUS ] = $this->get_post_status_properties();
return $statuses;
}
/**
* Returns the properties of this post status for registration.
*
* @return array
*/
private function get_post_status_properties() {
return [
'label' => _x( 'Draft', 'Order status', 'woocommerce' ),
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => false,
'show_in_admin_status_list' => true,
/* translators: %s: number of orders */
'label_count' => _n_noop( 'Drafts <span class="count">(%s)</span>', 'Drafts <span class="count">(%s)</span>', 'woocommerce' ),
];
}
/**
* Remove draft status from the 'status' argument of an $args array.
*
* @param array $args Array of arguments containing statuses in the status key.
* @internal
* @return array
*/
public function delete_draft_order_post_status_from_args( $args ) {
if ( ! array_key_exists( 'status', $args ) ) {
$statuses = [];
foreach ( wc_get_order_statuses() as $key => $label ) {
if ( self::DB_STATUS !== $key ) {
$statuses[] = str_replace( 'wc-', '', $key );
}
}
$args['status'] = $statuses;
} elseif ( self::DB_STATUS === $args['status'] ) {
$args['status'] = '';
} elseif ( is_array( $args['status'] ) ) {
$args['status'] = array_diff_key( $args['status'], array( self::STATUS => null ) );
}
return $args;
}
/**
* Append draft status to a list of statuses.
*
* @param array $statuses Array of statuses.
* @internal
* @return array
*/
public function append_draft_order_post_status( $statuses ) {
$statuses[] = self::STATUS;
return $statuses;
}
/**
* Delete draft orders older than a day in batches of 20.
*
* Ran on a daily cron schedule.
*
* @internal
*/
public function delete_expired_draft_orders() {
$count = 0;
$batch_size = 20;
$this->ensure_draft_status_registered();
$orders = wc_get_orders(
[
'date_modified' => '<=' . strtotime( '-1 DAY' ),
'limit' => $batch_size,
'status' => self::DB_STATUS,
'type' => 'shop_order',
]
);
// do we bail because the query results are unexpected?
try {
$this->assert_order_results( $orders, $batch_size );
if ( $orders ) {
foreach ( $orders as $order ) {
$order->delete( true );
$count ++;
}
}
if ( $batch_size === $count && function_exists( 'as_enqueue_async_action' ) ) {
as_enqueue_async_action( 'woocommerce_cleanup_draft_orders' );
}
} catch ( Exception $error ) {
wc_caught_exception( $error, __METHOD__ );
}
}
/**
* Since it's possible for third party code to clobber the `$wp_post_statuses` global,
* we need to do a final check here to make sure the draft post status is
* registered with the global so that it is not removed by WP_Query status
* validation checks.
*/
private function ensure_draft_status_registered() {
$is_registered = get_post_stati( [ 'name' => self::DB_STATUS ] );
if ( empty( $is_registered ) ) {
register_post_status(
self::DB_STATUS,
$this->get_post_status_properties()
);
}
}
/**
* Asserts whether incoming order results are expected given the query
* this service class executes.
*
* @param WC_Order[] $order_results The order results being asserted.
* @param int $expected_batch_size The expected batch size for the results.
* @throws Exception If any assertions fail, an exception is thrown.
*/
private function assert_order_results( $order_results, $expected_batch_size ) {
// if not an array, then just return because it won't get handled
// anyways.
if ( ! is_array( $order_results ) ) {
return;
}
$suffix = ' This is an indicator that something is filtering WooCommerce or WordPress queries and modifying the query parameters.';
// if count is greater than our expected batch size, then that's a problem.
if ( count( $order_results ) > 20 ) {
throw new Exception( 'There are an unexpected number of results returned from the query.' . $suffix );
}
// if any of the returned orders are not draft (or not a WC_Order), then that's a problem.
foreach ( $order_results as $order ) {
if ( ! ( $order instanceof WC_Order ) ) {
throw new Exception( 'The returned results contain a value that is not a WC_Order.' . $suffix );
}
if ( ! $order->has_status( self::STATUS ) ) {
throw new Exception( 'The results contain an order that is not a `wc-checkout-draft` status in the results.' . $suffix );
}
}
}
}
Domain/Services/Email/CustomerNewAccount.php 0000644 00000011042 15154173074 0015073 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services\Email;
use Automattic\WooCommerce\Blocks\Domain\Package;
/**
* Customer New Account.
*
* An email sent to the customer when they create an account.
* This is intended as a replacement to \WC_Email_Customer_New_Account(),
* with a set password link instead of emailing the new password in email
* content.
*
* @extends \WC_Email
*/
class CustomerNewAccount extends \WC_Email {
/**
* User login name.
*
* @var string
*/
public $user_login;
/**
* User email.
*
* @var string
*/
public $user_email;
/**
* Magic link to set initial password.
*
* @var string
*/
public $set_password_url;
/**
* Override (force) default template path
*
* @var string
*/
public $default_template_path;
/**
* Constructor.
*
* @param Package $package An instance of (Woo Blocks) Package.
*/
public function __construct( Package $package ) {
// Note - we're using the same ID as the real email.
// This ensures that any merchant tweaks (Settings > Emails)
// apply to this email (consistent with the core email).
$this->id = 'customer_new_account';
$this->customer_email = true;
$this->title = __( 'New account', 'woocommerce' );
$this->description = __( '“New Account” emails are sent when a customer signs up via the checkout flow.', 'woocommerce' );
$this->template_html = 'emails/customer-new-account-blocks.php';
$this->template_plain = 'emails/plain/customer-new-account-blocks.php';
$this->default_template_path = $package->get_path( '/templates/' );
// Call parent constructor.
parent::__construct();
}
/**
* Get email subject.
*
* @since 3.1.0
* @return string
*/
public function get_default_subject() {
return __( 'Your {site_title} account has been created!', 'woocommerce' );
}
/**
* Get email heading.
*
* @since 3.1.0
* @return string
*/
public function get_default_heading() {
return __( 'Welcome to {site_title}', 'woocommerce' );
}
/**
* Trigger.
*
* @param int $user_id User ID.
* @param string $user_pass User password.
* @param bool $password_generated Whether the password was generated automatically or not.
*/
public function trigger( $user_id, $user_pass = '', $password_generated = false ) {
$this->setup_locale();
if ( $user_id ) {
$this->object = new \WP_User( $user_id );
// Generate a magic link so user can set initial password.
$key = get_password_reset_key( $this->object );
if ( ! is_wp_error( $key ) ) {
$action = 'newaccount';
$this->set_password_url = wc_get_account_endpoint_url( 'lost-password' ) . "?action=$action&key=$key&login=" . rawurlencode( $this->object->user_login );
}
$this->user_login = stripslashes( $this->object->user_login );
$this->user_email = stripslashes( $this->object->user_email );
$this->recipient = $this->user_email;
}
if ( $this->is_enabled() && $this->get_recipient() ) {
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments(), $this->set_password_url );
}
$this->restore_locale();
}
/**
* Get content html.
*
* @return string
*/
public function get_content_html() {
return wc_get_template_html(
$this->template_html,
array(
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'user_login' => $this->user_login,
'blogname' => $this->get_blogname(),
'set_password_url' => $this->set_password_url,
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
),
'',
$this->default_template_path
);
}
/**
* Get content plain.
*
* @return string
*/
public function get_content_plain() {
return wc_get_template_html(
$this->template_plain,
array(
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'user_login' => $this->user_login,
'blogname' => $this->get_blogname(),
'set_password_url' => $this->set_password_url,
'sent_to_admin' => false,
'plain_text' => true,
'email' => $this,
),
'',
$this->default_template_path
);
}
/**
* Default content to show below main email content.
*
* @since 3.7.0
* @return string
*/
public function get_default_additional_content() {
return __( 'We look forward to seeing you soon.', 'woocommerce' );
}
}
Domain/Services/FeatureGating.php 0000644 00000007702 15154173074 0013011 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
/**
* Service class that handles the feature flags.
*
* @internal
*/
class FeatureGating {
/**
* Current flag value.
*
* @var int
*/
private $flag;
const EXPERIMENTAL_FLAG = 3;
const FEATURE_PLUGIN_FLAG = 2;
const CORE_FLAG = 1;
/**
* Current environment
*
* @var string
*/
private $environment;
const PRODUCTION_ENVIRONMENT = 'production';
const DEVELOPMENT_ENVIRONMENT = 'development';
const TEST_ENVIRONMENT = 'test';
/**
* Constructor
*
* @param int $flag Hardcoded flag value. Useful for tests.
* @param string $environment Hardcoded environment value. Useful for tests.
*/
public function __construct( $flag = 0, $environment = 'unset' ) {
$this->flag = $flag;
$this->environment = $environment;
$this->load_flag();
$this->load_environment();
}
/**
* Set correct flag.
*/
public function load_flag() {
if ( 0 === $this->flag ) {
$default_flag = defined( 'WC_BLOCKS_IS_FEATURE_PLUGIN' ) ? self::FEATURE_PLUGIN_FLAG : self::CORE_FLAG;
if ( file_exists( __DIR__ . '/../../../blocks.ini' ) ) {
$allowed_flags = [ self::EXPERIMENTAL_FLAG, self::FEATURE_PLUGIN_FLAG, self::CORE_FLAG ];
$woo_options = parse_ini_file( __DIR__ . '/../../../blocks.ini' );
$this->flag = is_array( $woo_options ) && in_array( intval( $woo_options['woocommerce_blocks_phase'] ), $allowed_flags, true ) ? $woo_options['woocommerce_blocks_phase'] : $default_flag;
} else {
$this->flag = $default_flag;
}
}
}
/**
* Set correct environment.
*/
public function load_environment() {
if ( 'unset' === $this->environment ) {
if ( file_exists( __DIR__ . '/../../../blocks.ini' ) ) {
$allowed_environments = [ self::PRODUCTION_ENVIRONMENT, self::DEVELOPMENT_ENVIRONMENT, self::TEST_ENVIRONMENT ];
$woo_options = parse_ini_file( __DIR__ . '/../../../blocks.ini' );
$this->environment = is_array( $woo_options ) && in_array( $woo_options['woocommerce_blocks_env'], $allowed_environments, true ) ? $woo_options['woocommerce_blocks_env'] : self::PRODUCTION_ENVIRONMENT;
} else {
$this->environment = self::PRODUCTION_ENVIRONMENT;
}
}
}
/**
* Returns the current flag value.
*
* @return int
*/
public function get_flag() {
return $this->flag;
}
/**
* Checks if we're executing the code in an experimental build mode.
*
* @return boolean
*/
public function is_experimental_build() {
return $this->flag >= self::EXPERIMENTAL_FLAG;
}
/**
* Checks if we're executing the code in an feature plugin or experimental build mode.
*
* @return boolean
*/
public function is_feature_plugin_build() {
return $this->flag >= self::FEATURE_PLUGIN_FLAG;
}
/**
* Returns the current environment value.
*
* @return string
*/
public function get_environment() {
return $this->environment;
}
/**
* Checks if we're executing the code in an development environment.
*
* @return boolean
*/
public function is_development_environment() {
return self::DEVELOPMENT_ENVIRONMENT === $this->environment;
}
/**
* Checks if we're executing the code in a production environment.
*
* @return boolean
*/
public function is_production_environment() {
return self::PRODUCTION_ENVIRONMENT === $this->environment;
}
/**
* Checks if we're executing the code in a test environment.
*
* @return boolean
*/
public function is_test_environment() {
return self::TEST_ENVIRONMENT === $this->environment;
}
/**
* Returns core flag value.
*
* @return number
*/
public static function get_core_flag() {
return self::CORE_FLAG;
}
/**
* Returns feature plugin flag value.
*
* @return number
*/
public static function get_feature_plugin_flag() {
return self::FEATURE_PLUGIN_FLAG;
}
/**
* Returns experimental flag value.
*
* @return number
*/
public static function get_experimental_flag() {
return self::EXPERIMENTAL_FLAG;
}
}
Domain/Services/GoogleAnalytics.php 0000644 00000006451 15154173074 0013350 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
/**
* Service class to integrate Blocks with the Google Analytics extension,
*/
class GoogleAnalytics {
/**
* Instance of the asset API.
*
* @var AssetApi
*/
protected $asset_api;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
*/
public function __construct( AssetApi $asset_api ) {
$this->asset_api = $asset_api;
}
/**
* Hook into WP.
*/
public function init() {
// Require Google Analytics Integration to be activated.
if ( ! class_exists( 'WC_Google_Analytics_Integration', false ) ) {
return;
}
add_action( 'init', array( $this, 'register_assets' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_filter( 'script_loader_tag', array( $this, 'async_script_loader_tags' ), 10, 3 );
}
/**
* Register scripts.
*/
public function register_assets() {
$this->asset_api->register_script( 'wc-blocks-google-analytics', 'build/wc-blocks-google-analytics.js', [ 'google-tag-manager' ] );
}
/**
* Enqueue the Google Tag Manager script if prerequisites are met.
*/
public function enqueue_scripts() {
$settings = $this->get_google_analytics_settings();
$prefix = strstr( strtoupper( $settings['ga_id'] ), '-', true );
// Require tracking to be enabled with a valid GA ID.
if ( ! in_array( $prefix, [ 'G', 'GT' ], true ) ) {
return;
}
/**
* Filter to disable Google Analytics tracking.
*
* @internal Matches filter name in GA extension.
* @since 4.9.0
*
* @param boolean $disable_tracking If true, tracking will be disabled.
*/
if ( apply_filters( 'woocommerce_ga_disable_tracking', ! wc_string_to_bool( $settings['ga_event_tracking_enabled'] ) ) ) {
return;
}
if ( ! wp_script_is( 'google-tag-manager', 'registered' ) ) {
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_register_script( 'google-tag-manager', 'https://www.googletagmanager.com/gtag/js?id=' . $settings['ga_id'], [], null, false );
wp_add_inline_script(
'google-tag-manager',
"
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '" . esc_js( $settings['ga_id'] ) . "', { 'send_page_view': false });"
);
}
wp_enqueue_script( 'wc-blocks-google-analytics' );
}
/**
* Get settings from the GA integration extension.
*
* @return array
*/
private function get_google_analytics_settings() {
return wp_parse_args(
get_option( 'woocommerce_google_analytics_settings' ),
[
'ga_id' => '',
'ga_event_tracking_enabled' => 'no',
]
);
}
/**
* Add async to script tags with defined handles.
*
* @param string $tag HTML for the script tag.
* @param string $handle Handle of script.
* @param string $src Src of script.
* @return string
*/
public function async_script_loader_tags( $tag, $handle, $src ) {
if ( ! in_array( $handle, array( 'google-tag-manager' ), true ) ) {
return $tag;
}
// If script was output manually in wp_head, abort.
if ( did_action( 'woocommerce_gtag_snippet' ) ) {
return '';
}
return str_replace( '<script src', '<script async src', $tag );
}
}
Domain/Services/Hydration.php 0000644 00000005322 15154173074 0012221 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
/**
* Service class that handles hydration of API data for blocks.
*/
class Hydration {
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
protected $asset_data_registry;
/**
* Cached notices to restore after hydrating the API.
*
* @var array
*/
protected $cached_store_notices = [];
/**
* Constructor.
*
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
*/
public function __construct( AssetDataRegistry $asset_data_registry ) {
$this->asset_data_registry = $asset_data_registry;
}
/**
* Hydrates the asset data registry with data from the API. Disables notices and nonces so requests contain valid
* data that is not polluted by the current session.
*
* @param array $path API paths to hydrate e.g. '/wc/store/v1/cart'.
* @return array Response data.
*/
public function get_rest_api_response_data( $path = '' ) {
$this->cache_store_notices();
$this->disable_nonce_check();
// Preload the request and add it to the array. It will be $preloaded_requests['path'] and contain 'body' and 'headers'.
$preloaded_requests = rest_preload_api_request( [], $path );
$this->restore_cached_store_notices();
$this->restore_nonce_check();
// Returns just the single preloaded request.
return $preloaded_requests[ $path ];
}
/**
* Disable the nonce check temporarily.
*/
protected function disable_nonce_check() {
add_filter( 'woocommerce_store_api_disable_nonce_check', [ $this, 'disable_nonce_check_callback' ] );
}
/**
* Callback to disable the nonce check. While we could use `__return_true`, we use a custom named callback so that
* we can remove it later without affecting other filters.
*/
public function disable_nonce_check_callback() {
return true;
}
/**
* Restore the nonce check.
*/
protected function restore_nonce_check() {
remove_filter( 'woocommerce_store_api_disable_nonce_check', [ $this, 'disable_nonce_check_callback' ] );
}
/**
* Cache notices before hydrating the API if the customer has a session.
*/
protected function cache_store_notices() {
if ( ! did_action( 'woocommerce_init' ) || null === WC()->session ) {
return;
}
$this->cached_store_notices = WC()->session->get( 'wc_notices', array() );
WC()->session->set( 'wc_notices', null );
}
/**
* Restore notices into current session from cache.
*/
protected function restore_cached_store_notices() {
if ( ! did_action( 'woocommerce_init' ) || null === WC()->session ) {
return;
}
WC()->session->set( 'wc_notices', $this->cached_store_notices );
$this->cached_store_notices = [];
}
}
Domain/Services/Notices.php 0000644 00000005451 15154173074 0011667 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
/**
* Service class for adding new-style Notices to WooCommerce core.
*
* @internal
*/
class Notices {
/**
* Holds the Package instance
*
* @var Package
*/
private $package;
/**
* Templates used for notices.
*
* @var array
*/
private $notice_templates = array(
'notices/error.php',
'notices/notice.php',
'notices/success.php',
);
/**
* Constructor
*
* @param Package $package An instance of the package class.
*/
public function __construct( Package $package ) {
$this->package = $package;
}
/**
* Set all hooks related to adding Checkout Draft order functionality to Woo Core. This is only enabled if the user
* is using the new block based cart/checkout.
*/
public function init() {
if ( CartCheckoutUtils::is_cart_block_default() || CartCheckoutUtils::is_checkout_block_default() ) {
add_filter( 'woocommerce_kses_notice_allowed_tags', [ $this, 'add_kses_notice_allowed_tags' ] );
add_filter( 'wc_get_template', [ $this, 'get_notices_template' ], 10, 5 );
add_action(
'wp_head',
function() {
// These pages may return notices in ajax responses, so we need the styles to be ready.
if ( is_cart() || is_checkout() ) {
wp_enqueue_style( 'wc-blocks-style' );
}
}
);
}
}
/**
* Allow SVG icon in notices.
*
* @param array $allowed_tags Allowed tags.
* @return array
*/
public function add_kses_notice_allowed_tags( $allowed_tags ) {
$svg_args = array(
'svg' => array(
'aria-hidden' => true,
'xmlns' => true,
'width' => true,
'height' => true,
'viewbox' => true,
'focusable' => true,
),
'path' => array(
'd' => true,
),
);
return array_merge( $allowed_tags, $svg_args );
}
/**
* Replaces core notice templates with those from blocks.
*
* The new notice templates match block components with matching icons and styling. The only difference is that core
* only has notices for info, success, and error notices, whereas blocks has notices for info, success, error,
* warning, and a default notice type.
*
* @param string $template Located template path.
* @param string $template_name Template name.
* @param array $args Template arguments.
* @param string $template_path Template path.
* @param string $default_path Default path.
* @return string
*/
public function get_notices_template( $template, $template_name, $args, $template_path, $default_path ) {
if ( in_array( $template_name, $this->notice_templates, true ) ) {
$template = $this->package->get_path( 'templates/' . $template_name );
wp_enqueue_style( 'wc-blocks-style' );
}
return $template;
}
}
InboxNotifications.php 0000644 00000001107 15154173074 0011074 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
* A class used to display inbox messages to merchants in the WooCommerce Admin dashboard.
*
* @package Automattic\WooCommerce\Blocks
* @since x.x.x
*/
class InboxNotifications {
const SURFACE_CART_CHECKOUT_NOTE_NAME = 'surface_cart_checkout';
/**
* Deletes the note.
*/
public static function delete_surface_cart_checkout_blocks_notification() {
Notes::delete_notes_with_name( self::SURFACE_CART_CHECKOUT_NOTE_NAME );
}
}
Integrations/IntegrationInterface.php 0000644 00000001563 15154173074 0014043 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Integrations;
/**
* Integration.Interface
*
* Integrations must use this interface when registering themselves with blocks,
*/
interface IntegrationInterface {
/**
* The name of the integration.
*
* @return string
*/
public function get_name();
/**
* When called invokes any initialization/setup for the integration.
*/
public function initialize();
/**
* Returns an array of script handles to enqueue in the frontend context.
*
* @return string[]
*/
public function get_script_handles();
/**
* Returns an array of script handles to enqueue in the editor context.
*
* @return string[]
*/
public function get_editor_script_handles();
/**
* An array of key, value pairs of data made available to the block on the client side.
*
* @return array
*/
public function get_script_data();
}
Integrations/IntegrationRegistry.php 0000644 00000012512 15154173074 0013747 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Integrations;
/**
* Class used for tracking registered integrations with various Block types.
*/
class IntegrationRegistry {
/**
* Integration identifier is used to construct hook names and is given when the integration registry is initialized.
*
* @var string
*/
protected $registry_identifier = '';
/**
* Registered integrations, as `$name => $instance` pairs.
*
* @var IntegrationInterface[]
*/
protected $registered_integrations = [];
/**
* Initializes all registered integrations.
*
* Integration identifier is used to construct hook names and is given when the integration registry is initialized.
*
* @param string $registry_identifier Identifier for this registry.
*/
public function initialize( $registry_identifier = '' ) {
if ( $registry_identifier ) {
$this->registry_identifier = $registry_identifier;
}
if ( empty( $this->registry_identifier ) ) {
_doing_it_wrong( __METHOD__, esc_html__( 'Integration registry requires an identifier.', 'woocommerce' ), '4.6.0' );
return false;
}
/**
* Fires when the IntegrationRegistry is initialized.
*
* Runs before integrations are initialized allowing new integration to be registered for use. This should be
* used as the primary hook for integrations to include their scripts, styles, and other code extending the
* blocks.
*
* @since 4.6.0
*
* @param IntegrationRegistry $this Instance of the IntegrationRegistry class which exposes the IntegrationRegistry::register() method.
*/
do_action( 'woocommerce_blocks_' . $this->registry_identifier . '_registration', $this );
foreach ( $this->get_all_registered() as $registered_integration ) {
$registered_integration->initialize();
}
}
/**
* Registers an integration.
*
* @param IntegrationInterface $integration An instance of IntegrationInterface.
*
* @return boolean True means registered successfully.
*/
public function register( IntegrationInterface $integration ) {
$name = $integration->get_name();
if ( $this->is_registered( $name ) ) {
/* translators: %s: Integration name. */
_doing_it_wrong( __METHOD__, esc_html( sprintf( __( '"%s" is already registered.', 'woocommerce' ), $name ) ), '4.6.0' );
return false;
}
$this->registered_integrations[ $name ] = $integration;
return true;
}
/**
* Checks if an integration is already registered.
*
* @param string $name Integration name.
* @return bool True if the integration is registered, false otherwise.
*/
public function is_registered( $name ) {
return isset( $this->registered_integrations[ $name ] );
}
/**
* Un-register an integration.
*
* @param string|IntegrationInterface $name Integration name, or alternatively a IntegrationInterface instance.
* @return boolean|IntegrationInterface Returns the unregistered integration instance if unregistered successfully.
*/
public function unregister( $name ) {
if ( $name instanceof IntegrationInterface ) {
$name = $name->get_name();
}
if ( ! $this->is_registered( $name ) ) {
/* translators: %s: Integration name. */
_doing_it_wrong( __METHOD__, esc_html( sprintf( __( 'Integration "%s" is not registered.', 'woocommerce' ), $name ) ), '4.6.0' );
return false;
}
$unregistered = $this->registered_integrations[ $name ];
unset( $this->registered_integrations[ $name ] );
return $unregistered;
}
/**
* Retrieves a registered Integration by name.
*
* @param string $name Integration name.
* @return IntegrationInterface|null The registered integration, or null if it is not registered.
*/
public function get_registered( $name ) {
return $this->is_registered( $name ) ? $this->registered_integrations[ $name ] : null;
}
/**
* Retrieves all registered integrations.
*
* @return IntegrationInterface[]
*/
public function get_all_registered() {
return $this->registered_integrations;
}
/**
* Gets an array of all registered integration's script handles for the editor.
*
* @return string[]
*/
public function get_all_registered_editor_script_handles() {
$script_handles = [];
$registered_integrations = $this->get_all_registered();
foreach ( $registered_integrations as $registered_integration ) {
$script_handles = array_merge(
$script_handles,
$registered_integration->get_editor_script_handles()
);
}
return array_unique( array_filter( $script_handles ) );
}
/**
* Gets an array of all registered integration's script handles.
*
* @return string[]
*/
public function get_all_registered_script_handles() {
$script_handles = [];
$registered_integrations = $this->get_all_registered();
foreach ( $registered_integrations as $registered_integration ) {
$script_handles = array_merge(
$script_handles,
$registered_integration->get_script_handles()
);
}
return array_unique( array_filter( $script_handles ) );
}
/**
* Gets an array of all registered integration's script data.
*
* @return array
*/
public function get_all_registered_script_data() {
$script_data = [];
$registered_integrations = $this->get_all_registered();
foreach ( $registered_integrations as $registered_integration ) {
$script_data[ $registered_integration->get_name() . '_data' ] = $registered_integration->get_script_data();
}
return array_filter( $script_data );
}
}
Interactivity/class-wc-interactivity-store.php 0000644 00000002022 15154173074 0015660 0 ustar 00 <?php
/**
* Manages the initial state of the Interactivity API store in the server and
* its serialization so it can be restored in the browser upon hydration.
*
* It's a private class, exposed by other functions, like `wc_store`.
*
* @access private
*/
class WC_Interactivity_Store {
/**
* Store.
*
* @var array
*/
private static $store = array();
/**
* Get store data.
*
* @return array
*/
static function get_data() {
return self::$store;
}
/**
* Merge data.
*
* @param array $data The data that will be merged with the exsisting store.
*/
static function merge_data( $data ) {
self::$store = array_replace_recursive( self::$store, $data );
}
/**
* Reset the store data.
*/
static function reset() {
self::$store = array();
}
/**
* Render the store data.
*/
static function render() {
if ( empty( self::$store ) ) {
return;
}
echo sprintf(
'<script id="wc-interactivity-store-data" type="application/json">%s</script>',
wp_json_encode( self::$store )
);
}
}
Interactivity/load.php 0000644 00000000177 15154173074 0011046 0 ustar 00 <?php
require __DIR__ . '/class-wc-interactivity-store.php';
require __DIR__ . '/store.php';
require __DIR__ . '/scripts.php';
Interactivity/scripts.php 0000644 00000003264 15154173074 0011616 0 ustar 00 <?php
/**
* Move interactive scripts to the footer. This is a temporary measure to make
* it work with `wc_store` and it should be replaced with deferred scripts or
* modules.
*/
function woocommerce_interactivity_move_interactive_scripts_to_the_footer() {
// Move the @woocommerce/interactivity package to the footer.
wp_script_add_data( 'wc-interactivity', 'group', 1 );
// Move all the view scripts of the interactive blocks to the footer.
$registered_blocks = \WP_Block_Type_Registry::get_instance()->get_all_registered();
foreach ( array_values( $registered_blocks ) as $block ) {
if ( isset( $block->supports['interactivity'] ) && $block->supports['interactivity'] ) {
foreach ( $block->view_script_handles as $handle ) {
wp_script_add_data( $handle, 'group', 1 );
}
}
}
}
add_action( 'wp_enqueue_scripts', 'woocommerce_interactivity_move_interactive_scripts_to_the_footer', 11 );
/**
* Register the Interactivity API runtime and make it available to be enqueued
* as a dependency in interactive blocks.
*/
function woocommerce_interactivity_register_runtime() {
$plugin_path = \Automattic\WooCommerce\Blocks\Package::get_path();
$plugin_url = plugin_dir_url( $plugin_path . '/index.php' );
$file = 'build/wc-interactivity.js';
$file_path = $plugin_path . $file;
$file_url = $plugin_url . $file;
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG && file_exists( $file_path ) ) {
$version = filemtime( $file_path );
} else {
$version = \Automattic\WooCommerce\Blocks\Package::get_version();
}
wp_register_script(
'wc-interactivity',
$file_url,
array(),
$version,
true
);
}
add_action( 'wp_enqueue_scripts', 'woocommerce_interactivity_register_runtime' );
Interactivity/store.php 0000644 00000000715 15154173074 0011261 0 ustar 00 <?php
/**
* Merge data with the exsisting store.
*
* @param array $data Data that will be merged with the exsisting store.
*
* @return $data The current store data.
*/
function wc_store( $data = null ) {
if ( $data ) {
WC_Interactivity_Store::merge_data( $data );
}
return WC_Interactivity_Store::get_data();
}
/**
* Render the Interactivity API store in the frontend.
*/
add_action( 'wp_footer', array( 'WC_Interactivity_Store', 'render' ), 8 );
Library.php 0000644 00000002076 15154173074 0006675 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\BlockTypes\AtomicBlock;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
/**
* Library class.
*
* @deprecated 5.0.0 This class will be removed in a future release. This has been replaced by BlockTypesController.
* @internal
*/
class Library {
/**
* Initialize block library features.
*
* @deprecated 5.0.0
*/
public static function init() {
_deprecated_function( 'Library::init', '5.0.0' );
}
/**
* Register custom tables within $wpdb object.
*
* @deprecated 5.0.0
*/
public static function define_tables() {
_deprecated_function( 'Library::define_tables', '5.0.0' );
}
/**
* Register blocks, hooking up assets and render functions as needed.
*
* @deprecated 5.0.0
*/
public static function register_blocks() {
_deprecated_function( 'Library::register_blocks', '5.0.0' );
}
}
Migration.php 0000644 00000003636 15154173074 0007225 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
/**
* Takes care of the migrations.
*
* @since 2.5.0
*/
class Migration {
/**
* DB updates and callbacks that need to be run per version.
*
* Please note that these functions are invoked when WooCommerce Blocks is updated from a previous version,
* but NOT when WooCommerce Blocks is newly installed.
*
* @var array
*/
private $db_upgrades = array(
'10.3.0' => array(
'wc_blocks_update_1030_blockified_product_grid_block',
),
);
/**
* Runs all the necessary migrations.
*
* @var array
*/
public function run_migrations() {
$current_db_version = get_option( Options::WC_BLOCK_VERSION, '' );
$schema_version = get_option( 'wc_blocks_db_schema_version', '' );
// This check is necessary because the version was not being set in the database until 10.3.0.
// Checking wc_blocks_db_schema_version determines if it's a fresh install (value will be empty)
// or an update from WC Blocks older than 10.3.0 (it will have some value). In the latter scenario
// we should run the migration.
// We can remove this check in the next months.
if ( ! empty( $schema_version ) && ( empty( $current_db_version ) ) ) {
$this->wc_blocks_update_1030_blockified_product_grid_block();
}
if ( empty( $current_db_version ) ) {
// This is a fresh install, so we don't need to run any migrations.
return;
}
foreach ( $this->db_upgrades as $version => $update_callbacks ) {
if ( version_compare( $current_db_version, $version, '<' ) ) {
foreach ( $update_callbacks as $update_callback ) {
$this->{$update_callback}();
}
}
}
}
/**
* Set a flag to indicate if the blockified Product Grid Block should be rendered by default.
*/
public static function wc_blocks_update_1030_blockified_product_grid_block() {
update_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE, wc_bool_to_string( false ) );
}
}
Options.php 0000644 00000000520 15154173074 0006714 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
/**
* Contains all the option names used by the plugin.
*/
class Options {
const WC_BLOCK_VERSION = 'wc_blocks_version';
const WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE = 'wc_blocks_use_blockified_product_grid_block_as_template';
}
Package.php 0000644 00000006254 15154173074 0006626 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\Domain\Package as NewPackage;
use Automattic\WooCommerce\Blocks\Domain\Bootstrap;
use Automattic\WooCommerce\Blocks\Registry\Container;
use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating;
/**
* Main package class.
*
* Returns information about the package and handles init.
*
* In the context of this plugin, it handles init and is called from the main
* plugin file (woocommerce-gutenberg-products-block.php).
*
* In the context of WooCommere core, it handles init and is called from
* WooCommerce's package loader. The main plugin file is _not_ loaded.
*
* @since 2.5.0
*/
class Package {
/**
* For back compat this is provided. Ideally, you should register your
* class with Automattic\Woocommerce\Blocks\Container and make Package a
* dependency.
*
* @since 2.5.0
* @return Package The Package instance class
*/
protected static function get_package() {
return self::container()->get( NewPackage::class );
}
/**
* Init the package - load the blocks library and define constants.
*
* @since 2.5.0 Handled by new NewPackage.
*/
public static function init() {
self::container()->get( Bootstrap::class );
}
/**
* Return the version of the package.
*
* @return string
*/
public static function get_version() {
return self::get_package()->get_version();
}
/**
* Return the path to the package.
*
* @return string
*/
public static function get_path() {
return self::get_package()->get_path();
}
/**
* Returns an instance of the FeatureGating class.
*
* @return FeatureGating
*/
public static function feature() {
return self::get_package()->feature();
}
/**
* Checks if we're executing the code in an experimental build mode.
*
* @return boolean
*/
public static function is_experimental_build() {
return self::get_package()->is_experimental_build();
}
/**
* Checks if we're executing the code in a feature plugin or experimental build mode.
*
* @return boolean
*/
public static function is_feature_plugin_build() {
return self::get_package()->is_feature_plugin_build();
}
/**
* Loads the dependency injection container for woocommerce blocks.
*
* @param boolean $reset Used to reset the container to a fresh instance.
* Note: this means all dependencies will be
* reconstructed.
*/
public static function container( $reset = false ) {
static $container;
if (
! $container instanceof Container
|| $reset
) {
$container = new Container();
// register Package.
$container->register(
NewPackage::class,
function ( $container ) {
// leave for automated version bumping.
$version = '11.1.2';
return new NewPackage(
$version,
dirname( __DIR__ ),
new FeatureGating()
);
}
);
// register Bootstrap.
$container->register(
Bootstrap::class,
function ( $container ) {
return new Bootstrap(
$container
);
}
);
// register Bootstrap.
$container->register(
Migration::class,
function () {
return new Migration();
}
);
}
return $container;
}
}
Patterns/dictionary.json 0000644 00000045145 15154173074 0011424 0 ustar 00 [
{
"name": "Banner",
"slug": "woocommerce-blocks/banner",
"images_total": 1,
"images_format": "landscape",
"content": {
"titles": [
{
"default": "Save up to 60%",
"ai_prompt": "A title advertising the sale"
}
],
"descriptions": [
{
"default": "Holiday Sale",
"ai_prompt": "A label with the sale name"
},
{
"default": "Make the day special with our collection of discounted products.",
"ai_prompt": "The main description of the sale"
}
],
"buttons": [
{
"default": "Shop Holiday Sale",
"ai_prompt": "The button text to go to the sale page"
}
]
}
},
{
"name": "Discount Banner",
"slug": "woocommerce-blocks/discount-banner",
"images_total": 1,
"images_format": "square",
"content": {
"titles": [
{
"default": "Up to 40% off",
"ai_prompt": "A title advertising the sale"
}
],
"descriptions": [
{
"default": "Select products",
"ai_prompt": "A description of the products on sale"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the sale page"
}
]
}
},
{
"name": "Discount Banner with Image",
"slug": "woocommerce-blocks/discount-banner-with-image",
"images_total": 1,
"images_format": "square",
"content": {
"titles": [
{
"default": "Up to 40% off",
"ai_prompt": "A title advertising the sale"
}
],
"descriptions": [
{
"default": "Select products",
"ai_prompt": "A description of the products on sale"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the sale page"
}
]
}
},
{
"name": "Featured Category Focus",
"slug": "woocommerce-blocks/featured-category-focus",
"images_total": 1,
"images_format": "landscape",
"content": {
"titles": [
{
"default": "Announcing our newest collection",
"ai_prompt": "The title of the featured category"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the featured category"
}
]
}
},
{
"name": "Featured Category Triple",
"slug": "woocommerce-blocks/featured-category-triple",
"images_total": 3,
"images_format": "portrait",
"content": {
"titles": [
{
"default": "Cupcakes",
"ai_prompt": "The title of the first featured category"
},
{
"default": "Sweet Danish",
"ai_prompt": "The title of the second featured category"
},
{
"default": "Warm Bread",
"ai_prompt": "The title of the third featured category"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the first featured category"
},
{
"default": "Shop now",
"ai_prompt": "The button text to go to the second featured category"
},
{
"default": "Shop now",
"ai_prompt": "The button text to go to the third featured category"
}
]
}
},
{
"name": "Featured Products 5-Item Grid",
"slug": "woocommerce-blocks/featured-products-5-item-grid",
"content": {
"titles": [
{
"default": "Shop new arrivals",
"ai_prompt": "The title of the featured products"
}
],
"buttons": [
{
"default": "Shop All",
"ai_prompt": "The button text to go to the featured products"
}
]
}
},
{
"name": "Featured Products: Fresh & Tasty",
"slug": "woocommerce-blocks/featured-products-fresh-and-tasty",
"images_total": 4,
"images_format": "portrait",
"content": {
"titles": [
{
"default": "Fresh & tasty goods",
"ai_prompt": "The title of the featured products"
}
],
"descriptions": [
{
"default": "Sweet Organic Lemons",
"ai_prompt": "The description of the first featured products"
},
{
"default": "Fresh Organic Tomatoes",
"ai_prompt": "The description of the second featured products"
},
{
"default": "Fresh Lettuce (Washed)",
"ai_prompt": "The description of the third featured products"
},
{
"default": "Russet Organic Potatoes",
"ai_prompt": "The description of the fourth featured products"
}
]
}
},
{
"name": "Hero Product 3 Split",
"slug": "woocommerce-blocks/hero-product-3-split",
"images_total": 1,
"images_format": "portrait",
"content": {
"titles": [
{
"default": "Endless Tee's",
"ai_prompt": "An impact phrase that advertises the displayed product"
},
{
"default": "Waterproof Membrane",
"ai_prompt": "A title describing the first displayed product feature"
},
{
"default": "Expert Craftsmanship",
"ai_prompt": "A title describing the second displayed product feature"
},
{
"default": "Durable Fabric",
"ai_prompt": "A title describing the third displayed product feature"
},
{
"default": "Sustainable Dyes",
"ai_prompt": "A title describing the fourth displayed product feature"
}
],
"descriptions": [
{
"default": "With high-quality materials and expert craftsmanship, our products are built to last and exceed your expectations.",
"ai_prompt": "A description of the product"
},
{
"default": "Never worry about the weather again. Keep yourself dry, warm, and looking stylish.",
"ai_prompt": "A description of the first displayed product feature"
},
{
"default": "Our products are made with expert craftsmanship and attention to detail, ensuring that every stitch and seam is perfect.",
"ai_prompt": "A description of the second displayed product feature"
},
{
"default": "We use only the highest-quality materials in our products, ensuring that they look great and last for years to come.",
"ai_prompt": "A description of the third displayed product feature"
},
{
"default": "From bold prints and colors to intricate details and textures, our products are a perfect combination of style and function.",
"ai_prompt": "A description of the fourth displayed product feature"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the product page"
}
]
}
},
{
"name": "Hero Product Chessboard",
"slug": "woocommerce-blocks/hero-product-chessboard",
"images_total": 2,
"images_format": "square",
"content": {
"titles": [
{
"default": "The Fall Collection",
"ai_prompt": "An impact phrase that advertises the displayed product"
},
{
"default": "Quality Materials",
"ai_prompt": "A title describing the first displayed product feature"
},
{
"default": "Expert Craftsmanship",
"ai_prompt": "A title describing the second displayed product feature"
},
{
"default": "Unique Design",
"ai_prompt": "A title describing the third displayed product feature"
},
{
"default": "Customer Satisfaction",
"ai_prompt": "A title describing the fourth displayed product feature"
}
],
"descriptions": [
{
"default": "With high-quality materials and expert craftsmanship, our products are built to last and exceed your expectations.",
"ai_prompt": "A description of the product"
},
{
"default": "We use only the highest-quality materials in our products, ensuring that they look great and last for years to come.",
"ai_prompt": "A description of the first displayed product feature"
},
{
"default": "Our products are made with expert craftsmanship and attention to detail, ensuring that every stitch and seam is perfect.",
"ai_prompt": "A description of the second displayed product feature"
},
{
"default": "From bold prints and colors to intricate details and textures, our products are a perfect combination of style and function.",
"ai_prompt": "A description of the third displayed product feature"
},
{
"default": "Our top priority is customer satisfaction, and we stand behind our products 100%. ",
"ai_prompt": "A description of the fourth displayed product feature"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the product page"
}
]
}
},
{
"name": "Hero Product Split",
"slug": "woocommerce-blocks/hero-product-split",
"images_total": 1,
"images_format": "landscape",
"content": {
"titles": [
{
"default": "Get cozy this fall with knit sweaters",
"ai_prompt": "An impact phrase that advertises the displayed product"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the product page"
}
]
}
},
{
"name": "Just Arrived Full Hero",
"slug": "woocommerce-blocks/just-arrived-full-hero",
"images_total": 1,
"images_format": "landscape",
"content": {
"titles": [
{
"default": "Just arrived",
"ai_prompt": "An impact phrase that advertises the displayed product collection"
}
],
"descriptions": [
{
"default": "Our early autumn collection is here.",
"ai_prompt": "A description of the product collection"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the product collection page"
}
]
}
},
{
"name": "Product Collection Banner",
"slug": "woocommerce-blocks/product-collection-banner",
"images_total": 1,
"images_format": "landscape",
"content": {
"titles": [
{
"default": "Brand New for the Holidays",
"ai_prompt": "An impact phrase that advertises the displayed product collection"
}
],
"descriptions": [
{
"default": "OCheck out our brand new collection of holiday products and find the right gift for anyone.",
"ai_prompt": "A description of the product collection"
}
],
"buttons": [
{
"default": "Shop now",
"ai_prompt": "The button text to go to the product collection page"
}
]
}
},
{
"name": "Product Collections Featured Collection",
"slug": "woocommerce-blocks/product-collections-featured-collection",
"content": {
"titles": [
{
"default": "This week's popular products",
"ai_prompt": "An impact phrase that advertises the displayed product collection"
}
]
}
},
{
"name": "Product Collections Featured Collections",
"slug": "woocommerce-blocks/product-collections-featured-collections",
"images_total": 4,
"images_format": "landscape",
"content": {
"titles": [
{
"default": "Tech gifts under $100",
"ai_prompt": "An impact phrase that advertises the first product collection"
},
{
"default": "For the gamers",
"ai_prompt": "An impact phrase that advertises the second product collection"
}
],
"buttons": [
{
"default": "Shop tech",
"ai_prompt": "The button text to go to the first product collection page"
},
{
"default": "Shop games",
"ai_prompt": "The button text to go to the second product collection page"
}
]
}
},
{
"name": "Product Collections Newest Arrivals",
"slug": "woocommerce-blocks/product-collections-newest-arrivals",
"content": {
"titles": [
{
"default": "Our newest arrivals",
"ai_prompt": "An impact phrase that advertises the displayed product collection"
}
],
"buttons": [
{
"default": "More new products",
"ai_prompt": "The button text to go to the product collection page"
}
]
}
},
{
"name": "Featured Products 2 Columns",
"slug": "woocommerce-blocks/featured-products-2-columns",
"content": {
"titles": [
{
"default": "Fan favorites",
"ai_prompt": "An impact phrase that advertises the features products"
}
],
"descriptions": [
{
"default": "Get ready to start the season right. All the fan favorites in one place at the best price.",
"ai_prompt": "A description of the featured products"
}
],
"buttons": [
{
"default": "Shop All",
"ai_prompt": "The button text to go to the featured products page"
}
]
}
},
{
"name": "Product Hero 2 Column 2 Row",
"slug": "woocommerce-blocks/product-hero-2-column-2-row",
"images_total": 2,
"images_format": "square",
"content": {
"titles": [
{
"default": "The Eden Jacket",
"ai_prompt": "An title that advertises the displayed product"
},
{
"default": "100% Woolen",
"ai_prompt": "A title that advertises the first product feature"
},
{
"default": "Fits your wardrobe",
"ai_prompt": "A title that advertises the second product feature"
},
{
"default": "Versatile",
"ai_prompt": "A title that advertises the third product feature"
},
{
"default": "Normal Fit",
"ai_prompt": "A title that advertises the fourth product feature"
}
],
"descriptions": [
{
"default": "Perfect for any look featuring a mid-rise, relax fitting silhouette.",
"ai_prompt": "A description of the displayed product"
},
{
"default": "Reflect your fashionable style.",
"ai_prompt": "A description of the first product feature"
},
{
"default": "Half tuck into your pants or layer over.",
"ai_prompt": "A description of the second product feature"
},
{
"default": "Button-down front for any type of mood or look.",
"ai_prompt": "A description of the third product feature"
},
{
"default": "42% Cupro 34% Linen 24% Viscose",
"ai_prompt": "A description of the fourth product feature"
}
],
"buttons": [
{
"default": "View product",
"ai_prompt": "The button text to go to the product page"
}
]
}
},
{
"name": "Shop by Price",
"slug": "woocommerce-blocks/shop-by-price",
"content": {
"titles": [
{
"default": "Outdoor Furniture & Accessories",
"ai_prompt": "An impact phrase that advertises the first product collection"
},
{
"default": "Summer Dinning",
"ai_prompt": "An impact phrase that advertises the second product collection"
},
{
"default": "Women's Styles",
"ai_prompt": "An impact phrase that advertises the third product collection"
},
{
"default": "Kids' Styles",
"ai_prompt": "An impact phrase that advertises the fourth product collection"
}
],
"descriptions": [
{
"default": "Highest rated",
"ai_prompt": "A description of the first product collection"
},
{
"default": "Under 15$",
"ai_prompt": "A description of the second product collection"
},
{
"default": "Under 25$",
"ai_prompt": "A description of the third product collection"
},
{
"default": "Under 20$",
"ai_prompt": "A description of the fourth product collection"
}
]
}
},
{
"name": "Small Discount Banner with Image",
"slug": "woocommerce-blocks/small-discount-banner-with-image",
"images_total": 1,
"images_format": "square",
"content": {
"titles": [
{
"default": "Chairs from $149",
"ai_prompt": "An impact phrase that advertises the products and their price"
}
]
}
},
{
"name": "Social: Follow us in social media",
"slug": "woocommerce-blocks/social-follow-us-in-social-media",
"images_total": 4,
"images_format": "square",
"content": {
"titles": [
{
"default": "Follow us in social media",
"ai_prompt": "An phrase that advertises the social media accounts"
}
]
}
},
{
"name": "Alternating Image and Text",
"slug": "woocommerce-blocks/alt-image-and-text",
"images_total": 2,
"images_format": "square",
"content": {
"titles": [
{
"default": "The goods",
"ai_prompt": "An impact phrase that advertises the products"
},
{
"default": "Created with love and care in Australia",
"ai_prompt": "An impact phrase that advertises the products"
},
{
"default": "About us",
"ai_prompt": "An impact phrase that advertises the brand"
},
{
"default": "Marl is an independent studio and artisanal gallery",
"ai_prompt": "An impact phrase that advertises the brand"
}
],
"descriptions": [
{
"default": "All items are 100% hand-made, using the potter’s wheel or traditional techniques.\n\nTimeless style.\n\nEarthy, organic feel.\n\nEnduring quality.\n\nUnique, one-of-a-kind pieces.",
"ai_prompt": "A description of the products"
},
{
"default": "We specialize in limited collections of handmade tableware. We collaborate with restaurants and cafes to create unique items that complement the menu perfectly. Please get in touch if you want to know more about our process and pricing.",
"ai_prompt": "A description of the products"
}
],
"buttons": [
{
"default": "Learn more",
"ai_prompt": "The button text to go to the product page"
}
]
}
},
{
"name": "Testimonials 3 Columns",
"slug": "woocommerce-blocks/testimonials-3-columns",
"content": {
"titles": [
{
"default": "Great experience",
"ai_prompt": "A title that advertises the first testimonial"
},
{
"default": "LOVE IT",
"ai_prompt": "A title that advertises the second testimonial"
},
{
"default": "Awesome couch and great buying experience",
"ai_prompt": "A title that advertises the third testimonial"
}
],
"descriptions": [
{
"default": "In the end the couch wasn't exactly what I was looking for but my experience with the Burrow team was excellent. First in providing a discount when the couch was delayed, then timely feedback and updates as the…\n\n~ Tanner P.",
"ai_prompt": "A description of the first testimonial"
},
{
"default": "Great couch. color as advertise. seat is nice and firm. Easy to put together. Versatile. Bought one for my mother in law as well. And she loves hers!\n\n~ Abigail N.",
"ai_prompt": "A description of the second testimonial"
},
{
"default": "I got the kind sofa. The look and feel is high quality, and I enjoy that it is a medium level of firmness. Assembly took a little longer than I expected, and it came in 4 boxes. I am excited about the time / st…\n\n~ Albert L.",
"ai_prompt": "A description of the third testimonial"
}
]
}
},
{
"name": "Testimonials Single",
"slug": "woocommerce-blocks/testimonials-single",
"images_total": 1,
"images_format": "square",
"content": {
"titles": [
{
"default": "Great experience",
"ai_prompt": "A title that advertises the testimonial"
}
],
"descriptions": [
{
"default": "In the end the couch wasn't exactly what I was looking for but my experience with the Burrow team was excellent. First in providing a discount when the couch was delayed, then timely feedback and updates as the...\n\n~ Anna W.",
"ai_prompt": "A description of the testimonial"
}
]
}
}
]
Payments/Api.php 0000644 00000016232 15154173074 0007601 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Payments\Integrations\BankTransfer;
use Automattic\WooCommerce\Blocks\Payments\Integrations\CashOnDelivery;
use Automattic\WooCommerce\Blocks\Payments\Integrations\Cheque;
use Automattic\WooCommerce\Blocks\Payments\Integrations\PayPal;
/**
* The Api class provides an interface to payment method registration.
*
* @since 2.6.0
*/
class Api {
/**
* Reference to the PaymentMethodRegistry instance.
*
* @var PaymentMethodRegistry
*/
private $payment_method_registry;
/**
* Reference to the AssetDataRegistry instance.
*
* @var AssetDataRegistry
*/
private $asset_registry;
/**
* Constructor
*
* @param PaymentMethodRegistry $payment_method_registry An instance of Payment Method Registry.
* @param AssetDataRegistry $asset_registry Used for registering data to pass along to the request.
*/
public function __construct( PaymentMethodRegistry $payment_method_registry, AssetDataRegistry $asset_registry ) {
$this->payment_method_registry = $payment_method_registry;
$this->asset_registry = $asset_registry;
}
/**
* Initialize class features.
*/
public function init() {
add_action( 'init', array( $this->payment_method_registry, 'initialize' ), 5 );
add_filter( 'woocommerce_blocks_register_script_dependencies', array( $this, 'add_payment_method_script_dependencies' ), 10, 2 );
add_action( 'woocommerce_blocks_checkout_enqueue_data', array( $this, 'add_payment_method_script_data' ) );
add_action( 'woocommerce_blocks_cart_enqueue_data', array( $this, 'add_payment_method_script_data' ) );
add_action( 'woocommerce_blocks_payment_method_type_registration', array( $this, 'register_payment_method_integrations' ) );
add_action( 'wp_print_scripts', array( $this, 'verify_payment_methods_dependencies' ), 1 );
}
/**
* Add payment method script handles as script dependencies.
*
* @param array $dependencies Array of script dependencies.
* @param string $handle Script handle.
* @return array
*/
public function add_payment_method_script_dependencies( $dependencies, $handle ) {
if ( ! in_array( $handle, [ 'wc-checkout-block', 'wc-checkout-block-frontend', 'wc-cart-block', 'wc-cart-block-frontend' ], true ) ) {
return $dependencies;
}
return array_merge( $dependencies, $this->payment_method_registry->get_all_active_payment_method_script_dependencies() );
}
/**
* Returns true if the payment gateway is enabled.
*
* @param object $gateway Payment gateway.
* @return boolean
*/
private function is_payment_gateway_enabled( $gateway ) {
return filter_var( $gateway->enabled, FILTER_VALIDATE_BOOLEAN );
}
/**
* Add payment method data to Asset Registry.
*/
public function add_payment_method_script_data() {
// Enqueue the order of enabled gateways.
if ( ! $this->asset_registry->exists( 'paymentMethodSortOrder' ) ) {
// We use payment_gateways() here to get the sort order of all enabled gateways. Some may be
// programmatically disabled later on, but we still need to know where the enabled ones are in the list.
$payment_gateways = WC()->payment_gateways->payment_gateways();
$enabled_gateways = array_filter( $payment_gateways, array( $this, 'is_payment_gateway_enabled' ) );
$this->asset_registry->add( 'paymentMethodSortOrder', array_keys( $enabled_gateways ) );
}
// Enqueue all registered gateway data (settings/config etc).
$script_data = $this->payment_method_registry->get_all_registered_script_data();
foreach ( $script_data as $asset_data_key => $asset_data_value ) {
if ( ! $this->asset_registry->exists( $asset_data_key ) ) {
$this->asset_registry->add( $asset_data_key, $asset_data_value );
}
}
}
/**
* Register payment method integrations bundled with blocks.
*
* @param PaymentMethodRegistry $payment_method_registry Payment method registry instance.
*/
public function register_payment_method_integrations( PaymentMethodRegistry $payment_method_registry ) {
$payment_method_registry->register(
Package::container()->get( Cheque::class )
);
$payment_method_registry->register(
Package::container()->get( PayPal::class )
);
$payment_method_registry->register(
Package::container()->get( BankTransfer::class )
);
$payment_method_registry->register(
Package::container()->get( CashOnDelivery::class )
);
}
/**
* Verify all dependencies of registered payment methods have been registered.
* If not, remove that payment method script from the list of dependencies
* of Cart and Checkout block scripts so it doesn't break the blocks and show
* an error in the admin.
*/
public function verify_payment_methods_dependencies() {
// Check that the wc-blocks script is registered before continuing. Some extensions may cause this function to run
// before the payment method scripts' dependencies are registered.
if ( ! wp_script_is( 'wc-blocks', 'registered' ) ) {
return;
}
$wp_scripts = wp_scripts();
$payment_method_scripts = $this->payment_method_registry->get_all_active_payment_method_script_dependencies();
foreach ( $payment_method_scripts as $payment_method_script ) {
if (
! array_key_exists( $payment_method_script, $wp_scripts->registered ) ||
! property_exists( $wp_scripts->registered[ $payment_method_script ], 'deps' )
) {
continue;
}
$deps = $wp_scripts->registered[ $payment_method_script ]->deps;
foreach ( $deps as $dep ) {
if ( ! wp_script_is( $dep, 'registered' ) ) {
$error_handle = $dep . '-dependency-error';
$error_message = sprintf(
'Payment gateway with handle \'%1$s\' has been deactivated in Cart and Checkout blocks because its dependency \'%2$s\' is not registered. Read the docs about registering assets for payment methods: https://github.com/woocommerce/woocommerce-blocks/blob/060f63c04f0f34f645200b5d4da9212125c49177/docs/third-party-developers/extensibility/checkout-payment-methods/payment-method-integration.md#registering-assets',
esc_html( $payment_method_script ),
esc_html( $dep )
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $error_message );
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter,WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_register_script( $error_handle, '' );
wp_enqueue_script( $error_handle );
wp_add_inline_script(
$error_handle,
sprintf( 'console.error( "%s" );', $error_message )
);
$cart_checkout_scripts = [ 'wc-cart-block', 'wc-cart-block-frontend', 'wc-checkout-block', 'wc-checkout-block-frontend' ];
foreach ( $cart_checkout_scripts as $script_handle ) {
if (
! array_key_exists( $script_handle, $wp_scripts->registered ) ||
! property_exists( $wp_scripts->registered[ $script_handle ], 'deps' )
) {
continue;
}
// Remove payment method script from dependencies.
$wp_scripts->registered[ $script_handle ]->deps = array_diff(
$wp_scripts->registered[ $script_handle ]->deps,
[ $payment_method_script ]
);
}
}
}
}
}
}
Payments/Integrations/AbstractPaymentMethodType.php 0000644 00000005425 15154173074 0016644 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodTypeInterface;
/**
* AbstractPaymentMethodType class.
*
* @since 2.6.0
*/
abstract class AbstractPaymentMethodType implements PaymentMethodTypeInterface {
/**
* Payment method name defined by payment methods extending this class.
*
* @var string
*/
protected $name = '';
/**
* Settings from the WP options table
*
* @var array
*/
protected $settings = [];
/**
* Get a setting from the settings array if set.
*
* @param string $name Setting name.
* @param mixed $default Value that is returned if the setting does not exist.
* @return mixed
*/
protected function get_setting( $name, $default = '' ) {
return isset( $this->settings[ $name ] ) ? $this->settings[ $name ] : $default;
}
/**
* Returns the name of the payment method.
*/
public function get_name() {
return $this->name;
}
/**
* Returns if this payment method should be active. If false, the scripts will not be enqueued.
*
* @return boolean
*/
public function is_active() {
return true;
}
/**
* Returns an array of script handles to enqueue for this payment method in
* the frontend context
*
* @return string[]
*/
public function get_payment_method_script_handles() {
return [];
}
/**
* Returns an array of script handles to enqueue for this payment method in
* the admin context
*
* @return string[]
*/
public function get_payment_method_script_handles_for_admin() {
return $this->get_payment_method_script_handles();
}
/**
* Returns an array of supported features.
*
* @return string[]
*/
public function get_supported_features() {
return [ 'products' ];
}
/**
* An array of key, value pairs of data made available to payment methods
* client side.
*
* @return array
*/
public function get_payment_method_data() {
return [];
}
/**
* Returns an array of script handles to enqueue in the frontend context.
*
* Alias of get_payment_method_script_handles. Defined by IntegrationInterface.
*
* @return string[]
*/
public function get_script_handles() {
return $this->get_payment_method_script_handles();
}
/**
* Returns an array of script handles to enqueue in the admin context.
*
* Alias of get_payment_method_script_handles_for_admin. Defined by IntegrationInterface.
*
* @return string[]
*/
public function get_editor_script_handles() {
return $this->get_payment_method_script_handles_for_admin();
}
/**
* An array of key, value pairs of data made available to the block on the client side.
*
* Alias of get_payment_method_data. Defined by IntegrationInterface.
*
* @return array
*/
public function get_script_data() {
return $this->get_payment_method_data();
}
}
Payments/Integrations/BankTransfer.php 0000644 00000003261 15154173074 0014114 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
use Automattic\WooCommerce\Blocks\Assets\Api;
/**
* Bank Transfer (BACS) payment method integration
*
* @since 3.0.0
*/
final class BankTransfer extends AbstractPaymentMethodType {
/**
* Payment method name/id/slug (matches id in WC_Gateway_BACS in core).
*
* @var string
*/
protected $name = 'bacs';
/**
* An instance of the Asset Api
*
* @var Api
*/
private $asset_api;
/**
* Constructor
*
* @param Api $asset_api An instance of Api.
*/
public function __construct( Api $asset_api ) {
$this->asset_api = $asset_api;
}
/**
* Initializes the payment method type.
*/
public function initialize() {
$this->settings = get_option( 'woocommerce_bacs_settings', [] );
}
/**
* Returns if this payment method should be active. If false, the scripts will not be enqueued.
*
* @return boolean
*/
public function is_active() {
return filter_var( $this->get_setting( 'enabled', false ), FILTER_VALIDATE_BOOLEAN );
}
/**
* Returns an array of scripts/handles to be registered for this payment method.
*
* @return array
*/
public function get_payment_method_script_handles() {
$this->asset_api->register_script(
'wc-payment-method-bacs',
'build/wc-payment-method-bacs.js'
);
return [ 'wc-payment-method-bacs' ];
}
/**
* Returns an array of key=>value pairs of data made available to the payment methods script.
*
* @return array
*/
public function get_payment_method_data() {
return [
'title' => $this->get_setting( 'title' ),
'description' => $this->get_setting( 'description' ),
'supports' => $this->get_supported_features(),
];
}
}
Payments/Integrations/CashOnDelivery.php 0000644 00000004756 15154173074 0014425 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
use Automattic\WooCommerce\Blocks\Assets\Api;
/**
* Cash on Delivery (COD) payment method integration
*
* @since 3.0.0
*/
final class CashOnDelivery extends AbstractPaymentMethodType {
/**
* Payment method name/id/slug (matches id in WC_Gateway_COD in core).
*
* @var string
*/
protected $name = 'cod';
/**
* An instance of the Asset Api
*
* @var Api
*/
private $asset_api;
/**
* Constructor
*
* @param Api $asset_api An instance of Api.
*/
public function __construct( Api $asset_api ) {
$this->asset_api = $asset_api;
}
/**
* Initializes the payment method type.
*/
public function initialize() {
$this->settings = get_option( 'woocommerce_cod_settings', [] );
}
/**
* Returns if this payment method should be active. If false, the scripts will not be enqueued.
*
* @return boolean
*/
public function is_active() {
return filter_var( $this->get_setting( 'enabled', false ), FILTER_VALIDATE_BOOLEAN );
}
/**
* Return enable_for_virtual option.
*
* @return boolean True if store allows COD payment for orders containing only virtual products.
*/
private function get_enable_for_virtual() {
return filter_var( $this->get_setting( 'enable_for_virtual', false ), FILTER_VALIDATE_BOOLEAN );
}
/**
* Return enable_for_methods option.
*
* @return array Array of shipping methods (string ids) that allow COD. (If empty, all support COD.)
*/
private function get_enable_for_methods() {
$enable_for_methods = $this->get_setting( 'enable_for_methods', [] );
if ( '' === $enable_for_methods ) {
return [];
}
return $enable_for_methods;
}
/**
* Returns an array of scripts/handles to be registered for this payment method.
*
* @return array
*/
public function get_payment_method_script_handles() {
$this->asset_api->register_script(
'wc-payment-method-cod',
'build/wc-payment-method-cod.js'
);
return [ 'wc-payment-method-cod' ];
}
/**
* Returns an array of key=>value pairs of data made available to the payment methods script.
*
* @return array
*/
public function get_payment_method_data() {
return [
'title' => $this->get_setting( 'title' ),
'description' => $this->get_setting( 'description' ),
'enableForVirtual' => $this->get_enable_for_virtual(),
'enableForShippingMethods' => $this->get_enable_for_methods(),
'supports' => $this->get_supported_features(),
];
}
}
Payments/Integrations/Cheque.php 0000644 00000003266 15154173074 0012753 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
use Exception;
use Automattic\WooCommerce\Blocks\Assets\Api;
/**
* Cheque payment method integration
*
* @since 2.6.0
*/
final class Cheque extends AbstractPaymentMethodType {
/**
* Payment method name defined by payment methods extending this class.
*
* @var string
*/
protected $name = 'cheque';
/**
* An instance of the Asset Api
*
* @var Api
*/
private $asset_api;
/**
* Constructor
*
* @param Api $asset_api An instance of Api.
*/
public function __construct( Api $asset_api ) {
$this->asset_api = $asset_api;
}
/**
* Initializes the payment method type.
*/
public function initialize() {
$this->settings = get_option( 'woocommerce_cheque_settings', [] );
}
/**
* Returns if this payment method should be active. If false, the scripts will not be enqueued.
*
* @return boolean
*/
public function is_active() {
return filter_var( $this->get_setting( 'enabled', false ), FILTER_VALIDATE_BOOLEAN );
}
/**
* Returns an array of scripts/handles to be registered for this payment method.
*
* @return array
*/
public function get_payment_method_script_handles() {
$this->asset_api->register_script(
'wc-payment-method-cheque',
'build/wc-payment-method-cheque.js'
);
return [ 'wc-payment-method-cheque' ];
}
/**
* Returns an array of key=>value pairs of data made available to the payment methods script.
*
* @return array
*/
public function get_payment_method_data() {
return [
'title' => $this->get_setting( 'title' ),
'description' => $this->get_setting( 'description' ),
'supports' => $this->get_supported_features(),
];
}
}
Payments/Integrations/PayPal.php 0000644 00000004614 15154173074 0012725 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
use WC_Gateway_Paypal;
use Automattic\WooCommerce\Blocks\Assets\Api;
/**
* PayPal Standard payment method integration
*
* @since 2.6.0
*/
final class PayPal extends AbstractPaymentMethodType {
/**
* Payment method name defined by payment methods extending this class.
*
* @var string
*/
protected $name = 'paypal';
/**
* An instance of the Asset Api
*
* @var Api
*/
private $asset_api;
/**
* Constructor
*
* @param Api $asset_api An instance of Api.
*/
public function __construct( Api $asset_api ) {
$this->asset_api = $asset_api;
}
/**
* Initializes the payment method type.
*/
public function initialize() {
$this->settings = get_option( 'woocommerce_paypal_settings', [] );
}
/**
* Returns if this payment method should be active. If false, the scripts will not be enqueued.
*
* @return boolean
*/
public function is_active() {
return filter_var( $this->get_setting( 'enabled', false ), FILTER_VALIDATE_BOOLEAN );
}
/**
* Returns an array of scripts/handles to be registered for this payment method.
*
* @return array
*/
public function get_payment_method_script_handles() {
$this->asset_api->register_script(
'wc-payment-method-paypal',
'build/wc-payment-method-paypal.js'
);
return [ 'wc-payment-method-paypal' ];
}
/**
* Returns an array of key=>value pairs of data made available to the payment methods script.
*
* @return array
*/
public function get_payment_method_data() {
return [
'title' => $this->get_setting( 'title' ),
'description' => $this->get_setting( 'description' ),
'supports' => $this->get_supported_features(),
];
}
/**
* Returns an array of supported features.
*
* @return string[]
*/
public function get_supported_features() {
$gateway = new WC_Gateway_Paypal();
$features = array_filter( $gateway->supports, array( $gateway, 'supports' ) );
/**
* Filter to control what features are available for each payment gateway.
*
* @since 4.4.0
*
* @example See docs/examples/payment-gateways-features-list.md
*
* @param array $features List of supported features.
* @param string $name Gateway name.
* @return array Updated list of supported features.
*/
return apply_filters( '__experimental_woocommerce_blocks_payment_gateway_features_list', $features, $this->get_name() );
}
}
Payments/PaymentMethodRegistry.php 0000644 00000003546 15154173074 0013403 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
/**
* Class used for interacting with payment method types.
*
* @since 2.6.0
*/
final class PaymentMethodRegistry extends IntegrationRegistry {
/**
* Integration identifier is used to construct hook names and is given when the integration registry is initialized.
*
* @var string
*/
protected $registry_identifier = 'payment_method_type';
/**
* Retrieves all registered payment methods that are also active.
*
* @return PaymentMethodTypeInterface[]
*/
public function get_all_active_registered() {
return array_filter(
$this->get_all_registered(),
function( $payment_method ) {
return $payment_method->is_active();
}
);
}
/**
* Gets an array of all registered payment method script handles, but only for active payment methods.
*
* @return string[]
*/
public function get_all_active_payment_method_script_dependencies() {
$script_handles = [];
$payment_methods = $this->get_all_active_registered();
foreach ( $payment_methods as $payment_method ) {
$script_handles = array_merge(
$script_handles,
is_admin() ? $payment_method->get_payment_method_script_handles_for_admin() : $payment_method->get_payment_method_script_handles()
);
}
return array_unique( array_filter( $script_handles ) );
}
/**
* Gets an array of all registered payment method script data, but only for active payment methods.
*
* @return array
*/
public function get_all_registered_script_data() {
$script_data = [];
$payment_methods = $this->get_all_active_registered();
foreach ( $payment_methods as $payment_method ) {
$script_data[ $payment_method->get_name() ] = $payment_method->get_payment_method_data();
}
return array( 'paymentMethodData' => array_filter( $script_data ) );
}
}
Payments/PaymentMethodTypeInterface.php 0000644 00000002012 15154173074 0014320 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Payments;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
interface PaymentMethodTypeInterface extends IntegrationInterface {
/**
* Returns if this payment method should be active. If false, the scripts will not be enqueued.
*
* @return boolean
*/
public function is_active();
/**
* Returns an array of script handles to enqueue for this payment method in
* the frontend context
*
* @return string[]
*/
public function get_payment_method_script_handles();
/**
* Returns an array of script handles to enqueue for this payment method in
* the admin context
*
* @return string[]
*/
public function get_payment_method_script_handles_for_admin();
/**
* An array of key, value pairs of data made available to payment methods
* client side.
*
* @return array
*/
public function get_payment_method_data();
/**
* Get array of supported features.
*
* @return string[]
*/
public function get_supported_features();
}
Registry/AbstractDependencyType.php 0000644 00000002362 15154173074 0013503 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Registry;
/**
* An abstract class for dependency types.
*
* Dependency types are instances of a dependency used by the
* Dependency Injection Container for storing dependencies to invoke as they
* are needed.
*
* @since 2.5.0
*/
abstract class AbstractDependencyType {
/**
* Holds a callable or value provided for this type.
*
* @var mixed
*/
private $callable_or_value;
/**
* Constructor
*
* @param mixed $callable_or_value A callable or value for the dependency
* type instance.
*/
public function __construct( $callable_or_value ) {
$this->callable_or_value = $callable_or_value;
}
/**
* Resolver for the internal dependency value.
*
* @param Container $container The Dependency Injection Container.
*
* @return mixed
*/
protected function resolve_value( Container $container ) {
$callback = $this->callable_or_value;
return \is_callable( $callback )
? $callback( $container )
: $callback;
}
/**
* Retrieves the value stored internally for this DependencyType
*
* @param Container $container The Dependency Injection Container.
*
* @return void
*/
abstract public function get( Container $container );
}
Registry/Container.php 0000644 00000006025 15154173074 0011021 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Registry;
use Closure;
use Exception;
/**
* A simple Dependency Injection Container
*
* This is used to manage dependencies used throughout the plugin.
*
* @since 2.5.0
*/
class Container {
/**
* A map of Dependency Type objects used to resolve dependencies.
*
* @var AbstractDependencyType[]
*/
private $registry = [];
/**
* Public api for adding a factory to the container.
*
* Factory dependencies will have the instantiation callback invoked
* every time the dependency is requested.
*
* Typical Usage:
*
* ```
* $container->register( MyClass::class, $container->factory( $mycallback ) );
* ```
*
* @param Closure $instantiation_callback This will be invoked when the
* dependency is required. It will
* receive an instance of this
* container so the callback can
* retrieve dependencies from the
* container.
*
* @return FactoryType An instance of the FactoryType dependency.
*/
public function factory( Closure $instantiation_callback ) {
return new FactoryType( $instantiation_callback );
}
/**
* Interface for registering a new dependency with the container.
*
* By default, the $value will be added as a shared dependency. This means
* that it will be a single instance shared among any other classes having
* that dependency.
*
* If you want a new instance every time it's required, then wrap the value
* in a call to the factory method (@see Container::factory for example)
*
* Note: Currently if the provided id already is registered in the container,
* the provided value is ignored.
*
* @param string $id A unique string identifier for the provided value.
* Typically it's the fully qualified name for the
* dependency.
* @param mixed $value The value for the dependency. Typically, this is a
* closure that will create the class instance needed.
*/
public function register( $id, $value ) {
if ( empty( $this->registry[ $id ] ) ) {
if ( ! $value instanceof FactoryType ) {
$value = new SharedType( $value );
}
$this->registry[ $id ] = $value;
}
}
/**
* Interface for retrieving the dependency stored in the container for the
* given identifier.
*
* @param string $id The identifier for the dependency being retrieved.
* @throws Exception If there is no dependency for the given identifier in
* the container.
*
* @return mixed Typically a class instance.
*/
public function get( $id ) {
if ( ! isset( $this->registry[ $id ] ) ) {
// this is a developer facing exception, hence it is not localized.
throw new Exception(
sprintf(
'Cannot construct an instance of %s because it has not been registered.',
$id
)
);
}
return $this->registry[ $id ]->get( $this );
}
}
Registry/FactoryType.php 0000644 00000000771 15154173074 0011352 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Registry;
/**
* Definition for the FactoryType dependency type.
*
* @since 2.5.0
*/
class FactoryType extends AbstractDependencyType {
/**
* Invokes and returns the value from the stored internal callback.
*
* @param Container $container An instance of the dependency injection
* container.
*
* @return mixed
*/
public function get( Container $container ) {
return $this->resolve_value( $container );
}
}
Registry/SharedType.php 0000644 00000001344 15154173074 0011146 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Registry;
/**
* A definition for the SharedType dependency type.
*
* @since 2.5.0
*/
class SharedType extends AbstractDependencyType {
/**
* Holds a cached instance of the value stored (or returned) internally.
*
* @var mixed
*/
private $shared_instance;
/**
* Returns the internal stored and shared value after initial generation.
*
* @param Container $container An instance of the dependency injection
* container.
*
* @return mixed
*/
public function get( Container $container ) {
if ( empty( $this->shared_instance ) ) {
$this->shared_instance = $this->resolve_value( $container );
}
return $this->shared_instance;
}
}
Shipping/PickupLocation.php 0000644 00000010722 15154173074 0011773 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Shipping;
use WC_Shipping_Method;
/**
* Local Pickup Shipping Method.
*/
class PickupLocation extends WC_Shipping_Method {
/**
* Pickup locations.
*
* @var array
*/
protected $pickup_locations = [];
/**
* Cost
*
* @var string
*/
protected $cost = '';
/**
* Constructor.
*/
public function __construct() {
$this->id = 'pickup_location';
$this->method_title = __( 'Local pickup', 'woocommerce' );
$this->method_description = __( 'Allow customers to choose a local pickup location during checkout.', 'woocommerce' );
$this->init();
}
/**
* Init function.
*/
public function init() {
$this->enabled = $this->get_option( 'enabled' );
$this->title = $this->get_option( 'title' );
$this->tax_status = $this->get_option( 'tax_status' );
$this->cost = $this->get_option( 'cost' );
$this->supports = [ 'settings', 'local-pickup' ];
$this->pickup_locations = get_option( $this->id . '_pickup_locations', [] );
add_filter( 'woocommerce_attribute_label', array( $this, 'translate_meta_data' ), 10, 3 );
}
/**
* Checks if a given address is complete.
*
* @param array $address Address.
* @return bool
*/
protected function has_valid_pickup_location( $address ) {
// Normalize address.
$address_fields = wp_parse_args(
(array) $address,
array(
'city' => '',
'postcode' => '',
'state' => '',
'country' => '',
)
);
// Country is always required.
if ( empty( $address_fields['country'] ) ) {
return false;
}
// If all fields are provided, we can skip further checks.
if ( ! empty( $address_fields['city'] ) && ! empty( $address_fields['postcode'] ) && ! empty( $address_fields['state'] ) ) {
return true;
}
// Check validity based on requirements for the country.
$country_address_fields = wc()->countries->get_address_fields( $address_fields['country'], 'shipping_' );
foreach ( $country_address_fields as $field_name => $field ) {
$key = str_replace( 'shipping_', '', $field_name );
if ( isset( $address_fields[ $key ] ) && true === $field['required'] && empty( $address_fields[ $key ] ) ) {
return false;
}
}
return true;
}
/**
* Calculate shipping.
*
* @param array $package Package information.
*/
public function calculate_shipping( $package = array() ) {
if ( $this->pickup_locations ) {
foreach ( $this->pickup_locations as $index => $location ) {
if ( ! $location['enabled'] ) {
continue;
}
$this->add_rate(
array(
'id' => $this->id . ':' . $index,
// This is the label shown in shipping rate/method context e.g. London (Local Pickup).
'label' => wp_kses_post( $this->title . ' (' . $location['name'] . ')' ),
'package' => $package,
'cost' => $this->cost,
'meta_data' => array(
'pickup_location' => wp_kses_post( $location['name'] ),
'pickup_address' => $this->has_valid_pickup_location( $location['address'] ) ? wc()->countries->get_formatted_address( $location['address'], ', ' ) : '',
'pickup_details' => wp_kses_post( $location['details'] ),
),
)
);
}
}
}
/**
* See if the method is available.
*
* @param array $package Package information.
* @return bool
*/
public function is_available( $package ) {
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
return apply_filters( 'woocommerce_shipping_' . $this->id . '_is_available', 'yes' === $this->enabled, $package, $this );
}
/**
* Translates meta data for the shipping method.
*
* @param string $label Meta label.
* @param string $name Meta key.
* @param mixed $product Product if applicable.
* @return string
*/
public function translate_meta_data( $label, $name, $product ) {
if ( $product ) {
return $label;
}
switch ( $name ) {
case 'pickup_location':
return __( 'Pickup location', 'woocommerce' );
case 'pickup_address':
return __( 'Pickup address', 'woocommerce' );
}
return $label;
}
/**
* Admin options screen.
*
* See also WC_Shipping_Method::admin_options().
*/
public function admin_options() {
global $hide_save_button;
$hide_save_button = true;
wp_enqueue_script( 'wc-shipping-method-pickup-location' );
echo '<h2>' . esc_html__( 'Local pickup', 'woocommerce' ) . '</h2>';
echo '<div class="wrap"><div id="wc-shipping-method-pickup-location-settings-container"></div></div>';
}
}
Shipping/ShippingController.php 0000644 00000037714 15154173074 0012706 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Shipping;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
use Automattic\WooCommerce\Utilities\ArrayUtil;
/**
* ShippingController class.
*
* @internal
*/
class ShippingController {
/**
* Instance of the asset API.
*
* @var AssetApi
*/
protected $asset_api;
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
protected $asset_data_registry;
/**
* Whether local pickup is enabled.
*
* @var bool
*/
private $local_pickup_enabled;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
*/
public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_registry ) {
$this->asset_api = $asset_api;
$this->asset_data_registry = $asset_data_registry;
$this->local_pickup_enabled = LocalPickupUtils::is_local_pickup_enabled();
}
/**
* Initialization method.
*/
public function init() {
if ( is_admin() ) {
$this->asset_data_registry->add(
'countryStates',
function() {
return WC()->countries->get_states();
},
true
);
}
$this->asset_data_registry->add( 'collectableMethodIds', array( 'Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils', 'get_local_pickup_method_ids' ), true );
$this->asset_data_registry->add( 'shippingCostRequiresAddress', get_option( 'woocommerce_shipping_cost_requires_address', false ) === 'yes' );
add_action( 'rest_api_init', [ $this, 'register_settings' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'admin_scripts' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'hydrate_client_settings' ] );
add_action( 'woocommerce_load_shipping_methods', array( $this, 'register_local_pickup' ) );
add_filter( 'woocommerce_local_pickup_methods', array( $this, 'register_local_pickup_method' ) );
add_filter( 'woocommerce_order_hide_shipping_address', array( $this, 'hide_shipping_address_for_local_pickup' ), 10 );
add_filter( 'woocommerce_customer_taxable_address', array( $this, 'filter_taxable_address' ) );
add_filter( 'woocommerce_shipping_packages', array( $this, 'filter_shipping_packages' ) );
add_filter( 'pre_update_option_woocommerce_pickup_location_settings', array( $this, 'flush_cache' ) );
add_filter( 'pre_update_option_pickup_location_pickup_locations', array( $this, 'flush_cache' ) );
add_filter( 'woocommerce_shipping_settings', array( $this, 'remove_shipping_settings' ) );
add_filter( 'wc_shipping_enabled', array( $this, 'force_shipping_enabled' ), 100, 1 );
add_filter( 'woocommerce_order_shipping_to_display', array( $this, 'show_local_pickup_details' ), 10, 2 );
// This is required to short circuit `show_shipping` from class-wc-cart.php - without it, that function
// returns based on the option's value in the DB and we can't override it any other way.
add_filter( 'option_woocommerce_shipping_cost_requires_address', array( $this, 'override_cost_requires_address_option' ) );
}
/**
* Overrides the option to force shipping calculations NOT to wait until an address is entered, but only if the
* Checkout page contains the Checkout Block.
*
* @param boolean $value Whether shipping cost calculation requires address to be entered.
* @return boolean Whether shipping cost calculation should require an address to be entered before calculating.
*/
public function override_cost_requires_address_option( $value ) {
if ( CartCheckoutUtils::is_checkout_block_default() && $this->local_pickup_enabled ) {
return 'no';
}
return $value;
}
/**
* Force shipping to be enabled if the Checkout block is in use on the Checkout page.
*
* @param boolean $enabled Whether shipping is currently enabled.
* @return boolean Whether shipping should continue to be enabled/disabled.
*/
public function force_shipping_enabled( $enabled ) {
if ( CartCheckoutUtils::is_checkout_block_default() && $this->local_pickup_enabled ) {
return true;
}
return $enabled;
}
/**
* Inject collection details onto the order received page.
*
* @param string $return Return value.
* @param \WC_Order $order Order object.
* @return string
*/
public function show_local_pickup_details( $return, $order ) {
// Confirm order is valid before proceeding further.
if ( ! $order instanceof \WC_Order ) {
return $return;
}
$shipping_method_ids = ArrayUtil::select( $order->get_shipping_methods(), 'get_method_id', ArrayUtil::SELECT_BY_OBJECT_METHOD );
$shipping_method_id = current( $shipping_method_ids );
// Ensure order used pickup location method, otherwise bail.
if ( 'pickup_location' !== $shipping_method_id ) {
return $return;
}
$shipping_method = current( $order->get_shipping_methods() );
$details = $shipping_method->get_meta( 'pickup_details' );
$location = $shipping_method->get_meta( 'pickup_location' );
$address = $shipping_method->get_meta( 'pickup_address' );
if ( ! $address ) {
return $return;
}
return sprintf(
// Translators: %s location name.
__( 'Collection from <strong>%s</strong>:', 'woocommerce' ),
$location
) . '<br/><address>' . str_replace( ',', ',<br/>', $address ) . '</address><br/>' . $details;
}
/**
* If the Checkout block Remove shipping settings from WC Core's admin panels that are now block settings.
*
* @param array $settings The default WC shipping settings.
* @return array|mixed The filtered settings with relevant items removed.
*/
public function remove_shipping_settings( $settings ) {
// Do not add the shipping calculator setting if the Cart block is not used on the WC cart page.
if ( CartCheckoutUtils::is_cart_block_default() ) {
// Ensure the 'Calculations' title is added to the `woocommerce_shipping_cost_requires_address` options
// group, since it is attached to the `woocommerce_enable_shipping_calc` option that gets removed if the
// Cart block is in use.
$calculations_title = '';
// Get Calculations title so we can add it to 'Hide shipping costs until an address is entered' option.
foreach ( $settings as $setting ) {
if ( 'woocommerce_enable_shipping_calc' === $setting['id'] ) {
$calculations_title = $setting['title'];
break;
}
}
// Add Calculations title to 'Hide shipping costs until an address is entered' option.
foreach ( $settings as $index => $setting ) {
if ( 'woocommerce_shipping_cost_requires_address' === $setting['id'] ) {
$settings[ $index ]['title'] = $calculations_title;
$settings[ $index ]['checkboxgroup'] = 'start';
break;
}
}
$settings = array_filter(
$settings,
function( $setting ) {
return ! in_array(
$setting['id'],
array(
'woocommerce_enable_shipping_calc',
),
true
);
}
);
}
if ( CartCheckoutUtils::is_checkout_block_default() && $this->local_pickup_enabled ) {
foreach ( $settings as $index => $setting ) {
if ( 'woocommerce_shipping_cost_requires_address' === $setting['id'] ) {
$settings[ $index ]['desc'] .= ' (' . __( 'Not available when using WooCommerce Blocks Local Pickup', 'woocommerce' ) . ')';
$settings[ $index ]['disabled'] = true;
$settings[ $index ]['value'] = 'no';
break;
}
}
}
return $settings;
}
/**
* Register Local Pickup settings for rest api.
*/
public function register_settings() {
register_setting(
'options',
'woocommerce_pickup_location_settings',
[
'type' => 'object',
'description' => 'WooCommerce Local Pickup Method Settings',
'default' => [],
'show_in_rest' => [
'name' => 'pickup_location_settings',
'schema' => [
'type' => 'object',
'properties' => array(
'enabled' => [
'description' => __( 'If enabled, this method will appear on the block based checkout.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'yes', 'no' ],
],
'title' => [
'description' => __( 'This controls the title which the user sees during checkout.', 'woocommerce' ),
'type' => 'string',
],
'tax_status' => [
'description' => __( 'If a cost is defined, this controls if taxes are applied to that cost.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'taxable', 'none' ],
],
'cost' => [
'description' => __( 'Optional cost to charge for local pickup.', 'woocommerce' ),
'type' => 'string',
],
),
],
],
]
);
register_setting(
'options',
'pickup_location_pickup_locations',
[
'type' => 'array',
'description' => 'WooCommerce Local Pickup Locations',
'default' => [],
'show_in_rest' => [
'name' => 'pickup_locations',
'schema' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => array(
'name' => [
'type' => 'string',
],
'address' => [
'type' => 'object',
'properties' => array(
'address_1' => [
'type' => 'string',
],
'city' => [
'type' => 'string',
],
'state' => [
'type' => 'string',
],
'postcode' => [
'type' => 'string',
],
'country' => [
'type' => 'string',
],
),
],
'details' => [
'type' => 'string',
],
'enabled' => [
'type' => 'boolean',
],
),
],
],
],
]
);
}
/**
* Hydrate client settings
*/
public function hydrate_client_settings() {
$locations = get_option( 'pickup_location_pickup_locations', [] );
$formatted_pickup_locations = [];
foreach ( $locations as $location ) {
$formatted_pickup_locations[] = [
'name' => $location['name'],
'address' => $location['address'],
'details' => $location['details'],
'enabled' => wc_string_to_bool( $location['enabled'] ),
];
}
$has_legacy_pickup = false;
// Get all shipping zones.
$shipping_zones = \WC_Shipping_Zones::get_zones( 'admin' );
$international_shipping_zone = new \WC_Shipping_Zone( 0 );
// Loop through each shipping zone.
foreach ( $shipping_zones as $shipping_zone ) {
// Get all registered rates for this shipping zone.
$shipping_methods = $shipping_zone['shipping_methods'];
// Loop through each registered rate.
foreach ( $shipping_methods as $shipping_method ) {
if ( 'local_pickup' === $shipping_method->id && 'yes' === $shipping_method->enabled ) {
$has_legacy_pickup = true;
break 2;
}
}
}
foreach ( $international_shipping_zone->get_shipping_methods( true ) as $shipping_method ) {
if ( 'local_pickup' === $shipping_method->id ) {
$has_legacy_pickup = true;
break;
}
}
$settings = array(
'pickupLocationSettings' => get_option( 'woocommerce_pickup_location_settings', [] ),
'pickupLocations' => $formatted_pickup_locations,
'readonlySettings' => array(
'hasLegacyPickup' => $has_legacy_pickup,
'storeCountry' => WC()->countries->get_base_country(),
'storeState' => WC()->countries->get_base_state(),
),
);
wp_add_inline_script(
'wc-shipping-method-pickup-location',
sprintf(
'var hydratedScreenSettings = %s;',
wp_json_encode( $settings )
),
'before'
);
}
/**
* Load admin scripts.
*/
public function admin_scripts() {
$this->asset_api->register_script( 'wc-shipping-method-pickup-location', 'build/wc-shipping-method-pickup-location.js', [], true );
}
/**
* Registers the Local Pickup shipping method used by the Checkout Block.
*/
public function register_local_pickup() {
if ( CartCheckoutUtils::is_checkout_block_default() ) {
wc()->shipping->register_shipping_method( new PickupLocation() );
}
}
/**
* Declares the Pickup Location shipping method as a Local Pickup method for WooCommerce.
*
* @param array $methods Shipping method ids.
* @return array
*/
public function register_local_pickup_method( $methods ) {
$methods[] = 'pickup_location';
return $methods;
}
/**
* Hides the shipping address on the order confirmation page when local pickup is selected.
*
* @param array $pickup_methods Method ids.
* @return array
*/
public function hide_shipping_address_for_local_pickup( $pickup_methods ) {
return array_merge( $pickup_methods, LocalPickupUtils::get_local_pickup_method_ids() );
}
/**
* Everytime we save or update local pickup settings, we flush the shipping
* transient group.
*
* @param array $settings The setting array we're saving.
* @return array $settings The setting array we're saving.
*/
public function flush_cache( $settings ) {
\WC_Cache_Helper::get_transient_version( 'shipping', true );
return $settings;
}
/**
* Filter the location used for taxes based on the chosen pickup location.
*
* @param array $address Location args.
* @return array
*/
public function filter_taxable_address( $address ) {
if ( null === WC()->session ) {
return $address;
}
// We only need to select from the first package, since pickup_location only supports a single package.
$chosen_method = current( WC()->session->get( 'chosen_shipping_methods', array() ) ) ?? '';
$chosen_method_id = explode( ':', $chosen_method )[0];
$chosen_method_instance = explode( ':', $chosen_method )[1] ?? 0;
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
if ( $chosen_method_id && true === apply_filters( 'woocommerce_apply_base_tax_for_local_pickup', true ) && in_array( $chosen_method_id, LocalPickupUtils::get_local_pickup_method_ids(), true ) ) {
$pickup_locations = get_option( 'pickup_location_pickup_locations', [] );
$pickup_location = $pickup_locations[ $chosen_method_instance ] ?? [];
if ( isset( $pickup_location['address'], $pickup_location['address']['country'] ) && ! empty( $pickup_location['address']['country'] ) ) {
$address = array(
$pickup_locations[ $chosen_method_instance ]['address']['country'],
$pickup_locations[ $chosen_method_instance ]['address']['state'],
$pickup_locations[ $chosen_method_instance ]['address']['postcode'],
$pickup_locations[ $chosen_method_instance ]['address']['city'],
);
}
}
return $address;
}
/**
* Local Pickup requires all packages to support local pickup. This is because the entire order must be picked up
* so that all packages get the same tax rates applied during checkout.
*
* If a shipping package does not support local pickup (e.g. if disabled by an extension), this filters the option
* out for all packages. This will in turn disable the "pickup" toggle in Block Checkout.
*
* @param array $packages Array of shipping packages.
* @return array
*/
public function filter_shipping_packages( $packages ) {
// Check all packages for an instance of a collectable shipping method.
$valid_packages = array_filter(
$packages,
function( $package ) {
$shipping_method_ids = ArrayUtil::select( $package['rates'] ?? [], 'get_method_id', ArrayUtil::SELECT_BY_OBJECT_METHOD );
return ! empty( array_intersect( LocalPickupUtils::get_local_pickup_method_ids(), $shipping_method_ids ) );
}
);
// Remove pickup location from rates arrays.
if ( count( $valid_packages ) !== count( $packages ) ) {
$packages = array_map(
function( $package ) {
if ( ! is_array( $package['rates'] ) ) {
$package['rates'] = [];
return $package;
}
$package['rates'] = array_filter(
$package['rates'],
function( $rate ) {
return ! in_array( $rate->get_method_id(), LocalPickupUtils::get_local_pickup_method_ids(), true );
}
);
return $package;
},
$packages
);
}
return $packages;
}
}
StoreApi/Authentication.php 0000644 00000024121 15154173074 0011771 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\StoreApi\Utilities\RateLimits;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
/**
* Authentication class.
*/
class Authentication {
/**
* Hook into WP lifecycle events. This is hooked by the StoreAPI class on `rest_api_init`.
*/
public function init() {
if ( ! $this->is_request_to_store_api() ) {
return;
}
add_filter( 'rest_authentication_errors', array( $this, 'check_authentication' ) );
add_action( 'set_logged_in_cookie', array( $this, 'set_logged_in_cookie' ) );
add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 10, 3 );
add_filter( 'rest_allowed_cors_headers', array( $this, 'allowed_cors_headers' ) );
// Remove the default CORS headers--we will add our own.
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
}
/**
* Add allowed cors headers for store API headers.
*
* @param array $allowed_headers Allowed headers.
* @return array
*/
public function allowed_cors_headers( $allowed_headers ) {
$allowed_headers[] = 'Cart-Token';
$allowed_headers[] = 'Nonce';
$allowed_headers[] = 'X-WC-Store-API-Nonce';
return $allowed_headers;
}
/**
* Add CORS headers to a response object.
*
* These checks prevent access to the Store API from non-allowed origins. By default, the WordPress REST API allows
* access from any origin. Because some Store API routes return PII, we need to add our own CORS headers.
*
* Allowed origins can be changed using the WordPress `allowed_http_origins` or `allowed_http_origin` filters if
* access needs to be granted to other domains.
*
* Users of valid Cart Tokens are also allowed access from any origin.
*
* @param bool $value Whether the request has already been served.
* @param \WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`.
* @param \WP_REST_Request $request Request used to generate the response.
* @return bool
*/
public function send_cors_headers( $value, $result, $request ) {
$origin = get_http_origin();
if ( 'null' !== $origin ) {
$origin = esc_url_raw( $origin );
}
// Send standard CORS headers.
$server = rest_get_server();
$server->send_header( 'Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE' );
$server->send_header( 'Access-Control-Allow-Credentials', 'true' );
$server->send_header( 'Vary', 'Origin', false );
// Allow preflight requests, certain http origins, and any origin if a cart token is present. Preflight requests
// are allowed because we'll be unable to validate cart token headers at that point.
if ( $this->is_preflight() || $this->has_valid_cart_token( $request ) || is_allowed_http_origin( $origin ) ) {
$server->send_header( 'Access-Control-Allow-Origin', $origin );
}
// Exit early during preflight requests. This is so someone cannot access API data by sending an OPTIONS request
// with preflight headers and a _GET property to override the method.
if ( $this->is_preflight() ) {
exit;
}
return $value;
}
/**
* Is the request a preflight request? Checks the request method
*
* @return boolean
*/
protected function is_preflight() {
return isset( $_SERVER['REQUEST_METHOD'], $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'], $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'], $_SERVER['HTTP_ORIGIN'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'];
}
/**
* Checks if we're using a cart token to access the Store API.
*
* @param \WP_REST_Request $request Request object.
* @return boolean
*/
protected function has_valid_cart_token( \WP_REST_Request $request ) {
$cart_token = $request->get_header( 'Cart-Token' );
return $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() );
}
/**
* Gets the secret for the cart token using wp_salt.
*
* @return string
*/
protected function get_cart_token_secret() {
return '@' . wp_salt();
}
/**
* The Store API does not require authentication.
*
* @param \WP_Error|mixed $result Error from another authentication handler, null if we should handle it, or another value if not.
* @return \WP_Error|null|bool
*/
public function check_authentication( $result ) {
// Enable Rate Limiting for logged-in users without 'edit posts' capability.
if ( ! current_user_can( 'edit_posts' ) ) {
$result = $this->apply_rate_limiting( $result );
}
// Pass through errors from other authentication methods used before this one.
return ! empty( $result ) ? $result : true;
}
/**
* When the login cookies are set, they are not available until the next page reload. For the Store API, specifically
* for returning updated nonces, we need this to be available immediately.
*
* @param string $logged_in_cookie The value for the logged in cookie.
*/
public function set_logged_in_cookie( $logged_in_cookie ) {
if ( ! defined( 'LOGGED_IN_COOKIE' ) ) {
return;
}
$_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie;
}
/**
* Applies Rate Limiting to the request, and passes through any errors from other authentication methods used before this one.
*
* @param \WP_Error|mixed $result Error from another authentication handler, null if we should handle it, or another value if not.
* @return \WP_Error|null|bool
*/
protected function apply_rate_limiting( $result ) {
$rate_limiting_options = RateLimits::get_options();
if ( $rate_limiting_options->enabled ) {
$action_id = 'store_api_request_';
if ( is_user_logged_in() ) {
$action_id .= get_current_user_id();
} else {
$ip_address = self::get_ip_address( $rate_limiting_options->proxy_support );
$action_id .= md5( $ip_address );
}
$retry = RateLimits::is_exceeded_retry_after( $action_id );
$server = rest_get_server();
$server->send_header( 'RateLimit-Limit', $rate_limiting_options->limit );
if ( false !== $retry ) {
$server->send_header( 'RateLimit-Retry-After', $retry );
$server->send_header( 'RateLimit-Remaining', 0 );
$server->send_header( 'RateLimit-Reset', time() + $retry );
$ip_address = $ip_address ?? self::get_ip_address( $rate_limiting_options->proxy_support );
/**
* Fires when the rate limit is exceeded.
*
* @since 8.9.0
*
* @param string $ip_address The IP address of the request.
*/
do_action( 'woocommerce_store_api_rate_limit_exceeded', $ip_address );
return new \WP_Error(
'rate_limit_exceeded',
sprintf(
'Too many requests. Please wait %d seconds before trying again.',
$retry
),
array( 'status' => 400 )
);
}
$rate_limit = RateLimits::update_rate_limit( $action_id );
$server->send_header( 'RateLimit-Remaining', $rate_limit->remaining );
$server->send_header( 'RateLimit-Reset', $rate_limit->reset );
}
return $result;
}
/**
* Check if is request to the Store API.
*
* @return bool
*/
protected function is_request_to_store_api() {
if ( empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
return false;
}
return 0 === strpos( $GLOBALS['wp']->query_vars['rest_route'], '/wc/store/' );
}
/**
* Get current user IP Address.
*
* X_REAL_IP and CLIENT_IP are custom implementations designed to facilitate obtaining a user's ip through proxies, load balancers etc.
*
* _FORWARDED_FOR (XFF) request header is a de-facto standard header for identifying the originating IP address of a client connecting to a web server through a proxy server.
* Note for X_FORWARDED_FOR, Proxy servers can send through this header like this: X-Forwarded-For: client1, proxy1, proxy2.
* Make sure we always only send through the first IP in the list which should always be the client IP.
* Documentation at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
*
* Forwarded request header contains information that may be added by reverse proxy servers (load balancers, CDNs, and so on).
* Documentation at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
* Full RFC at https://datatracker.ietf.org/doc/html/rfc7239
*
* @param boolean $proxy_support Enables/disables proxy support.
*
* @return string
*/
protected static function get_ip_address( bool $proxy_support = false ) {
if ( ! $proxy_support ) {
return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? 'unresolved_ip' ) ) );
}
if ( array_key_exists( 'HTTP_X_REAL_IP', $_SERVER ) ) {
return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ) );
}
if ( array_key_exists( 'HTTP_CLIENT_IP', $_SERVER ) ) {
return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) ) );
}
if ( array_key_exists( 'HTTP_X_FORWARDED_FOR', $_SERVER ) ) {
$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
if ( is_array( $ips ) && ! empty( $ips ) ) {
return self::validate_ip( trim( $ips[0] ) );
}
}
if ( array_key_exists( 'HTTP_FORWARDED', $_SERVER ) ) {
// Using regex instead of explode() for a smaller code footprint.
// Expected format: Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43,for="[2001:db8:cafe::17]:4711"...
preg_match(
'/(?<=for\=)[^;,]*/i', // We catch everything on the first "for" entry, and validate later.
sanitize_text_field( wp_unslash( $_SERVER['HTTP_FORWARDED'] ) ),
$matches
);
if ( strpos( $matches[0] ?? '', '"[' ) !== false ) { // Detect for ipv6, eg "[ipv6]:port".
preg_match(
'/(?<=\[).*(?=\])/i', // We catch only the ipv6 and overwrite $matches.
$matches[0],
$matches
);
}
if ( ! empty( $matches ) ) {
return self::validate_ip( trim( $matches[0] ) );
}
}
return '0.0.0.0';
}
/**
* Uses filter_var() to validate and return ipv4 and ipv6 addresses
* Will return 0.0.0.0 if the ip is not valid. This is done to group and still rate limit invalid ips.
*
* @param string $ip ipv4 or ipv6 ip string.
*
* @return string
*/
protected static function validate_ip( $ip ) {
$ip = filter_var(
$ip,
FILTER_VALIDATE_IP,
array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 )
);
return $ip ?: '0.0.0.0';
}
}
StoreApi/Exceptions/InvalidCartException.php 0000644 00000002747 15154173074 0015224 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
use WP_Error;
/**
* InvalidCartException class.
*
* @internal This exception is thrown if the cart is in an erroneous state.
*/
class InvalidCartException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
public $error_code;
/**
* Additional error data.
*
* @var array
*/
public $additional_data = [];
/**
* All errors to display to the user.
*
* @var WP_Error
*/
public $error;
/**
* Setup exception.
*
* @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param WP_Error $error The WP_Error object containing all errors relating to stock availability.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, WP_Error $error, $additional_data = [] ) {
$this->error_code = $error_code;
$this->error = $error;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct( '', 409 );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns the list of messages.
*
* @return WP_Error
*/
public function getError() {
return $this->error;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
}
StoreApi/Exceptions/InvalidStockLevelsInCartException.php 0000644 00000003041 15154173074 0017656 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
use WP_Error;
/**
* InvalidStockLevelsInCartException class.
*
* This exception is thrown if any items are out of stock after each product on a draft order has been stock checked.
*/
class InvalidStockLevelsInCartException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
public $error_code;
/**
* Additional error data.
*
* @var array
*/
public $additional_data = [];
/**
* All errors to display to the user.
*
* @var WP_Error
*/
public $error;
/**
* Setup exception.
*
* @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param WP_Error $error The WP_Error object containing all errors relating to stock availability.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, $error, $additional_data = [] ) {
$this->error_code = $error_code;
$this->error = $error;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct( '', 409 );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns the list of messages.
*
* @return WP_Error
*/
public function getError() {
return $this->error;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
}
StoreApi/Exceptions/NotPurchasableException.php 0000644 00000000400 15154173074 0015716 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
/**
* NotPurchasableException class.
*
* This exception is thrown when an item in the cart is not able to be purchased.
*/
class NotPurchasableException extends StockAvailabilityException {}
StoreApi/Exceptions/OutOfStockException.php 0000644 00000000374 15154173074 0015056 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
/**
* OutOfStockException class.
*
* This exception is thrown when an item in a draft order is out of stock completely.
*/
class OutOfStockException extends StockAvailabilityException {}
StoreApi/Exceptions/PartialOutOfStockException.php 0000644 00000000446 15154173074 0016373 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
/**
* PartialOutOfStockException class.
*
* This exception is thrown when an item in a draft order has a quantity greater than what is available in stock.
*/
class PartialOutOfStockException extends StockAvailabilityException {}
StoreApi/Exceptions/RouteException.php 0000644 00000002371 15154173074 0014113 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
/**
* RouteException class.
*/
class RouteException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
public $error_code;
/**
* Additional error data.
*
* @var array
*/
public $additional_data = [];
/**
* Setup exception.
*
* @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param string $message User-friendly translated error message, e.g. 'Product ID is invalid'.
* @param int $http_status_code Proper HTTP status code to respond with, e.g. 400.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, $message, $http_status_code = 400, $additional_data = [] ) {
$this->error_code = $error_code;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct( $message, $http_status_code );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
}
StoreApi/Exceptions/StockAvailabilityException.php 0000644 00000003044 15154173074 0016431 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
/**
* StockAvailabilityException class.
*
* This exception is thrown when more than one of a product that can only be purchased individually is in a cart.
*/
class StockAvailabilityException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
public $error_code;
/**
* The name of the product that can only be purchased individually.
*
* @var string
*/
public $product_name;
/**
* Additional error data.
*
* @var array
*/
public $additional_data = [];
/**
* Setup exception.
*
* @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param string $product_name The name of the product that can only be purchased individually.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, $product_name, $additional_data = [] ) {
$this->error_code = $error_code;
$this->product_name = $product_name;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct();
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
/**
* Returns the product name.
*
* @return string
*/
public function getProductName() {
return $this->product_name;
}
}
StoreApi/Exceptions/TooManyInCartException.php 0000644 00000000436 15154173074 0015504 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Exceptions;
/**
* TooManyInCartException class.
*
* This exception is thrown when more than one of a product that can only be purchased individually is in a cart.
*/
class TooManyInCartException extends StockAvailabilityException {}
StoreApi/Formatters/CurrencyFormatter.php 0000644 00000002506 15154173074 0014621 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Formatters;
/**
* Currency Formatter.
*
* Formats an array of monetary values by inserting currency data.
*/
class CurrencyFormatter implements FormatterInterface {
/**
* Format a given value and return the result.
*
* @param array $value Value to format.
* @param array $options Options that influence the formatting.
* @return array
*/
public function format( $value, array $options = [] ) {
$position = get_option( 'woocommerce_currency_pos' );
$symbol = html_entity_decode( get_woocommerce_currency_symbol() );
$prefix = '';
$suffix = '';
switch ( $position ) {
case 'left_space':
$prefix = $symbol . ' ';
break;
case 'left':
$prefix = $symbol;
break;
case 'right_space':
$suffix = ' ' . $symbol;
break;
case 'right':
$suffix = $symbol;
break;
}
return array_merge(
(array) $value,
[
'currency_code' => get_woocommerce_currency(),
'currency_symbol' => $symbol,
'currency_minor_unit' => wc_get_price_decimals(),
'currency_decimal_separator' => wc_get_price_decimal_separator(),
'currency_thousand_separator' => wc_get_price_thousand_separator(),
'currency_prefix' => $prefix,
'currency_suffix' => $suffix,
]
);
}
}
StoreApi/Formatters/DefaultFormatter.php 0000644 00000000633 15154173074 0014412 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Formatters;
/**
* Default Formatter.
*/
class DefaultFormatter implements FormatterInterface {
/**
* Format a given value and return the result.
*
* @param mixed $value Value to format.
* @param array $options Options that influence the formatting.
* @return mixed
*/
public function format( $value, array $options = [] ) {
return $value;
}
}
StoreApi/Formatters/FormatterInterface.php 0000644 00000000557 15154173074 0014733 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Formatters;
/**
* FormatterInterface.
*/
interface FormatterInterface {
/**
* Format a given value and return the result.
*
* @param mixed $value Value to format.
* @param array $options Options that influence the formatting.
* @return mixed
*/
public function format( $value, array $options = [] );
}
StoreApi/Formatters/HtmlFormatter.php 0000644 00000001602 15154173074 0013727 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Formatters;
/**
* Html Formatter.
*
* Formats HTML in API responses.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class HtmlFormatter implements FormatterInterface {
/**
* Format a given value and return the result.
*
* The wptexturize, convert_chars, and trim functions are also used in the `the_title` filter.
* The function wp_kses_post removes disallowed HTML tags.
*
* @param string|array $value Value to format.
* @param array $options Options that influence the formatting.
* @return string
*/
public function format( $value, array $options = [] ) {
if ( is_array( $value ) ) {
return array_map( [ $this, 'format' ], $value );
}
return is_scalar( $value ) ? wp_kses_post( trim( convert_chars( wptexturize( $value ) ) ) ) : $value;
}
}
StoreApi/Formatters/MoneyFormatter.php 0000644 00000001402 15154173074 0014110 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Formatters;
/**
* Money Formatter.
*
* Formats monetary values using store settings.
*/
class MoneyFormatter implements FormatterInterface {
/**
* Format a given value and return the result.
*
* @param mixed $value Value to format.
* @param array $options Options that influence the formatting.
* @return mixed
*/
public function format( $value, array $options = [] ) {
$options = wp_parse_args(
$options,
[
'decimals' => wc_get_price_decimals(),
'rounding_mode' => PHP_ROUND_HALF_UP,
]
);
return (string) intval(
round(
( (float) wc_format_decimal( $value ) ) * ( 10 ** absint( $options['decimals'] ) ),
0,
absint( $options['rounding_mode'] )
)
);
}
}
StoreApi/Formatters.php 0000644 00000002327 15154173074 0011144 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use \Exception;
use Automattic\WooCommerce\StoreApi\Formatters\DefaultFormatter;
/**
* Formatters class.
*
* Allows formatter classes to be registered. Formatters are exposed to extensions via the ExtendSchema class.
*/
class Formatters {
/**
* Holds an array of formatter class instances.
*
* @var array
*/
private $formatters = [];
/**
* Get a new instance of a formatter class.
*
* @throws Exception An Exception is thrown if a non-existing formatter is used and the user is admin.
*
* @param string $name Name of the formatter.
* @return FormatterInterface Formatter class instance.
*/
public function __get( $name ) {
if ( ! isset( $this->formatters[ $name ] ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && current_user_can( 'manage_woocommerce' ) ) {
throw new Exception( $name . ' formatter does not exist' );
}
return new DefaultFormatter();
}
return $this->formatters[ $name ];
}
/**
* Register a formatter class for usage.
*
* @param string $name Name of the formatter.
* @param string $class A formatter class name.
*/
public function register( $name, $class ) {
$this->formatters[ $name ] = new $class();
}
}
StoreApi/Legacy.php 0000644 00000004375 15154173074 0010227 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\Blocks\Package;
/**
* Legacy class.
*/
class Legacy {
/**
* Hook into WP lifecycle events.
*/
public function init() {
add_action( 'woocommerce_rest_checkout_process_payment_with_context', array( $this, 'process_legacy_payment' ), 999, 2 );
}
/**
* Attempt to process a payment for the checkout API if no payment methods support the
* woocommerce_rest_checkout_process_payment_with_context action.
*
* @param PaymentContext $context Holds context for the payment.
* @param PaymentResult $result Result of the payment.
*/
public function process_legacy_payment( PaymentContext $context, PaymentResult &$result ) {
if ( $result->status ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification
$post_data = $_POST;
// Set constants.
wc_maybe_define_constant( 'WOOCOMMERCE_CHECKOUT', true );
// Add the payment data from the API to the POST global.
$_POST = $context->payment_data;
// Call the process payment method of the chosen gateway.
$payment_method_object = $context->get_payment_method_instance();
if ( ! $payment_method_object instanceof \WC_Payment_Gateway ) {
return;
}
$payment_method_object->validate_fields();
// If errors were thrown, we need to abort.
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_payment_error' );
// Process Payment.
$gateway_result = $payment_method_object->process_payment( $context->order->get_id() );
// Restore $_POST data.
$_POST = $post_data;
// If `process_payment` added notices, clear them. Notices are not displayed from the API -- payment should fail,
// and a generic notice will be shown instead if payment failed.
wc_clear_notices();
// Handle result.
$result->set_status( isset( $gateway_result['result'] ) && 'success' === $gateway_result['result'] ? 'success' : 'failure' );
// set payment_details from result.
$result->set_payment_details( array_merge( $result->payment_details, $gateway_result ) );
$result->set_redirect_url( $gateway_result['redirect'] );
}
}
StoreApi/Payments/PaymentContext.php 0000644 00000003473 15154173074 0013603 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Payments;
/**
* PaymentContext class.
*/
class PaymentContext {
/**
* Payment method ID.
*
* @var string
*/
protected $payment_method = '';
/**
* Order object for the order being paid.
*
* @var \WC_Order
*/
protected $order;
/**
* Holds data to send to the payment gateway to support payment.
*
* @var array Key value pairs.
*/
protected $payment_data = [];
/**
* Magic getter for protected properties.
*
* @param string $name Property name.
*/
public function __get( $name ) {
if ( in_array( $name, [ 'payment_method', 'order', 'payment_data' ], true ) ) {
return $this->$name;
}
return null;
}
/**
* Set the chosen payment method ID context.
*
* @param string $payment_method Payment method ID.
*/
public function set_payment_method( $payment_method ) {
$this->payment_method = (string) $payment_method;
}
/**
* Retrieve the payment method instance for the current set payment method.
*
* @return {\WC_Payment_Gateway|null} An instance of the payment gateway if it exists.
*/
public function get_payment_method_instance() {
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
if ( ! isset( $available_gateways[ $this->payment_method ] ) ) {
return;
}
return $available_gateways[ $this->payment_method ];
}
/**
* Set the order context.
*
* @param \WC_Order $order Order object.
*/
public function set_order( \WC_Order $order ) {
$this->order = $order;
}
/**
* Set payment data context.
*
* @param array $payment_data Array of key value pairs of data.
*/
public function set_payment_data( $payment_data = [] ) {
$this->payment_data = [];
foreach ( $payment_data as $key => $value ) {
$this->payment_data[ (string) $key ] = (string) $value;
}
}
}
StoreApi/Payments/PaymentResult.php 0000644 00000003730 15154173074 0013431 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Payments;
/**
* PaymentResult class.
*/
class PaymentResult {
/**
* List of valid payment statuses.
*
* @var array
*/
protected $valid_statuses = [ 'success', 'failure', 'pending', 'error' ];
/**
* Current payment status.
*
* @var string
*/
protected $status = '';
/**
* Array of details about the payment.
*
* @var string
*/
protected $payment_details = [];
/**
* Redirect URL for checkout.
*
* @var string
*/
protected $redirect_url = '';
/**
* Constructor.
*
* @param string $status Sets the payment status for the result.
*/
public function __construct( $status = '' ) {
if ( $status ) {
$this->set_status( $status );
}
}
/**
* Magic getter for protected properties.
*
* @param string $name Property name.
*/
public function __get( $name ) {
if ( in_array( $name, [ 'status', 'payment_details', 'redirect_url' ], true ) ) {
return $this->$name;
}
return null;
}
/**
* Set payment status.
*
* @throws \Exception When an invalid status is provided.
*
* @param string $payment_status Status to set.
*/
public function set_status( $payment_status ) {
if ( ! in_array( $payment_status, $this->valid_statuses, true ) ) {
throw new \Exception( sprintf( 'Invalid payment status %s. Use one of %s', $payment_status, implode( ', ', $this->valid_statuses ) ) );
}
$this->status = $payment_status;
}
/**
* Set payment details.
*
* @param array $payment_details Array of key value pairs of data.
*/
public function set_payment_details( $payment_details = [] ) {
$this->payment_details = [];
foreach ( $payment_details as $key => $value ) {
$this->payment_details[ (string) $key ] = (string) $value;
}
}
/**
* Set redirect URL.
*
* @param array $redirect_url URL to redirect the customer to after checkout.
*/
public function set_redirect_url( $redirect_url = [] ) {
$this->redirect_url = esc_url_raw( $redirect_url );
}
}
StoreApi/Routes/RouteInterface.php 0000644 00000000520 15154173074 0013207 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes;
/**
* RouteInterface.
*/
interface RouteInterface {
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path();
/**
* Get arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args();
}
StoreApi/Routes/V1/AbstractCartRoute.php 0000644 00000023647 15154173074 0014171 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
use Automattic\WooCommerce\StoreApi\SessionHandler;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
/**
* Abstract Cart Route
*/
abstract class AbstractCartRoute extends AbstractRoute {
use DraftOrderTrait;
/**
* The route's schema.
*
* @var string
*/
const SCHEMA_TYPE = 'cart';
/**
* Schema class instance.
*
* @var CartSchema
*/
protected $schema;
/**
* Schema class for the cart.
*
* @var CartSchema
*/
protected $cart_schema;
/**
* Schema class for the cart item.
*
* @var CartItemSchema
*/
protected $cart_item_schema;
/**
* Cart controller class instance.
*
* @var CartController
*/
protected $cart_controller;
/**
* Order controller class instance.
*
* @var OrderController
*/
protected $order_controller;
/**
* Constructor.
*
* @param SchemaController $schema_controller Schema Controller instance.
* @param AbstractSchema $schema Schema class for this route.
*/
public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) {
parent::__construct( $schema_controller, $schema );
$this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER );
$this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER );
$this->cart_controller = new CartController();
$this->order_controller = new OrderController();
}
/**
* Are we updating data or getting data?
*
* @param \WP_REST_Request $request Request object.
* @return boolean
*/
protected function is_update_request( \WP_REST_Request $request ) {
return in_array( $request->get_method(), [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true );
}
/**
* Get the route response based on the type of request.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response
*/
public function get_response( \WP_REST_Request $request ) {
$this->load_cart_session( $request );
$this->cart_controller->calculate_totals();
$response = null;
$nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null;
if ( is_wp_error( $nonce_check ) ) {
$response = $nonce_check;
}
if ( ! $response ) {
try {
$response = $this->get_response_by_request_method( $request );
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
}
}
// For update requests, this will recalculate cart totals and sync draft orders with the current cart.
if ( $this->is_update_request( $request ) ) {
$this->cart_updated( $request );
}
// Format error responses.
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
return $this->add_response_headers( rest_ensure_response( $response ) );
}
/**
* Add nonce headers to a response object.
*
* @param \WP_REST_Response $response The response object.
*
* @return \WP_REST_Response
*/
protected function add_response_headers( \WP_REST_Response $response ) {
$nonce = wp_create_nonce( 'wc_store_api' );
$response->header( 'Nonce', $nonce );
$response->header( 'Nonce-Timestamp', time() );
$response->header( 'User-ID', get_current_user_id() );
$response->header( 'Cart-Token', $this->get_cart_token() );
// The following headers are deprecated and should be removed in a future version.
$response->header( 'X-WC-Store-API-Nonce', $nonce );
return $response;
}
/**
* Load the cart session before handling responses.
*
* @param \WP_REST_Request $request Request object.
*/
protected function load_cart_session( \WP_REST_Request $request ) {
$cart_token = $request->get_header( 'Cart-Token' );
if ( $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() ) ) {
// Overrides the core session class.
add_filter(
'woocommerce_session_handler',
function () {
return SessionHandler::class;
}
);
}
$this->cart_controller->load_cart();
}
/**
* Generates a cart token for the response headers.
*
* Current namespace is used as the token Issuer.
* *
*
* @return string
*/
protected function get_cart_token() {
return JsonWebToken::create(
[
'user_id' => wc()->session->get_customer_id(),
'exp' => $this->get_cart_token_expiration(),
'iss' => $this->namespace,
],
$this->get_cart_token_secret()
);
}
/**
* Gets the secret for the cart token using wp_salt.
*
* @return string
*/
protected function get_cart_token_secret() {
return '@' . wp_salt();
}
/**
* Gets the expiration of the cart token. Defaults to 48h.
*
* @return int
*/
protected function get_cart_token_expiration() {
/**
* Filters the session expiration.
*
* @since 8.7.0
*
* @param int $expiration Expiration in seconds.
*/
return time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) );
}
/**
* Checks if a nonce is required for the route.
*
* @param \WP_REST_Request $request Request.
*
* @return bool
*/
protected function requires_nonce( \WP_REST_Request $request ) {
return $this->is_update_request( $request );
}
/**
* Triggered after an update to cart data. Re-calculates totals and updates draft orders (if they already exist) to
* keep all data in sync.
*
* @param \WP_REST_Request $request Request object.
*/
protected function cart_updated( \WP_REST_Request $request ) {
$draft_order = $this->get_draft_order();
if ( $draft_order ) {
// This does not trigger a recalculation of the cart--endpoints should have already done so before returning
// the cart response.
$this->order_controller->update_order_from_cart( $draft_order, false );
wc_do_deprecated_action(
'woocommerce_blocks_cart_update_order_from_request',
array(
$draft_order,
$request,
),
'7.2.0',
'woocommerce_store_api_cart_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_cart_update_order_from_request instead.'
);
/**
* Fires when the order is synced with cart data from a cart route.
*
* @since 7.2.0
*
* @param \WC_Order $draft_order Order object.
* @param \WC_Customer $customer Customer object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_cart_update_order_from_request', $draft_order, $request );
}
}
/**
* For non-GET endpoints, require and validate a nonce to prevent CSRF attacks.
*
* Nonces will mismatch if the logged in session cookie is different! If using a client to test, set this cookie
* to match the logged in cookie in your browser.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_Error|boolean
*/
protected function check_nonce( \WP_REST_Request $request ) {
$nonce = null;
if ( $request->get_header( 'Nonce' ) ) {
$nonce = $request->get_header( 'Nonce' );
} elseif ( $request->get_header( 'X-WC-Store-API-Nonce' ) ) {
$nonce = $request->get_header( 'X-WC-Store-API-Nonce' );
// @todo Remove handling and sending of deprecated X-WC-Store-API-Nonce Header (Blocks 7.5.0)
wc_deprecated_argument( 'X-WC-Store-API-Nonce', '7.2.0', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5' );
rest_handle_deprecated_argument( 'X-WC-Store-API-Nonce', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5', '7.2.0' );
}
/**
* Filters the Store API nonce check.
*
* This can be used to disable the nonce check when testing API endpoints via a REST API client.
*
* @since 4.5.0
*
* @param boolean $disable_nonce_check If true, nonce checks will be disabled.
*
* @return boolean
*/
if ( apply_filters( 'woocommerce_store_api_disable_nonce_check', false ) ) {
return true;
}
if ( null === $nonce ) {
return $this->get_route_error_response( 'woocommerce_rest_missing_nonce', __( 'Missing the Nonce header. This endpoint requires a valid nonce.', 'woocommerce' ), 401 );
}
if ( ! wp_verify_nonce( $nonce, 'wc_store_api' ) ) {
return $this->get_route_error_response( 'woocommerce_rest_invalid_nonce', __( 'Nonce is invalid.', 'woocommerce' ), 403 );
}
return true;
}
/**
* Get route response when something went wrong.
*
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
switch ( $http_status_code ) {
case 409:
// If there was a conflict, return the cart so the client can resolve it.
$cart = $this->cart_controller->get_cart_instance();
return new \WP_Error(
$error_code,
$error_message,
array_merge(
$additional_data,
[
'status' => $http_status_code,
'cart' => $this->cart_schema->get_item_response( $cart ),
]
)
);
}
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
}
}
StoreApi/Routes/V1/AbstractRoute.php 0000644 00000023260 15154173074 0013346 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Routes\RouteInterface;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Schemas\v1\AbstractSchema;
use WP_Error;
/**
* AbstractRoute class.
*/
abstract class AbstractRoute implements RouteInterface {
/**
* Schema class instance.
*
* @var AbstractSchema
*/
protected $schema;
/**
* Route namespace.
*
* @var string
*/
protected $namespace = 'wc/store/v1';
/**
* Schema Controller instance.
*
* @var SchemaController
*/
protected $schema_controller;
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = '';
/**
* The routes schema version.
*
* @var integer
*/
const SCHEMA_VERSION = 1;
/**
* Constructor.
*
* @param SchemaController $schema_controller Schema Controller instance.
* @param AbstractSchema $schema Schema class for this route.
*/
public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) {
$this->schema_controller = $schema_controller;
$this->schema = $schema;
}
/**
* Get the namespace for this route.
*
* @return string
*/
public function get_namespace() {
return $this->namespace;
}
/**
* Set the namespace for this route.
*
* @param string $namespace Given namespace.
*/
public function set_namespace( $namespace ) {
$this->namespace = $namespace;
}
/**
* Get item schema properties.
*
* @return array
*/
public function get_item_schema() {
return $this->schema->get_item_schema();
}
/**
* Get the route response based on the type of request.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
public function get_response( \WP_REST_Request $request ) {
$response = null;
try {
$response = $this->get_response_by_request_method( $request );
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( InvalidCartException $error ) {
$response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
}
return is_wp_error( $response ) ? $this->error_to_response( $response ) : $response;
}
/**
* Get the route response based on the type of request.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_response_by_request_method( \WP_REST_Request $request ) {
switch ( $request->get_method() ) {
case 'POST':
return $this->get_route_post_response( $request );
case 'PUT':
case 'PATCH':
return $this->get_route_update_response( $request );
case 'DELETE':
return $this->get_route_delete_response( $request );
}
return $this->get_route_response( $request );
}
/**
* Converts an error to a response object. Based on \WP_REST_Server.
*
* @param \WP_Error $error WP_Error instance.
* @return \WP_REST_Response List of associative arrays with code and message keys.
*/
protected function error_to_response( $error ) {
$error_data = $error->get_error_data();
$status = isset( $error_data, $error_data['status'] ) ? $error_data['status'] : 500;
$errors = [];
foreach ( (array) $error->errors as $code => $messages ) {
foreach ( (array) $messages as $message ) {
$errors[] = array(
'code' => $code,
'message' => $message,
'data' => $error->get_error_data( $code ),
);
}
}
$data = array_shift( $errors );
if ( count( $errors ) ) {
$data['additional_errors'] = $errors;
}
return new \WP_REST_Response( $data, $status );
}
/**
* Get route response for GET requests.
*
* When implemented, should return a \WP_REST_Response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 );
}
/**
* Get route response for POST requests.
*
* When implemented, should return a \WP_REST_Response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 );
}
/**
* Get route response for PUT requests.
*
* When implemented, should return a \WP_REST_Response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_update_response( \WP_REST_Request $request ) {
return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 );
}
/**
* Get route response for DELETE requests.
*
* When implemented, should return a \WP_REST_Response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_delete_response( \WP_REST_Request $request ) {
return $this->get_route_error_response( 'woocommerce_rest_invalid_endpoint', __( 'Method not implemented', 'woocommerce' ), 404 );
}
/**
* Get route response when something went wrong.
*
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
return new \WP_Error( $error_code, $error_message, array_merge( $additional_data, [ 'status' => $http_status_code ] ) );
}
/**
* Get route response when something went wrong and the supplied error is a WP_Error. This currently only happens
* when an item in the cart is out of stock, partially out of stock, can only be bought individually, or when the
* item is not purchasable.
*
* @param WP_Error $error_object The WP_Error object containing the error.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return WP_Error WP Error object.
*/
protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) {
$error_object->add_data( array_merge( $additional_data, [ 'status' => $http_status_code ] ) );
return $error_object;
}
/**
* Prepare a single item for response.
*
* @param mixed $item Item to format to schema.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
$response = rest_ensure_response( $this->schema->get_item_response( $item ) );
$response->add_links( $this->prepare_links( $item, $request ) );
return $response;
}
/**
* Retrieves the context param.
*
* Ensures consistent descriptions between endpoints, and populates enum from schema.
*
* @param array $args Optional. Additional arguments for context parameter. Default empty array.
* @return array Context parameter details.
*/
protected function get_context_param( $args = array() ) {
$param_details = array(
'description' => __( 'Scope under which the request is made; determines fields present in response.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
$schema = $this->get_item_schema();
if ( empty( $schema['properties'] ) ) {
return array_merge( $param_details, $args );
}
$contexts = array();
foreach ( $schema['properties'] as $attributes ) {
if ( ! empty( $attributes['context'] ) ) {
$contexts = array_merge( $contexts, $attributes['context'] );
}
}
if ( ! empty( $contexts ) ) {
$param_details['enum'] = array_unique( $contexts );
rsort( $param_details['enum'] );
}
return array_merge( $param_details, $args );
}
/**
* Prepares a response for insertion into a collection.
*
* @param \WP_REST_Response $response Response object.
* @return array|mixed Response data, ready for insertion into collection data.
*/
protected function prepare_response_for_collection( \WP_REST_Response $response ) {
$data = (array) $response->get_data();
$server = rest_get_server();
$links = $server::get_compact_response_links( $response );
if ( ! empty( $links ) ) {
$data['_links'] = $links;
}
return $data;
}
/**
* Prepare links for the request.
*
* @param mixed $item Item to prepare.
* @param \WP_REST_Request $request Request object.
* @return array
*/
protected function prepare_links( $item, $request ) {
return [];
}
/**
* Retrieves the query params for the collections.
*
* @return array Query parameters for the collection.
*/
public function get_collection_params() {
return array(
'context' => $this->get_context_param(),
);
}
}
StoreApi/Routes/V1/AbstractTermsRoute.php 0000644 00000011306 15154173074 0014357 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use WP_Term_Query;
/**
* AbstractTermsRoute class.
*/
abstract class AbstractTermsRoute extends AbstractRoute {
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'term';
/**
* Get the query params for collections of attributes.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param();
$params['context']['default'] = 'view';
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set. Defaults to no limit if left blank.', 'woocommerce' ),
'type' => 'integer',
'minimum' => 0,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['search'] = array(
'description' => __( 'Limit results to those matching a string.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);
$params['exclude'] = array(
'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['include'] = array(
'description' => __( 'Limit result set to specific ids.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['order'] = array(
'description' => __( 'Sort ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'asc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort by term property.', 'woocommerce' ),
'type' => 'string',
'default' => 'name',
'enum' => array(
'name',
'slug',
'count',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['hide_empty'] = array(
'description' => __( 'If true, empty terms will not be returned.', 'woocommerce' ),
'type' => 'boolean',
'default' => true,
);
return $params;
}
/**
* Get terms matching passed in args.
*
* @param string $taxonomy Taxonomy to get terms from.
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response
*/
protected function get_terms_response( $taxonomy, $request ) {
$page = (int) $request['page'];
$per_page = $request['per_page'] ? (int) $request['per_page'] : 0;
$prepared_args = array(
'taxonomy' => $taxonomy,
'exclude' => $request['exclude'],
'include' => $request['include'],
'order' => $request['order'],
'orderby' => $request['orderby'],
'hide_empty' => (bool) $request['hide_empty'],
'number' => $per_page,
'offset' => $per_page > 0 ? ( $page - 1 ) * $per_page : 0,
'search' => $request['search'],
);
$term_query = new WP_Term_Query();
$objects = $term_query->query( $prepared_args );
$return = [];
foreach ( $objects as $object ) {
$data = $this->prepare_item_for_response( $object, $request );
$return[] = $this->prepare_response_for_collection( $data );
}
$response = rest_ensure_response( $return );
// See if pagination is needed before calculating.
if ( $per_page > 0 && ( count( $objects ) === $per_page || $page > 1 ) ) {
$term_count = $this->get_term_count( $taxonomy, $prepared_args );
$response = ( new Pagination() )->add_headers( $response, $request, $term_count, ceil( $term_count / $per_page ) );
}
return $response;
}
/**
* Get count of terms for current query.
*
* @param string $taxonomy Taxonomy to get terms from.
* @param array $args Array of args to pass to wp_count_terms.
* @return int
*/
protected function get_term_count( $taxonomy, $args ) {
$count_args = $args;
unset( $count_args['number'], $count_args['offset'] );
return (int) wp_count_terms( $taxonomy, $count_args );
}
}
StoreApi/Routes/V1/Batch.php 0000644 00000006522 15154173074 0011607 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Routes\RouteInterface;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use WP_REST_Request;
use WP_REST_Response;
/**
* Batch Route class.
*/
class Batch extends AbstractRoute implements RouteInterface {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'batch';
/**
* The schema item identifier.
*
* @var string
*/
const SCHEMA_TYPE = 'batch';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/batch';
}
/**
* Get arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return array(
'callback' => [ $this, 'get_response' ],
'methods' => 'POST',
'permission_callback' => '__return_true',
'args' => array(
'validation' => array(
'type' => 'string',
'enum' => array( 'require-all-validate', 'normal' ),
'default' => 'normal',
),
'requests' => array(
'required' => true,
'type' => 'array',
'maxItems' => 25,
'items' => array(
'type' => 'object',
'properties' => array(
'method' => array(
'type' => 'string',
'enum' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ),
'default' => 'POST',
),
'path' => array(
'type' => 'string',
'required' => true,
),
'body' => array(
'type' => 'object',
'properties' => array(),
'additionalProperties' => true,
),
'headers' => array(
'type' => 'object',
'properties' => array(),
'additionalProperties' => array(
'type' => array( 'string', 'array' ),
'items' => array(
'type' => 'string',
),
),
),
),
),
),
),
);
}
/**
* Get the route response.
*
* @see WP_REST_Server::serve_batch_request_v1
* https://developer.wordpress.org/reference/classes/wp_rest_server/serve_batch_request_v1/
*
* @throws RouteException On error.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function get_response( WP_REST_Request $request ) {
try {
foreach ( $request['requests'] as $args ) {
if ( ! stristr( $args['path'], 'wc/store' ) ) {
throw new RouteException( 'woocommerce_rest_invalid_path', __( 'Invalid path provided.', 'woocommerce' ), 400 );
}
}
$response = rest_get_server()->serve_batch_request_v1( $request );
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
}
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
$nonce = wp_create_nonce( 'wc_store_api' );
$response->header( 'Nonce', $nonce );
$response->header( 'X-WC-Store-API-Nonce', $nonce );
$response->header( 'Nonce-Timestamp', time() );
$response->header( 'User-ID', get_current_user_id() );
return $response;
}
}
StoreApi/Routes/V1/Cart.php 0000644 00000002226 15154173074 0011454 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
/**
* Cart class.
*/
class Cart extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return rest_ensure_response( $this->schema->get_item_response( $this->cart_controller->get_cart_instance() ) );
}
}
StoreApi/Routes/V1/CartAddItem.php 0000644 00000007502 15154173074 0012706 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartAddItem class.
*/
class CartAddItem extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-add-item';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/add-item';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'id' => [
'description' => __( 'The cart item product or variation ID.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'sanitize_callback' => 'absint',
],
'quantity' => [
'description' => __( 'Quantity of this item to add to the cart.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'sanitize_callback' => 'wc_stock_amount',
],
],
'variation' => [
'description' => __( 'Chosen attributes (for variations).', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'attribute' => [
'description' => __( 'Variation attribute name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'value' => [
'description' => __( 'Variation attribute value.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
],
],
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
// Do not allow key to be specified during creation.
if ( ! empty( $request['key'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_item_exists', __( 'Cannot create an existing cart item.', 'woocommerce' ), 400 );
}
$cart = $this->cart_controller->get_cart_instance();
/**
* Filters cart item data sent via the API before it is passed to the cart controller.
*
* This hook filters cart items. It allows the request data to be changed, for example, quantity, or
* supplemental cart item data, before it is passed into CartController::add_to_cart and stored to session.
*
* CartController::add_to_cart only expects the keys id, quantity, variation, and cart_item_data, so other values
* may be ignored. CartController::add_to_cart (and core) do already have a filter hook called
* woocommerce_add_cart_item, but this does not have access to the original Store API request like this hook does.
*
* @since 8.8.0
*
* @param array $customer_data An array of customer (user) data.
* @return array
*/
$add_to_cart_data = apply_filters(
'woocommerce_store_api_add_to_cart_data',
array(
'id' => $request['id'],
'quantity' => $request['quantity'],
'variation' => $request['variation'],
'cart_item_data' => [],
),
$request
);
$this->cart_controller->add_to_cart( $add_to_cart_data );
$response = rest_ensure_response( $this->schema->get_item_response( $cart ) );
$response->set_status( 201 );
return $response;
}
}
StoreApi/Routes/V1/CartApplyCoupon.php 0000644 00000003565 15154173074 0013655 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartApplyCoupon class.
*/
class CartApplyCoupon extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-apply-coupon';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/apply-coupon';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'code' => [
'description' => __( 'Unique identifier for the coupon within the cart.', 'woocommerce' ),
'type' => 'string',
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
if ( ! wc_coupons_enabled() ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woocommerce' ), 404 );
}
$cart = $this->cart_controller->get_cart_instance();
$coupon_code = wc_format_coupon_code( wp_unslash( $request['code'] ) );
try {
$this->cart_controller->apply_coupon( $coupon_code );
} catch ( \WC_REST_Exception $e ) {
throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() );
}
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}
StoreApi/Routes/V1/CartCoupons.php 0000644 00000007430 15154173074 0013025 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartCoupons class.
*/
class CartCoupons extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-coupons';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'cart-coupon';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/coupons';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
],
[
'methods' => \WP_REST_Server::DELETABLE,
'permission_callback' => '__return_true',
'callback' => [ $this, 'get_response' ],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Get a collection of cart coupons.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$cart_coupons = $this->cart_controller->get_cart_coupons();
$items = [];
foreach ( $cart_coupons as $coupon_code ) {
$response = rest_ensure_response( $this->schema->get_item_response( $coupon_code ) );
$response->add_links( $this->prepare_links( $coupon_code, $request ) );
$response = $this->prepare_response_for_collection( $response );
$items[] = $response;
}
$response = rest_ensure_response( $items );
return $response;
}
/**
* Add a coupon to the cart and return the result.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
if ( ! wc_coupons_enabled() ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woocommerce' ), 404 );
}
try {
$this->cart_controller->apply_coupon( $request['code'] );
} catch ( \WC_REST_Exception $e ) {
throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() );
}
$response = $this->prepare_item_for_response( $request['code'], $request );
$response->set_status( 201 );
return $response;
}
/**
* Deletes all coupons in the cart.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_delete_response( \WP_REST_Request $request ) {
$cart = $this->cart_controller->get_cart_instance();
$cart->remove_coupons();
$cart->calculate_totals();
return new \WP_REST_Response( [], 200 );
}
/**
* Prepare links for the request.
*
* @param string $coupon_code Coupon code.
* @param \WP_REST_Request $request Request object.
* @return array
*/
protected function prepare_links( $coupon_code, $request ) {
$base = $this->get_namespace() . $this->get_path();
$links = array(
'self' => array(
'href' => rest_url( trailingslashit( $base ) . $coupon_code ),
),
'collection' => array(
'href' => rest_url( $base ),
),
);
return $links;
}
}
StoreApi/Routes/V1/CartCouponsByCode.php 0000644 00000005040 15154173074 0014106 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartCouponsByCode class.
*/
class CartCouponsByCode extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-coupons-by-code';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'cart-coupon';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/coupons/(?P<code>[\w-]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => [
'code' => [
'description' => __( 'Unique identifier for the coupon within the cart.', 'woocommerce' ),
'type' => 'string',
],
],
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => \WP_REST_Server::DELETABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Get a single cart coupon.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
if ( ! $this->cart_controller->has_coupon( $request['code'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', __( 'Coupon does not exist in the cart.', 'woocommerce' ), 404 );
}
return $this->prepare_item_for_response( $request['code'], $request );
}
/**
* Delete a single cart coupon.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_delete_response( \WP_REST_Request $request ) {
if ( ! $this->cart_controller->has_coupon( $request['code'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', __( 'Coupon does not exist in the cart.', 'woocommerce' ), 404 );
}
$cart = $this->cart_controller->get_cart_instance();
$cart->remove_coupon( $request['code'] );
$cart->calculate_totals();
return new \WP_REST_Response( null, 204 );
}
}
StoreApi/Routes/V1/CartExtensions.php 0000644 00000003437 15154173074 0013541 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartExtensions class.
*/
class CartExtensions extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-extensions';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'cart-extensions';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/extensions';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'namespace' => [
'description' => __( 'Extension\'s name - this will be used to ensure the data in the request is routed appropriately.', 'woocommerce' ),
'type' => 'string',
],
'data' => [
'description' => __( 'Additional data to pass to the extension', 'woocommerce' ),
'type' => 'object',
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
try {
return $this->schema->get_item_response( $request );
} catch ( \WC_REST_Exception $e ) {
throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() );
}
}
}
StoreApi/Routes/V1/CartItems.php 0000644 00000007255 15154173074 0012465 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartItems class.
*/
class CartItems extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-items';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'cart-item';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/items';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'get_response' ),
'permission_callback' => '__return_true',
'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
],
[
'methods' => \WP_REST_Server::DELETABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Get a collection of cart items.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$cart_items = $this->cart_controller->get_cart_items();
$items = [];
foreach ( $cart_items as $cart_item ) {
$data = $this->prepare_item_for_response( $cart_item, $request );
$items[] = $this->prepare_response_for_collection( $data );
}
$response = rest_ensure_response( $items );
return $response;
}
/**
* Creates one item from the collection.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
// Do not allow key to be specified during creation.
if ( ! empty( $request['key'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_item_exists', __( 'Cannot create an existing cart item.', 'woocommerce' ), 400 );
}
$result = $this->cart_controller->add_to_cart(
[
'id' => $request['id'],
'quantity' => $request['quantity'],
'variation' => $request['variation'],
]
);
$response = rest_ensure_response( $this->prepare_item_for_response( $this->cart_controller->get_cart_item( $result ), $request ) );
$response->set_status( 201 );
return $response;
}
/**
* Deletes all items in the cart.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_delete_response( \WP_REST_Request $request ) {
$this->cart_controller->empty_cart();
return new \WP_REST_Response( [], 200 );
}
/**
* Prepare links for the request.
*
* @param array $cart_item Object to prepare.
* @param \WP_REST_Request $request Request object.
* @return array
*/
protected function prepare_links( $cart_item, $request ) {
$base = $this->get_namespace() . $this->get_path();
$links = array(
'self' => array(
'href' => rest_url( trailingslashit( $base ) . $cart_item['key'] ),
),
'collection' => array(
'href' => rest_url( $base ),
),
);
return $links;
}
}
StoreApi/Routes/V1/CartItemsByKey.php 0000644 00000007666 15154173074 0013437 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartItemsByKey class.
*/
class CartItemsByKey extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-items-by-key';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'cart-item';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/items/(?P<key>[\w-]{32})';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => [
'key' => [
'description' => __( 'Unique identifier for the item within the cart.', 'woocommerce' ),
'type' => 'string',
],
],
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'get_response' ),
'permission_callback' => '__return_true',
'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ),
],
[
'methods' => \WP_REST_Server::DELETABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Get a single cart items.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$cart_item = $this->cart_controller->get_cart_item( $request['key'] );
if ( empty( $cart_item ) ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 );
}
$data = $this->prepare_item_for_response( $cart_item, $request );
$response = rest_ensure_response( $data );
return $response;
}
/**
* Update a single cart item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_update_response( \WP_REST_Request $request ) {
$cart = $this->cart_controller->get_cart_instance();
if ( isset( $request['quantity'] ) ) {
$this->cart_controller->set_cart_item_quantity( $request['key'], $request['quantity'] );
}
return rest_ensure_response( $this->prepare_item_for_response( $this->cart_controller->get_cart_item( $request['key'] ), $request ) );
}
/**
* Delete a single cart item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_delete_response( \WP_REST_Request $request ) {
$cart = $this->cart_controller->get_cart_instance();
$cart_item = $this->cart_controller->get_cart_item( $request['key'] );
if ( empty( $cart_item ) ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 );
}
$cart->remove_cart_item( $request['key'] );
return new \WP_REST_Response( null, 204 );
}
/**
* Prepare links for the request.
*
* @param array $cart_item Object to prepare.
* @param \WP_REST_Request $request Request object.
* @return array
*/
protected function prepare_links( $cart_item, $request ) {
$base = $this->get_namespace() . $this->get_path();
$links = array(
'self' => array(
'href' => rest_url( trailingslashit( $base ) . $cart_item['key'] ),
),
'collection' => array(
'href' => rest_url( $base ),
),
);
return $links;
}
}
StoreApi/Routes/V1/CartRemoveCoupon.php 0000644 00000004424 15154173074 0014020 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartRemoveCoupon class.
*/
class CartRemoveCoupon extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-remove-coupon';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/remove-coupon';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'code' => [
'description' => __( 'Unique identifier for the coupon within the cart.', 'woocommerce' ),
'type' => 'string',
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
if ( ! wc_coupons_enabled() ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woocommerce' ), 404 );
}
$cart = $this->cart_controller->get_cart_instance();
$coupon_code = wc_format_coupon_code( $request['code'] );
$coupon = new \WC_Coupon( $coupon_code );
if ( $coupon->get_code() !== $coupon_code || ! $coupon->is_valid() ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_error', __( 'Invalid coupon code.', 'woocommerce' ), 400 );
}
if ( ! $this->cart_controller->has_coupon( $coupon_code ) ) {
throw new RouteException( 'woocommerce_rest_cart_coupon_invalid_code', __( 'Coupon cannot be removed because it is not already applied to the cart.', 'woocommerce' ), 409 );
}
$cart = $this->cart_controller->get_cart_instance();
$cart->remove_coupon( $coupon_code );
$cart->calculate_totals();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}
StoreApi/Routes/V1/CartRemoveItem.php 0000644 00000004146 15154173074 0013454 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartRemoveItem class.
*/
class CartRemoveItem extends AbstractCartRoute {
use DraftOrderTrait;
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-remove-item';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/remove-item';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'key' => [
'description' => __( 'Unique identifier (key) for the cart item.', 'woocommerce' ),
'type' => 'string',
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$cart = $this->cart_controller->get_cart_instance();
$cart_item = $this->cart_controller->get_cart_item( $request['key'] );
if ( empty( $cart_item ) ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item no longer exists or is invalid.', 'woocommerce' ), 409 );
}
$cart->remove_cart_item( $request['key'] );
$this->maybe_release_stock();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
/**
* If there is a draft order, releases stock.
*
* @return void
*/
protected function maybe_release_stock() {
$draft_order_id = $this->get_draft_order_id();
if ( ! $draft_order_id ) {
return;
}
wc_release_stock_for_order( $draft_order_id );
}
}
StoreApi/Routes/V1/CartSelectShippingRate.php 0000644 00000006567 15154173074 0015146 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* CartSelectShippingRate class.
*/
class CartSelectShippingRate extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-select-shipping-rate';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/select-shipping-rate';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'package_id' => array(
'description' => __( 'The ID of the package being shipped. Leave blank to apply to all packages.', 'woocommerce' ),
'type' => [ 'integer', 'string', 'null' ],
'required' => false,
),
'rate_id' => [
'description' => __( 'The chosen rate ID for the package.', 'woocommerce' ),
'type' => 'string',
'required' => true,
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
if ( ! wc_shipping_enabled() ) {
throw new RouteException( 'woocommerce_rest_shipping_disabled', __( 'Shipping is disabled.', 'woocommerce' ), 404 );
}
if ( ! isset( $request['rate_id'] ) ) {
throw new RouteException( 'woocommerce_rest_cart_missing_rate_id', __( 'Invalid Rate ID.', 'woocommerce' ), 400 );
}
$cart = $this->cart_controller->get_cart_instance();
$package_id = isset( $request['package_id'] ) ? sanitize_text_field( wp_unslash( $request['package_id'] ) ) : null;
$rate_id = sanitize_text_field( wp_unslash( $request['rate_id'] ) );
try {
if ( ! is_null( $package_id ) ) {
$this->cart_controller->select_shipping_rate( $package_id, $rate_id );
} else {
foreach ( $this->cart_controller->get_shipping_packages() as $package ) {
$this->cart_controller->select_shipping_rate( $package['package_id'], $rate_id );
}
}
} catch ( \WC_Rest_Exception $e ) {
throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() );
}
/**
* Fires an action after a shipping method has been chosen for package(s) via the Store API.
*
* This allows extensions to perform addition actions after a shipping method has been chosen, but before the
* cart totals are recalculated.
*
* @since 9.0.0
*
* @param string|null $package_id The sanitized ID of the package being updated. Null if all packages are being updated.
* @param string $rate_id The sanitized chosen rate ID for the package.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_cart_select_shipping_rate', $package_id, $rate_id, $request );
$cart->calculate_shipping();
$cart->calculate_totals();
return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) );
}
}
StoreApi/Routes/V1/CartUpdateCustomer.php 0000644 00000022774 15154173074 0014353 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils;
/**
* CartUpdateCustomer class.
*
* Updates the customer billing and shipping addresses, recalculates the cart totals, and returns an updated cart.
*/
class CartUpdateCustomer extends AbstractCartRoute {
use DraftOrderTrait;
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-update-customer';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/update-customer';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'billing_address' => [
'description' => __( 'Billing address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->schema->billing_address_schema->get_properties(),
'sanitize_callback' => null,
],
'shipping_address' => [
'description' => __( 'Shipping address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->schema->shipping_address_schema->get_properties(),
'sanitize_callback' => null,
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Validate address params now they are populated.
*
* @param \WP_REST_Request $request Request object.
* @param array $billing Billing address.
* @param array $shipping Shipping address.
* @return \WP_Error|true
*/
protected function validate_address_params( $request, $billing, $shipping ) {
$posted_billing = isset( $request['billing_address'] );
$posted_shipping = isset( $request['shipping_address'] );
$invalid_params = array();
$invalid_details = array();
if ( $posted_billing ) {
$billing_validation_check = $this->schema->billing_address_schema->validate_callback( $billing, $request, 'billing_address' );
if ( false === $billing_validation_check ) {
$invalid_params['billing_address'] = __( 'Invalid parameter.', 'woocommerce' );
} elseif ( is_wp_error( $billing_validation_check ) ) {
$invalid_params['billing_address'] = implode( ' ', $billing_validation_check->get_error_messages() );
$invalid_details['billing_address'] = \rest_convert_error_to_response( $billing_validation_check )->get_data();
}
}
if ( $posted_shipping ) {
$shipping_validation_check = $this->schema->shipping_address_schema->validate_callback( $shipping, $request, 'shipping_address' );
if ( false === $shipping_validation_check ) {
$invalid_params['shipping_address'] = __( 'Invalid parameter.', 'woocommerce' );
} elseif ( is_wp_error( $shipping_validation_check ) ) {
$invalid_params['shipping_address'] = implode( ' ', $shipping_validation_check->get_error_messages() );
$invalid_details['shipping_address'] = \rest_convert_error_to_response( $shipping_validation_check )->get_data();
}
}
if ( $invalid_params ) {
return new \WP_Error(
'rest_invalid_param',
/* translators: %s: List of invalid parameters. */
sprintf( __( 'Invalid parameter(s): %s', 'woocommerce' ), implode( ', ', array_keys( $invalid_params ) ) ),
[
'status' => 400,
'params' => $invalid_params,
'details' => $invalid_details,
]
);
}
return true;
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$cart = $this->cart_controller->get_cart_instance();
$customer = wc()->customer;
// Get data from request object and merge with customer object, then sanitize.
$billing = $this->schema->billing_address_schema->sanitize_callback(
wp_parse_args(
$request['billing_address'] ?? [],
$this->get_customer_billing_address( $customer )
),
$request,
'billing_address'
);
$shipping = $this->schema->billing_address_schema->sanitize_callback(
wp_parse_args(
$request['shipping_address'] ?? [],
$this->get_customer_shipping_address( $customer )
),
$request,
'shipping_address'
);
// If the cart does not need shipping, shipping address is forced to match billing address unless defined.
if ( ! $cart->needs_shipping() && ! isset( $request['shipping_address'] ) ) {
$shipping = $billing;
}
// Run validation and sanitization now that the cart and customer data is loaded.
$billing = $this->schema->billing_address_schema->sanitize_callback( $billing, $request, 'billing_address' );
$shipping = $this->schema->shipping_address_schema->sanitize_callback( $shipping, $request, 'shipping_address' );
// Validate data now everything is clean..
$validation_check = $this->validate_address_params( $request, $billing, $shipping );
if ( is_wp_error( $validation_check ) ) {
return rest_ensure_response( $validation_check );
}
$customer->set_props(
array(
'billing_first_name' => $billing['first_name'] ?? null,
'billing_last_name' => $billing['last_name'] ?? null,
'billing_company' => $billing['company'] ?? null,
'billing_address_1' => $billing['address_1'] ?? null,
'billing_address_2' => $billing['address_2'] ?? null,
'billing_city' => $billing['city'] ?? null,
'billing_state' => $billing['state'] ?? null,
'billing_postcode' => $billing['postcode'] ?? null,
'billing_country' => $billing['country'] ?? null,
'billing_phone' => $billing['phone'] ?? null,
'billing_email' => $billing['email'] ?? null,
'shipping_first_name' => $shipping['first_name'] ?? null,
'shipping_last_name' => $shipping['last_name'] ?? null,
'shipping_company' => $shipping['company'] ?? null,
'shipping_address_1' => $shipping['address_1'] ?? null,
'shipping_address_2' => $shipping['address_2'] ?? null,
'shipping_city' => $shipping['city'] ?? null,
'shipping_state' => $shipping['state'] ?? null,
'shipping_postcode' => $shipping['postcode'] ?? null,
'shipping_country' => $shipping['country'] ?? null,
'shipping_phone' => $shipping['phone'] ?? null,
)
);
wc_do_deprecated_action(
'woocommerce_blocks_cart_update_customer_from_request',
array(
$customer,
$request,
),
'7.2.0',
'woocommerce_store_api_cart_update_customer_from_request',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_cart_update_customer_from_request instead.'
);
/**
* Fires when the Checkout Block/Store API updates a customer from the API request data.
*
* @since 7.2.0
*
* @param \WC_Customer $customer Customer object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_cart_update_customer_from_request', $customer, $request );
$customer->save();
$this->cart_controller->calculate_totals();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
/**
* Get full customer billing address.
*
* @param \WC_Customer $customer Customer object.
* @return array
*/
protected function get_customer_billing_address( \WC_Customer $customer ) {
$validation_util = new ValidationUtils();
$billing_country = $customer->get_billing_country();
$billing_state = $customer->get_billing_state();
/**
* There's a bug in WooCommerce core in which not having a state ("") would result in us validating against the store's state.
* This resets the state to an empty string if it doesn't match the country.
*
* @todo Removing this handling once we fix the issue with the state value always being the store one.
*/
if ( ! $validation_util->validate_state( $billing_state, $billing_country ) ) {
$billing_state = '';
}
return [
'first_name' => $customer->get_billing_first_name(),
'last_name' => $customer->get_billing_last_name(),
'company' => $customer->get_billing_company(),
'address_1' => $customer->get_billing_address_1(),
'address_2' => $customer->get_billing_address_2(),
'city' => $customer->get_billing_city(),
'state' => $billing_state,
'postcode' => $customer->get_billing_postcode(),
'country' => $billing_country,
'phone' => $customer->get_billing_phone(),
'email' => $customer->get_billing_email(),
];
}
/**
* Get full customer shipping address.
*
* @param \WC_Customer $customer Customer object.
* @return array
*/
protected function get_customer_shipping_address( \WC_Customer $customer ) {
return [
'first_name' => $customer->get_shipping_first_name(),
'last_name' => $customer->get_shipping_last_name(),
'company' => $customer->get_shipping_company(),
'address_1' => $customer->get_shipping_address_1(),
'address_2' => $customer->get_shipping_address_2(),
'city' => $customer->get_shipping_city(),
'state' => $customer->get_shipping_state(),
'postcode' => $customer->get_shipping_postcode(),
'country' => $customer->get_shipping_country(),
'phone' => $customer->get_shipping_phone(),
];
}
}
StoreApi/Routes/V1/CartUpdateItem.php 0000644 00000003204 15154173074 0013433 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
/**
* CartUpdateItem class.
*/
class CartUpdateItem extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-update-item';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/update-item';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'key' => [
'description' => __( 'Unique identifier (key) for the cart item to update.', 'woocommerce' ),
'type' => 'string',
],
'quantity' => [
'description' => __( 'New quantity of the item in the cart.', 'woocommerce' ),
'type' => 'integer',
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
* .
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$cart = $this->cart_controller->get_cart_instance();
if ( isset( $request['quantity'] ) ) {
$this->cart_controller->set_cart_item_quantity( $request['key'], $request['quantity'] );
}
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}
StoreApi/Routes/V1/Checkout.php 0000644 00000055600 15154173074 0012334 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidStockLevelsInCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
/**
* Checkout class.
*/
class Checkout extends AbstractCartRoute {
use DraftOrderTrait;
use CheckoutTrait;
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'checkout';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'checkout';
/**
* Holds the current order being processed.
*
* @var \WC_Order
*/
private $order = null;
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/checkout';
}
/**
* Checks if a nonce is required for the route.
*
* @param \WP_REST_Request $request Request.
* @return bool
*/
protected function requires_nonce( \WP_REST_Request $request ) {
return true;
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => array_merge(
[
'payment_data' => [
'description' => __( 'Data to pass through to the payment method when processing payment.', 'woocommerce' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => [ 'string', 'boolean' ],
],
],
],
],
],
$this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE )
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Get the route response based on the type of request.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response
*/
public function get_response( \WP_REST_Request $request ) {
$this->load_cart_session( $request );
$this->cart_controller->calculate_totals();
$response = null;
$nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null;
if ( is_wp_error( $nonce_check ) ) {
$response = $nonce_check;
}
if ( ! $response ) {
try {
$response = $this->get_response_by_request_method( $request );
} catch ( InvalidCartException $error ) {
$response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() );
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
}
}
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
}
return $this->add_response_headers( $response );
}
/**
* Convert the cart into a new draft order, or update an existing draft order, and return an updated cart response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$this->create_or_update_draft_order( $request );
return $this->prepare_item_for_response(
(object) [
'order' => $this->order,
'payment_result' => new PaymentResult(),
],
$request
);
}
/**
* Process an order.
*
* 1. Obtain Draft Order
* 2. Process Request
* 3. Process Customer
* 4. Validate Order
* 5. Process Payment
*
* @throws RouteException On error.
* @throws InvalidStockLevelsInCartException On error.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
/**
* Validate items etc are allowed in the order before the order is processed. This will fix violations and tell
* the customer.
*/
$this->cart_controller->validate_cart();
/**
* Obtain Draft Order and process request data.
*
* Note: Customer data is persisted from the request first so that OrderController::update_addresses_from_cart
* uses the up to date customer address.
*/
$this->update_customer_from_request( $request );
$this->create_or_update_draft_order( $request );
$this->update_order_from_request( $request );
/**
* Process customer data.
*
* Update order with customer details, and sign up a user account as necessary.
*/
$this->process_customer( $request );
/**
* Validate order.
*
* This logic ensures the order is valid before payment is attempted.
*/
$this->order_controller->validate_order_before_payment( $this->order );
wc_do_deprecated_action(
'__experimental_woocommerce_blocks_checkout_order_processed',
array(
$this->order,
),
'6.3.0',
'woocommerce_store_api_checkout_order_processed',
'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_order_processed instead.'
);
wc_do_deprecated_action(
'woocommerce_blocks_checkout_order_processed',
array(
$this->order,
),
'7.2.0',
'woocommerce_store_api_checkout_order_processed',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_order_processed instead.'
);
/**
* Fires before an order is processed by the Checkout Block/Store API.
*
* This hook informs extensions that $order has completed processing and is ready for payment.
*
* This is similar to existing core hook woocommerce_checkout_order_processed. We're using a new action:
* - To keep the interface focused (only pass $order, not passing request data).
* - This also explicitly indicates these orders are from checkout block/StoreAPI.
*
* @since 7.2.0
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3238
* @example See docs/examples/checkout-order-processed.md
* @param \WC_Order $order Order object.
*/
do_action( 'woocommerce_store_api_checkout_order_processed', $this->order );
/**
* Process the payment and return the results.
*/
$payment_result = new PaymentResult();
if ( $this->order->needs_payment() ) {
$this->process_payment( $request, $payment_result );
} else {
$this->process_without_payment( $request, $payment_result );
}
return $this->prepare_item_for_response(
(object) [
'order' => wc_get_order( $this->order ),
'payment_result' => $payment_result,
],
$request
);
}
/**
* Get route response when something went wrong.
*
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
$error_from_message = new \WP_Error(
$error_code,
$error_message
);
// 409 is when there was a conflict, so we return the cart so the client can resolve it.
if ( 409 === $http_status_code ) {
return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code, true );
}
return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code );
}
/**
* Get route response when something went wrong.
*
* @param \WP_Error $error_object User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) {
// 409 is when there was a conflict, so we return the cart so the client can resolve it.
if ( 409 === $http_status_code ) {
return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code, true );
}
return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code );
}
/**
* Adds additional data to the \WP_Error object.
*
* @param \WP_Error $error The error object to add the cart to.
* @param array $data The data to add to the error object.
* @param int $http_status_code The HTTP status code this error should return.
* @param bool $include_cart Whether the cart should be included in the error data.
* @returns \WP_Error The \WP_Error with the cart added.
*/
private function add_data_to_error_object( $error, $data, $http_status_code, bool $include_cart = false ) {
$data = array_merge( $data, [ 'status' => $http_status_code ] );
if ( $include_cart ) {
$data = array_merge( $data, [ 'cart' => wc()->api->get_endpoint_data( '/wc/store/v1/cart' ) ] );
}
$error->add_data( $data );
return $error;
}
/**
* Create or update a draft order based on the cart.
*
* @param \WP_REST_Request $request Full details about the request.
* @throws RouteException On error.
*/
private function create_or_update_draft_order( \WP_REST_Request $request ) {
$this->order = $this->get_draft_order();
if ( ! $this->order ) {
$this->order = $this->order_controller->create_order_from_cart();
} else {
$this->order_controller->update_order_from_cart( $this->order, true );
}
wc_do_deprecated_action(
'__experimental_woocommerce_blocks_checkout_update_order_meta',
array(
$this->order,
),
'6.3.0',
'woocommerce_store_api_checkout_update_order_meta',
'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_meta instead.'
);
wc_do_deprecated_action(
'woocommerce_blocks_checkout_update_order_meta',
array(
$this->order,
),
'7.2.0',
'woocommerce_store_api_checkout_update_order_meta',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_meta instead.'
);
/**
* Fires when the Checkout Block/Store API updates an order's meta data.
*
* This hook gives extensions the chance to add or update meta data on the $order.
* Throwing an exception from a callback attached to this action will make the Checkout Block render in a warning state, effectively preventing checkout.
*
* This is similar to existing core hook woocommerce_checkout_update_order_meta.
* We're using a new action:
* - To keep the interface focused (only pass $order, not passing request data).
* - This also explicitly indicates these orders are from checkout block/StoreAPI.
*
* @since 7.2.0
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3686
*
* @param \WC_Order $order Order object.
*/
do_action( 'woocommerce_store_api_checkout_update_order_meta', $this->order );
// Confirm order is valid before proceeding further.
if ( ! $this->order instanceof \WC_Order ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_order',
__( 'Unable to create order', 'woocommerce' ),
500
);
}
// Store order ID to session.
$this->set_draft_order_id( $this->order->get_id() );
/**
* Try to reserve stock for the order.
*
* If creating a draft order on checkout entry, set the timeout to 10 mins.
* If POSTing to the checkout (attempting to pay), set the timeout to 60 mins (using the woocommerce_hold_stock_minutes option).
*/
try {
$reserve_stock = new ReserveStock();
$duration = $request->get_method() === 'POST' ? (int) get_option( 'woocommerce_hold_stock_minutes', 60 ) : 10;
$reserve_stock->reserve_stock_for_order( $this->order, $duration );
} catch ( ReserveStockException $e ) {
throw new RouteException(
$e->getErrorCode(),
$e->getMessage(),
$e->getCode()
);
}
}
/**
* Updates the current customer session using data from the request (e.g. address data).
*
* Address session data is synced to the order itself later on by OrderController::update_order_from_cart()
*
* @param \WP_REST_Request $request Full details about the request.
*/
private function update_customer_from_request( \WP_REST_Request $request ) {
$customer = wc()->customer;
// Billing address is a required field.
foreach ( $request['billing_address'] as $key => $value ) {
if ( is_callable( [ $customer, "set_billing_$key" ] ) ) {
$customer->{"set_billing_$key"}( $value );
}
}
// If shipping address (optional field) was not provided, set it to the given billing address (required field).
$shipping_address_values = $request['shipping_address'] ?? $request['billing_address'];
foreach ( $shipping_address_values as $key => $value ) {
if ( is_callable( [ $customer, "set_shipping_$key" ] ) ) {
$customer->{"set_shipping_$key"}( $value );
} elseif ( 'phone' === $key ) {
$customer->update_meta_data( 'shipping_phone', $value );
}
}
/**
* Fires when the Checkout Block/Store API updates a customer from the API request data.
*
* @since 8.2.0
*
* @param \WC_Customer $customer Customer object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request );
$customer->save();
}
/**
* Gets the chosen payment method from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WC_Payment_Gateway|null
*/
private function get_request_payment_method( \WP_REST_Request $request ) {
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
$request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) );
$requires_payment_method = $this->order->needs_payment();
if ( empty( $request_payment_method ) ) {
if ( $requires_payment_method ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_payment_method',
__( 'No payment method provided.', 'woocommerce' ),
400
);
}
return null;
}
if ( ! isset( $available_gateways[ $request_payment_method ] ) ) {
$all_payment_gateways = WC()->payment_gateways->payment_gateways();
$gateway_title = isset( $all_payment_gateways[ $request_payment_method ] ) ? $all_payment_gateways[ $request_payment_method ]->get_title() : $request_payment_method;
throw new RouteException(
'woocommerce_rest_checkout_payment_method_disabled',
sprintf(
// Translators: %s Payment method ID.
__( '%s is not available for this order—please choose a different payment method', 'woocommerce' ),
esc_html( $gateway_title )
),
400
);
}
return $available_gateways[ $request_payment_method ];
}
/**
* Order processing relating to customer account.
*
* Creates a customer account as needed (based on request & store settings) and updates the order with the new customer ID.
* Updates the order with user details (e.g. address).
*
* @throws RouteException API error object with error details.
* @param \WP_REST_Request $request Request object.
*/
private function process_customer( \WP_REST_Request $request ) {
try {
if ( $this->should_create_customer_account( $request ) ) {
$customer_id = $this->create_customer_account(
$request['billing_address']['email'],
$request['billing_address']['first_name'],
$request['billing_address']['last_name']
);
// Log the customer in.
wc_set_customer_auth_cookie( $customer_id );
// Associate customer with the order.
$this->order->set_customer_id( $customer_id );
$this->order->save();
}
} catch ( \Exception $error ) {
switch ( $error->getMessage() ) {
case 'registration-error-invalid-email':
throw new RouteException(
'registration-error-invalid-email',
__( 'Please provide a valid email address.', 'woocommerce' ),
400
);
case 'registration-error-email-exists':
throw new RouteException(
'registration-error-email-exists',
__( 'An account is already registered with your email address. Please log in before proceeding.', 'woocommerce' ),
400
);
}
}
// Persist customer address data to account.
$this->order_controller->sync_customer_data_with_order( $this->order );
}
/**
* Check request options and store (shop) config to determine if a user account should be created as part of order
* processing.
*
* @param \WP_REST_Request $request The current request object being handled.
* @return boolean True if a new user account should be created.
*/
private function should_create_customer_account( \WP_REST_Request $request ) {
if ( is_user_logged_in() ) {
return false;
}
// Return false if registration is not enabled for the store.
if ( false === filter_var( wc()->checkout()->is_registration_enabled(), FILTER_VALIDATE_BOOLEAN ) ) {
return false;
}
// Return true if the store requires an account for all purchases. Note - checkbox is not displayed to shopper in this case.
if ( true === filter_var( wc()->checkout()->is_registration_required(), FILTER_VALIDATE_BOOLEAN ) ) {
return true;
}
// Create an account if requested via the endpoint.
if ( true === filter_var( $request['create_account'], FILTER_VALIDATE_BOOLEAN ) ) {
// User has requested an account as part of checkout processing.
return true;
}
return false;
}
/**
* Create a new account for a customer.
*
* The account is created with a generated username. The customer is sent
* an email notifying them about the account and containing a link to set
* their (initial) password.
*
* Intended as a replacement for wc_create_new_customer in WC core.
*
* @throws \Exception If an error is encountered when creating the user account.
*
* @param string $user_email The email address to use for the new account.
* @param string $first_name The first name to use for the new account.
* @param string $last_name The last name to use for the new account.
*
* @return int User id if successful
*/
private function create_customer_account( $user_email, $first_name, $last_name ) {
if ( empty( $user_email ) || ! is_email( $user_email ) ) {
throw new \Exception( 'registration-error-invalid-email' );
}
if ( email_exists( $user_email ) ) {
throw new \Exception( 'registration-error-email-exists' );
}
$username = wc_create_new_customer_username( $user_email );
// Handle password creation.
$password = wp_generate_password();
$password_generated = true;
// Use WP_Error to handle registration errors.
$errors = new \WP_Error();
/**
* Fires before a customer account is registered.
*
* This hook fires before customer accounts are created and passes the form data (username, email) and an array
* of errors.
*
* This could be used to add extra validation logic and append errors to the array.
*
* @since 7.2.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $username Customer username.
* @param string $user_email Customer email address.
* @param \WP_Error $errors Error object.
*/
do_action( 'woocommerce_register_post', $username, $user_email, $errors );
/**
* Filters registration errors before a customer account is registered.
*
* This hook filters registration errors. This can be used to manipulate the array of errors before
* they are displayed.
*
* @since 7.2.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param \WP_Error $errors Error object.
* @param string $username Customer username.
* @param string $user_email Customer email address.
* @return \WP_Error
*/
$errors = apply_filters( 'woocommerce_registration_errors', $errors, $username, $user_email );
if ( is_wp_error( $errors ) && $errors->get_error_code() ) {
throw new \Exception( $errors->get_error_code() );
}
/**
* Filters customer data before a customer account is registered.
*
* This hook filters customer data. It allows user data to be changed, for example, username, password, email,
* first name, last name, and role.
*
* @since 7.2.0
*
* @param array $customer_data An array of customer (user) data.
* @return array
*/
$new_customer_data = apply_filters(
'woocommerce_new_customer_data',
array(
'user_login' => $username,
'user_pass' => $password,
'user_email' => $user_email,
'first_name' => $first_name,
'last_name' => $last_name,
'role' => 'customer',
'source' => 'store-api,',
)
);
$customer_id = wp_insert_user( $new_customer_data );
if ( is_wp_error( $customer_id ) ) {
throw $this->map_create_account_error( $customer_id );
}
// Set account flag to remind customer to update generated password.
update_user_option( $customer_id, 'default_password_nag', true, true );
/**
* Fires after a customer account has been registered.
*
* This hook fires after customer accounts are created and passes the customer data.
*
* @since 7.2.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param integer $customer_id New customer (user) ID.
* @param array $new_customer_data Array of customer (user) data.
* @param string $password_generated The generated password for the account.
*/
do_action( 'woocommerce_created_customer', $customer_id, $new_customer_data, $password_generated );
return $customer_id;
}
/**
* Convert an account creation error to an exception.
*
* @param \WP_Error $error An error object.
* @return \Exception.
*/
private function map_create_account_error( \WP_Error $error ) {
switch ( $error->get_error_code() ) {
// WordPress core error codes.
case 'empty_username':
case 'invalid_username':
case 'empty_email':
case 'invalid_email':
case 'email_exists':
case 'registerfail':
return new \Exception( 'woocommerce_rest_checkout_create_account_failure' );
}
return new \Exception( 'woocommerce_rest_checkout_create_account_failure' );
}
}
StoreApi/Routes/V1/CheckoutOrder.php 0000644 00000017057 15154173074 0013334 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidStockLevelsInCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\OrderAuthorizationTrait;
use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
/**
* CheckoutOrder class.
*/
class CheckoutOrder extends AbstractCartRoute {
use OrderAuthorizationTrait;
use CheckoutTrait;
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'checkout-order';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'checkout-order';
/**
* Holds the current order being processed.
*
* @var \WC_Order
*/
private $order = null;
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/checkout/(?P<id>[\d]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => [ $this, 'is_authorized' ],
'args' => array_merge(
[
'payment_data' => [
'description' => __( 'Data to pass through to the payment method when processing payment.', 'woocommerce' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => [ 'string', 'boolean' ],
],
],
],
],
],
$this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE )
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Process an order.
*
* 1. Process Request
* 2. Process Customer
* 3. Validate Order
* 4. Process Payment
*
* @throws RouteException On error.
* @throws InvalidStockLevelsInCartException On error.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$order_id = absint( $request['id'] );
$this->order = wc_get_order( $order_id );
if ( $this->order->get_status() !== 'pending' && $this->order->get_status() !== 'failed' ) {
return new \WP_Error(
'invalid_order_update_status',
__( 'This order cannot be paid for.', 'woocommerce' )
);
}
/**
* Process request data.
*
* Note: Customer data is persisted from the request first so that OrderController::update_addresses_from_cart
* uses the up to date customer address.
*/
$this->update_billing_address( $request );
$this->update_order_from_request( $request );
/**
* Process customer data.
*
* Update order with customer details, and sign up a user account as necessary.
*/
$this->process_customer( $request );
/**
* Validate order.
*
* This logic ensures the order is valid before payment is attempted.
*/
$this->order_controller->validate_order_before_payment( $this->order );
/**
* Fires before an order is processed by the Checkout Block/Store API.
*
* This hook informs extensions that $order has completed processing and is ready for payment.
*
* This is similar to existing core hook woocommerce_checkout_order_processed. We're using a new action:
* - To keep the interface focused (only pass $order, not passing request data).
* - This also explicitly indicates these orders are from checkout block/StoreAPI.
*
* @since 7.2.0
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3238
* @example See docs/examples/checkout-order-processed.md
* @param \WC_Order $order Order object.
*/
do_action( 'woocommerce_store_api_checkout_order_processed', $this->order );
/**
* Process the payment and return the results.
*/
$payment_result = new PaymentResult();
if ( $this->order->needs_payment() ) {
$this->process_payment( $request, $payment_result );
} else {
$this->process_without_payment( $request, $payment_result );
}
return $this->prepare_item_for_response(
(object) [
'order' => wc_get_order( $this->order ),
'payment_result' => $payment_result,
],
$request
);
}
/**
* Updates the current customer session using data from the request (e.g. address data).
*
* Address session data is synced to the order itself later on by OrderController::update_order_from_cart()
*
* @param \WP_REST_Request $request Full details about the request.
*/
private function update_billing_address( \WP_REST_Request $request ) {
$customer = wc()->customer;
$billing = $request['billing_address'];
$shipping = $request['shipping_address'];
// Billing address is a required field.
foreach ( $billing as $key => $value ) {
if ( is_callable( [ $customer, "set_billing_$key" ] ) ) {
$customer->{"set_billing_$key"}( $value );
}
}
// If shipping address (optional field) was not provided, set it to the given billing address (required field).
$shipping_address_values = $shipping ?? $billing;
foreach ( $shipping_address_values as $key => $value ) {
if ( is_callable( [ $customer, "set_shipping_$key" ] ) ) {
$customer->{"set_shipping_$key"}( $value );
} elseif ( 'phone' === $key ) {
$customer->update_meta_data( 'shipping_phone', $value );
}
}
/**
* Fires when the Checkout Block/Store API updates a customer from the API request data.
*
* @since 8.2.0
*
* @param \WC_Customer $customer Customer object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request );
$customer->save();
$this->order->set_billing_address( $billing );
$this->order->set_shipping_address( $shipping );
$this->order->save();
$this->order->calculate_totals();
}
/**
* Gets the chosen payment method from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WC_Payment_Gateway|null
*/
private function get_request_payment_method( \WP_REST_Request $request ) {
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
$request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) );
$requires_payment_method = $this->order->needs_payment();
if ( empty( $request_payment_method ) ) {
if ( $requires_payment_method ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_payment_method',
__( 'No payment method provided.', 'woocommerce' ),
400
);
}
return null;
}
if ( ! isset( $available_gateways[ $request_payment_method ] ) ) {
throw new RouteException(
'woocommerce_rest_checkout_payment_method_disabled',
sprintf(
// Translators: %s Payment method ID.
__( 'The %s payment gateway is not available.', 'woocommerce' ),
esc_html( $request_payment_method )
),
400
);
}
return $available_gateways[ $request_payment_method ];
}
/**
* Updates the order with user details (e.g. address).
*
* @throws RouteException API error object with error details.
* @param \WP_REST_Request $request Request object.
*/
private function process_customer( \WP_REST_Request $request ) {
$this->order_controller->sync_customer_data_with_order( $this->order );
}
}
StoreApi/Routes/V1/Order.php 0000644 00000004136 15154173074 0011640 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\v1\AbstractSchema;
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\OrderAuthorizationTrait;
/**
* Order class.
*/
class Order extends AbstractRoute {
use OrderAuthorizationTrait;
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'order';
/**
* The schema item identifier.
*
* @var string
*/
const SCHEMA_TYPE = 'order';
/**
* Order controller class instance.
*
* @var OrderController
*/
protected $order_controller;
/**
* Constructor.
*
* @param SchemaController $schema_controller Schema Controller instance.
* @param AbstractSchema $schema Schema class for this route.
*/
public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) {
parent::__construct( $schema_controller, $schema );
$this->order_controller = new OrderController();
}
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/order/(?P<id>[\d]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => [ $this, 'is_authorized' ],
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$order_id = absint( $request['id'] );
return rest_ensure_response( $this->schema->get_item_response( wc_get_order( $order_id ) ) );
}
}
StoreApi/Routes/V1/ProductAttributeTerms.php 0000644 00000003224 15154173074 0015101 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* ProductAttributeTerms class.
*/
class ProductAttributeTerms extends AbstractTermsRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-attribute-terms';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/attributes/(?P<attribute_id>[\d]+)/terms';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => array(
'attribute_id' => array(
'description' => __( 'Unique identifier for the attribute.', 'woocommerce' ),
'type' => 'integer',
),
),
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of attribute terms.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$attribute = wc_get_attribute( $request['attribute_id'] );
if ( ! $attribute || ! taxonomy_exists( $attribute->slug ) ) {
throw new RouteException( 'woocommerce_rest_taxonomy_invalid', __( 'Attribute does not exist.', 'woocommerce' ), 404 );
}
return $this->get_terms_response( $attribute->slug, $request );
}
}
StoreApi/Routes/V1/ProductAttributes.php 0000644 00000002634 15154173074 0014255 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
/**
* ProductAttributes class.
*/
class ProductAttributes extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-attributes';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product-attribute';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/attributes';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of attributes.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$ids = wc_get_attribute_taxonomy_ids();
$return = [];
foreach ( $ids as $id ) {
$object = wc_get_attribute( $id );
$data = $this->prepare_item_for_response( $object, $request );
$return[] = $this->prepare_response_for_collection( $data );
}
return rest_ensure_response( $return );
}
}
StoreApi/Routes/V1/ProductAttributesById.php 0000644 00000003475 15154173074 0015031 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* ProductAttributesById class.
*/
class ProductAttributesById extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-attributes-by-id';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product-attribute';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/attributes/(?P<id>[\d]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
),
),
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => array(
'context' => $this->get_context_param(
array(
'default' => 'view',
)
),
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a single item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$object = wc_get_attribute( (int) $request['id'] );
if ( ! $object || 0 === $object->id ) {
throw new RouteException( 'woocommerce_rest_attribute_invalid_id', __( 'Invalid attribute ID.', 'woocommerce' ), 404 );
}
$data = $this->prepare_item_for_response( $object, $request );
$response = rest_ensure_response( $data );
return $response;
}
}
StoreApi/Routes/V1/ProductCategories.php 0000644 00000002243 15154173074 0014210 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
/**
* ProductCategories class.
*/
class ProductCategories extends AbstractTermsRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-categories';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product-category';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/categories';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of terms.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return $this->get_terms_response( 'product_cat', $request );
}
}
StoreApi/Routes/V1/ProductCategoriesById.php 0000644 00000003443 15154173074 0014763 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* ProductCategoriesById class.
*/
class ProductCategoriesById extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-categories-by-id';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product-category';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/categories/(?P<id>[\d]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
),
),
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => array(
'context' => $this->get_context_param(
array(
'default' => 'view',
)
),
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a single item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$object = get_term( (int) $request['id'], 'product_cat' );
if ( ! $object || 0 === $object->id ) {
throw new RouteException( 'woocommerce_rest_category_invalid_id', __( 'Invalid category ID.', 'woocommerce' ), 404 );
}
$data = $this->prepare_item_for_response( $object, $request );
return rest_ensure_response( $data );
}
}
StoreApi/Routes/V1/ProductCollectionData.php 0000644 00000011650 15154173074 0015012 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQueryFilters;
/**
* ProductCollectionData route.
* Get aggregate data from a collection of products.
*
* Supports the same parameters as /products, but returns a different response.
*/
class ProductCollectionData extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-collection-data';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product-collection-data';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/collection-data';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of posts and add the post title filter option to \WP_Query.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$data = [
'min_price' => null,
'max_price' => null,
'attribute_counts' => null,
'stock_status_counts' => null,
'rating_counts' => null,
];
$filters = new ProductQueryFilters();
if ( ! empty( $request['calculate_price_range'] ) ) {
$filter_request = clone $request;
$filter_request->set_param( 'min_price', null );
$filter_request->set_param( 'max_price', null );
$price_results = $filters->get_filtered_price( $filter_request );
$data['min_price'] = $price_results->min_price;
$data['max_price'] = $price_results->max_price;
}
if ( ! empty( $request['calculate_stock_status_counts'] ) ) {
$filter_request = clone $request;
$counts = $filters->get_stock_status_counts( $filter_request );
$data['stock_status_counts'] = [];
foreach ( $counts as $key => $value ) {
$data['stock_status_counts'][] = (object) [
'status' => $key,
'count' => $value,
];
}
}
if ( ! empty( $request['calculate_attribute_counts'] ) ) {
foreach ( $request['calculate_attribute_counts'] as $attributes_to_count ) {
if ( ! isset( $attributes_to_count['taxonomy'] ) ) {
continue;
}
$counts = $filters->get_attribute_counts( $request, $attributes_to_count['taxonomy'] );
foreach ( $counts as $key => $value ) {
$data['attribute_counts'][] = (object) [
'term' => $key,
'count' => $value,
];
}
}
}
if ( ! empty( $request['calculate_rating_counts'] ) ) {
$filter_request = clone $request;
$counts = $filters->get_rating_counts( $filter_request );
$data['rating_counts'] = [];
foreach ( $counts as $key => $value ) {
$data['rating_counts'][] = (object) [
'rating' => $key,
'count' => $value,
];
}
}
return rest_ensure_response( $this->schema->get_item_response( $data ) );
}
/**
* Get the query params for collections of products.
*
* @return array
*/
public function get_collection_params() {
$params = ( new Products( $this->schema_controller, $this->schema ) )->get_collection_params();
$params['calculate_price_range'] = [
'description' => __( 'If true, calculates the minimum and maximum product prices for the collection.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
];
$params['calculate_stock_status_counts'] = [
'description' => __( 'If true, calculates stock counts for products in the collection.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
];
$params['calculate_attribute_counts'] = [
'description' => __( 'If requested, calculates attribute term counts for products in the collection.', 'woocommerce' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'taxonomy' => [
'description' => __( 'Taxonomy name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'query_type' => [
'description' => __( 'Filter condition being performed which may affect counts. Valid values include "and" and "or".', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'and', 'or' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
'default' => [],
];
$params['calculate_rating_counts'] = [
'description' => __( 'If true, calculates rating counts for products in the collection.', 'woocommerce' ),
'type' => 'boolean',
'default' => false,
];
return $params;
}
}
StoreApi/Routes/V1/ProductReviews.php 0000644 00000015061 15154173074 0013551 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use WP_Comment_Query;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
/**
* ProductReviews class.
*/
class ProductReviews extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-reviews';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product-review';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/reviews';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of reviews.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$prepared_args = array(
'type' => 'review',
'status' => 'approve',
'no_found_rows' => false,
'offset' => $request['offset'],
'order' => $request['order'],
'number' => $request['per_page'],
'post__in' => $request['product_id'],
);
/**
* Map category id to list of product ids.
*/
if ( ! empty( $request['category_id'] ) ) {
$category_ids = $request['category_id'];
$child_ids = [];
foreach ( $category_ids as $category_id ) {
$child_ids = array_merge( $child_ids, get_term_children( $category_id, 'product_cat' ) );
}
$category_ids = array_unique( array_merge( $category_ids, $child_ids ) );
$product_ids = get_objects_in_term( $category_ids, 'product_cat' );
$prepared_args['post__in'] = isset( $prepared_args['post__in'] ) ? array_merge( $prepared_args['post__in'], $product_ids ) : $product_ids;
}
if ( 'rating' === $request['orderby'] ) {
$prepared_args['meta_query'] = array( // phpcs:ignore
'relation' => 'OR',
array(
'key' => 'rating',
'compare' => 'EXISTS',
),
array(
'key' => 'rating',
'compare' => 'NOT EXISTS',
),
);
}
$prepared_args['orderby'] = $this->normalize_query_param( $request['orderby'] );
if ( empty( $request['offset'] ) ) {
$prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 );
}
$query = new WP_Comment_Query();
$query_result = $query->query( $prepared_args );
$response_objects = array();
foreach ( $query_result as $review ) {
$data = $this->prepare_item_for_response( $review, $request );
$response_objects[] = $this->prepare_response_for_collection( $data );
}
$total_reviews = (int) $query->found_comments;
$max_pages = (int) $query->max_num_pages;
if ( $total_reviews < 1 ) {
// Out-of-bounds, run the query again without LIMIT for total count.
unset( $prepared_args['number'], $prepared_args['offset'] );
$query = new WP_Comment_Query();
$prepared_args['count'] = true;
$total_reviews = $query->query( $prepared_args );
$max_pages = $request['per_page'] ? ceil( $total_reviews / $request['per_page'] ) : 1;
}
$response = rest_ensure_response( $response_objects );
$response = ( new Pagination() )->add_headers( $response, $request, $total_reviews, $max_pages );
return $response;
}
/**
* Prepends internal property prefix to query parameters to match our response fields.
*
* @param string $query_param Query parameter.
* @return string
*/
protected function normalize_query_param( $query_param ) {
$prefix = 'comment_';
switch ( $query_param ) {
case 'id':
$normalized = $prefix . 'ID';
break;
case 'product':
$normalized = $prefix . 'post_ID';
break;
case 'rating':
$normalized = 'meta_value_num';
break;
default:
$normalized = $prefix . $query_param;
break;
}
return $normalized;
}
/**
* Get the query params for collections of products.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param();
$params['context']['default'] = 'view';
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set. Defaults to no limit if left blank.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 0,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['offset'] = array(
'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'date_gmt',
'id',
'rating',
'product',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['category_id'] = array(
'description' => __( 'Limit result set to reviews from specific category IDs.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_id'] = array(
'description' => __( 'Limit result set to reviews from specific product IDs.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
}
StoreApi/Routes/V1/ProductTags.php 0000644 00000002054 15154173074 0013021 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
/**
* ProductTags class.
*/
class ProductTags extends AbstractTermsRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'product-tags';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/tags';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of terms.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
return $this->get_terms_response( 'product_tag', $request );
}
}
StoreApi/Routes/V1/Products.php 0000644 00000034433 15154173074 0012373 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
/**
* Products class.
*/
class Products extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'products';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a collection of posts and add the post title filter option to \WP_Query.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$response = new \WP_REST_Response();
$product_query = new ProductQuery();
// Only get objects during GET requests.
if ( \WP_REST_Server::READABLE === $request->get_method() ) {
$query_results = $product_query->get_objects( $request );
$response_objects = [];
foreach ( $query_results['objects'] as $object ) {
$data = rest_ensure_response( $this->schema->get_item_response( $object ) );
$response_objects[] = $this->prepare_response_for_collection( $data );
}
$response->set_data( $response_objects );
} else {
$query_results = $product_query->get_results( $request );
}
$response = ( new Pagination() )->add_headers( $response, $request, $query_results['total'], $query_results['pages'] );
$response->header( 'Last-Modified', $product_query->get_last_modified() );
return $response;
}
/**
* Prepare links for the request.
*
* @param \WC_Product $item Product object.
* @param \WP_REST_Request $request Request object.
* @return array
*/
protected function prepare_links( $item, $request ) {
$links = array(
'self' => array(
'href' => rest_url( $this->get_namespace() . $this->get_path() . '/' . $item->get_id() ),
),
'collection' => array(
'href' => rest_url( $this->get_namespace() . $this->get_path() ),
),
);
if ( $item->get_parent_id() ) {
$links['up'] = array(
'href' => rest_url( $this->get_namespace() . $this->get_path() . '/' . $item->get_parent_id() ),
);
}
return $links;
}
/**
* Get the query params for collections of products.
*
* @return array
*/
public function get_collection_params() {
$params = [];
$params['context'] = $this->get_context_param();
$params['context']['default'] = 'view';
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set. Defaults to no limit if left blank.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
'minimum' => 0,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['search'] = array(
'description' => __( 'Limit results to those matching a string.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);
$params['slug'] = array(
'description' => __( 'Limit result set to products with specific slug(s). Use commas to separate.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
'description' => __( 'Limit response to resources created after a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
'description' => __( 'Limit response to resources created before a given ISO8601 compliant date.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['date_column'] = array(
'description' => __( 'When limiting response using after/before, which date column to compare against.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'date_gmt',
'modified',
'modified_gmt',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['exclude'] = array(
'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => [],
'sanitize_callback' => 'wp_parse_id_list',
);
$params['include'] = array(
'description' => __( 'Limit result set to specific ids.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => [],
'sanitize_callback' => 'wp_parse_id_list',
);
$params['offset'] = array(
'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'woocommerce' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
'modified',
'id',
'include',
'title',
'slug',
'price',
'popularity',
'rating',
'menu_order',
'comment_count',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['parent'] = array(
'description' => __( 'Limit result set to those of particular parent IDs.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => [],
'sanitize_callback' => 'wp_parse_id_list',
);
$params['parent_exclude'] = array(
'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'sanitize_callback' => 'wp_parse_id_list',
'default' => [],
);
$params['type'] = array(
'description' => __( 'Limit result set to products assigned a specific type.', 'woocommerce' ),
'type' => 'string',
'enum' => array_merge( array_keys( wc_get_product_types() ), [ 'variation' ] ),
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
$params['sku'] = array(
'description' => __( 'Limit result set to products with specific SKU(s). Use commas to separate.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);
$params['featured'] = array(
'description' => __( 'Limit result set to featured products.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['category'] = array(
'description' => __( 'Limit result set to products assigned a specific category ID.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['category_operator'] = array(
'description' => __( 'Operator to compare product category terms.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'in', 'not_in', 'and' ],
'default' => 'in',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
// If the $_REQUEST contains a taxonomy query, add it to the params and sanitize it.
foreach ( $_REQUEST as $param => $value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( str_starts_with( $param, '_unstable_tax_' ) && ! str_ends_with( $param, '_operator' ) ) {
$params[ $param ] = array(
'description' => __( 'Limit result set to products assigned a specific category ID.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
}
if ( str_starts_with( $param, '_unstable_tax_' ) && str_ends_with( $param, '_operator' ) ) {
$params[ $param ] = array(
'description' => __( 'Operator to compare product category terms.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'in', 'not_in', 'and' ],
'default' => 'in',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
}
}
$params['tag'] = array(
'description' => __( 'Limit result set to products assigned a specific tag ID.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['tag_operator'] = array(
'description' => __( 'Operator to compare product tags.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'in', 'not_in', 'and' ],
'default' => 'in',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
$params['on_sale'] = array(
'description' => __( 'Limit result set to products on sale.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
$params['min_price'] = array(
'description' => __( 'Limit result set to products based on a minimum price, provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);
$params['max_price'] = array(
'description' => __( 'Limit result set to products based on a maximum price, provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);
$params['stock_status'] = array(
'description' => __( 'Limit result set to products with specified stock status.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => array_keys( wc_get_product_stock_status_options() ),
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
),
'default' => [],
);
$params['attributes'] = array(
'description' => __( 'Limit result set to products with selected global attributes.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'attribute' => array(
'description' => __( 'Attribute taxonomy name.', 'woocommerce' ),
'type' => 'string',
'sanitize_callback' => 'wc_sanitize_taxonomy_name',
),
'term_id' => array(
'description' => __( 'List of attribute term IDs.', 'woocommerce' ),
'type' => 'array',
'items' => [
'type' => 'integer',
],
'sanitize_callback' => 'wp_parse_id_list',
),
'slug' => array(
'description' => __( 'List of attribute slug(s). If a term ID is provided, this will be ignored.', 'woocommerce' ),
'type' => 'array',
'items' => [
'type' => 'string',
],
'sanitize_callback' => 'wp_parse_slug_list',
),
'operator' => array(
'description' => __( 'Operator to compare product attribute terms.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'in', 'not_in', 'and' ],
),
),
),
'default' => [],
);
$params['attribute_relation'] = array(
'description' => __( 'The logical relationship between attributes when filtering across multiple at once.', 'woocommerce' ),
'type' => 'string',
'enum' => [ 'in', 'and' ],
'default' => 'and',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
$params['catalog_visibility'] = array(
'description' => __( 'Determines if hidden or visible catalog products are shown.', 'woocommerce' ),
'type' => 'string',
'enum' => array( 'any', 'visible', 'catalog', 'search', 'hidden' ),
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
$params['rating'] = array(
'description' => __( 'Limit result set to products with a certain average rating.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
'enum' => range( 1, 5 ),
),
'default' => [],
'sanitize_callback' => 'wp_parse_id_list',
);
return $params;
}
}
StoreApi/Routes/V1/ProductsById.php 0000644 00000003323 15154173074 0013135 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* ProductsById class.
*/
class ProductsById extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'products-by-id';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/(?P<id>[\d]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
),
),
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => array(
'context' => $this->get_context_param(
array(
'default' => 'view',
)
),
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a single item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$object = wc_get_product( (int) $request['id'] );
if ( ! $object || 0 === $object->get_id() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 );
}
return rest_ensure_response( $this->schema->get_item_response( $object ) );
}
}
StoreApi/Routes/V1/ProductsBySlug.php 0000644 00000005021 15154173074 0013510 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* ProductsBySlug class.
*/
class ProductsBySlug extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'products-by-slug';
/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/(?P<slug>[\S]+)';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => array(
'slug' => array(
'description' => __( 'Slug of the resource.', 'woocommerce' ),
'type' => 'string',
),
),
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => array(
'context' => $this->get_context_param(
array(
'default' => 'view',
)
),
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Get a single item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$slug = sanitize_title( $request['slug'] );
$object = $this->get_product_by_slug( $slug );
if ( ! $object ) {
$object = $this->get_product_variation_by_slug( $slug );
}
if ( ! $object || 0 === $object->get_id() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 );
}
return rest_ensure_response( $this->schema->get_item_response( $object ) );
}
/**
* Get a product by slug.
*
* @param string $slug The slug of the product.
*/
public function get_product_by_slug( $slug ) {
return wc_get_product( get_page_by_path( $slug, OBJECT, 'product' ) );
}
/**
* Get a product variation by slug.
*
* @param string $slug The slug of the product variation.
*/
private function get_product_variation_by_slug( $slug ) {
global $wpdb;
$result = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_name, post_parent, post_type
FROM $wpdb->posts
WHERE post_name = %s
AND post_type = 'product_variation'",
$slug
)
);
if ( ! $result ) {
return null;
}
return wc_get_product( $result[0]->ID );
}
}
StoreApi/RoutesController.php 0000644 00000011115 15154173074 0012336 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Exception;
use Routes\AbstractRoute;
/**
* RoutesController class.
*/
class RoutesController {
/**
* Stores schema_controller.
*
* @var SchemaController
*/
protected $schema_controller;
/**
* Stores routes.
*
* @var array
*/
protected $routes = [];
/**
* Constructor.
*
* @param SchemaController $schema_controller Schema controller class passed to each route.
*/
public function __construct( SchemaController $schema_controller ) {
$this->schema_controller = $schema_controller;
$this->routes = [
'v1' => [
Routes\V1\Batch::IDENTIFIER => Routes\V1\Batch::class,
Routes\V1\Cart::IDENTIFIER => Routes\V1\Cart::class,
Routes\V1\CartAddItem::IDENTIFIER => Routes\V1\CartAddItem::class,
Routes\V1\CartApplyCoupon::IDENTIFIER => Routes\V1\CartApplyCoupon::class,
Routes\V1\CartCoupons::IDENTIFIER => Routes\V1\CartCoupons::class,
Routes\V1\CartCouponsByCode::IDENTIFIER => Routes\V1\CartCouponsByCode::class,
Routes\V1\CartExtensions::IDENTIFIER => Routes\V1\CartExtensions::class,
Routes\V1\CartItems::IDENTIFIER => Routes\V1\CartItems::class,
Routes\V1\CartItemsByKey::IDENTIFIER => Routes\V1\CartItemsByKey::class,
Routes\V1\CartRemoveCoupon::IDENTIFIER => Routes\V1\CartRemoveCoupon::class,
Routes\V1\CartRemoveItem::IDENTIFIER => Routes\V1\CartRemoveItem::class,
Routes\V1\CartSelectShippingRate::IDENTIFIER => Routes\V1\CartSelectShippingRate::class,
Routes\V1\CartUpdateItem::IDENTIFIER => Routes\V1\CartUpdateItem::class,
Routes\V1\CartUpdateCustomer::IDENTIFIER => Routes\V1\CartUpdateCustomer::class,
Routes\V1\Checkout::IDENTIFIER => Routes\V1\Checkout::class,
Routes\V1\ProductAttributes::IDENTIFIER => Routes\V1\ProductAttributes::class,
Routes\V1\ProductAttributesById::IDENTIFIER => Routes\V1\ProductAttributesById::class,
Routes\V1\ProductAttributeTerms::IDENTIFIER => Routes\V1\ProductAttributeTerms::class,
Routes\V1\ProductCategories::IDENTIFIER => Routes\V1\ProductCategories::class,
Routes\V1\ProductCategoriesById::IDENTIFIER => Routes\V1\ProductCategoriesById::class,
Routes\V1\ProductCollectionData::IDENTIFIER => Routes\V1\ProductCollectionData::class,
Routes\V1\ProductReviews::IDENTIFIER => Routes\V1\ProductReviews::class,
Routes\V1\ProductTags::IDENTIFIER => Routes\V1\ProductTags::class,
Routes\V1\Products::IDENTIFIER => Routes\V1\Products::class,
Routes\V1\ProductsById::IDENTIFIER => Routes\V1\ProductsById::class,
Routes\V1\ProductsBySlug::IDENTIFIER => Routes\V1\ProductsBySlug::class,
],
];
if ( Package::is_experimental_build() ) {
$this->routes['v1'][ Routes\V1\Order::IDENTIFIER ] = Routes\V1\Order::class;
$this->routes['v1'][ Routes\V1\CheckoutOrder::IDENTIFIER ] = Routes\V1\CheckoutOrder::class;
}
}
/**
* Register all Store API routes. This includes routes under specific version namespaces.
*/
public function register_all_routes() {
$this->register_routes( 'v1', 'wc/store' );
$this->register_routes( 'v1', 'wc/store/v1' );
}
/**
* Get a route class instance.
*
* Each route class is instantized with the SchemaController instance, and its main Schema Type.
*
* @throws \Exception If the schema does not exist.
* @param string $name Name of schema.
* @param string $version API Version being requested.
* @return AbstractRoute
*/
public function get( $name, $version = 'v1' ) {
$route = $this->routes[ $version ][ $name ] ?? false;
if ( ! $route ) {
throw new \Exception( "{$name} {$version} route does not exist" );
}
return new $route(
$this->schema_controller,
$this->schema_controller->get( $route::SCHEMA_TYPE, $route::SCHEMA_VERSION )
);
}
/**
* Register defined list of routes with WordPress.
*
* @param string $version API Version being registered..
* @param string $namespace Overrides the default route namespace.
*/
protected function register_routes( $version = 'v1', $namespace = 'wc/store/v1' ) {
if ( ! isset( $this->routes[ $version ] ) ) {
return;
}
$route_identifiers = array_keys( $this->routes[ $version ] );
foreach ( $route_identifiers as $route ) {
$route_instance = $this->get( $route, $version );
$route_instance->set_namespace( $namespace );
register_rest_route(
$route_instance->get_namespace(),
$route_instance->get_path(),
$route_instance->get_args()
);
}
}
}
StoreApi/SchemaController.php 0000644 00000006107 15154173074 0012262 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* SchemaController class.
*/
class SchemaController {
/**
* Stores schema class instances.
*
* @var Schemas\V1\AbstractSchema[]
*/
protected $schemas = [];
/**
* Stores Rest Extending instance
*
* @var ExtendSchema
*/
private $extend;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
*/
public function __construct( ExtendSchema $extend ) {
$this->extend = $extend;
$this->schemas = [
'v1' => [
Schemas\V1\BatchSchema::IDENTIFIER => Schemas\V1\BatchSchema::class,
Schemas\V1\ErrorSchema::IDENTIFIER => Schemas\V1\ErrorSchema::class,
Schemas\V1\ImageAttachmentSchema::IDENTIFIER => Schemas\V1\ImageAttachmentSchema::class,
Schemas\V1\TermSchema::IDENTIFIER => Schemas\V1\TermSchema::class,
Schemas\V1\BillingAddressSchema::IDENTIFIER => Schemas\V1\BillingAddressSchema::class,
Schemas\V1\ShippingAddressSchema::IDENTIFIER => Schemas\V1\ShippingAddressSchema::class,
Schemas\V1\CartShippingRateSchema::IDENTIFIER => Schemas\V1\CartShippingRateSchema::class,
Schemas\V1\CartCouponSchema::IDENTIFIER => Schemas\V1\CartCouponSchema::class,
Schemas\V1\CartFeeSchema::IDENTIFIER => Schemas\V1\CartFeeSchema::class,
Schemas\V1\CartItemSchema::IDENTIFIER => Schemas\V1\CartItemSchema::class,
Schemas\V1\CartSchema::IDENTIFIER => Schemas\V1\CartSchema::class,
Schemas\V1\CartExtensionsSchema::IDENTIFIER => Schemas\V1\CartExtensionsSchema::class,
Schemas\V1\CheckoutOrderSchema::IDENTIFIER => Schemas\V1\CheckoutOrderSchema::class,
Schemas\V1\CheckoutSchema::IDENTIFIER => Schemas\V1\CheckoutSchema::class,
Schemas\V1\OrderItemSchema::IDENTIFIER => Schemas\V1\OrderItemSchema::class,
Schemas\V1\OrderCouponSchema::IDENTIFIER => Schemas\V1\OrderCouponSchema::class,
Schemas\V1\OrderFeeSchema::IDENTIFIER => Schemas\V1\OrderFeeSchema::class,
Schemas\V1\OrderSchema::IDENTIFIER => Schemas\V1\OrderSchema::class,
Schemas\V1\ProductSchema::IDENTIFIER => Schemas\V1\ProductSchema::class,
Schemas\V1\ProductAttributeSchema::IDENTIFIER => Schemas\V1\ProductAttributeSchema::class,
Schemas\V1\ProductCategorySchema::IDENTIFIER => Schemas\V1\ProductCategorySchema::class,
Schemas\V1\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class,
Schemas\V1\ProductReviewSchema::IDENTIFIER => Schemas\V1\ProductReviewSchema::class,
],
];
}
/**
* Get a schema class instance.
*
* @throws \Exception If the schema does not exist.
*
* @param string $name Name of schema.
* @param int $version API Version being requested.
* @return Schemas\V1\AbstractSchema A new instance of the requested schema.
*/
public function get( $name, $version = 1 ) {
$schema = $this->schemas[ "v{$version}" ][ $name ] ?? false;
if ( ! $schema ) {
throw new \Exception( "{$name} v{$version} schema does not exist" );
}
return new $schema( $this->extend, $this );
}
}
StoreApi/Schemas/ExtendSchema.php 0000644 00000025303 15154173074 0012750 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema;
use Automattic\WooCommerce\StoreApi\Formatters;
/**
* Provides utility functions to extend Store API schemas.
*
* Note there are also helpers that map to these methods.
*
* @see woocommerce_store_api_register_endpoint_data()
* @see woocommerce_store_api_register_update_callback()
* @see woocommerce_store_api_register_payment_requirements()
* @see woocommerce_store_api_get_formatter()
*/
final class ExtendSchema {
/**
* List of Store API schema that is allowed to be extended by extensions.
*
* @var string[]
*/
private $endpoints = [
CartItemSchema::IDENTIFIER,
CartSchema::IDENTIFIER,
CheckoutSchema::IDENTIFIER,
ProductSchema::IDENTIFIER,
];
/**
* Holds the formatters class instance.
*
* @var Formatters
*/
private $formatters;
/**
* Data to be extended
*
* @var array
*/
private $extend_data = [];
/**
* Data to be extended
*
* @var array
*/
private $callback_methods = [];
/**
* Array of payment requirements
*
* @var array
*/
private $payment_requirements = [];
/**
* Constructor
*
* @param Formatters $formatters An instance of the formatters class.
*/
public function __construct( Formatters $formatters ) {
$this->formatters = $formatters;
}
/**
* Register endpoint data under a specified namespace
*
* @param array $args {
* An array of elements that make up a post to update or insert.
*
* @type string $endpoint Required. The endpoint to extend.
* @type string $namespace Required. Plugin namespace.
* @type callable $schema_callback Callback executed to add schema data.
* @type callable $data_callback Callback executed to add endpoint data.
* @type string $schema_type The type of data, object or array.
* }
*
* @throws \Exception On failure to register.
*/
public function register_endpoint_data( $args ) {
$args = wp_parse_args(
$args,
[
'endpoint' => '',
'namespace' => '',
'schema_callback' => null,
'data_callback' => null,
'schema_type' => ARRAY_A,
]
);
if ( ! is_string( $args['namespace'] ) || empty( $args['namespace'] ) ) {
$this->throw_exception( 'You must provide a plugin namespace when extending a Store REST endpoint.' );
}
if ( ! in_array( $args['endpoint'], $this->endpoints, true ) ) {
$this->throw_exception(
sprintf( 'You must provide a valid Store REST endpoint to extend, valid endpoints are: %1$s. You provided %2$s.', implode( ', ', $this->endpoints ), $args['endpoint'] )
);
}
if ( ! is_null( $args['schema_callback'] ) && ! is_callable( $args['schema_callback'] ) ) {
$this->throw_exception( '$schema_callback must be a callable function.' );
}
if ( ! is_null( $args['data_callback'] ) && ! is_callable( $args['data_callback'] ) ) {
$this->throw_exception( '$data_callback must be a callable function.' );
}
if ( ! in_array( $args['schema_type'], [ ARRAY_N, ARRAY_A ], true ) ) {
$this->throw_exception(
sprintf( 'Data type must be either ARRAY_N for a numeric array or ARRAY_A for an object like array. You provided %1$s.', $args['schema_type'] )
);
}
$this->extend_data[ $args['endpoint'] ][ $args['namespace'] ] = [
'schema_callback' => $args['schema_callback'],
'data_callback' => $args['data_callback'],
'schema_type' => $args['schema_type'],
];
}
/**
* Add callback functions that can be executed by the cart/extensions endpoint.
*
* @param array $args {
* An array of elements that make up the callback configuration.
*
* @type string $namespace Required. Plugin namespace.
* @type callable $callback Required. The function/callable to execute.
* }
*
* @throws \Exception On failure to register.
*/
public function register_update_callback( $args ) {
$args = wp_parse_args(
$args,
[
'namespace' => '',
'callback' => null,
]
);
if ( ! is_string( $args['namespace'] ) || empty( $args['namespace'] ) ) {
throw new \Exception( 'You must provide a plugin namespace when extending a Store REST endpoint.' );
}
if ( ! is_callable( $args['callback'] ) ) {
throw new \Exception( 'There is no valid callback supplied to register_update_callback.' );
}
$this->callback_methods[ $args['namespace'] ] = $args;
}
/**
* Registers and validates payment requirements callbacks.
*
* @param array $args {
* Array of registration data.
*
* @type callable $data_callback Required. Callback executed to add payment requirements data.
* }
*
* @throws \Exception On failure to register.
*/
public function register_payment_requirements( $args ) {
if ( empty( $args['data_callback'] ) || ! is_callable( $args['data_callback'] ) ) {
$this->throw_exception( '$data_callback must be a callable function.' );
}
$this->payment_requirements[] = $args['data_callback'];
}
/**
* Returns a formatter instance.
*
* @param string $name Formatter name.
* @return FormatterInterface
*/
public function get_formatter( $name ) {
return $this->formatters->$name;
}
/**
* Get callback for a specific endpoint and namespace.
*
* @param string $namespace The namespace to get callbacks for.
*
* @return callable The callback registered by the extension.
* @throws \Exception When callback is not callable or parameters are incorrect.
*/
public function get_update_callback( $namespace ) {
if ( ! is_string( $namespace ) ) {
throw new \Exception( 'You must provide a plugin namespace when extending a Store REST endpoint.' );
}
if ( ! array_key_exists( $namespace, $this->callback_methods ) ) {
throw new \Exception( sprintf( 'There is no such namespace registered: %1$s.', $namespace ) );
}
if ( ! array_key_exists( 'callback', $this->callback_methods[ $namespace ] ) || ! is_callable( $this->callback_methods[ $namespace ]['callback'] ) ) {
throw new \Exception( sprintf( 'There is no valid callback registered for: %1$s.', $namespace ) );
}
return $this->callback_methods[ $namespace ]['callback'];
}
/**
* Returns the registered endpoint data
*
* @param string $endpoint A valid identifier.
* @param array $passed_args Passed arguments from the Schema class.
* @return object Returns an casted object with registered endpoint data.
* @throws \Exception If a registered callback throws an error, or silently logs it.
*/
public function get_endpoint_data( $endpoint, array $passed_args = [] ) {
$registered_data = [];
if ( isset( $this->extend_data[ $endpoint ] ) ) {
foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) {
if ( is_null( $callbacks['data_callback'] ) ) {
continue;
}
try {
$data = $callbacks['data_callback']( ...$passed_args );
if ( ! is_array( $data ) ) {
$data = [];
throw new \Exception( '$data_callback must return an array.' );
}
} catch ( \Throwable $e ) {
$this->throw_exception( $e );
}
$registered_data[ $namespace ] = $data;
}
}
return (object) $registered_data;
}
/**
* Returns the registered endpoint schema
*
* @param string $endpoint A valid identifier.
* @param array $passed_args Passed arguments from the Schema class.
* @return object Returns an array with registered schema data.
* @throws \Exception If a registered callback throws an error, or silently logs it.
*/
public function get_endpoint_schema( $endpoint, array $passed_args = [] ) {
$registered_schema = [];
if ( isset( $this->extend_data[ $endpoint ] ) ) {
foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) {
if ( is_null( $callbacks['schema_callback'] ) ) {
continue;
}
try {
$schema = $callbacks['schema_callback']( ...$passed_args );
if ( ! is_array( $schema ) ) {
$schema = [];
throw new \Exception( '$schema_callback must return an array.' );
}
} catch ( \Throwable $e ) {
$this->throw_exception( $e );
}
$registered_schema[ $namespace ] = $this->format_extensions_properties( $namespace, $schema, $callbacks['schema_type'] );
}
}
return (object) $registered_schema;
}
/**
* Returns the additional payment requirements for the cart which are required to make payments. Values listed here
* are compared against each Payment Gateways "supports" flag.
*
* @param array $requirements list of requirements that should be added to the collected requirements.
* @return array Returns a list of payment requirements.
* @throws \Exception If a registered callback throws an error, or silently logs it.
*/
public function get_payment_requirements( array $requirements = [ 'products' ] ) {
if ( ! empty( $this->payment_requirements ) ) {
foreach ( $this->payment_requirements as $callback ) {
try {
$data = $callback();
if ( ! is_array( $data ) ) {
throw new \Exception( '$data_callback must return an array.' );
}
$requirements = array_unique( array_merge( $requirements, $data ) );
} catch ( \Throwable $e ) {
$this->throw_exception( $e );
}
}
}
return $requirements;
}
/**
* Throws error and/or silently logs it.
*
* @param string|\Throwable $exception_or_error Error message or \Exception.
* @throws \Exception An error to throw if we have debug enabled and user is admin.
*/
private function throw_exception( $exception_or_error ) {
$exception = is_string( $exception_or_error ) ? new \Exception( $exception_or_error ) : $exception_or_error;
wc_caught_exception( $exception );
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && current_user_can( 'manage_woocommerce' ) ) {
throw $exception;
}
}
/**
* Format schema for an extension.
*
* @param string $namespace Error message or \Exception.
* @param array $schema An error to throw if we have debug enabled and user is admin.
* @param string $schema_type How should data be shaped.
* @return array Formatted schema.
*/
private function format_extensions_properties( $namespace, $schema, $schema_type ) {
if ( ARRAY_N === $schema_type ) {
return [
/* translators: %s: extension namespace */
'description' => sprintf( __( 'Extension data registered by %s', 'woocommerce' ), $namespace ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => $schema,
];
}
return [
/* translators: %s: extension namespace */
'description' => sprintf( __( 'Extension data registered by %s', 'woocommerce' ), $namespace ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $schema,
];
}
}
StoreApi/Schemas/V1/AbstractAddressSchema.php 0000644 00000013343 15154173074 0015061 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils;
/**
* AddressSchema class.
*
* Provides a generic address schema for composition in other schemas.
*/
abstract class AbstractAddressSchema extends AbstractSchema {
/**
* Term properties.
*
* @internal Note that required properties don't require values, just that they are included in the request.
* @return array
*/
public function get_properties() {
return [
'first_name' => [
'description' => __( 'First name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'last_name' => [
'description' => __( 'Last name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'company' => [
'description' => __( 'Company', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'address_1' => [
'description' => __( 'Address', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'address_2' => [
'description' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'city' => [
'description' => __( 'City', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'state' => [
'description' => __( 'State/County code, or name of the state, county, province, or district.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'postcode' => [
'description' => __( 'Postal code', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'country' => [
'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'phone' => [
'description' => __( 'Phone', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
];
}
/**
* Sanitize and format the given address object.
*
* @param array $address Value being sanitized.
* @param \WP_REST_Request $request The Request.
* @param string $param The param being sanitized.
* @return array
*/
public function sanitize_callback( $address, $request, $param ) {
$validation_util = new ValidationUtils();
$address = array_merge( array_fill_keys( array_keys( $this->get_properties() ), '' ), (array) $address );
$address['country'] = wc_strtoupper( sanitize_text_field( wp_unslash( $address['country'] ) ) );
$address['first_name'] = sanitize_text_field( wp_unslash( $address['first_name'] ) );
$address['last_name'] = sanitize_text_field( wp_unslash( $address['last_name'] ) );
$address['company'] = sanitize_text_field( wp_unslash( $address['company'] ) );
$address['address_1'] = sanitize_text_field( wp_unslash( $address['address_1'] ) );
$address['address_2'] = sanitize_text_field( wp_unslash( $address['address_2'] ) );
$address['city'] = sanitize_text_field( wp_unslash( $address['city'] ) );
$address['state'] = $validation_util->format_state( sanitize_text_field( wp_unslash( $address['state'] ) ), $address['country'] );
$address['postcode'] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( wp_unslash( $address['postcode'] ) ), $address['country'] ) : '';
$address['phone'] = sanitize_text_field( wp_unslash( $address['phone'] ) );
return $address;
}
/**
* Validate the given address object.
*
* @see rest_validate_value_from_schema
*
* @param array $address Value being sanitized.
* @param \WP_REST_Request $request The Request.
* @param string $param The param being sanitized.
* @return true|\WP_Error
*/
public function validate_callback( $address, $request, $param ) {
$errors = new \WP_Error();
$address = $this->sanitize_callback( $address, $request, $param );
$validation_util = new ValidationUtils();
if ( ! empty( $address['country'] ) && ! in_array( $address['country'], array_keys( wc()->countries->get_countries() ), true ) ) {
$errors->add(
'invalid_country',
sprintf(
/* translators: %s valid country codes */
__( 'Invalid country code provided. Must be one of: %s', 'woocommerce' ),
implode( ', ', array_keys( wc()->countries->get_countries() ) )
)
);
return $errors;
}
if ( ! empty( $address['state'] ) && ! $validation_util->validate_state( $address['state'], $address['country'] ) ) {
$errors->add(
'invalid_state',
sprintf(
/* translators: %1$s given state, %2$s valid states */
__( 'The provided state (%1$s) is not valid. Must be one of: %2$s', 'woocommerce' ),
esc_html( $address['state'] ),
implode( ', ', array_keys( $validation_util->get_states_for_country( $address['country'] ) ) )
)
);
}
if ( ! empty( $address['postcode'] ) && ! \WC_Validation::is_postcode( $address['postcode'], $address['country'] ) ) {
$errors->add(
'invalid_postcode',
__( 'The provided postcode / ZIP is not valid', 'woocommerce' )
);
}
if ( ! empty( $address['phone'] ) && ! \WC_Validation::is_phone( $address['phone'] ) ) {
$errors->add(
'invalid_phone',
__( 'The provided phone number is not valid', 'woocommerce' )
);
}
return $errors->has_errors( $errors ) ? $errors : true;
}
}
StoreApi/Schemas/V1/AbstractSchema.php 0000644 00000030234 15154173074 0013551 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* AbstractSchema class.
*
* For REST Route Schemas
*/
abstract class AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'Schema';
/**
* Rest extend instance.
*
* @var ExtendSchema
*/
protected $extend;
/**
* Schema Controller instance.
*
* @var SchemaController
*/
protected $controller;
/**
* Extending key that gets added to endpoint.
*
* @var string
*/
const EXTENDING_KEY = 'extensions';
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
$this->extend = $extend;
$this->controller = $controller;
}
/**
* Returns the full item schema.
*
* @return array
*/
public function get_item_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => $this->title,
'type' => 'object',
'properties' => $this->get_properties(),
);
}
/**
* Returns the full item response.
*
* @param mixed $item Item to get response for.
* @return array|stdClass
*/
public function get_item_response( $item ) {
return [];
}
/**
* Return schema properties.
*
* @return array
*/
abstract public function get_properties();
/**
* Recursive removal of arg_options.
*
* @param array $properties Schema properties.
*/
protected function remove_arg_options( $properties ) {
return array_map(
function( $property ) {
if ( isset( $property['properties'] ) ) {
$property['properties'] = $this->remove_arg_options( $property['properties'] );
} elseif ( isset( $property['items']['properties'] ) ) {
$property['items']['properties'] = $this->remove_arg_options( $property['items']['properties'] );
}
unset( $property['arg_options'] );
return $property;
},
(array) $properties
);
}
/**
* Returns the public schema.
*
* @return array
*/
public function get_public_item_schema() {
$schema = $this->get_item_schema();
if ( isset( $schema['properties'] ) ) {
$schema['properties'] = $this->remove_arg_options( $schema['properties'] );
}
return $schema;
}
/**
* Returns extended data for a specific endpoint.
*
* @param string $endpoint The endpoint identifier.
* @param array ...$passed_args An array of arguments to be passed to callbacks.
* @return object the data that will get added.
*/
protected function get_extended_data( $endpoint, ...$passed_args ) {
return $this->extend->get_endpoint_data( $endpoint, $passed_args );
}
/**
* Gets an array of schema defaults recursively.
*
* @param array $properties Schema property data.
* @return array Array of defaults, pulled from arg_options
*/
protected function get_recursive_schema_property_defaults( $properties ) {
$defaults = [];
foreach ( $properties as $property_key => $property_value ) {
if ( isset( $property_value['arg_options']['default'] ) ) {
$defaults[ $property_key ] = $property_value['arg_options']['default'];
} elseif ( isset( $property_value['properties'] ) ) {
$defaults[ $property_key ] = $this->get_recursive_schema_property_defaults( $property_value['properties'] );
}
}
return $defaults;
}
/**
* Gets a function that validates recursively.
*
* @param array $properties Schema property data.
* @return function Anonymous validation callback.
*/
protected function get_recursive_validate_callback( $properties ) {
/**
* Validate a request argument based on details registered to the route.
*
* @param mixed $values
* @param \WP_REST_Request $request
* @param string $param
* @return true|\WP_Error
*/
return function ( $values, $request, $param ) use ( $properties ) {
foreach ( $properties as $property_key => $property_value ) {
$current_value = isset( $values[ $property_key ] ) ? $values[ $property_key ] : null;
if ( isset( $property_value['arg_options']['validate_callback'] ) ) {
$callback = $property_value['arg_options']['validate_callback'];
$result = is_callable( $callback ) ? $callback( $current_value, $request, $param ) : false;
} else {
$result = rest_validate_value_from_schema( $current_value, $property_value, $param . ' > ' . $property_key );
}
if ( ! $result || is_wp_error( $result ) ) {
return $result;
}
if ( isset( $property_value['properties'] ) ) {
$validate_callback = $this->get_recursive_validate_callback( $property_value['properties'] );
return $validate_callback( $current_value, $request, $param . ' > ' . $property_key );
}
}
return true;
};
}
/**
* Gets a function that sanitizes recursively.
*
* @param array $properties Schema property data.
* @return function Anonymous validation callback.
*/
protected function get_recursive_sanitize_callback( $properties ) {
/**
* Validate a request argument based on details registered to the route.
*
* @param mixed $values
* @param \WP_REST_Request $request
* @param string $param
* @return true|\WP_Error
*/
return function ( $values, $request, $param ) use ( $properties ) {
$sanitized_values = [];
foreach ( $properties as $property_key => $property_value ) {
$current_value = isset( $values[ $property_key ] ) ? $values[ $property_key ] : null;
if ( isset( $property_value['arg_options']['sanitize_callback'] ) ) {
$callback = $property_value['arg_options']['sanitize_callback'];
$current_value = is_callable( $callback ) ? $callback( $current_value, $request, $param ) : $current_value;
} else {
$current_value = rest_sanitize_value_from_schema( $current_value, $property_value, $param . ' > ' . $property_key );
}
// If sanitization failed, return the WP_Error object straight away.
if ( is_wp_error( $current_value ) ) {
return $current_value;
}
if ( isset( $property_value['properties'] ) ) {
$sanitize_callback = $this->get_recursive_sanitize_callback( $property_value['properties'] );
$sanitized_values[ $property_key ] = $sanitize_callback( $current_value, $request, $param . ' > ' . $property_key );
} else {
$sanitized_values[ $property_key ] = $current_value;
}
}
return $sanitized_values;
};
}
/**
* Returns extended schema for a specific endpoint.
*
* @param string $endpoint The endpoint identifer.
* @param array ...$passed_args An array of arguments to be passed to callbacks.
* @return array the data that will get added.
*/
protected function get_extended_schema( $endpoint, ...$passed_args ) {
$extended_schema = $this->extend->get_endpoint_schema( $endpoint, $passed_args );
$defaults = $this->get_recursive_schema_property_defaults( $extended_schema );
return [
'type' => 'object',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'default' => $defaults,
'validate_callback' => $this->get_recursive_validate_callback( $extended_schema ),
'sanitize_callback' => $this->get_recursive_sanitize_callback( $extended_schema ),
],
'properties' => $extended_schema,
];
}
/**
* Apply a schema get_item_response callback to an array of items and return the result.
*
* @param AbstractSchema $schema Schema class instance.
* @param array $items Array of items.
* @return array Array of values from the callback function.
*/
protected function get_item_responses_from_schema( AbstractSchema $schema, $items ) {
$items = array_filter( $items );
if ( empty( $items ) ) {
return [];
}
return array_values( array_map( [ $schema, 'get_item_response' ], $items ) );
}
/**
* Retrieves an array of endpoint arguments from the item schema for the controller.
*
* @uses rest_get_endpoint_args_for_schema()
* @param string $method Optional. HTTP method of the request.
* @return array Endpoint arguments.
*/
public function get_endpoint_args_for_item_schema( $method = \WP_REST_Server::CREATABLE ) {
$schema = $this->get_item_schema();
$endpoint_args = rest_get_endpoint_args_for_schema( $schema, $method );
$endpoint_args = $this->remove_arg_options( $endpoint_args );
return $endpoint_args;
}
/**
* Force all schema properties to be readonly.
*
* @param array $properties Schema.
* @return array Updated schema.
*/
protected function force_schema_readonly( $properties ) {
return array_map(
function( $property ) {
$property['readonly'] = true;
if ( isset( $property['items']['properties'] ) ) {
$property['items']['properties'] = $this->force_schema_readonly( $property['items']['properties'] );
}
return $property;
},
(array) $properties
);
}
/**
* Returns consistent currency schema used across endpoints for prices.
*
* @return array
*/
protected function get_store_currency_properties() {
return [
'currency_code' => [
'description' => __( 'Currency code (in ISO format) for returned prices.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'currency_symbol' => [
'description' => __( 'Currency symbol for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'currency_minor_unit' => [
'description' => __( 'Currency minor unit (number of digits after the decimal separator) for returned prices.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'currency_decimal_separator' => array(
'description' => __( 'Decimal separator for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'currency_thousand_separator' => array(
'description' => __( 'Thousand separator for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'currency_prefix' => array(
'description' => __( 'Price prefix for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'currency_suffix' => array(
'description' => __( 'Price prefix for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
];
}
/**
* Adds currency data to an array of monetary values.
*
* @param array $values Monetary amounts.
* @return array Monetary amounts with currency data appended.
*/
protected function prepare_currency_response( $values ) {
return $this->extend->get_formatter( 'currency' )->format( $values );
}
/**
* Convert monetary values from WooCommerce to string based integers, using
* the smallest unit of a currency.
*
* @param string|float $amount Monetary amount with decimals.
* @param int $decimals Number of decimals the amount is formatted with.
* @param int $rounding_mode Defaults to the PHP_ROUND_HALF_UP constant.
* @return string The new amount.
*/
protected function prepare_money_response( $amount, $decimals = 2, $rounding_mode = PHP_ROUND_HALF_UP ) {
return $this->extend->get_formatter( 'money' )->format(
$amount,
[
'decimals' => $decimals,
'rounding_mode' => $rounding_mode,
]
);
}
/**
* Prepares HTML based content, such as post titles and content, for the API response.
*
* @param string|array $response Data to format.
* @return string|array Formatted data.
*/
protected function prepare_html_response( $response ) {
return $this->extend->get_formatter( 'html' )->format( $response );
}
}
StoreApi/Schemas/V1/BatchSchema.php 0000644 00000000653 15154173074 0013031 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* BatchSchema class.
*/
class BatchSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'batch';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'batch';
/**
* Batch schema properties.
*
* @return array
*/
public function get_properties() {
return [];
}
}
StoreApi/Schemas/V1/BillingAddressSchema.php 0000644 00000007333 15154173074 0014700 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils;
/**
* BillingAddressSchema class.
*
* Provides a generic billing address schema for composition in other schemas.
*/
class BillingAddressSchema extends AbstractAddressSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'billing_address';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'billing-address';
/**
* Term properties.
*
* @return array
*/
public function get_properties() {
$properties = parent::get_properties();
return array_merge(
$properties,
[
'email' => [
'description' => __( 'Email', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
]
);
}
/**
* Sanitize and format the given address object.
*
* @param array $address Value being sanitized.
* @param \WP_REST_Request $request The Request.
* @param string $param The param being sanitized.
* @return array
*/
public function sanitize_callback( $address, $request, $param ) {
$address = parent::sanitize_callback( $address, $request, $param );
$address['email'] = sanitize_text_field( wp_unslash( $address['email'] ) );
return $address;
}
/**
* Validate the given address object.
*
* @param array $address Value being sanitized.
* @param \WP_REST_Request $request The Request.
* @param string $param The param being sanitized.
* @return true|\WP_Error
*/
public function validate_callback( $address, $request, $param ) {
$errors = parent::validate_callback( $address, $request, $param );
$address = $this->sanitize_callback( $address, $request, $param );
$errors = is_wp_error( $errors ) ? $errors : new \WP_Error();
if ( ! empty( $address['email'] ) && ! is_email( $address['email'] ) ) {
$errors->add(
'invalid_email',
__( 'The provided email address is not valid', 'woocommerce' )
);
}
return $errors->has_errors( $errors ) ? $errors : true;
}
/**
* Convert a term object into an object suitable for the response.
*
* @param \WC_Order|\WC_Customer $address An object with billing address.
*
* @throws RouteException When the invalid object types are provided.
* @return array
*/
public function get_item_response( $address ) {
$validation_util = new ValidationUtils();
if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) {
$billing_country = $address->get_billing_country();
$billing_state = $address->get_billing_state();
if ( ! $validation_util->validate_state( $billing_state, $billing_country ) ) {
$billing_state = '';
}
return $this->prepare_html_response(
[
'first_name' => $address->get_billing_first_name(),
'last_name' => $address->get_billing_last_name(),
'company' => $address->get_billing_company(),
'address_1' => $address->get_billing_address_1(),
'address_2' => $address->get_billing_address_2(),
'city' => $address->get_billing_city(),
'state' => $billing_state,
'postcode' => $address->get_billing_postcode(),
'country' => $billing_country,
'email' => $address->get_billing_email(),
'phone' => $address->get_billing_phone(),
]
);
}
throw new RouteException(
'invalid_object_type',
sprintf(
/* translators: Placeholders are class and method names */
__( '%1$s requires an instance of %2$s or %3$s for the address', 'woocommerce' ),
'BillingAddressSchema::get_item_response',
'WC_Customer',
'WC_Order'
),
500
);
}
}
StoreApi/Schemas/V1/CartCouponSchema.php 0000644 00000006304 15154173074 0014064 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
/**
* CartCouponSchema class.
*/
class CartCouponSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart_coupon';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-coupon';
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'code' => [
'description' => __( 'The coupon\'s unique code.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'sanitize_callback' => 'wc_format_coupon_code',
'validate_callback' => [ $this, 'coupon_exists' ],
],
],
'discount_type' => [
'description' => __( 'The discount type for the coupon (e.g. percentage or fixed amount)', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'validate_callback' => [ $this, 'coupon_exists' ],
],
],
'totals' => [
'description' => __( 'Total amounts provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'total_discount' => [
'description' => __( 'Total discount applied by this coupon.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_discount_tax' => [
'description' => __( 'Total tax removed due to discount applied by this coupon.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
]
),
],
];
}
/**
* Check given coupon exists.
*
* @param string $coupon_code Coupon code.
* @return bool
*/
public function coupon_exists( $coupon_code ) {
$coupon = new \WC_Coupon( $coupon_code );
return (bool) $coupon->get_id() || $coupon->get_virtual();
}
/**
* Generate a response from passed coupon code.
*
* @param string $coupon_code Coupon code from the cart.
* @return array
*/
public function get_item_response( $coupon_code ) {
$controller = new CartController();
$cart = $controller->get_cart_instance();
$total_discounts = $cart->get_coupon_discount_totals();
$total_discount_taxes = $cart->get_coupon_discount_tax_totals();
$coupon = new \WC_Coupon( $coupon_code );
return [
'code' => $coupon_code,
'discount_type' => $coupon->get_discount_type(),
'totals' => (object) $this->prepare_currency_response(
[
'total_discount' => $this->prepare_money_response( isset( $total_discounts[ $coupon_code ] ) ? $total_discounts[ $coupon_code ] : 0, wc_get_price_decimals() ),
'total_discount_tax' => $this->prepare_money_response( isset( $total_discount_taxes[ $coupon_code ] ) ? $total_discount_taxes[ $coupon_code ] : 0, wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ),
]
),
];
}
}
StoreApi/Schemas/V1/CartExtensionsSchema.php 0000644 00000004025 15154173074 0014756 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
/**
* Class CartExtensionsSchema
*/
class CartExtensionsSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart-extensions';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-extensions';
/**
* Cart schema instance.
*
* @var CartSchema
*/
public $cart_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->cart_schema = $this->controller->get( CartSchema::IDENTIFIER );
}
/**
* Cart extensions schema properties.
*
* @return array
*/
public function get_properties() {
return [];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @param \WP_REST_Request $request Request containing data for the extension callback.
* @throws RouteException When callback is not callable or parameters are incorrect.
*
* @return array
*/
public function get_item_response( $request = null ) {
try {
$callback = $this->extend->get_update_callback( $request['namespace'] );
} catch ( \Exception $e ) {
throw new RouteException(
'woocommerce_rest_cart_extensions_error',
$e->getMessage(),
400
);
}
$controller = new CartController();
if ( is_callable( $callback ) ) {
$callback( $request['data'] );
// We recalculate the cart if we had something to run.
$controller->calculate_totals();
}
$cart = $controller->get_cart_instance();
return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) );
}
}
StoreApi/Schemas/V1/CartFeeSchema.php 0000644 00000004171 15154173074 0013320 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* CartFeeSchema class.
*/
class CartFeeSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart_fee';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-fee';
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => [
'description' => __( 'Unique identifier for the fee within the cart.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Fee name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'totals' => [
'description' => __( 'Fee total amounts provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'total' => [
'description' => __( 'Total amount for this fee.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_tax' => [
'description' => __( 'Total tax amount for this fee.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
]
),
],
];
}
/**
* Convert a WooCommerce cart fee to an object suitable for the response.
*
* @param array $fee Cart fee data.
* @return array
*/
public function get_item_response( $fee ) {
return [
'key' => $fee->id,
'name' => $this->prepare_html_response( $fee->name ),
'totals' => (object) $this->prepare_currency_response(
[
'total' => $this->prepare_money_response( $fee->total, wc_get_price_decimals() ),
'total_tax' => $this->prepare_money_response( $fee->tax, wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ),
]
),
];
}
}
StoreApi/Schemas/V1/CartItemSchema.php 0000644 00000012041 15154173074 0013512 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
/**
* CartItemSchema class.
*/
class CartItemSchema extends ItemSchema {
use ProductItemTrait;
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart_item';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-item';
/**
* Convert a WooCommerce cart item to an object suitable for the response.
*
* @param array $cart_item Cart item array.
* @return array
*/
public function get_item_response( $cart_item ) {
$product = $cart_item['data'];
/**
* Filter the product permalink.
*
* This is a hook taken from the legacy cart/mini-cart templates that allows the permalink to be changed for a
* product. This is specific to the cart endpoint.
*
* @since 9.9.0
*
* @param string $product_permalink Product permalink.
* @param array $cart_item Cart item array.
* @param string $cart_item_key Cart item key.
*/
$product_permalink = apply_filters( 'woocommerce_cart_item_permalink', $product->get_permalink(), $cart_item, $cart_item['key'] );
return [
'key' => $cart_item['key'],
'id' => $product->get_id(),
'quantity' => wc_stock_amount( $cart_item['quantity'] ),
'quantity_limits' => (object) ( new QuantityLimits() )->get_cart_item_quantity_limits( $cart_item ),
'name' => $this->prepare_html_response( $product->get_title() ),
'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ),
'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ),
'sku' => $this->prepare_html_response( $product->get_sku() ),
'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
'backorders_allowed' => (bool) $product->backorders_allowed(),
'show_backorder_badge' => (bool) $product->backorders_require_notification() && $product->is_on_backorder( $cart_item['quantity'] ),
'sold_individually' => $product->is_sold_individually(),
'permalink' => $product_permalink,
'images' => $this->get_images( $product ),
'variation' => $this->format_variation_data( $cart_item['variation'], $product ),
'item_data' => $this->get_item_data( $cart_item ),
'prices' => (object) $this->prepare_product_price_response( $product, get_option( 'woocommerce_tax_display_cart' ) ),
'totals' => (object) $this->prepare_currency_response(
[
'line_subtotal' => $this->prepare_money_response( $cart_item['line_subtotal'], wc_get_price_decimals() ),
'line_subtotal_tax' => $this->prepare_money_response( $cart_item['line_subtotal_tax'], wc_get_price_decimals() ),
'line_total' => $this->prepare_money_response( $cart_item['line_total'], wc_get_price_decimals() ),
'line_total_tax' => $this->prepare_money_response( $cart_item['line_tax'], wc_get_price_decimals() ),
]
),
'catalog_visibility' => $product->get_catalog_visibility(),
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER, $cart_item ),
];
}
/**
* Format cart item data removing any HTML tag.
*
* @param array $cart_item Cart item array.
* @return array
*/
protected function get_item_data( $cart_item ) {
/**
* Filters cart item data.
*
* Filters the variation option name for custom option slugs.
*
* @since 4.3.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $item_data Cart item data. Empty by default.
* @param array $cart_item Cart item array.
* @return array
*/
$item_data = apply_filters( 'woocommerce_get_item_data', array(), $cart_item );
$clean_item_data = [];
foreach ( $item_data as $data ) {
// We will check each piece of data in the item data element to ensure it is scalar. Extensions could add arrays
// to this, which would cause a fatal in wp_strip_all_tags. If it is not scalar, we will return an empty array,
// which will be filtered out in get_item_data (after this function has run).
foreach ( $data as $data_value ) {
if ( ! is_scalar( $data_value ) ) {
continue 2;
}
}
$clean_item_data[] = $this->format_item_data_element( $data );
}
return $clean_item_data;
}
/**
* Remove HTML tags from cart item data and set the `hidden` property to `__experimental_woocommerce_blocks_hidden`.
*
* @param array $item_data_element Individual element of a cart item data.
* @return array
*/
protected function format_item_data_element( $item_data_element ) {
if ( array_key_exists( '__experimental_woocommerce_blocks_hidden', $item_data_element ) ) {
$item_data_element['hidden'] = $item_data_element['__experimental_woocommerce_blocks_hidden'];
}
return array_map( 'wp_strip_all_tags', $item_data_element );
}
}
StoreApi/Schemas/V1/CartSchema.php 0000644 00000040315 15154173074 0012700 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use WC_Tax;
/**
* CartSchema class.
*/
class CartSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart';
/**
* Item schema instance.
*
* @var CartItemSchema
*/
public $item_schema;
/**
* Coupon schema instance.
*
* @var CartCouponSchema
*/
public $coupon_schema;
/**
* Product item schema instance representing cross-sell items.
*
* @var ProductSchema
*/
public $cross_sells_item_schema;
/**
* Fee schema instance.
*
* @var CartFeeSchema
*/
public $fee_schema;
/**
* Shipping rates schema instance.
*
* @var CartShippingRateSchema
*/
public $shipping_rate_schema;
/**
* Shipping address schema instance.
*
* @var ShippingAddressSchema
*/
public $shipping_address_schema;
/**
* Billing address schema instance.
*
* @var BillingAddressSchema
*/
public $billing_address_schema;
/**
* Error schema instance.
*
* @var ErrorSchema
*/
public $error_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->item_schema = $this->controller->get( CartItemSchema::IDENTIFIER );
$this->cross_sells_item_schema = $this->controller->get( ProductSchema::IDENTIFIER );
$this->coupon_schema = $this->controller->get( CartCouponSchema::IDENTIFIER );
$this->fee_schema = $this->controller->get( CartFeeSchema::IDENTIFIER );
$this->shipping_rate_schema = $this->controller->get( CartShippingRateSchema::IDENTIFIER );
$this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER );
$this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER );
$this->error_schema = $this->controller->get( ErrorSchema::IDENTIFIER );
}
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'coupons' => [
'description' => __( 'List of applied cart coupons.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->coupon_schema->get_properties() ),
],
],
'shipping_rates' => [
'description' => __( 'List of available shipping rates for the cart.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->shipping_rate_schema->get_properties() ),
],
],
'shipping_address' => [
'description' => __( 'Current set shipping address for the customer.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $this->force_schema_readonly( $this->shipping_address_schema->get_properties() ),
],
'billing_address' => [
'description' => __( 'Current set billing address for the customer.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $this->force_schema_readonly( $this->billing_address_schema->get_properties() ),
],
'items' => [
'description' => __( 'List of cart items.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->item_schema->get_properties() ),
],
],
'items_count' => [
'description' => __( 'Number of items in the cart.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'items_weight' => [
'description' => __( 'Total weight (in grams) of all products in the cart.', 'woocommerce' ),
'type' => 'number',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'cross_sells' => [
'description' => __( 'List of cross-sells items related to cart items.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->cross_sells_item_schema->get_properties() ),
],
],
'needs_payment' => [
'description' => __( 'True if the cart needs payment. False for carts with only free products and no shipping costs.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'needs_shipping' => [
'description' => __( 'True if the cart needs shipping. False for carts with only digital goods or stores with no shipping methods set-up.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'has_calculated_shipping' => [
'description' => __( 'True if the cart meets the criteria for showing shipping costs, and rates have been calculated and included in the totals.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'fees' => [
'description' => __( 'List of cart fees.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->fee_schema->get_properties() ),
],
],
'totals' => [
'description' => __( 'Cart total amounts provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'total_items' => [
'description' => __( 'Total price of items in the cart.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_items_tax' => [
'description' => __( 'Total tax on items in the cart.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_fees' => [
'description' => __( 'Total price of any applied fees.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_fees_tax' => [
'description' => __( 'Total tax on fees.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_discount' => [
'description' => __( 'Total discount from applied coupons.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_discount_tax' => [
'description' => __( 'Total tax removed due to discount from applied coupons.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_shipping' => [
'description' => __( 'Total price of shipping. If shipping has not been calculated, a null response will be sent.', 'woocommerce' ),
'type' => [ 'string', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_shipping_tax' => [
'description' => __( 'Total tax on shipping. If shipping has not been calculated, a null response will be sent.', 'woocommerce' ),
'type' => [ 'string', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_price' => [
'description' => __( 'Total price the customer will pay.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_tax' => [
'description' => __( 'Total tax applied to items and shipping.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'tax_lines' => [
'description' => __( 'Lines of taxes applied to items and shipping.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'name' => [
'description' => __( 'The name of the tax.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'price' => [
'description' => __( 'The amount of tax charged.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'rate' => [
'description' => __( 'The rate at which tax is applied.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
]
),
],
'errors' => [
'description' => __( 'List of cart item errors, for example, items in the cart which are out of stock.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->error_schema->get_properties() ),
],
],
'payment_methods' => [
'description' => __( 'List of available payment method IDs that can be used to process the order.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'payment_requirements' => [
'description' => __( 'List of required payment gateway features to process the order.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
/**
* Convert a woo cart into an object suitable for the response.
*
* @param \WC_Cart $cart Cart class instance.
* @return array
*/
public function get_item_response( $cart ) {
$controller = new CartController();
// Get cart errors first so if recalculations are performed, it's reflected in the response.
$cart_errors = $this->get_cart_errors( $cart );
// The core cart class will not include shipping in the cart totals if `show_shipping()` returns false. This can
// happen if an address is required, or through the use of hooks. This tracks if shipping has actually been
// calculated so we can avoid returning costs and rates prematurely.
$has_calculated_shipping = $cart->show_shipping();
// Get shipping packages to return in the response from the cart.
$shipping_packages = $has_calculated_shipping ? $controller->get_shipping_packages() : [];
// Get visible cross sells products.
$cross_sells = array_filter( array_map( 'wc_get_product', $cart->get_cross_sells() ), 'wc_products_array_filter_visible' );
return [
'items' => $this->get_item_responses_from_schema( $this->item_schema, $cart->get_cart() ),
'coupons' => $this->get_item_responses_from_schema( $this->coupon_schema, $cart->get_applied_coupons() ),
'fees' => $this->get_item_responses_from_schema( $this->fee_schema, $cart->get_fees() ),
'totals' => (object) $this->prepare_currency_response( $this->get_totals( $cart ) ),
'shipping_address' => (object) $this->shipping_address_schema->get_item_response( wc()->customer ),
'billing_address' => (object) $this->billing_address_schema->get_item_response( wc()->customer ),
'needs_payment' => $cart->needs_payment(),
'needs_shipping' => $cart->needs_shipping(),
'payment_requirements' => $this->extend->get_payment_requirements(),
'has_calculated_shipping' => $has_calculated_shipping,
'shipping_rates' => $this->get_item_responses_from_schema( $this->shipping_rate_schema, $shipping_packages ),
'items_count' => $cart->get_cart_contents_count(),
'items_weight' => wc_get_weight( $cart->get_cart_contents_weight(), 'g' ),
'cross_sells' => $this->get_item_responses_from_schema( $this->cross_sells_item_schema, $cross_sells ),
'errors' => $cart_errors,
'payment_methods' => array_values( wp_list_pluck( WC()->payment_gateways->get_available_payment_gateways(), 'id' ) ),
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ),
];
}
/**
* Get total data.
*
* @param \WC_Cart $cart Cart class instance.
* @return array
*/
protected function get_totals( $cart ) {
$has_calculated_shipping = $cart->show_shipping();
$decimals = wc_get_price_decimals();
return [
'total_items' => $this->prepare_money_response( $cart->get_subtotal(), $decimals ),
'total_items_tax' => $this->prepare_money_response( $cart->get_subtotal_tax(), $decimals ),
'total_fees' => $this->prepare_money_response( $cart->get_fee_total(), $decimals ),
'total_fees_tax' => $this->prepare_money_response( $cart->get_fee_tax(), $decimals ),
'total_discount' => $this->prepare_money_response( $cart->get_discount_total(), $decimals ),
'total_discount_tax' => $this->prepare_money_response( $cart->get_discount_tax(), $decimals ),
'total_shipping' => $has_calculated_shipping ? $this->prepare_money_response( $cart->get_shipping_total(), $decimals ) : null,
'total_shipping_tax' => $has_calculated_shipping ? $this->prepare_money_response( $cart->get_shipping_tax(), $decimals ) : null,
// Explicitly request context='edit'; default ('view') will render total as markup.
'total_price' => $this->prepare_money_response( $cart->get_total( 'edit' ), $decimals ),
'total_tax' => $this->prepare_money_response( $cart->get_total_tax(), $decimals ),
'tax_lines' => $this->get_tax_lines( $cart ),
];
}
/**
* Get tax lines from the cart and format to match schema.
*
* @param \WC_Cart $cart Cart class instance.
* @return array
*/
protected function get_tax_lines( $cart ) {
$tax_lines = [];
if ( 'itemized' !== get_option( 'woocommerce_tax_total_display' ) ) {
return $tax_lines;
}
$cart_tax_totals = $cart->get_tax_totals();
$decimals = wc_get_price_decimals();
foreach ( $cart_tax_totals as $cart_tax_total ) {
$tax_lines[] = array(
'name' => $cart_tax_total->label,
'price' => $this->prepare_money_response( $cart_tax_total->amount, $decimals ),
'rate' => WC_Tax::get_rate_percent( $cart_tax_total->tax_rate_id ),
);
}
return $tax_lines;
}
/**
* Get cart validation errors.
*
* @param \WC_Cart $cart Cart class instance.
* @return array
*/
protected function get_cart_errors( $cart ) {
$controller = new CartController();
$errors = $controller->get_cart_errors();
$cart_errors = [];
foreach ( (array) $errors->errors as $code => $messages ) {
foreach ( (array) $messages as $message ) {
$cart_errors[] = new \WP_Error(
$code,
$message,
$errors->get_error_data( $code )
);
}
}
return array_values( array_map( [ $this->error_schema, 'get_item_response' ], $cart_errors ) );
}
}
StoreApi/Schemas/V1/CartShippingRateSchema.php 0000644 00000026572 15154173074 0015227 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use WC_Shipping_Rate as ShippingRate;
/**
* CartShippingRateSchema class.
*/
class CartShippingRateSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'cart-shipping-rate';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-shipping-rate';
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'package_id' => [
'description' => __( 'The ID of the package the shipping rates belong to.', 'woocommerce' ),
'type' => [ 'integer', 'string' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the package.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'destination' => [
'description' => __( 'Shipping destination address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'address_1' => [
'description' => __( 'First line of the address being shipped to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'address_2' => [
'description' => __( 'Second line of the address being shipped to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'city' => [
'description' => __( 'City of the address being shipped to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'state' => [
'description' => __( 'ISO code, or name, for the state, province, or district of the address being shipped to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'postcode' => [
'description' => __( 'Zip or Postcode of the address being shipped to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'country' => [
'description' => __( 'ISO code for the country of the address being shipped to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
'items' => [
'description' => __( 'List of cart items the returned shipping rates apply to.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'description' => __( 'Unique identifier for the item within the cart.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the item.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'quantity' => [
'description' => __( 'Quantity of the item in the current package.', 'woocommerce' ),
'type' => 'number',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'shipping_rates' => [
'description' => __( 'List of shipping rates.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->get_rate_properties(),
],
],
];
}
/**
* Schema for a single rate.
*
* @return array
*/
protected function get_rate_properties() {
return array_merge(
[
'rate_id' => [
'description' => __( 'ID of the shipping rate.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the shipping rate, e.g. Express shipping.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'description' => [
'description' => __( 'Description of the shipping rate, e.g. Dispatched via USPS.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'delivery_time' => [
'description' => __( 'Delivery time estimate text, e.g. 3-5 business days.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'price' => [
'description' => __( 'Price of this shipping rate using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'taxes' => [
'description' => __( 'Taxes applied to this shipping rate using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'method_id' => [
'description' => __( 'ID of the shipping method that provided the rate.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'instance_id' => [
'description' => __( 'Instance ID of the shipping method that provided the rate.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'meta_data' => [
'description' => __( 'Meta data attached to the shipping rate.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'description' => __( 'Meta key.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'value' => [
'description' => __( 'Meta value.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'selected' => [
'description' => __( 'True if this is the rate currently selected by the customer for the cart.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
$this->get_store_currency_properties()
);
}
/**
* Convert a shipping rate from WooCommerce into a valid response.
*
* @param array $package Shipping package complete with rates from WooCommerce.
* @return array
*/
public function get_item_response( $package ) {
return [
'package_id' => $package['package_id'],
'name' => $package['package_name'],
'destination' => $this->prepare_package_destination_response( $package ),
'items' => $this->prepare_package_items_response( $package ),
'shipping_rates' => $this->prepare_package_shipping_rates_response( $package ),
];
}
/**
* Gets and formats the destination address of a package.
*
* @param array $package Shipping package complete with rates from WooCommerce.
* @return object
*/
protected function prepare_package_destination_response( $package ) {
// If address_1 fails check address for back compatability.
$address = isset( $package['destination']['address_1'] ) ? $package['destination']['address_1'] : $package['destination']['address'];
return (object) $this->prepare_html_response(
[
'address_1' => $address,
'address_2' => $package['destination']['address_2'],
'city' => $package['destination']['city'],
'state' => $package['destination']['state'],
'postcode' => $package['destination']['postcode'],
'country' => $package['destination']['country'],
]
);
}
/**
* Gets items from a package and creates an array of strings containing product names and quantities.
*
* @param array $package Shipping package complete with rates from WooCommerce.
* @return array
*/
protected function prepare_package_items_response( $package ) {
$items = array();
foreach ( $package['contents'] as $values ) {
$items[] = [
'key' => $values['key'],
'name' => $values['data']->get_name(),
'quantity' => $values['quantity'],
];
}
return $items;
}
/**
* Prepare an array of rates from a package for the response.
*
* @param array $package Shipping package complete with rates from WooCommerce.
* @return array
*/
protected function prepare_package_shipping_rates_response( $package ) {
$rates = $package['rates'];
$selected_rates = wc()->session->get( 'chosen_shipping_methods', array() );
$selected_rate = isset( $selected_rates[ $package['package_id'] ] ) ? $selected_rates[ $package['package_id'] ] : '';
if ( empty( $selected_rate ) && ! empty( $package['rates'] ) ) {
$selected_rate = wc_get_chosen_shipping_method_for_package( $package['package_id'], $package );
}
$response = [];
foreach ( $package['rates'] as $rate ) {
$response[] = $this->get_rate_response( $rate, $selected_rate );
}
return $response;
}
/**
* Response for a single rate.
*
* @param WC_Shipping_Rate $rate Rate object.
* @param string $selected_rate Selected rate.
* @return array
*/
protected function get_rate_response( $rate, $selected_rate = '' ) {
return $this->prepare_currency_response(
[
'rate_id' => $this->get_rate_prop( $rate, 'id' ),
'name' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'label' ) ),
'description' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'description' ) ),
'delivery_time' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'delivery_time' ) ),
'price' => $this->prepare_money_response( $this->get_rate_prop( $rate, 'cost' ), wc_get_price_decimals() ),
'taxes' => $this->prepare_money_response( array_sum( (array) $this->get_rate_prop( $rate, 'taxes' ) ), wc_get_price_decimals() ),
'instance_id' => $this->get_rate_prop( $rate, 'instance_id' ),
'method_id' => $this->get_rate_prop( $rate, 'method_id' ),
'meta_data' => $this->get_rate_meta_data( $rate ),
'selected' => $selected_rate === $this->get_rate_prop( $rate, 'id' ),
]
);
}
/**
* Gets a prop of the rate object, if callable.
*
* @param WC_Shipping_Rate $rate Rate object.
* @param string $prop Prop name.
* @return string
*/
protected function get_rate_prop( $rate, $prop ) {
$getter = 'get_' . $prop;
return \is_callable( array( $rate, $getter ) ) ? $rate->$getter() : '';
}
/**
* Converts rate meta data into a suitable response object.
*
* @param WC_Shipping_Rate $rate Rate object.
* @return array
*/
protected function get_rate_meta_data( $rate ) {
$meta_data = $rate->get_meta_data();
return array_reduce(
array_keys( $meta_data ),
function( $return, $key ) use ( $meta_data ) {
$return[] = [
'key' => $key,
'value' => $meta_data[ $key ],
];
return $return;
},
[]
);
}
}
StoreApi/Schemas/V1/CheckoutOrderSchema.php 0000644 00000001360 15154173074 0014545 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* CheckoutOrderSchema class.
*/
class CheckoutOrderSchema extends CheckoutSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'checkout-order';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'checkout-order';
/**
* Checkout schema properties.
*
* @return array
*/
public function get_properties() {
$parent_properties = parent::get_properties();
unset( $parent_properties['create_account'] );
return $parent_properties;
}
}
StoreApi/Schemas/V1/CheckoutSchema.php 0000644 00000017301 15154173074 0013553 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* CheckoutSchema class.
*/
class CheckoutSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'checkout';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'checkout';
/**
* Billing address schema instance.
*
* @var BillingAddressSchema
*/
protected $billing_address_schema;
/**
* Shipping address schema instance.
*
* @var ShippingAddressSchema
*/
protected $shipping_address_schema;
/**
* Image Attachment schema instance.
*
* @var ImageAttachmentSchema
*/
protected $image_attachment_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER );
$this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER );
$this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
}
/**
* Checkout schema properties.
*
* @return array
*/
public function get_properties() {
return [
'order_id' => [
'description' => __( 'The order ID to process during checkout.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'status' => [
'description' => __( 'Order status. Payment providers will update this value after payment.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'order_key' => [
'description' => __( 'Order key used to check validity or protect access to certain order data.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'order_number' => [
'description' => __( 'Order number used for display.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'customer_note' => [
'description' => __( 'Note added to the order by the customer during checkout.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'customer_id' => [
'description' => __( 'Customer ID if registered. Will return 0 for guests.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'billing_address' => [
'description' => __( 'Billing address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->billing_address_schema->get_properties(),
'arg_options' => [
'sanitize_callback' => [ $this->billing_address_schema, 'sanitize_callback' ],
'validate_callback' => [ $this->billing_address_schema, 'validate_callback' ],
],
'required' => true,
],
'shipping_address' => [
'description' => __( 'Shipping address.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->shipping_address_schema->get_properties(),
'arg_options' => [
'sanitize_callback' => [ $this->shipping_address_schema, 'sanitize_callback' ],
'validate_callback' => [ $this->shipping_address_schema, 'validate_callback' ],
],
],
'payment_method' => [
'description' => __( 'The ID of the payment method being used to process the payment.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
// Validation may be based on cart contents which is not available here; this returns all enabled
// gateways. Further validation occurs during the request.
'enum' => array_values( WC()->payment_gateways->get_payment_gateway_ids() ),
],
'create_account' => [
'description' => __( 'Whether to create a new user account as part of order processing.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
],
'payment_result' => [
'description' => __( 'Result of payment processing, or false if not yet processed.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'payment_status' => [
'description' => __( 'Status of the payment returned by the gateway. One of success, pending, failure, error.', 'woocommerce' ),
'readonly' => true,
'type' => 'string',
],
'payment_details' => [
'description' => __( 'An array of data being returned from the payment gateway.', 'woocommerce' ),
'readonly' => true,
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => 'string',
],
],
],
],
'redirect_url' => [
'description' => __( 'A URL to redirect the customer after checkout. This could be, for example, a link to the payment processors website.', 'woocommerce' ),
'readonly' => true,
'type' => 'string',
],
],
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
/**
* Return the response for checkout.
*
* @param object $item Results from checkout action.
* @return array
*/
public function get_item_response( $item ) {
return $this->get_checkout_response( $item->order, $item->payment_result );
}
/**
* Get the checkout response based on the current order and any payments.
*
* @param \WC_Order $order Order object.
* @param PaymentResult $payment_result Payment result object.
* @return array
*/
protected function get_checkout_response( \WC_Order $order, PaymentResult $payment_result = null ) {
return [
'order_id' => $order->get_id(),
'status' => $order->get_status(),
'order_key' => $order->get_order_key(),
'order_number' => $order->get_order_number(),
'customer_note' => $order->get_customer_note(),
'customer_id' => $order->get_customer_id(),
'billing_address' => (object) $this->billing_address_schema->get_item_response( $order ),
'shipping_address' => (object) $this->shipping_address_schema->get_item_response( $order ),
'payment_method' => $order->get_payment_method(),
'payment_result' => [
'payment_status' => $payment_result->status,
'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ),
'redirect_url' => $payment_result->redirect_url,
],
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ),
];
}
/**
* This prepares the payment details for the response so it's following the
* schema where it's an array of objects.
*
* @param array $payment_details An array of payment details from the processed payment.
*
* @return array An array of objects where each object has the key and value
* as distinct properties.
*/
protected function prepare_payment_details_for_response( array $payment_details ) {
return array_map(
function( $key, $value ) {
return (object) [
'key' => $key,
'value' => $value,
];
},
array_keys( $payment_details ),
$payment_details
);
}
}
StoreApi/Schemas/V1/ErrorSchema.php 0000644 00000002177 15154173074 0013104 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* ErrorSchema class.
*/
class ErrorSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'error';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'error';
/**
* Product schema properties.
*
* @return array
*/
public function get_properties() {
return [
'code' => [
'description' => __( 'Error code', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'message' => [
'description' => __( 'Error message', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
];
}
/**
* Convert a WP_Error into an object suitable for the response.
*
* @param \WP_Error $error Error object.
* @return array
*/
public function get_item_response( $error ) {
return [
'code' => $this->prepare_html_response( $error->get_error_code() ),
'message' => $this->prepare_html_response( $error->get_error_message() ),
];
}
}
StoreApi/Schemas/V1/ImageAttachmentSchema.php 0000644 00000005070 15154173074 0015041 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* ImageAttachmentSchema class.
*/
class ImageAttachmentSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'image';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'image';
/**
* Product schema properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => [
'description' => __( 'Image ID.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
],
'src' => [
'description' => __( 'Full size image URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit' ],
],
'thumbnail' => [
'description' => __( 'Thumbnail URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit' ],
],
'srcset' => [
'description' => __( 'Thumbnail srcset for responsive images.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'sizes' => [
'description' => __( 'Thumbnail sizes for responsive images.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'name' => [
'description' => __( 'Image name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'alt' => [
'description' => __( 'Image alternative text.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
];
}
/**
* Convert a WooCommerce product into an object suitable for the response.
*
* @param int $attachment_id Image attachment ID.
* @return object|null
*/
public function get_item_response( $attachment_id ) {
if ( ! $attachment_id ) {
return null;
}
$attachment = wp_get_attachment_image_src( $attachment_id, 'full' );
if ( ! is_array( $attachment ) ) {
return null;
}
$thumbnail = wp_get_attachment_image_src( $attachment_id, 'woocommerce_thumbnail' );
return (object) [
'id' => (int) $attachment_id,
'src' => current( $attachment ),
'thumbnail' => current( $thumbnail ),
'srcset' => (string) wp_get_attachment_image_srcset( $attachment_id, 'full' ),
'sizes' => (string) wp_get_attachment_image_sizes( $attachment_id, 'full' ),
'name' => get_the_title( $attachment_id ),
'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
];
}
}
StoreApi/Schemas/V1/ItemSchema.php 0000644 00000025476 15154173074 0012720 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* ItemSchema class.
*/
abstract class ItemSchema extends ProductSchema {
/**
* Item schema properties.
*
* @return array
*/
public function get_properties() {
return [
'key' => [
'description' => __( 'Unique identifier for the item.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'id' => [
'description' => __( 'The item product or variation ID.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'quantity' => [
'description' => __( 'Quantity of this item.', 'woocommerce' ),
'type' => 'number',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'quantity_limits' => [
'description' => __( 'How the quantity of this item should be controlled, for example, any limits in place.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'minimum' => [
'description' => __( 'The minimum quantity allowed for this line item.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'maximum' => [
'description' => __( 'The maximum quantity allowed for this line item.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'multiple_of' => [
'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'default' => 1,
],
'editable' => [
'description' => __( 'If the quantity is editable or fixed.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'default' => true,
],
],
],
'name' => [
'description' => __( 'Product name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'short_description' => [
'description' => __( 'Product short description in HTML format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'description' => [
'description' => __( 'Product full description in HTML format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sku' => [
'description' => __( 'Stock keeping unit, if applicable.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'low_stock_remaining' => [
'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ),
'type' => [ 'integer', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'backorders_allowed' => [
'description' => __( 'True if backorders are allowed past stock availability.', 'woocommerce' ),
'type' => [ 'boolean' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'show_backorder_badge' => [
'description' => __( 'True if the product is on backorder.', 'woocommerce' ),
'type' => [ 'boolean' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sold_individually' => [
'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'permalink' => [
'description' => __( 'Product URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'images' => [
'description' => __( 'List of images.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->image_attachment_schema->get_properties(),
],
],
'variation' => [
'description' => __( 'Chosen attributes (for variations).', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'attribute' => [
'description' => __( 'Variation attribute name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'value' => [
'description' => __( 'Variation attribute value.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'item_data' => [
'description' => __( 'Metadata related to the item', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'name' => [
'description' => __( 'Name of the metadata.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'value' => [
'description' => __( 'Value of the metadata.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'display' => [
'description' => __( 'Optionally, how the metadata value should be displayed to the user.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'prices' => [
'description' => __( 'Price data for the product in the current line item, including or excluding taxes based on the "display prices during cart and checkout" setting. Provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'price' => [
'description' => __( 'Current product price.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'regular_price' => [
'description' => __( 'Regular product price.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sale_price' => [
'description' => __( 'Sale product price, if applicable.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'price_range' => [
'description' => __( 'Price range, if applicable.', 'woocommerce' ),
'type' => [ 'object', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'min_amount' => [
'description' => __( 'Price amount.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'max_amount' => [
'description' => __( 'Price amount.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
'raw_prices' => [
'description' => __( 'Raw unrounded product prices used in calculations. Provided using a higher unit of precision than the currency.', 'woocommerce' ),
'type' => [ 'object', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'precision' => [
'description' => __( 'Decimal precision of the returned prices.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'price' => [
'description' => __( 'Current product price.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'regular_price' => [
'description' => __( 'Regular product price.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sale_price' => [
'description' => __( 'Sale product price, if applicable.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
]
),
],
'totals' => [
'description' => __( 'Item total amounts provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'line_subtotal' => [
'description' => __( 'Line subtotal (the price of the product before coupon discounts have been applied).', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'line_subtotal_tax' => [
'description' => __( 'Line subtotal tax.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'line_total' => [
'description' => __( 'Line total (the price of the product after coupon discounts have been applied).', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'line_total_tax' => [
'description' => __( 'Line total tax.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
]
),
],
'catalog_visibility' => [
'description' => __( 'Whether the product is visible in the catalog', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
}
StoreApi/Schemas/V1/OrderCouponSchema.php 0000644 00000004644 15154173074 0014253 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* OrderCouponSchema class.
*/
class OrderCouponSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'order_coupon';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'order-coupon';
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'code' => [
'description' => __( 'The coupons unique code.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'discount_type' => [
'description' => __( 'The discount type for the coupon (e.g. percentage or fixed amount)', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'totals' => [
'description' => __( 'Total amounts provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'total_discount' => [
'description' => __( 'Total discount applied by this coupon.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_discount_tax' => [
'description' => __( 'Total tax removed due to discount applied by this coupon.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
]
),
],
];
}
/**
* Convert an order coupon to an object suitable for the response.
*
* @param \WC_Order_Item_Coupon $coupon Order coupon object.
* @return array
*/
public function get_item_response( $coupon ) {
$coupon_object = new \WC_Coupon( $coupon->get_code() );
return [
'code' => $coupon->get_code(),
'discount_type' => $coupon_object ? $coupon_object->get_discount_type() : '',
'totals' => (object) $this->prepare_currency_response(
[
'total_discount' => $this->prepare_money_response( $coupon->get_discount(), wc_get_price_decimals() ),
'total_discount_tax' => $this->prepare_money_response( $coupon->get_discount_tax(), wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ),
]
),
];
}
}
StoreApi/Schemas/V1/OrderFeeSchema.php 0000644 00000004315 15154173074 0013502 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* OrderFeeSchema class.
*/
class OrderFeeSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'order_fee';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'order-fee';
/**
* Cart schema properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => [
'description' => __( 'Unique identifier for the fee within the cart', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Fee name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'totals' => [
'description' => __( 'Fee total amounts provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'total' => [
'description' => __( 'Total amount for this fee.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_tax' => [
'description' => __( 'Total tax amount for this fee.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
]
),
],
];
}
/**
* Convert a WooCommerce cart fee to an object suitable for the response.
*
* @param \WC_Order_Item_Fee $fee Order fee object.
* @return array
*/
public function get_item_response( $fee ) {
if ( ! $fee ) {
return [];
}
return [
'key' => $fee->get_id(),
'name' => $this->prepare_html_response( $fee->get_name() ),
'totals' => (object) $this->prepare_currency_response(
[
'total' => $this->prepare_money_response( $fee->get_total(), wc_get_price_decimals() ),
'total_tax' => $this->prepare_money_response( $fee->get_total_tax(), wc_get_price_decimals(), PHP_ROUND_HALF_DOWN ),
]
),
];
}
}
StoreApi/Schemas/V1/OrderItemSchema.php 0000644 00000005445 15154173074 0013706 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait;
/**
* OrderItemSchema class.
*/
class OrderItemSchema extends ItemSchema {
use ProductItemTrait;
/**
* The schema item name.
*
* @var string
*/
protected $title = 'order_item';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'order-item';
/**
* Get order items data.
*
* @param \WC_Order_Item_Product $order_item Order item instance.
* @return array
*/
public function get_item_response( $order_item ) {
$order = $order_item->get_order();
$product = $order_item->get_product();
return [
'key' => $order->get_order_key(),
'id' => $order_item->get_id(),
'quantity' => $order_item->get_quantity(),
'quantity_limits' => array(
'minimum' => $order_item->get_quantity(),
'maximum' => $order_item->get_quantity(),
'multiple_of' => 1,
'editable' => false,
),
'name' => $order_item->get_name(),
'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ),
'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ),
'sku' => $this->prepare_html_response( $product->get_sku() ),
'low_stock_remaining' => null,
'backorders_allowed' => false,
'show_backorder_badge' => false,
'sold_individually' => $product->is_sold_individually(),
'permalink' => $product->get_permalink(),
'images' => $this->get_images( $product ),
'variation' => $this->format_variation_data( $product->get_attributes(), $product ),
'item_data' => $order_item->get_all_formatted_meta_data(),
'prices' => (object) $this->prepare_product_price_response( $product, get_option( 'woocommerce_tax_display_cart' ) ),
'totals' => (object) $this->prepare_currency_response( $this->get_totals( $order_item ) ),
'catalog_visibility' => $product->get_catalog_visibility(),
];
}
/**
* Get totals data.
*
* @param \WC_Order_Item_Product $order_item Order item instance.
* @return array
*/
public function get_totals( $order_item ) {
return [
'line_subtotal' => $this->prepare_money_response( $order_item->get_subtotal(), wc_get_price_decimals() ),
'line_subtotal_tax' => $this->prepare_money_response( $order_item->get_subtotal_tax(), wc_get_price_decimals() ),
'line_total' => $this->prepare_money_response( $order_item->get_total(), wc_get_price_decimals() ),
'line_total_tax' => $this->prepare_money_response( $order_item->get_total_tax(), wc_get_price_decimals() ),
];
}
}
StoreApi/Schemas/V1/OrderSchema.php 0000644 00000031325 15154173074 0013063 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
/**
* OrderSchema class.
*/
class OrderSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'order';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'order';
/**
* Item schema instance.
*
* @var OrderItemSchema
*/
public $item_schema;
/**
* Order controller class instance.
*
* @var OrderController
*/
protected $order_controller;
/**
* Coupon schema instance.
*
* @var OrderCouponSchema
*/
public $coupon_schema;
/**
* Product item schema instance representing cross-sell items.
*
* @var ProductSchema
*/
public $cross_sells_item_schema;
/**
* Fee schema instance.
*
* @var OrderFeeSchema
*/
public $fee_schema;
/**
* Shipping rates schema instance.
*
* @var CartShippingRateSchema
*/
public $shipping_rate_schema;
/**
* Shipping address schema instance.
*
* @var ShippingAddressSchema
*/
public $shipping_address_schema;
/**
* Billing address schema instance.
*
* @var BillingAddressSchema
*/
public $billing_address_schema;
/**
* Error schema instance.
*
* @var ErrorSchema
*/
public $error_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->item_schema = $this->controller->get( OrderItemSchema::IDENTIFIER );
$this->coupon_schema = $this->controller->get( OrderCouponSchema::IDENTIFIER );
$this->fee_schema = $this->controller->get( OrderFeeSchema::IDENTIFIER );
$this->shipping_rate_schema = $this->controller->get( CartShippingRateSchema::IDENTIFIER );
$this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER );
$this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER );
$this->error_schema = $this->controller->get( ErrorSchema::IDENTIFIER );
$this->order_controller = new OrderController();
}
/**
* Order schema properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => [
'description' => __( 'The order ID.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'items' => [
'description' => __( 'Line items data.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->item_schema->get_properties() ),
],
],
'totals' => [
'description' => __( 'Order totals.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'subtotal' => [
'description' => __( 'Subtotal of the order.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_discount' => [
'description' => __( 'Total discount from applied coupons.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_shipping' => [
'description' => __( 'Total price of shipping.', 'woocommerce' ),
'type' => [ 'string', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_fees' => [
'description' => __( 'Total price of any applied fees.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_tax' => [
'description' => __( 'Total tax applied to the order.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_refund' => [
'description' => __( 'Total refund applied to the order.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_price' => [
'description' => __( 'Total price the customer will pay.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_items' => [
'description' => __( 'Total price of items in the order.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_items_tax' => [
'description' => __( 'Total tax on items in the order.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_fees_tax' => [
'description' => __( 'Total tax on fees.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_discount_tax' => [
'description' => __( 'Total tax removed due to discount from applied coupons.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'total_shipping_tax' => [
'description' => __( 'Total tax on shipping. If shipping has not been calculated, a null response will be sent.', 'woocommerce' ),
'type' => [ 'string', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'tax_lines' => [
'description' => __( 'Lines of taxes applied to items and shipping.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'name' => [
'description' => __( 'The name of the tax.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'price' => [
'description' => __( 'The amount of tax charged.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'rate' => [
'description' => __( 'The rate at which tax is applied.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
]
),
],
'coupons' => [
'description' => __( 'List of applied cart coupons.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->coupon_schema->get_properties() ),
],
],
'shipping_address' => [
'description' => __( 'Current set shipping address for the customer.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $this->force_schema_readonly( $this->shipping_address_schema->get_properties() ),
],
'billing_address' => [
'description' => __( 'Current set billing address for the customer.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $this->force_schema_readonly( $this->billing_address_schema->get_properties() ),
],
'needs_payment' => [
'description' => __( 'True if the cart needs payment. False for carts with only free products and no shipping costs.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'needs_shipping' => [
'description' => __( 'True if the cart needs shipping. False for carts with only digital goods or stores with no shipping methods set-up.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'errors' => [
'description' => __( 'List of cart item errors, for example, items in the cart which are out of stock.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( $this->error_schema->get_properties() ),
],
],
'payment_requirements' => [
'description' => __( 'List of required payment gateway features to process the order.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'status' => [
'description' => __( 'Status of the order.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
];
}
/**
* Get an order for response.
*
* @param \WC_Order $order Order instance.
* @return array
*/
public function get_item_response( $order ) {
$order_id = $order->get_id();
$errors = [];
$failed_order_stock_error = $this->order_controller->get_failed_order_stock_error( $order_id );
if ( $failed_order_stock_error ) {
$errors[] = $failed_order_stock_error;
}
return [
'id' => $order_id,
'status' => $order->get_status(),
'items' => $this->get_item_responses_from_schema( $this->item_schema, $order->get_items() ),
'coupons' => $this->get_item_responses_from_schema( $this->coupon_schema, $order->get_items( 'coupon' ) ),
'fees' => $this->get_item_responses_from_schema( $this->fee_schema, $order->get_items( 'fee' ) ),
'totals' => (object) $this->prepare_currency_response( $this->get_totals( $order ) ),
'shipping_address' => (object) $this->shipping_address_schema->get_item_response( $order ),
'billing_address' => (object) $this->billing_address_schema->get_item_response( $order ),
'needs_payment' => $order->needs_payment(),
'needs_shipping' => $order->needs_shipping_address(),
'payment_requirements' => $this->extend->get_payment_requirements(),
'errors' => $errors,
];
}
/**
* Get total data.
*
* @param \WC_Order $order Order instance.
* @return array
*/
protected function get_totals( $order ) {
return [
'subtotal' => $this->prepare_money_response( $order->get_subtotal() ),
'total_discount' => $this->prepare_money_response( $order->get_total_discount() ),
'total_shipping' => $this->prepare_money_response( $order->get_total_shipping() ),
'total_fees' => $this->prepare_money_response( $order->get_total_fees() ),
'total_tax' => $this->prepare_money_response( $order->get_total_tax() ),
'total_refund' => $this->prepare_money_response( $order->get_total_refunded() ),
'total_price' => $this->prepare_money_response( $order->get_total() ),
'total_items' => $this->prepare_money_response(
array_sum(
array_map(
function( $item ) {
return $item->get_total();
},
array_values( $order->get_items( 'line_item' ) )
)
)
),
'total_items_tax' => $this->prepare_money_response(
array_sum(
array_map(
function( $item ) {
return $item->get_tax_total();
},
array_values( $order->get_items( 'tax' ) )
)
)
),
'total_fees_tax' => $this->prepare_money_response(
array_sum(
array_map(
function( $item ) {
return $item->get_total_tax();
},
array_values( $order->get_items( 'fee' ) )
)
)
),
'total_discount_tax' => $this->prepare_money_response( $order->get_discount_tax() ),
'total_shipping_tax' => $this->prepare_money_response( $order->get_shipping_tax() ),
'tax_lines' => array_map(
function( $item ) {
return [
'name' => $item->get_name(),
'price' => $item->get_tax_total(),
'rate' => strval( $item->get_rate_percent() ),
];
},
array_values( $order->get_items( 'tax' ) )
),
];
}
}
StoreApi/Schemas/V1/ProductAttributeSchema.php 0000644 00000004773 15154173074 0015323 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* ProductAttributeSchema class.
*/
class ProductAttributeSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'product_attribute';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'product-attribute';
/**
* Term properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Attribute name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'taxonomy' => array(
'description' => __( 'The attribute taxonomy name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'type' => array(
'description' => __( 'Attribute type.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'order' => array(
'description' => __( 'How terms in this attribute are sorted by default.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'has_archives' => array(
'description' => __( 'If this attribute has term archive pages.', 'woocommerce' ),
'type' => 'boolean',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'count' => array(
'description' => __( 'Number of terms in the attribute taxonomy.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
];
}
/**
* Convert an attribute object into an object suitable for the response.
*
* @param object $attribute Attribute object.
* @return array
*/
public function get_item_response( $attribute ) {
return [
'id' => (int) $attribute->id,
'name' => $this->prepare_html_response( $attribute->name ),
'taxonomy' => $attribute->slug,
'type' => $attribute->type,
'order' => $attribute->order_by,
'has_archives' => $attribute->has_archives,
'count' => (int) \wp_count_terms( $attribute->slug ),
];
}
}
StoreApi/Schemas/V1/ProductCategorySchema.php 0000644 00000006773 15154173074 0015137 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* ProductCategorySchema class.
*/
class ProductCategorySchema extends TermSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'product-category';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'product-category';
/**
* Image attachment schema instance.
*
* @var ImageAttachmentSchema
*/
protected $image_attachment_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
}
/**
* Term properties.
*
* @return array
*/
public function get_properties() {
$schema = parent::get_properties();
$schema['image'] = [
'description' => __( 'Category image.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
'properties' => $this->image_attachment_schema->get_properties(),
];
$schema['review_count'] = [
'description' => __( 'Number of reviews for products in this category.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
];
$schema['permalink'] = [
'description' => __( 'Category URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
];
return $schema;
}
/**
* Convert a term object into an object suitable for the response.
*
* @param \WP_Term $term Term object.
* @return array
*/
public function get_item_response( $term ) {
$response = parent::get_item_response( $term );
$count = get_term_meta( $term->term_id, 'product_count_product_cat', true );
if ( $count ) {
$response['count'] = (int) $count;
}
$response['image'] = $this->image_attachment_schema->get_item_response( get_term_meta( $term->term_id, 'thumbnail_id', true ) );
$response['review_count'] = $this->get_category_review_count( $term );
$response['permalink'] = get_term_link( $term->term_id, 'product_cat' );
return $response;
}
/**
* Get total number of reviews for products in a category.
*
* @param \WP_Term $term Term object.
* @return int
*/
protected function get_category_review_count( $term ) {
global $wpdb;
$children = get_term_children( $term->term_id, 'product_cat' );
if ( ! $children || is_wp_error( $children ) ) {
$terms_to_count_str = absint( $term->term_id );
} else {
$terms_to_count = array_unique( array_map( 'absint', array_merge( array( $term->term_id ), $children ) ) );
$terms_to_count_str = implode( ',', $terms_to_count );
}
$products_of_category_sql = "
SELECT SUM(comment_count) as review_count
FROM {$wpdb->posts} AS posts
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id
WHERE term_relationships.term_taxonomy_id IN (" . esc_sql( $terms_to_count_str ) . ')
';
$review_count = $wpdb->get_var( $products_of_category_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return (int) $review_count;
}
}
StoreApi/Schemas/V1/ProductCollectionDataSchema.php 0000644 00000010271 15154173074 0016233 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* ProductCollectionDataSchema class.
*/
class ProductCollectionDataSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'product-collection-data';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'product-collection-data';
/**
* Product collection data schema properties.
*
* @return array
*/
public function get_properties() {
return [
'price_range' => [
'description' => __( 'Min and max prices found in collection of products, provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => [ 'object', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'min_price' => [
'description' => __( 'Min price found in collection of products.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'max_price' => [
'description' => __( 'Max price found in collection of products.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
]
),
],
'attribute_counts' => [
'description' => __( 'Returns number of products within attribute terms.', 'woocommerce' ),
'type' => [ 'array', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'term' => [
'description' => __( 'Term ID', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'count' => [
'description' => __( 'Number of products.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'rating_counts' => [
'description' => __( 'Returns number of products with each average rating.', 'woocommerce' ),
'type' => [ 'array', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'rating' => [
'description' => __( 'Average rating', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'count' => [
'description' => __( 'Number of products.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'stock_status_counts' => [
'description' => __( 'Returns number of products with each stock status.', 'woocommerce' ),
'type' => [ 'array', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'status' => [
'description' => __( 'Status', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'count' => [
'description' => __( 'Number of products.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
];
}
/**
* Format data.
*
* @param array $data Collection data to format and return.
* @return array
*/
public function get_item_response( $data ) {
return [
'price_range' => ! is_null( $data['min_price'] ) && ! is_null( $data['max_price'] ) ? (object) $this->prepare_currency_response(
[
'min_price' => $this->prepare_money_response( $data['min_price'], wc_get_price_decimals() ),
'max_price' => $this->prepare_money_response( $data['max_price'], wc_get_price_decimals() ),
]
) : null,
'attribute_counts' => $data['attribute_counts'],
'rating_counts' => $data['rating_counts'],
'stock_status_counts' => $data['stock_status_counts'],
];
}
}
StoreApi/Schemas/V1/ProductReviewSchema.php 0000644 00000014451 15154173074 0014613 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\SchemaController;
/**
* ProductReviewSchema class.
*/
class ProductReviewSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'product_review';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'product-review';
/**
* Image attachment schema instance.
*
* @var ImageAttachmentSchema
*/
protected $image_attachment_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
}
/**
* Product review schema properties.
*
* @return array
*/
public function get_properties() {
$properties = [
'id' => [
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'date_created' => [
'description' => __( "The date the review was created, in the site's timezone.", 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'formatted_date_created' => [
'description' => __( "The date the review was created, in the site's timezone in human-readable format.", 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'date_created_gmt' => [
'description' => __( 'The date the review was created, as GMT.', 'woocommerce' ),
'type' => 'string',
'format' => 'date-time',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'product_id' => [
'description' => __( 'Unique identifier for the product that the review belongs to.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'product_name' => [
'description' => __( 'Name of the product that the review belongs to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'product_permalink' => [
'description' => __( 'Permalink of the product that the review belongs to.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'product_image' => [
'description' => __( 'Image of the product that the review belongs to.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $this->image_attachment_schema->get_properties(),
],
'reviewer' => [
'description' => __( 'Reviewer name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'review' => [
'description' => __( 'The content of the review.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'sanitize_callback' => 'wp_filter_post_kses',
],
'readonly' => true,
],
'rating' => [
'description' => __( 'Review rating (0 to 5).', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'verified' => [
'description' => __( 'Shows if the reviewer bought the product or not.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
];
if ( get_option( 'show_avatars' ) ) {
$avatar_properties = array();
$avatar_sizes = rest_get_avatar_sizes();
foreach ( $avatar_sizes as $size ) {
$avatar_properties[ $size ] = array(
/* translators: %d: avatar image size in pixels */
'description' => sprintf( __( 'Avatar URL with image size of %d pixels.', 'woocommerce' ), $size ),
'type' => 'string',
'format' => 'uri',
'context' => array( 'embed', 'view', 'edit' ),
);
}
$properties['reviewer_avatar_urls'] = array(
'description' => __( 'Avatar URLs for the object reviewer.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $avatar_properties,
);
}
return $properties;
}
/**
* Convert a WooCommerce product into an object suitable for the response.
*
* @param \WP_Comment $review Product review object.
* @return array
*/
public function get_item_response( $review ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$rating = get_comment_meta( $review->comment_ID, 'rating', true ) === '' ? null : (int) get_comment_meta( $review->comment_ID, 'rating', true );
$data = [
'id' => (int) $review->comment_ID,
'date_created' => wc_rest_prepare_date_response( $review->comment_date ),
'formatted_date_created' => get_comment_date( 'F j, Y', $review->comment_ID ),
'date_created_gmt' => wc_rest_prepare_date_response( $review->comment_date_gmt ),
'product_id' => (int) $review->comment_post_ID,
'product_name' => get_the_title( (int) $review->comment_post_ID ),
'product_permalink' => get_permalink( (int) $review->comment_post_ID ),
'product_image' => $this->image_attachment_schema->get_item_response( get_post_thumbnail_id( (int) $review->comment_post_ID ) ),
'reviewer' => $review->comment_author,
'review' => $review->comment_content,
'rating' => $rating,
'verified' => wc_review_is_from_verified_owner( $review->comment_ID ),
'reviewer_avatar_urls' => rest_get_avatar_urls( $review->comment_author_email ),
];
if ( 'view' === $context ) {
$data['review'] = wpautop( $data['review'] );
}
return $data;
}
}
StoreApi/Schemas/V1/ProductSchema.php 0000644 00000073027 15154173074 0013435 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
/**
* ProductSchema class.
*/
class ProductSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'product';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'product';
/**
* Image attachment schema instance.
*
* @var ImageAttachmentSchema
*/
protected $image_attachment_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
}
/**
* Product schema properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => [
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Product name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'slug' => [
'description' => __( 'Product slug.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'parent' => [
'description' => __( 'ID of the parent product, if applicable.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'type' => [
'description' => __( 'Product type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'variation' => [
'description' => __( 'Product variation attributes, if applicable.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'permalink' => [
'description' => __( 'Product URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'short_description' => [
'description' => __( 'Product short description in HTML format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'description' => [
'description' => __( 'Product full description in HTML format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'on_sale' => [
'description' => __( 'Is the product on sale?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sku' => [
'description' => __( 'Unique identifier.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'prices' => [
'description' => __( 'Price data provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
[
'price' => [
'description' => __( 'Current product price.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'regular_price' => [
'description' => __( 'Regular product price.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sale_price' => [
'description' => __( 'Sale product price, if applicable.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'price_range' => [
'description' => __( 'Price range, if applicable.', 'woocommerce' ),
'type' => [ 'object', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'min_amount' => [
'description' => __( 'Price amount.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'max_amount' => [
'description' => __( 'Price amount.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
]
),
],
'price_html' => array(
'description' => __( 'Price string formatted as HTML.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'average_rating' => [
'description' => __( 'Reviews average rating.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'review_count' => [
'description' => __( 'Amount of reviews that the product has.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'images' => [
'description' => __( 'List of images.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => $this->image_attachment_schema->get_properties(),
],
],
'categories' => [
'description' => __( 'List of categories, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Category ID', 'woocommerce' ),
'type' => 'number',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Category name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'Category slug', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'link' => [
'description' => __( 'Category link', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'tags' => [
'description' => __( 'List of tags, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Tag ID', 'woocommerce' ),
'type' => 'number',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Tag name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'Tag slug', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'link' => [
'description' => __( 'Tag link', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
'attributes' => [
'description' => __( 'List of attributes (taxonomy terms) assigned to the product. For variable products, these are mapped to variations (see the `variations` field).', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The attribute name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'taxonomy' => [
'description' => __( 'The attribute taxonomy, or null if the attribute is not taxonomy based.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'has_variations' => [
'description' => __( 'True if this attribute is used by product variations.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'terms' => [
'description' => __( 'List of assigned attribute terms.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The term ID, or 0 if the attribute is not a global attribute.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The term name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'The term slug.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'default' => [
'description' => __( 'If this is a default attribute', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
],
],
],
'variations' => [
'description' => __( 'List of variation IDs, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'attributes' => [
'description' => __( 'List of variation attributes.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'name' => [
'description' => __( 'The attribute name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'value' => [
'description' => __( 'The assigned attribute.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
],
],
],
'has_options' => [
'description' => __( 'Does the product have additional options before it can be added to the cart?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'is_purchasable' => [
'description' => __( 'Is the product purchasable?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'is_in_stock' => [
'description' => __( 'Is the product in stock?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'is_on_backorder' => [
'description' => __( 'Is the product stock backordered? This will also return false if backorder notifications are turned off.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'low_stock_remaining' => [
'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ),
'type' => [ 'integer', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sold_individually' => [
'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'add_to_cart' => [
'description' => __( 'Add to cart button parameters.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'text' => [
'description' => __( 'Button text.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'description' => [
'description' => __( 'Button description.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'url' => [
'description' => __( 'Add to cart URL.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'minimum' => [
'description' => __( 'The minimum quantity that can be added to the cart.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'maximum' => [
'description' => __( 'The maximum quantity that can be added to the cart.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'multiple_of' => [
'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'default' => 1,
],
],
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
/**
* Convert a WooCommerce product into an object suitable for the response.
*
* @param \WC_Product $product Product instance.
* @return array
*/
public function get_item_response( $product ) {
return [
'id' => $product->get_id(),
'name' => $this->prepare_html_response( $product->get_title() ),
'slug' => $product->get_slug(),
'parent' => $product->get_parent_id(),
'type' => $product->get_type(),
'variation' => $this->prepare_html_response( $product->is_type( 'variation' ) ? wc_get_formatted_variation( $product, true, true, false ) : '' ),
'permalink' => $product->get_permalink(),
'sku' => $this->prepare_html_response( $product->get_sku() ),
'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ),
'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ),
'on_sale' => $product->is_on_sale(),
'prices' => (object) $this->prepare_product_price_response( $product ),
'price_html' => $this->prepare_html_response( $product->get_price_html() ),
'average_rating' => (string) $product->get_average_rating(),
'review_count' => $product->get_review_count(),
'images' => $this->get_images( $product ),
'categories' => $this->get_term_list( $product, 'product_cat' ),
'tags' => $this->get_term_list( $product, 'product_tag' ),
'attributes' => $this->get_attributes( $product ),
'variations' => $this->get_variations( $product ),
'has_options' => $product->has_options(),
'is_purchasable' => $product->is_purchasable(),
'is_in_stock' => $product->is_in_stock(),
'is_on_backorder' => 'onbackorder' === $product->get_stock_status(),
'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
'sold_individually' => $product->is_sold_individually(),
'add_to_cart' => (object) array_merge(
[
'text' => $this->prepare_html_response( $product->add_to_cart_text() ),
'description' => $this->prepare_html_response( $product->add_to_cart_description() ),
'url' => $this->prepare_html_response( $product->add_to_cart_url() ),
],
( new QuantityLimits() )->get_add_to_cart_limits( $product )
),
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER, $product ),
];
}
/**
* Get list of product images.
*
* @param \WC_Product $product Product instance.
* @return array
*/
protected function get_images( \WC_Product $product ) {
$attachment_ids = array_merge( [ $product->get_image_id() ], $product->get_gallery_image_ids() );
return array_values( array_filter( array_map( [ $this->image_attachment_schema, 'get_item_response' ], $attachment_ids ) ) );
}
/**
* Gets remaining stock amount for a product.
*
* @param \WC_Product $product Product instance.
* @return integer|null
*/
protected function get_remaining_stock( \WC_Product $product ) {
if ( is_null( $product->get_stock_quantity() ) ) {
return null;
}
return $product->get_stock_quantity();
}
/**
* If a product has low stock, return the remaining stock amount for display.
*
* @param \WC_Product $product Product instance.
* @return integer|null
*/
protected function get_low_stock_remaining( \WC_Product $product ) {
$remaining_stock = $this->get_remaining_stock( $product );
$stock_format = get_option( 'woocommerce_stock_format' );
// Don't show the low stock badge if the settings doesn't allow it.
if ( 'no_amount' === $stock_format ) {
return null;
}
// Show the low stock badge if the remaining stock is below or equal to the threshold.
if ( ! is_null( $remaining_stock ) && $remaining_stock <= wc_get_low_stock_amount( $product ) ) {
return max( $remaining_stock, 0 );
}
return null;
}
/**
* Returns true if the given attribute is valid.
*
* @param mixed $attribute Object or variable to check.
* @return boolean
*/
protected function filter_valid_attribute( $attribute ) {
return is_a( $attribute, '\WC_Product_Attribute' );
}
/**
* Returns true if the given attribute is valid and used for variations.
*
* @param mixed $attribute Object or variable to check.
* @return boolean
*/
protected function filter_variation_attribute( $attribute ) {
return $this->filter_valid_attribute( $attribute ) && $attribute->get_variation();
}
/**
* Get variation IDs and attributes from the DB.
*
* @param \WC_Product $product Product instance.
* @returns array
*/
protected function get_variations( \WC_Product $product ) {
$variation_ids = $product->is_type( 'variable' ) ? $product->get_visible_children() : [];
if ( ! count( $variation_ids ) ) {
return [];
}
/**
* Gets default variation data which applies to all of this products variations.
*/
$attributes = array_filter( $product->get_attributes(), [ $this, 'filter_variation_attribute' ] );
$default_variation_meta_data = array_reduce(
$attributes,
function( $defaults, $attribute ) use ( $product ) {
$meta_key = wc_variation_attribute_name( $attribute->get_name() );
$defaults[ $meta_key ] = [
'name' => wc_attribute_label( $attribute->get_name(), $product ),
'value' => null,
];
return $defaults;
},
[]
);
$default_variation_meta_keys = array_keys( $default_variation_meta_data );
/**
* Gets individual variation data from the database, using cache where possible.
*/
$cache_group = 'product_variation_meta_data';
$cache_value = wp_cache_get( $product->get_id(), $cache_group );
$last_modified = get_the_modified_date( 'U', $product->get_id() );
if ( false === $cache_value || $last_modified !== $cache_value['last_modified'] ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$variation_meta_data = $wpdb->get_results(
"
SELECT post_id as variation_id, meta_key as attribute_key, meta_value as attribute_value
FROM {$wpdb->postmeta}
WHERE post_id IN (" . implode( ',', array_map( 'esc_sql', $variation_ids ) ) . ")
AND meta_key IN ('" . implode( "','", array_map( 'esc_sql', $default_variation_meta_keys ) ) . "')
"
);
// phpcs:enable
wp_cache_set(
$product->get_id(),
[
'last_modified' => $last_modified,
'data' => $variation_meta_data,
],
$cache_group
);
} else {
$variation_meta_data = $cache_value['data'];
}
/**
* Merges and formats default variation data with individual variation data.
*/
$attributes_by_variation = array_reduce(
$variation_meta_data,
function( $values, $data ) use ( $default_variation_meta_keys ) {
// The query above only includes the keys of $default_variation_meta_data so we know all of the attributes
// being processed here apply to this product. However, we need an additional check here because the
// cache may have been primed elsewhere and include keys from other products.
// @see AbstractProductGrid::prime_product_variations.
if ( in_array( $data->attribute_key, $default_variation_meta_keys, true ) ) {
$values[ $data->variation_id ][ $data->attribute_key ] = $data->attribute_value;
}
return $values;
},
array_fill_keys( $variation_ids, [] )
);
$variations = [];
foreach ( $variation_ids as $variation_id ) {
$attribute_data = $default_variation_meta_data;
foreach ( $attributes_by_variation[ $variation_id ] as $meta_key => $meta_value ) {
if ( '' !== $meta_value ) {
$attribute_data[ $meta_key ]['value'] = $meta_value;
}
}
$variations[] = (object) [
'id' => $variation_id,
'attributes' => array_values( $attribute_data ),
];
}
return $variations;
}
/**
* Get list of product attributes and attribute terms.
*
* @param \WC_Product $product Product instance.
* @return array
*/
protected function get_attributes( \WC_Product $product ) {
$attributes = array_filter( $product->get_attributes(), [ $this, 'filter_valid_attribute' ] );
$default_attributes = $product->get_default_attributes();
$return = [];
foreach ( $attributes as $attribute_slug => $attribute ) {
// Only visible or variation attributes will be exposed by this API.
if ( ! $attribute->get_visible() && ! $attribute->get_variation() ) {
continue;
}
$terms = $attribute->is_taxonomy() ? array_map( [ $this, 'prepare_product_attribute_taxonomy_value' ], $attribute->get_terms() ) : array_map( [ $this, 'prepare_product_attribute_value' ], $attribute->get_options() );
// Custom attribute names are sanitized to be the array keys.
// So when we do the array_key_exists check below we also need to sanitize the attribute names.
$sanitized_attribute_name = sanitize_key( $attribute->get_name() );
if ( array_key_exists( $sanitized_attribute_name, $default_attributes ) ) {
foreach ( $terms as $term ) {
$term->default = $term->slug === $default_attributes[ $sanitized_attribute_name ];
}
}
$return[] = (object) [
'id' => $attribute->get_id(),
'name' => wc_attribute_label( $attribute->get_name(), $product ),
'taxonomy' => $attribute->is_taxonomy() ? $attribute->get_name() : null,
'has_variations' => true === $attribute->get_variation(),
'terms' => $terms,
];
}
return $return;
}
/**
* Prepare an attribute term for the response.
*
* @param \WP_Term $term Term object.
* @return object
*/
protected function prepare_product_attribute_taxonomy_value( \WP_Term $term ) {
return $this->prepare_product_attribute_value( $term->name, $term->term_id, $term->slug );
}
/**
* Prepare an attribute term for the response.
*
* @param string $name Attribute term name.
* @param int $id Attribute term ID.
* @param string $slug Attribute term slug.
* @return object
*/
protected function prepare_product_attribute_value( $name, $id = 0, $slug = '' ) {
return (object) [
'id' => (int) $id,
'name' => $name,
'slug' => $slug ? $slug : $name,
];
}
/**
* Get an array of pricing data.
*
* @param \WC_Product $product Product instance.
* @param string $tax_display_mode If returned prices are incl or excl of tax.
* @return array
*/
protected function prepare_product_price_response( \WC_Product $product, $tax_display_mode = '' ) {
$prices = [];
$tax_display_mode = $this->get_tax_display_mode( $tax_display_mode );
$price_function = $this->get_price_function_from_tax_display_mode( $tax_display_mode );
// If we have a variable product, get the price from the variations (this will use the min value).
if ( $product->is_type( 'variable' ) ) {
$regular_price = $product->get_variation_regular_price();
$sale_price = $product->get_variation_sale_price();
} else {
$regular_price = $product->get_regular_price();
$sale_price = $product->get_sale_price();
}
$prices['price'] = $this->prepare_money_response( $price_function( $product ), wc_get_price_decimals() );
$prices['regular_price'] = $this->prepare_money_response( $price_function( $product, [ 'price' => $regular_price ] ), wc_get_price_decimals() );
$prices['sale_price'] = $this->prepare_money_response( $price_function( $product, [ 'price' => $sale_price ] ), wc_get_price_decimals() );
$prices['price_range'] = $this->get_price_range( $product, $tax_display_mode );
return $this->prepare_currency_response( $prices );
}
/**
* WooCommerce can return prices including or excluding tax; choose the correct method based on tax display mode.
*
* @param string $tax_display_mode Provided tax display mode.
* @return string Valid tax display mode.
*/
protected function get_tax_display_mode( $tax_display_mode = '' ) {
return in_array( $tax_display_mode, [ 'incl', 'excl' ], true ) ? $tax_display_mode : get_option( 'woocommerce_tax_display_shop' );
}
/**
* WooCommerce can return prices including or excluding tax; choose the correct method based on tax display mode.
*
* @param string $tax_display_mode If returned prices are incl or excl of tax.
* @return string Function name.
*/
protected function get_price_function_from_tax_display_mode( $tax_display_mode ) {
return 'incl' === $tax_display_mode ? 'wc_get_price_including_tax' : 'wc_get_price_excluding_tax';
}
/**
* Get price range from certain product types.
*
* @param \WC_Product $product Product instance.
* @param string $tax_display_mode If returned prices are incl or excl of tax.
* @return object|null
*/
protected function get_price_range( \WC_Product $product, $tax_display_mode = '' ) {
$tax_display_mode = $this->get_tax_display_mode( $tax_display_mode );
if ( $product->is_type( 'variable' ) ) {
$prices = $product->get_variation_prices( true );
if ( ! empty( $prices['price'] ) && ( min( $prices['price'] ) !== max( $prices['price'] ) ) ) {
return (object) [
'min_amount' => $this->prepare_money_response( min( $prices['price'] ), wc_get_price_decimals() ),
'max_amount' => $this->prepare_money_response( max( $prices['price'] ), wc_get_price_decimals() ),
];
}
}
if ( $product->is_type( 'grouped' ) ) {
$children = array_filter( array_map( 'wc_get_product', $product->get_children() ), 'wc_products_array_filter_visible_grouped' );
$price_function = 'incl' === $tax_display_mode ? 'wc_get_price_including_tax' : 'wc_get_price_excluding_tax';
foreach ( $children as $child ) {
if ( '' !== $child->get_price() ) {
$child_prices[] = $price_function( $child );
}
}
if ( ! empty( $child_prices ) ) {
return (object) [
'min_amount' => $this->prepare_money_response( min( $child_prices ), wc_get_price_decimals() ),
'max_amount' => $this->prepare_money_response( max( $child_prices ), wc_get_price_decimals() ),
];
}
}
return null;
}
/**
* Returns a list of terms assigned to the product.
*
* @param \WC_Product $product Product object.
* @param string $taxonomy Taxonomy name.
* @return array Array of terms (id, name, slug).
*/
protected function get_term_list( \WC_Product $product, $taxonomy = '' ) {
if ( ! $taxonomy ) {
return [];
}
$terms = get_the_terms( $product->get_id(), $taxonomy );
if ( ! $terms || is_wp_error( $terms ) ) {
return [];
}
$return = [];
$default_category = (int) get_option( 'default_product_cat', 0 );
foreach ( $terms as $term ) {
$link = get_term_link( $term, $taxonomy );
if ( is_wp_error( $link ) ) {
continue;
}
if ( $term->term_id === $default_category ) {
continue;
}
$return[] = (object) [
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'link' => $link,
];
}
return $return;
}
}
StoreApi/Schemas/V1/ShippingAddressSchema.php 0000644 00000004123 15154173074 0015073 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils;
/**
* ShippingAddressSchema class.
*
* Provides a generic shipping address schema for composition in other schemas.
*/
class ShippingAddressSchema extends AbstractAddressSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'shipping_address';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'shipping-address';
/**
* Convert a term object into an object suitable for the response.
*
* @param \WC_Order|\WC_Customer $address An object with shipping address.
*
* @throws RouteException When the invalid object types are provided.
* @return array
*/
public function get_item_response( $address ) {
$validation_util = new ValidationUtils();
if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) {
$shipping_country = $address->get_shipping_country();
$shipping_state = $address->get_shipping_state();
if ( ! $validation_util->validate_state( $shipping_state, $shipping_country ) ) {
$shipping_state = '';
}
return $this->prepare_html_response(
[
'first_name' => $address->get_shipping_first_name(),
'last_name' => $address->get_shipping_last_name(),
'company' => $address->get_shipping_company(),
'address_1' => $address->get_shipping_address_1(),
'address_2' => $address->get_shipping_address_2(),
'city' => $address->get_shipping_city(),
'state' => $shipping_state,
'postcode' => $address->get_shipping_postcode(),
'country' => $shipping_country,
'phone' => $address->get_shipping_phone(),
]
);
}
throw new RouteException(
'invalid_object_type',
sprintf(
/* translators: Placeholders are class and method names */
__( '%1$s requires an instance of %2$s or %3$s for the address', 'woocommerce' ),
'ShippingAddressSchema::get_item_response',
'WC_Customer',
'WC_Order'
),
500
);
}
}
StoreApi/Schemas/V1/TermSchema.php 0000644 00000004235 15154173074 0012717 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
/**
* TermSchema class.
*/
class TermSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'term';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'term';
/**
* Term properties.
*
* @return array
*/
public function get_properties() {
return [
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Term name.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'slug' => array(
'description' => __( 'String based identifier for the term.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'description' => array(
'description' => __( 'Term description.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'parent' => array(
'description' => __( 'Parent term ID, if applicable.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'count' => array(
'description' => __( 'Number of objects (posts of any type) assigned to the term.', 'woocommerce' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
];
}
/**
* Convert a term object into an object suitable for the response.
*
* @param \WP_Term $term Term object.
* @return array
*/
public function get_item_response( $term ) {
return [
'id' => (int) $term->term_id,
'name' => $this->prepare_html_response( $term->name ),
'slug' => $term->slug,
'description' => $this->prepare_html_response( $term->description ),
'parent' => (int) $term->parent,
'count' => (int) $term->count,
];
}
}
StoreApi/SessionHandler.php 0000644 00000005237 15154173074 0011742 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use WC_Session;
defined( 'ABSPATH' ) || exit;
/**
* SessionHandler class
*/
final class SessionHandler extends WC_Session {
/**
* Token from HTTP headers.
*
* @var string
*/
protected $token;
/**
* Table name for session data.
*
* @var string Custom session table name
*/
protected $table;
/**
* Expiration timestamp.
*
* @var int
*/
protected $session_expiration;
/**
* Constructor for the session class.
*/
public function __construct() {
$this->token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) );
$this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';
}
/**
* Init hooks and session data.
*/
public function init() {
$this->init_session_from_token();
add_action( 'shutdown', array( $this, 'save_data' ), 20 );
}
/**
* Process the token header to load the correct session.
*/
protected function init_session_from_token() {
$payload = JsonWebToken::get_parts( $this->token )->payload;
$this->_customer_id = $payload->user_id;
$this->session_expiration = $payload->exp;
$this->_data = (array) $this->get_session( $this->_customer_id, array() );
}
/**
* Returns the session.
*
* @param string $customer_id Customer ID.
* @param mixed $default Default session value.
*
* @return string|array|bool
*/
public function get_session( $customer_id, $default = false ) {
global $wpdb;
// This mimics behaviour from default WC_Session_Handler class. There will be no sessions retrieved while WP setup is due.
if ( Constants::is_defined( 'WP_SETUP_CONFIG' ) ) {
return false;
}
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT session_value FROM $this->table WHERE session_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$customer_id
)
);
if ( is_null( $value ) ) {
$value = $default;
}
return maybe_unserialize( $value );
}
/**
* Save data and delete user session.
*/
public function save_data() {
// Dirty if something changed - prevents saving nothing new.
if ( $this->_dirty ) {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
"INSERT INTO $this->table (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->_customer_id,
maybe_serialize( $this->_data ),
$this->session_expiration
)
);
$this->_dirty = false;
}
}
}
StoreApi/StoreApi.php 0000644 00000005344 15154173075 0010547 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\Blocks\Registry\Container;
use Automattic\WooCommerce\StoreApi\Formatters;
use Automattic\WooCommerce\StoreApi\Authentication;
use Automattic\WooCommerce\StoreApi\Legacy;
use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
use Automattic\WooCommerce\StoreApi\RoutesController;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* StoreApi Main Class.
*/
final class StoreApi {
/**
* Init and hook in Store API functionality.
*/
public function init() {
add_action(
'rest_api_init',
function() {
self::container()->get( Legacy::class )->init();
self::container()->get( RoutesController::class )->register_all_routes();
}
);
// Runs on priority 11 after rest_api_default_filters() which is hooked at 10.
add_action(
'rest_api_init',
function() {
self::container()->get( Authentication::class )->init();
},
11
);
}
/**
* Loads the DI container for Store API.
*
* @internal This uses the Blocks DI container. If Store API were to move to core, this container could be replaced
* with a different compatible container.
*
* @param boolean $reset Used to reset the container to a fresh instance. Note: this means all dependencies will be reconstructed.
* @return mixed
*/
public static function container( $reset = false ) {
static $container;
if ( $reset ) {
$container = null;
}
if ( $container ) {
return $container;
}
$container = new Container();
$container->register(
Authentication::class,
function () {
return new Authentication();
}
);
$container->register(
Legacy::class,
function () {
return new Legacy();
}
);
$container->register(
RoutesController::class,
function ( $container ) {
return new RoutesController(
$container->get( SchemaController::class )
);
}
);
$container->register(
SchemaController::class,
function ( $container ) {
return new SchemaController(
$container->get( ExtendSchema::class )
);
}
);
$container->register(
ExtendSchema::class,
function ( $container ) {
return new ExtendSchema(
$container->get( Formatters::class )
);
}
);
$container->register(
Formatters::class,
function () {
$formatters = new Formatters();
$formatters->register( 'money', MoneyFormatter::class );
$formatters->register( 'html', HtmlFormatter::class );
$formatters->register( 'currency', CurrencyFormatter::class );
return $formatters;
}
);
return $container;
}
}
StoreApi/Utilities/ArrayUtils.php 0000644 00000002032 15154173075 0013062 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* ArrayUtils class used for custom functions to operate on arrays
*/
class ArrayUtils {
/**
* Join a string with a natural language conjunction at the end.
*
* @param array $array The array to join together with the natural language conjunction.
* @param bool $enclose_items_with_quotes Whether each item in the array should be enclosed within quotation marks.
*
* @return string a string containing a list of items and a natural language conjuction.
*/
public static function natural_language_join( $array, $enclose_items_with_quotes = false ) {
if ( true === $enclose_items_with_quotes ) {
$array = array_map(
function( $item ) {
return '"' . $item . '"';
},
$array
);
}
$last = array_pop( $array );
if ( $array ) {
return sprintf(
/* translators: 1: The first n-1 items of a list 2: the last item in the list. */
__( '%1$s and %2$s', 'woocommerce' ),
implode( ', ', $array ),
$last
);
}
return $last;
}
}
StoreApi/Utilities/CartController.php 0000644 00000130432 15154173075 0013726 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\NotPurchasableException;
use Automattic\WooCommerce\StoreApi\Exceptions\OutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\PartialOutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Exceptions\TooManyInCartException;
use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
use Automattic\WooCommerce\Blocks\Package;
use WP_Error;
/**
* Woo Cart Controller class.
*
* Helper class to bridge the gap between the cart API and Woo core.
*/
class CartController {
use DraftOrderTrait;
/**
* Makes the cart and sessions available to a route by loading them from core.
*/
public function load_cart() {
if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
wc_load_cart();
}
}
/**
* Recalculates the cart totals.
*/
public function calculate_totals() {
$cart = $this->get_cart_instance();
$cart->get_cart();
$cart->calculate_fees();
$cart->calculate_shipping();
$cart->calculate_totals();
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*
* @todo Overriding the core add_to_cart method was necessary because core outputs notices when an item is added to
* the cart. For us this would cause notices to build up and output on the store, out of context. Core would need
* refactoring to split notices out from other cart actions.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return string
*/
public function add_to_cart( $request ) {
$cart = $this->get_cart_instance();
$request = wp_parse_args(
$request,
[
'id' => 0,
'quantity' => 1,
'variation' => [],
'cart_item_data' => [],
]
);
$request = $this->filter_request_data( $this->parse_variation_data( $request ) );
$product = $this->get_product_for_cart( $request );
$cart_id = $cart->generate_cart_id(
$this->get_product_id( $product ),
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
$this->validate_add_to_cart( $product, $request );
$quantity_limits = new QuantityLimits();
$existing_cart_id = $cart->find_product_in_cart( $cart_id );
if ( $existing_cart_id ) {
$cart_item = $cart->cart_contents[ $existing_cart_id ];
$quantity_validation = $quantity_limits->validate_cart_item_quantity( $request['quantity'] + $cart_item['quantity'], $cart_item );
if ( is_wp_error( $quantity_validation ) ) {
throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
}
$cart->set_quantity( $existing_cart_id, $request['quantity'] + $cart->cart_contents[ $existing_cart_id ]['quantity'], true );
return $existing_cart_id;
}
// Normalize quantity.
$add_to_cart_limits = $quantity_limits->get_add_to_cart_limits( $product );
$request_quantity = (int) $request['quantity'];
if ( $add_to_cart_limits['maximum'] ) {
$request_quantity = min( $request_quantity, $add_to_cart_limits['maximum'] );
}
$request_quantity = max( $request_quantity, $add_to_cart_limits['minimum'] );
$request_quantity = $quantity_limits->limit_to_multiple( $request_quantity, $add_to_cart_limits['multiple_of'] );
/**
* Filters the item being added to the cart.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_item_data Array of cart item data being added to the cart.
* @param string $cart_id Id of the item in the cart.
* @return array Updated cart item data.
*/
$cart->cart_contents[ $cart_id ] = apply_filters(
'woocommerce_add_cart_item',
array_merge(
$request['cart_item_data'],
array(
'key' => $cart_id,
'product_id' => $this->get_product_id( $product ),
'variation_id' => $this->get_variation_id( $product ),
'variation' => $request['variation'],
'quantity' => $request_quantity,
'data' => $product,
'data_hash' => wc_get_cart_item_data_hash( $product ),
)
),
$cart_id
);
/**
* Filters the entire cart contents when the cart changes.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_contents Array of all cart items.
* @return array Updated array of all cart items.
*/
$cart->cart_contents = apply_filters( 'woocommerce_cart_contents_changed', $cart->cart_contents );
/**
* Fires when an item is added to the cart.
*
* This hook fires when an item is added to the cart. This is triggered from the Store API in this context, but
* WooCommerce core add to cart events trigger the same hook.
*
* @since 2.5.0
*
* @internal Matches action name in WooCommerce core.
*
* @param string $cart_id ID of the item in the cart.
* @param integer $product_id ID of the product added to the cart.
* @param integer $request_quantity Quantity of the item added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param array $variation Array of variation data.
* @param array $cart_item_data Array of other cart item data.
*/
do_action(
'woocommerce_add_to_cart',
$cart_id,
$this->get_product_id( $product ),
$request_quantity,
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
return $cart_id;
}
/**
* Based on core `set_quantity` method, but validates if an item is sold individually first and enforces any limits in
* place.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param string $item_id Cart item id.
* @param integer $quantity Cart quantity.
*/
public function set_cart_item_quantity( $item_id, $quantity = 1 ) {
$cart_item = $this->get_cart_item( $item_id );
if ( empty( $cart_item ) ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 );
}
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_product', __( 'Cart item is invalid.', 'woocommerce' ), 404 );
}
$quantity_validation = ( new QuantityLimits() )->validate_cart_item_quantity( $quantity, $cart_item );
if ( is_wp_error( $quantity_validation ) ) {
throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
}
$cart = $this->get_cart_instance();
$cart->set_quantity( $item_id, $quantity );
}
/**
* Validate all items in the cart and check for errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Product $product Product object associated with the cart item.
* @param array $request Add to cart request params.
*/
public function validate_add_to_cart( \WC_Product $product, $request ) {
if ( ! $product->is_purchasable() ) {
$this->throw_default_product_exception( $product );
}
if ( ! $product->is_in_stock() ) {
throw new RouteException(
'woocommerce_rest_product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( 'You cannot add "%s" to the cart because the product is out of stock.', 'woocommerce' ),
$product->get_name()
),
400
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart + $request['quantity'] ) {
throw new RouteException(
'woocommerce_rest_product_partially_out_of_stock',
sprintf(
/* translators: 1: product name 2: quantity in stock */
__( 'You cannot add that amount of "%1$s" to the cart because there is not enough stock (%2$s remaining).', 'woocommerce' ),
$product->get_name(),
wc_format_stock_quantity_for_display( $qty_remaining, $product )
),
400
);
}
}
/**
* Filters if an item being added to the cart passed validation checks.
*
* Allow 3rd parties to validate if an item can be added to the cart. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to exceptions instead.
*
* @since 7.2.0
*
* @deprecated
* @param boolean $passed_validation True if the item passed validation.
* @param integer $product_id Product ID being validated.
* @param integer $quantity Quantity added to the cart.
* @param integer $variation_id Variation ID being added to the cart.
* @param array $variation Variation data.
* @return boolean
*/
$passed_validation = apply_filters(
'woocommerce_add_to_cart_validation',
true,
$this->get_product_id( $product ),
$request['quantity'],
$this->get_variation_id( $product ),
$request['variation']
);
if ( ! $passed_validation ) {
// Validation did not pass - see if an error notice was thrown.
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_add_to_cart_error' );
// If no notice was thrown, throw the default notice instead.
$this->throw_default_product_exception( $product );
}
/**
* Fires during validation when adding an item to the cart via the Store API.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
* @deprecated 7.1.0 Use woocommerce_store_api_validate_add_to_cart instead.
*/
wc_do_deprecated_action(
'wooocommerce_store_api_validate_add_to_cart',
array(
$product,
$request,
),
'7.1.0',
'woocommerce_store_api_validate_add_to_cart',
'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_add_to_cart instead.'
);
/**
* Fires during validation when adding an item to the cart via the Store API.
*
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from happening.
*
* @since 7.1.0
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
*/
do_action( 'woocommerce_store_api_validate_add_to_cart', $product, $request );
}
/**
* Generates the error message for out of stock products and adds product names to it.
*
* @param string $singular The message to use when only one product is in the list.
* @param string $plural The message to use when more than one product is in the list.
* @param array $items The list of cart items whose names should be inserted into the message.
* @returns string The translated and correctly pluralised message.
*/
private function add_product_names_to_message( $singular, $plural, $items ) {
$product_names = wc_list_pluck( $items, 'getProductName' );
$message = ( count( $items ) > 1 ) ? $plural : $singular;
return sprintf(
$message,
ArrayUtils::natural_language_join( $product_names, true )
);
}
/**
* Takes a string describing the type of stock extension, whether there is a single product or multiple products
* causing this exception and returns an appropriate error message.
*
* @param string $exception_type The type of exception encountered.
* @param string $singular_or_plural Whether to get the error message for a single product or multiple.
*
* @return string
*/
private function get_error_message_for_stock_exception_type( $exception_type, $singular_or_plural ) {
$stock_error_messages = [
'out_of_stock' => [
/* translators: %s: product name. */
'singular' => __(
'%s is out of stock and cannot be purchased. Please remove it from your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'%s are out of stock and cannot be purchased. Please remove them from your cart.',
'woocommerce'
),
],
'not_purchasable' => [
/* translators: %s: product name. */
'singular' => __(
'%s cannot be purchased. Please remove it from your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'%s cannot be purchased. Please remove them from your cart.',
'woocommerce'
),
],
'too_many_in_cart' => [
/* translators: %s: product names. */
'singular' => __(
'There are too many %s in the cart. Only 1 can be purchased. Please reduce the quantity in your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'There are too many %s in the cart. Only 1 of each can be purchased. Please reduce the quantities in your cart.',
'woocommerce'
),
],
'partial_out_of_stock' => [
/* translators: %s: product names. */
'singular' => __(
'There is not enough %s in stock. Please reduce the quantity in your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'There are not enough %s in stock. Please reduce the quantities in your cart.',
'woocommerce'
),
],
];
if (
isset( $stock_error_messages[ $exception_type ] ) &&
isset( $stock_error_messages[ $exception_type ][ $singular_or_plural ] )
) {
return $stock_error_messages[ $exception_type ][ $singular_or_plural ];
}
return __( 'There was an error with an item in your cart.', 'woocommerce' );
}
/**
* Validate cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected in the cart.
*/
public function validate_cart() {
$this->validate_cart_items();
$this->validate_cart_coupons();
$cart = $this->get_cart_instance();
$cart_errors = new WP_Error();
/**
* Fires an action to validate the cart.
*
* Functions hooking into this should add custom errors using the provided WP_Error instance.
*
* @since 7.2.0
*
* @example See docs/examples/validate-cart.md
*
* @param \WP_Error $errors WP_Error object.
* @param \WC_Cart $cart Cart object.
*/
do_action( 'woocommerce_store_api_cart_errors', $cart_errors, $cart );
if ( $cart_errors->has_errors() ) {
throw new InvalidCartException(
'woocommerce_cart_error',
$cart_errors,
409
);
}
// Before running the woocommerce_check_cart_items hook, unhook validation from the core cart.
remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_items' ), 1 );
remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_coupons' ), 1 );
/**
* Fires when cart items are being validated.
*
* Allow 3rd parties to validate cart items. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to wp errors instead.
*
* @since 7.2.0
*
* @deprecated
* @internal Matches action name in WooCommerce core.
*/
do_action( 'woocommerce_check_cart_items' );
$cart_errors = NoticeHandler::convert_notices_to_wp_errors( 'woocommerce_rest_cart_item_error' );
if ( $cart_errors->has_errors() ) {
throw new InvalidCartException(
'woocommerce_cart_error',
$cart_errors,
409
);
}
}
/**
* Validate all items in the cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected due to insufficient stock levels.
*/
public function validate_cart_items() {
$cart = $this->get_cart_instance();
$cart_items = $this->get_cart_items();
$errors = [];
$out_of_stock_products = [];
$too_many_in_cart_products = [];
$partial_out_of_stock_products = [];
$not_purchasable_products = [];
foreach ( $cart_items as $cart_item_key => $cart_item ) {
try {
$this->validate_cart_item( $cart_item );
} catch ( RouteException $error ) {
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
} catch ( TooManyInCartException $error ) {
$too_many_in_cart_products[] = $error;
} catch ( NotPurchasableException $error ) {
$not_purchasable_products[] = $error;
} catch ( PartialOutOfStockException $error ) {
$partial_out_of_stock_products[] = $error;
} catch ( OutOfStockException $error ) {
$out_of_stock_products[] = $error;
}
}
if ( count( $errors ) > 0 ) {
$error = new WP_Error();
foreach ( $errors as $wp_error ) {
$error->merge_from( $wp_error );
}
throw new InvalidCartException(
'woocommerce_cart_error',
$error,
409
);
}
$error = $this->stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products );
if ( $error->has_errors() ) {
throw new InvalidCartException(
'woocommerce_stock_availability_error',
$error,
409
);
}
}
/**
* This method will take arrays of exceptions relating to stock, and will convert them to a WP_Error object.
*
* @param TooManyInCartException[] $too_many_in_cart_products Array of TooManyInCartExceptions.
* @param NotPurchasableException[] $not_purchasable_products Array of NotPurchasableExceptions.
* @param PartialOutOfStockException[] $partial_out_of_stock_products Array of PartialOutOfStockExceptions.
* @param OutOfStockException[] $out_of_stock_products Array of OutOfStockExceptions.
*
* @return WP_Error The WP_Error object returned. Will have errors if any exceptions were in the args. It will be empty if they do not.
*/
private function stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products ) {
$error = new WP_Error();
if ( count( $out_of_stock_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'plural' );
$error->add(
'woocommerce_rest_product_out_of_stock',
$this->add_product_names_to_message( $singular_error, $plural_error, $out_of_stock_products )
);
}
if ( count( $not_purchasable_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'plural' );
$error->add(
'woocommerce_rest_product_not_purchasable',
$this->add_product_names_to_message( $singular_error, $plural_error, $not_purchasable_products )
);
}
if ( count( $too_many_in_cart_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'plural' );
$error->add(
'woocommerce_rest_product_too_many_in_cart',
$this->add_product_names_to_message( $singular_error, $plural_error, $too_many_in_cart_products )
);
}
if ( count( $partial_out_of_stock_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'plural' );
$error->add(
'woocommerce_rest_product_partially_out_of_stock',
$this->add_product_names_to_message( $singular_error, $plural_error, $partial_out_of_stock_products )
);
}
return $error;
}
/**
* Validates an existing cart item and returns any errors.
*
* @throws TooManyInCartException Exception if more than one product that can only be purchased individually is in
* the cart.
* @throws PartialOutOfStockException Exception if an item has a quantity greater than what is available in stock.
* @throws OutOfStockException Exception thrown when an item is entirely out of stock.
* @throws NotPurchasableException Exception thrown when an item is not purchasable.
* @param array $cart_item Cart item array.
*/
public function validate_cart_item( $cart_item ) {
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
return;
}
if ( ! $product->is_purchasable() ) {
throw new NotPurchasableException(
'woocommerce_rest_product_not_purchasable',
$product->get_name()
);
}
if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
throw new TooManyInCartException(
'woocommerce_rest_product_too_many_in_cart',
$product->get_name()
);
}
if ( ! $product->is_in_stock() ) {
throw new OutOfStockException(
'woocommerce_rest_product_out_of_stock',
$product->get_name()
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart ) {
throw new PartialOutOfStockException(
'woocommerce_rest_product_partially_out_of_stock',
$product->get_name()
);
}
}
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occurring.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
* @deprecated 7.1.0 Use woocommerce_store_api_validate_cart_item instead.
*/
wc_do_deprecated_action(
'wooocommerce_store_api_validate_cart_item',
array(
$product,
$cart_item,
),
'7.1.0',
'woocommerce_store_api_validate_cart_item',
'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_cart_item instead.'
);
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occurring.
*
* @since 7.1.0
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
*/
do_action( 'woocommerce_store_api_validate_cart_item', $product, $cart_item );
}
/**
* Validate all coupons in the cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected.
*/
public function validate_cart_coupons() {
$cart_coupons = $this->get_cart_coupons();
$errors = [];
foreach ( $cart_coupons as $code ) {
$coupon = new \WC_Coupon( $code );
try {
$this->validate_cart_coupon( $coupon );
} catch ( RouteException $error ) {
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
}
}
if ( ! empty( $errors ) ) {
$error = new WP_Error();
foreach ( $errors as $wp_error ) {
$error->merge_from( $wp_error );
}
throw new InvalidCartException(
'woocommerce_coupons_error',
$error,
409
);
}
}
/**
* Validate the cart and get a list of errors.
*
* @return WP_Error A WP_Error instance containing the cart's errors.
*/
public function get_cart_errors() {
$errors = new WP_Error();
try {
$this->validate_cart();
} catch ( RouteException $error ) {
$errors->add( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
} catch ( InvalidCartException $error ) {
$errors->merge_from( $error->getError() );
} catch ( \Exception $error ) {
$errors->add( $error->getCode(), $error->getMessage() );
}
return $errors;
}
/**
* Get main instance of cart class.
*
* @throws RouteException When cart cannot be loaded.
* @return \WC_Cart
*/
public function get_cart_instance() {
$cart = wc()->cart;
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
throw new RouteException( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woocommerce' ), 500 );
}
return $cart;
}
/**
* Return a cart item from the woo core cart class.
*
* @param string $item_id Cart item id.
* @return array
*/
public function get_cart_item( $item_id ) {
$cart = $this->get_cart_instance();
return isset( $cart->cart_contents[ $item_id ] ) ? $cart->cart_contents[ $item_id ] : [];
}
/**
* Returns all cart items.
*
* @param callable $callback Optional callback to apply to the array filter.
* @return array
*/
public function get_cart_items( $callback = null ) {
$cart = $this->get_cart_instance();
return $callback ? array_filter( $cart->get_cart(), $callback ) : array_filter( $cart->get_cart() );
}
/**
* Get hashes for items in the current cart. Useful for tracking changes.
*
* @return array
*/
public function get_cart_hashes() {
$cart = $this->get_cart_instance();
return [
'line_items' => $cart->get_cart_hash(),
'shipping' => md5( wp_json_encode( $cart->shipping_methods ) ),
'fees' => md5( wp_json_encode( $cart->get_fees() ) ),
'coupons' => md5( wp_json_encode( $cart->get_applied_coupons() ) ),
'taxes' => md5( wp_json_encode( $cart->get_taxes() ) ),
];
}
/**
* Empty cart contents.
*/
public function empty_cart() {
$cart = $this->get_cart_instance();
$cart->empty_cart();
}
/**
* See if cart has applied coupon by code.
*
* @param string $coupon_code Cart coupon code.
* @return bool
*/
public function has_coupon( $coupon_code ) {
$cart = $this->get_cart_instance();
return $cart->has_discount( $coupon_code );
}
/**
* Returns all applied coupons.
*
* @param callable $callback Optional callback to apply to the array filter.
* @return array
*/
public function get_cart_coupons( $callback = null ) {
$cart = $this->get_cart_instance();
return $callback ? array_filter( $cart->get_applied_coupons(), $callback ) : array_filter( $cart->get_applied_coupons() );
}
/**
* Get shipping packages from the cart with calculated shipping rates.
*
* @todo this can be refactored once https://github.com/woocommerce/woocommerce/pull/26101 lands.
*
* @param bool $calculate_rates Should rates for the packages also be returned.
* @return array
*/
public function get_shipping_packages( $calculate_rates = true ) {
$cart = $this->get_cart_instance();
// See if we need to calculate anything.
if ( ! $cart->needs_shipping() ) {
return [];
}
$packages = $cart->get_shipping_packages();
// Add extra package data to array.
if ( count( $packages ) ) {
$packages = array_map(
function( $key, $package, $index ) {
$package['package_id'] = isset( $package['package_id'] ) ? $package['package_id'] : $key;
$package['package_name'] = isset( $package['package_name'] ) ? $package['package_name'] : $this->get_package_name( $package, $index );
return $package;
},
array_keys( $packages ),
$packages,
range( 1, count( $packages ) )
);
}
$packages = $calculate_rates ? wc()->shipping()->calculate_shipping( $packages ) : $packages;
return $packages;
}
/**
* Creates a name for a package.
*
* @param array $package Shipping package from WooCommerce.
* @param int $index Package number.
* @return string
*/
protected function get_package_name( $package, $index ) {
/**
* Filters the shipping package name.
*
* @since 4.3.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $shipping_package_name Shipping package name.
* @param string $package_id Shipping package ID.
* @param array $package Shipping package from WooCommerce.
* @return string Shipping package name.
*/
return apply_filters(
'woocommerce_shipping_package_name',
$index > 1 ?
sprintf(
/* translators: %d: shipping package number */
_x( 'Shipment %d', 'shipping packages', 'woocommerce' ),
$index
) :
_x( 'Shipment 1', 'shipping packages', 'woocommerce' ),
$package['package_id'],
$package
);
}
/**
* Selects a shipping rate.
*
* @param int|string $package_id ID of the package to choose a rate for.
* @param string $rate_id ID of the rate being chosen.
*/
public function select_shipping_rate( $package_id, $rate_id ) {
$cart = $this->get_cart_instance();
$session_data = wc()->session->get( 'chosen_shipping_methods' ) ? wc()->session->get( 'chosen_shipping_methods' ) : [];
$session_data[ $package_id ] = $rate_id;
wc()->session->set( 'chosen_shipping_methods', $session_data );
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*
* @todo Overriding the core apply_coupon method was necessary because core outputs notices when a coupon gets
* applied. For us this would cause notices to build up and output on the store, out of context. Core would need
* refactoring to split notices out from other cart actions.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param string $coupon_code Coupon code.
*/
public function apply_coupon( $coupon_code ) {
$cart = $this->get_cart_instance();
$applied_coupons = $this->get_cart_coupons();
$coupon = new \WC_Coupon( $coupon_code );
if ( $coupon->get_code() !== $coupon_code ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s coupon code */
__( '"%s" is an invalid coupon code.', 'woocommerce' ),
esc_html( $coupon_code )
),
400
);
}
if ( $this->has_coupon( $coupon_code ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s coupon code */
__( 'Coupon code "%s" has already been applied.', 'woocommerce' ),
esc_html( $coupon_code )
),
400
);
}
if ( ! $coupon->is_valid() ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
wp_strip_all_tags( $coupon->get_error_message() ),
400
);
}
// Prevents new coupons being added if individual use coupons are already in the cart.
$individual_use_coupons = $this->get_cart_coupons(
function( $code ) {
$coupon = new \WC_Coupon( $code );
return $coupon->get_individual_use();
}
);
foreach ( $individual_use_coupons as $code ) {
$individual_use_coupon = new \WC_Coupon( $code );
/**
* Filters if a coupon can be applied alongside other individual use coupons.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param boolean $apply_with_individual_use_coupon Defaults to false.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Coupon $individual_use_coupon Individual use coupon already applied to the cart.
* @param array $applied_coupons Array of applied coupons already applied to the cart.
* @return boolean
*/
if ( false === apply_filters( 'woocommerce_apply_with_individual_use_coupon', false, $coupon, $individual_use_coupon, $applied_coupons ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s: coupon code */
__( '"%s" has already been applied and cannot be used in conjunction with other coupons.', 'woocommerce' ),
$code
),
400
);
}
}
if ( $coupon->get_individual_use() ) {
/**
* Filter coupons to remove when applying an individual use coupon.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $coupons Array of coupons to remove from the cart.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param array $applied_coupons Array of applied coupons already applied to the cart.
* @return array
*/
$coupons_to_remove = array_diff( $applied_coupons, apply_filters( 'woocommerce_apply_individual_use_coupon', array(), $coupon, $applied_coupons ) );
foreach ( $coupons_to_remove as $code ) {
$cart->remove_coupon( $code );
}
$applied_coupons = array_diff( $applied_coupons, $coupons_to_remove );
}
$applied_coupons[] = $coupon_code;
$cart->set_applied_coupons( $applied_coupons );
/**
* Fires after a coupon has been applied to the cart.
*
* @since 2.6.0
*
* @internal Matches action name in WooCommerce core.
*
* @param string $coupon_code The coupon code that was applied.
*/
do_action( 'woocommerce_applied_coupon', $coupon_code );
}
/**
* Validates an existing cart coupon and returns any errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Coupon $coupon Coupon object applied to the cart.
*/
protected function validate_cart_coupon( \WC_Coupon $coupon ) {
if ( ! $coupon->is_valid() ) {
$cart = $this->get_cart_instance();
$cart->remove_coupon( $coupon->get_code() );
$cart->calculate_totals();
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %1$s coupon code, %2$s reason. */
__( 'The "%1$s" coupon has been removed from your cart: %2$s', 'woocommerce' ),
$coupon->get_code(),
wp_strip_all_tags( $coupon->get_error_message() )
),
409
);
}
}
/**
* Gets the qty of a product across line items.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_product_quantity_in_cart( $product ) {
$cart = $this->get_cart_instance();
$product_quantities = $cart->get_cart_item_quantities();
$product_id = $product->get_stock_managed_by_id();
return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
}
/**
* Gets remaining stock for a product.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_remaining_stock_for_product( $product ) {
$reserve_stock = new ReserveStock();
$qty_reserved = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
return $product->get_stock_quantity() - $qty_reserved;
}
/**
* Get a product object to be added to the cart.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return \WC_Product|Error Returns a product object if purchasable.
*/
protected function get_product_for_cart( $request ) {
$product = wc_get_product( $request['id'] );
if ( ! $product || 'trash' === $product->get_status() ) {
throw new RouteException(
'woocommerce_rest_cart_invalid_product',
__( 'This product cannot be added to the cart.', 'woocommerce' ),
400
);
}
return $product;
}
/**
* For a given product, get the product ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_product_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
}
/**
* For a given product, get the variation ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_variation_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_id() : 0;
}
/**
* Default exception thrown when an item cannot be added to the cart.
*
* @throws RouteException Exception with code woocommerce_rest_product_not_purchasable.
*
* @param \WC_Product $product Product object associated with the cart item.
*/
protected function throw_default_product_exception( \WC_Product $product ) {
throw new RouteException(
'woocommerce_rest_product_not_purchasable',
sprintf(
/* translators: %s: product name */
__( '"%s" is not available for purchase.', 'woocommerce' ),
$product->get_name()
),
400
);
}
/**
* Filter data for add to cart requests.
*
* @param array $request Add to cart request params.
* @return array Updated request array.
*/
protected function filter_request_data( $request ) {
$product_id = $request['id'];
$variation_id = 0;
$product = wc_get_product( $product_id );
if ( $product->is_type( 'variation' ) ) {
$product_id = $product->get_parent_id();
$variation_id = $product->get_id();
}
/**
* Filter cart item data for add to cart requests.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_item_data Array of other cart item data.
* @param integer $product_id ID of the product added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param integer $quantity Quantity of the item added to the cart.
* @return array
*/
$request['cart_item_data'] = (array) apply_filters(
'woocommerce_add_cart_item_data',
$request['cart_item_data'],
$product_id,
$variation_id,
$request['quantity']
);
if ( $product->is_sold_individually() ) {
/**
* Filter sold individually quantity for add to cart requests.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param integer $sold_individually_quantity Defaults to 1.
* @param integer $quantity Quantity of the item added to the cart.
* @param integer $product_id ID of the product added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param array $cart_item_data Array of other cart item data.
* @return integer
*/
$request['quantity'] = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $request['quantity'], $product_id, $variation_id, $request['cart_item_data'] );
}
return $request;
}
/**
* If variations are set, validate and format the values ready to add to the cart.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return array Updated request array.
*/
protected function parse_variation_data( $request ) {
$product = $this->get_product_for_cart( $request );
// Remove variation request if not needed.
if ( ! $product->is_type( array( 'variation', 'variable' ) ) ) {
$request['variation'] = [];
return $request;
}
// Flatten data and format posted values.
$variable_product_attributes = $this->get_variable_product_attributes( $product );
$request['variation'] = $this->sanitize_variation_data( wp_list_pluck( $request['variation'], 'value', 'attribute' ), $variable_product_attributes );
// If we have a parent product, find the variation ID.
if ( $product->is_type( 'variable' ) ) {
$request['id'] = $this->get_variation_id_from_variation_data( $request, $product );
}
// Now we have a variation ID, get the valid set of attributes for this variation. They will have an attribute_ prefix since they are from meta.
$expected_attributes = wc_get_product_variation_attributes( $request['id'] );
$missing_attributes = [];
foreach ( $variable_product_attributes as $attribute ) {
if ( ! $attribute['is_variation'] ) {
continue;
}
$prefixed_attribute_name = 'attribute_' . sanitize_title( $attribute['name'] );
$expected_value = isset( $expected_attributes[ $prefixed_attribute_name ] ) ? $expected_attributes[ $prefixed_attribute_name ] : '';
$attribute_label = wc_attribute_label( $attribute['name'] );
if ( isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
$given_value = $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ];
if ( $expected_value === $given_value ) {
continue;
}
// If valid values are empty, this is an 'any' variation so get all possible values.
if ( '' === $expected_value && in_array( $given_value, $attribute->get_slugs(), true ) ) {
continue;
}
throw new RouteException(
'woocommerce_rest_invalid_variation_data',
/* translators: %1$s: Attribute name, %2$s: Allowed values. */
sprintf( __( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woocommerce' ), $attribute_label, implode( ', ', $attribute->get_slugs() ) ),
400
);
}
// Fills request array with unspecified attributes that have default values. This ensures the variation always has full data.
if ( '' !== $expected_value && ! isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
$request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] = $expected_value;
}
// If no attribute was posted, only error if the variation has an 'any' attribute which requires a value.
if ( '' === $expected_value ) {
$missing_attributes[] = $attribute_label;
}
}
if ( ! empty( $missing_attributes ) ) {
throw new RouteException(
'woocommerce_rest_missing_variation_data',
/* translators: %s: Attribute name. */
__( 'Missing variation data for variable product.', 'woocommerce' ) . ' ' . sprintf( _n( '%s is a required field', '%s are required fields', count( $missing_attributes ), 'woocommerce' ), wc_format_list_of_items( $missing_attributes ) ),
400
);
}
ksort( $request['variation'] );
return $request;
}
/**
* Try to match request data to a variation ID and return the ID.
*
* @throws RouteException Exception if variation cannot be found.
*
* @param array $request Add to cart request params.
* @param \WC_Product $product Product being added to the cart.
* @return int Matching variation ID.
*/
protected function get_variation_id_from_variation_data( $request, $product ) {
$data_store = \WC_Data_Store::load( 'product' );
$match_attributes = $request['variation'];
$variation_id = $data_store->find_matching_product_variation( $product, $match_attributes );
if ( empty( $variation_id ) ) {
throw new RouteException(
'woocommerce_rest_variation_id_from_variation_data',
__( 'No matching variation found.', 'woocommerce' ),
400
);
}
return $variation_id;
}
/**
* Format and sanitize variation data posted to the API.
*
* Labels are converted to names (e.g. Size to pa_size), and values are cleaned.
*
* @throws RouteException Exception if variation cannot be found.
*
* @param array $variation_data Key value pairs of attributes and values.
* @param array $variable_product_attributes Product attributes we're expecting.
* @return array
*/
protected function sanitize_variation_data( $variation_data, $variable_product_attributes ) {
$return = [];
foreach ( $variable_product_attributes as $attribute ) {
if ( ! $attribute['is_variation'] ) {
continue;
}
$attribute_label = wc_attribute_label( $attribute['name'] );
$variation_attribute_name = wc_variation_attribute_name( $attribute['name'] );
// Attribute labels e.g. Size.
if ( isset( $variation_data[ $attribute_label ] ) ) {
$return[ $variation_attribute_name ] =
$attribute['is_taxonomy']
?
sanitize_title( $variation_data[ $attribute_label ] )
:
html_entity_decode(
wc_clean( $variation_data[ $attribute_label ] ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
continue;
}
// Attribute slugs e.g. pa_size.
if ( isset( $variation_data[ $attribute['name'] ] ) ) {
$return[ $variation_attribute_name ] =
$attribute['is_taxonomy']
?
sanitize_title( $variation_data[ $attribute['name'] ] )
:
html_entity_decode(
wc_clean( $variation_data[ $attribute['name'] ] ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
}
}
return $return;
}
/**
* Get product attributes from the variable product (which may be the parent if the product object is a variation).
*
* @throws RouteException Exception if product is invalid.
*
* @param \WC_Product $product Product being added to the cart.
* @return array
*/
protected function get_variable_product_attributes( $product ) {
if ( $product->is_type( 'variation' ) ) {
$product = wc_get_product( $product->get_parent_id() );
}
if ( ! $product || 'trash' === $product->get_status() ) {
throw new RouteException(
'woocommerce_rest_cart_invalid_parent_product',
__( 'This product cannot be added to the cart.', 'woocommerce' ),
400
);
}
return $product->get_attributes();
}
}
StoreApi/Utilities/CheckoutTrait.php 0000644 00000014754 15154173075 0013552 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
/**
* CheckoutTrait
*
* Shared functionality for checkout route.
*/
trait CheckoutTrait {
/**
* Prepare a single item for response. Handles setting the status based on the payment result.
*
* @param mixed $item Item to format to schema.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
$response = parent::prepare_item_for_response( $item, $request );
$status_codes = [
'success' => 200,
'pending' => 202,
'failure' => 400,
'error' => 500,
];
if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) {
$response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 );
}
return $response;
}
/**
* For orders which do not require payment, just update status.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
*/
private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
// Transition the order to pending, and then completed. This ensures transactional emails fire for pending_to_complete events.
$this->order->update_status( 'pending' );
$this->order->payment_complete();
// Mark the payment as successful.
$payment_result->set_status( 'success' );
$payment_result->set_redirect_url( $this->order->get_checkout_order_received_url() );
}
/**
* Fires an action hook instructing active payment gateways to process the payment for an order and provide a result.
*
* @throws RouteException On error.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
*/
private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
try {
// Transition the order to pending before making payment.
$this->order->update_status( 'pending' );
// Prepare the payment context object to pass through payment hooks.
$context = new PaymentContext();
$context->set_payment_method( $this->get_request_payment_method_id( $request ) );
$context->set_payment_data( $this->get_request_payment_data( $request ) );
$context->set_order( $this->order );
/**
* Process payment with context.
*
* @hook woocommerce_rest_checkout_process_payment_with_context
*
* @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message.
*
* @param PaymentContext $context Holds context for the payment, including order ID and payment method.
* @param PaymentResult $payment_result Result object for the transaction.
*/
do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] );
if ( ! $payment_result instanceof PaymentResult ) {
throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 );
}
} catch ( \Exception $e ) {
throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', $e->getMessage(), 402 );
}
}
/**
* Gets the chosen payment method ID from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return string
*/
private function get_request_payment_method_id( \WP_REST_Request $request ) {
$payment_method = $this->get_request_payment_method( $request );
return is_null( $payment_method ) ? '' : $payment_method->id;
}
/**
* Gets and formats payment request data.
*
* @param \WP_REST_Request $request Request object.
* @return array
*/
private function get_request_payment_data( \WP_REST_Request $request ) {
static $payment_data = [];
if ( ! empty( $payment_data ) ) {
return $payment_data;
}
if ( ! empty( $request['payment_data'] ) ) {
foreach ( $request['payment_data'] as $data ) {
$payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] );
}
}
return $payment_data;
}
/**
* Update the current order using the posted values from the request.
*
* @param \WP_REST_Request $request Full details about the request.
*/
private function update_order_from_request( \WP_REST_Request $request ) {
$this->order->set_customer_note( $request['customer_note'] ?? '' );
$this->order->set_payment_method( $this->get_request_payment_method_id( $request ) );
$this->order->set_payment_method_title( $this->get_request_payment_method_title( $request ) );
wc_do_deprecated_action(
'__experimental_woocommerce_blocks_checkout_update_order_from_request',
array(
$this->order,
$request,
),
'6.3.0',
'woocommerce_store_api_checkout_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
);
wc_do_deprecated_action(
'woocommerce_blocks_checkout_update_order_from_request',
array(
$this->order,
$request,
),
'7.2.0',
'woocommerce_store_api_checkout_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
);
/**
* Fires when the Checkout Block/Store API updates an order's from the API request data.
*
* This hook gives extensions the chance to update orders based on the data in the request. This can be used in
* conjunction with the ExtendSchema class to post custom data and then process it.
*
* @since 7.2.0
*
* @param \WC_Order $order Order object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_checkout_update_order_from_request', $this->order, $request );
$this->order->save();
}
/**
* Gets the chosen payment method title from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return string
*/
private function get_request_payment_method_title( \WP_REST_Request $request ) {
$payment_method = $this->get_request_payment_method( $request );
return is_null( $payment_method ) ? '' : $payment_method->get_title();
}
}
StoreApi/Utilities/DraftOrderTrait.php 0000644 00000003417 15154173075 0014033 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* DraftOrderTrait
*
* Shared functionality for getting and setting draft order IDs from session.
*/
trait DraftOrderTrait {
/**
* Gets draft order data from the customer session.
*
* @return integer
*/
protected function get_draft_order_id() {
if ( ! wc()->session ) {
wc()->initialize_session();
}
return wc()->session->get( 'store_api_draft_order', 0 );
}
/**
* Updates draft order data in the customer session.
*
* @param integer $order_id Draft order ID.
*/
protected function set_draft_order_id( $order_id ) {
if ( ! wc()->session ) {
wc()->initialize_session();
}
wc()->session->set( 'store_api_draft_order', $order_id );
}
/**
* Uses the draft order ID to return an order object, if valid.
*
* @return \WC_Order|null;
*/
protected function get_draft_order() {
$draft_order_id = $this->get_draft_order_id();
$draft_order = $draft_order_id ? wc_get_order( $draft_order_id ) : false;
return $this->is_valid_draft_order( $draft_order ) ? $draft_order : null;
}
/**
* Whether the passed argument is a draft order or an order that is
* pending/failed and the cart hasn't changed.
*
* @param \WC_Order $order_object Order object to check.
* @return boolean Whether the order is valid as a draft order.
*/
protected function is_valid_draft_order( $order_object ) {
if ( ! $order_object instanceof \WC_Order ) {
return false;
}
// Draft orders are okay.
if ( $order_object->has_status( 'checkout-draft' ) ) {
return true;
}
// Pending and failed orders can be retried if the cart hasn't changed.
if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
return true;
}
return false;
}
}
StoreApi/Utilities/JsonWebToken.php 0000644 00000012040 15154173075 0013333 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* JsonWebToken class.
*
* Simple Json Web Token generator & verifier static utility class, currently supporting only HS256 signatures.
*/
final class JsonWebToken {
/**
* JWT header type.
*
* @var string
*/
private static $type = 'JWT';
/**
* JWT algorithm to generate signature.
*
* @var string
*/
private static $algorithm = 'HS256';
/**
* Generates a token from provided data and secret.
*
* @param array $payload Payload data.
* @param string $secret The secret used to generate the signature.
*
* @return string
*/
public static function create( array $payload, string $secret ) {
$header = self::to_base_64_url( self::generate_header() );
$payload = self::to_base_64_url( self::generate_payload( $payload ) );
$signature = self::to_base_64_url( self::generate_signature( $header . '.' . $payload, $secret ) );
return $header . '.' . $payload . '.' . $signature;
}
/**
* Validates a provided token against the provided secret.
* Checks for format, valid header for our class, expiration claim validity and signature.
* https://datatracker.ietf.org/doc/html/rfc7519#section-7.2
*
* @param string $token Full token string.
* @param string $secret The secret used to generate the signature.
*
* @return bool
*/
public static function validate( string $token, string $secret ) {
/**
* Confirm the structure of a JSON Web Token, it has three parts separated
* by dots and complies with Base64URL standards.
*/
if ( preg_match( '/^[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+$/', $token ) !== 1 ) {
return false;
}
$parts = self::get_parts( $token );
/**
* Check if header declares a supported JWT by this class.
*/
if (
! is_object( $parts->header ) ||
! property_exists( $parts->header, 'typ' ) ||
! property_exists( $parts->header, 'alg' ) ||
self::$type !== $parts->header->typ ||
self::$algorithm !== $parts->header->alg
) {
return false;
}
/**
* Check if token is expired.
*/
if ( ! property_exists( $parts->payload, 'exp' ) || time() > (int) $parts->payload->exp ) {
return false;
}
/**
* Check if the token is based on our secret.
*/
$encoded_regenerated_signature = self::to_base_64_url(
self::generate_signature( $parts->header_encoded . '.' . $parts->payload_encoded, $secret )
);
return hash_equals( $encoded_regenerated_signature, $parts->signature_encoded );
}
/**
* Returns the decoded/encoded header, payload and signature from a token string.
*
* @param string $token Full token string.
*
* @return object
*/
public static function get_parts( string $token ) {
$parts = explode( '.', $token );
return (object) array(
'header' => json_decode( self::from_base_64_url( $parts[0] ) ),
'header_encoded' => $parts[0],
'payload' => json_decode( self::from_base_64_url( $parts[1] ) ),
'payload_encoded' => $parts[1],
'signature' => self::from_base_64_url( $parts[2] ),
'signature_encoded' => $parts[2],
);
}
/**
* Generates the json formatted header for our HS256 JWT token.
*
* @return string|bool
*/
private static function generate_header() {
return wp_json_encode(
array(
'alg' => self::$algorithm,
'typ' => self::$type,
)
);
}
/**
* Generates a sha256 signature for the provided string using the provided secret.
*
* @param string $string Header + Payload token substring.
* @param string $secret The secret used to generate the signature.
*
* @return false|string
*/
private static function generate_signature( string $string, string $secret ) {
return hash_hmac(
'sha256',
$string,
$secret,
true
);
}
/**
* Generates the payload in json formatted string.
*
* @param array $payload Payload data.
*
* @return string|bool
*/
private static function generate_payload( array $payload ) {
return wp_json_encode( array_merge( $payload, [ 'iat' => time() ] ) );
}
/**
* Encodes a string to url safe base64.
*
* @param string $string The string to be encoded.
*
* @return string
*/
private static function to_base_64_url( string $string ) {
return str_replace(
array( '+', '/', '=' ),
array( '-', '_', '' ),
base64_encode( $string ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
);
}
/**
* Decodes a string encoded using url safe base64, supporting auto padding.
*
* @param string $string the string to be decoded.
*
* @return string
*/
private static function from_base_64_url( string $string ) {
/**
* Add padding to base64 strings which require it. Some base64 URL strings
* which are decoded will have missing padding which is represented by the
* equals sign.
*/
if ( strlen( $string ) % 4 !== 0 ) {
return self::from_base_64_url( $string . '=' );
}
return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
str_replace(
array( '-', '_' ),
array( '+', '/' ),
$string
)
);
}
}
StoreApi/Utilities/LocalPickupUtils.php 0000644 00000002574 15154173075 0014225 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* Util class for local pickup related functionality, this contains methods that need to be accessed from places besides
* the ShippingController, i.e. the OrderController.
*/
class LocalPickupUtils {
/**
* Checks if WC Blocks local pickup is enabled.
*
* @return bool True if local pickup is enabled.
*/
public static function is_local_pickup_enabled() {
$pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [] );
return wc_string_to_bool( $pickup_location_settings['enabled'] ?? 'no' );
}
/**
* Gets a list of payment method ids that support the 'local-pickup' feature.
*
* @return string[] List of payment method ids that support the 'local-pickup' feature.
*/
public static function get_local_pickup_method_ids() {
$all_methods_supporting_local_pickup = array_reduce(
WC()->shipping()->get_shipping_methods(),
function( $methods, $method ) {
if ( $method->supports( 'local-pickup' ) ) {
$methods[] = $method->id;
}
return $methods;
},
array()
);
// We use array_values because this will be used in JS, so we don't need the (numerical) keys.
return array_values(
// This array_unique is necessary because WC()->shipping()->get_shipping_methods() can return duplicates.
array_unique(
$all_methods_supporting_local_pickup
)
);
}
}
StoreApi/Utilities/NoticeHandler.php 0000644 00000004165 15154173075 0013513 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use WP_Error;
/**
* NoticeHandler class.
* Helper class to handle notices.
*/
class NoticeHandler {
/**
* Convert queued error notices into an exception.
*
* For example, Payment methods may add error notices during validate_fields call to prevent checkout.
* Since we're not rendering notices at all, we need to convert them to exceptions.
*
* This method will find the first error message and thrown an exception instead. Discards notices once complete.
*
* @throws RouteException If an error notice is detected, Exception is thrown.
*
* @param string $error_code Error code for the thrown exceptions.
*/
public static function convert_notices_to_exceptions( $error_code = 'unknown_server_error' ) {
if ( 0 === wc_notice_count( 'error' ) ) {
wc_clear_notices();
return;
}
$error_notices = wc_get_notices( 'error' );
// Prevent notices from being output later on.
wc_clear_notices();
foreach ( $error_notices as $error_notice ) {
throw new RouteException( $error_code, wp_strip_all_tags( $error_notice['notice'] ), 400 );
}
}
/**
* Collects queued error notices into a \WP_Error.
*
* For example, cart validation processes may add error notices to prevent checkout.
* Since we're not rendering notices at all, we need to catch them and group them in a single WP_Error instance.
*
* This method will discard notices once complete.
*
* @param string $error_code Error code for the thrown exceptions.
*
* @return \WP_Error The WP_Error object containing all error notices.
*/
public static function convert_notices_to_wp_errors( $error_code = 'unknown_server_error' ) {
$errors = new WP_Error();
if ( 0 === wc_notice_count( 'error' ) ) {
wc_clear_notices();
return $errors;
}
$error_notices = wc_get_notices( 'error' );
// Prevent notices from being output later on.
wc_clear_notices();
foreach ( $error_notices as $error_notice ) {
$errors->add( $error_code, wp_strip_all_tags( $error_notice['notice'] ) );
}
return $errors;
}
}
StoreApi/Utilities/OrderAuthorizationTrait.php 0000644 00000004352 15154173075 0015632 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* OrderAuthorizationTrait
*
* Shared functionality for getting order authorization.
*/
trait OrderAuthorizationTrait {
/**
* Check if authorized to get the order.
*
* @throws RouteException If the order is not found or the order key is invalid.
*
* @param \WP_REST_Request $request Request object.
* @return boolean|WP_Error
*/
public function is_authorized( \WP_REST_Request $request ) {
$order_id = absint( $request['id'] );
$order_key = sanitize_text_field( wp_unslash( $request->get_param( 'key' ) ) );
$billing_email = sanitize_text_field( wp_unslash( $request->get_param( 'billing_email' ) ) );
try {
// In this context, pay_for_order capability checks that the current user ID matches the customer ID stored
// within the order, or if the order was placed by a guest.
// See https://github.com/woocommerce/woocommerce/blob/abcedbefe02f9e89122771100c42ff588da3e8e0/plugins/woocommerce/includes/wc-user-functions.php#L458.
if ( ! current_user_can( 'pay_for_order', $order_id ) ) {
throw new RouteException( 'woocommerce_rest_invalid_user', __( 'This order belongs to a different customer.', 'woocommerce' ), 403 );
}
if ( get_current_user_id() === 0 ) {
$this->order_controller->validate_order_key( $order_id, $order_key );
$this->validate_billing_email_matches_order( $order_id, $billing_email );
}
} catch ( RouteException $error ) {
return new \WP_Error(
$error->getErrorCode(),
$error->getMessage(),
array( 'status' => $error->getCode() )
);
}
return true;
}
/**
* Validate a given billing email against an existing order.
*
* @throws RouteException Exception if invalid data is detected.
* @param integer $order_id Order ID.
* @param string $billing_email Billing email.
*/
public function validate_billing_email_matches_order( $order_id, $billing_email ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $billing_email || $order->get_billing_email() !== $billing_email ) {
throw new RouteException( 'woocommerce_rest_invalid_billing_email', __( 'Invalid billing email provided.', 'woocommerce' ), 401 );
}
}
}
StoreApi/Utilities/OrderController.php 0000644 00000056121 15154173075 0014112 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use \Exception;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* OrderController class.
* Helper class which creates and syncs orders with the cart.
*/
class OrderController {
/**
* Create order and set props based on global settings.
*
* @throws RouteException Exception if invalid data is detected.
*
* @return \WC_Order A new order object.
*/
public function create_order_from_cart() {
if ( wc()->cart->is_empty() ) {
throw new RouteException(
'woocommerce_rest_cart_empty',
__( 'Cannot create order from empty cart.', 'woocommerce' ),
400
);
}
add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
$order = new \WC_Order();
$order->set_status( 'checkout-draft' );
$order->set_created_via( 'store-api' );
$this->update_order_from_cart( $order );
remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
return $order;
}
/**
* Update an order using data from the current cart.
*
* @param \WC_Order $order The order object to update.
* @param boolean $update_totals Whether to update totals or not.
*/
public function update_order_from_cart( \WC_Order $order, $update_totals = true ) {
/**
* This filter ensures that local pickup locations are still used for order taxes by forcing the address used to
* calculate tax for an order to match the current address of the customer.
*
* - The method `$customer->get_taxable_address()` runs the filter `woocommerce_customer_taxable_address`.
* - While we have a session, our `ShippingController::filter_taxable_address` function uses this hook to set
* the customer address to the pickup location address if local pickup is the chosen method.
*
* Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are calculated,
* resulting in the wrong taxes being applied with local pickup.
*
* The alternative would be to instead use `woocommerce_order_get_tax_location` to return the pickup location
* address directly, however since we have the customer filter in place we don't need to duplicate effort.
*
* @see \WC_Abstract_Order::get_tax_location()
*/
add_filter(
'woocommerce_order_get_tax_location',
function( $location ) {
if ( ! is_null( wc()->customer ) ) {
$taxable_address = wc()->customer->get_taxable_address();
$location = array(
'country' => $taxable_address[0],
'state' => $taxable_address[1],
'postcode' => $taxable_address[2],
'city' => $taxable_address[3],
);
}
return $location;
}
);
// Ensure cart is current.
if ( $update_totals ) {
wc()->cart->calculate_shipping();
wc()->cart->calculate_totals();
}
// Update the current order to match the current cart.
$this->update_line_items_from_cart( $order );
$this->update_addresses_from_cart( $order );
$order->set_currency( get_woocommerce_currency() );
$order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );
$order->set_customer_id( get_current_user_id() );
$order->set_customer_ip_address( \WC_Geolocation::get_ip_address() );
$order->set_customer_user_agent( wc_get_user_agent() );
$order->update_meta_data( 'is_vat_exempt', wc()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' );
$order->calculate_totals();
}
/**
* Copies order data to customer object (not the session), so values persist for future checkouts.
*
* @param \WC_Order $order Order object.
*/
public function sync_customer_data_with_order( \WC_Order $order ) {
if ( $order->get_customer_id() ) {
$customer = new \WC_Customer( $order->get_customer_id() );
$customer->set_props(
[
'billing_first_name' => $order->get_billing_first_name(),
'billing_last_name' => $order->get_billing_last_name(),
'billing_company' => $order->get_billing_company(),
'billing_address_1' => $order->get_billing_address_1(),
'billing_address_2' => $order->get_billing_address_2(),
'billing_city' => $order->get_billing_city(),
'billing_state' => $order->get_billing_state(),
'billing_postcode' => $order->get_billing_postcode(),
'billing_country' => $order->get_billing_country(),
'billing_email' => $order->get_billing_email(),
'billing_phone' => $order->get_billing_phone(),
'shipping_first_name' => $order->get_shipping_first_name(),
'shipping_last_name' => $order->get_shipping_last_name(),
'shipping_company' => $order->get_shipping_company(),
'shipping_address_1' => $order->get_shipping_address_1(),
'shipping_address_2' => $order->get_shipping_address_2(),
'shipping_city' => $order->get_shipping_city(),
'shipping_state' => $order->get_shipping_state(),
'shipping_postcode' => $order->get_shipping_postcode(),
'shipping_country' => $order->get_shipping_country(),
'shipping_phone' => $order->get_shipping_phone(),
]
);
$customer->save();
};
}
/**
* Final validation ran before payment is taken.
*
* By this point we have an order populated with customer data and items.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
public function validate_order_before_payment( \WC_Order $order ) {
$needs_shipping = wc()->cart->needs_shipping();
$chosen_shipping_methods = wc()->session->get( 'chosen_shipping_methods' );
$this->validate_coupons( $order );
$this->validate_email( $order );
$this->validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods );
$this->validate_addresses( $order );
}
/**
* Convert a coupon code to a coupon object.
*
* @param string $coupon_code Coupon code.
* @return \WC_Coupon Coupon object.
*/
protected function get_coupon( $coupon_code ) {
return new \WC_Coupon( $coupon_code );
}
/**
* Validate coupons applied to the order and remove those that are not valid.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
protected function validate_coupons( \WC_Order $order ) {
$coupon_codes = $order->get_coupon_codes();
$coupons = array_filter( array_map( [ $this, 'get_coupon' ], $coupon_codes ) );
$validators = [ 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' ];
$coupon_errors = [];
foreach ( $coupons as $coupon ) {
try {
array_walk(
$validators,
function( $validator, $index, $params ) {
call_user_func_array( [ $this, $validator ], $params );
},
[ $coupon, $order ]
);
} catch ( Exception $error ) {
$coupon_errors[ $coupon->get_code() ] = $error->getMessage();
}
}
if ( $coupon_errors ) {
// Remove all coupons that were not valid.
foreach ( $coupon_errors as $coupon_code => $message ) {
wc()->cart->remove_coupon( $coupon_code );
}
// Recalculate totals.
wc()->cart->calculate_totals();
// Re-sync order with cart.
$this->update_order_from_cart( $order );
// Return exception so customer can review before payment.
throw new RouteException(
'woocommerce_rest_cart_coupon_errors',
sprintf(
/* translators: %s Coupon codes. */
__( 'Invalid coupons were removed from the cart: "%s"', 'woocommerce' ),
implode( '", "', array_keys( $coupon_errors ) )
),
409,
[
'removed_coupons' => $coupon_errors,
]
);
}
}
/**
* Validates the customer email. This is a required field.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
protected function validate_email( \WC_Order $order ) {
$email = $order->get_billing_email();
if ( empty( $email ) ) {
throw new RouteException(
'woocommerce_rest_missing_email_address',
__( 'A valid email address is required', 'woocommerce' ),
400
);
}
if ( ! is_email( $email ) ) {
throw new RouteException(
'woocommerce_rest_invalid_email_address',
sprintf(
/* translators: %s provided email. */
__( 'The provided email address (%s) is not valid—please provide a valid email address', 'woocommerce' ),
esc_html( $email )
),
400
);
}
}
/**
* Validates customer address data based on the locale to ensure required fields are set.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
protected function validate_addresses( \WC_Order $order ) {
$errors = new \WP_Error();
$needs_shipping = wc()->cart->needs_shipping();
$billing_address = $order->get_address( 'billing' );
$shipping_address = $order->get_address( 'shipping' );
if ( $needs_shipping && ! $this->validate_allowed_country( $shipping_address['country'], (array) wc()->countries->get_shipping_countries() ) ) {
throw new RouteException(
'woocommerce_rest_invalid_address_country',
sprintf(
/* translators: %s country code. */
__( 'Sorry, we do not ship orders to the provided country (%s)', 'woocommerce' ),
$shipping_address['country']
),
400,
[
'allowed_countries' => array_keys( wc()->countries->get_shipping_countries() ),
]
);
}
if ( ! $this->validate_allowed_country( $billing_address['country'], (array) wc()->countries->get_allowed_countries() ) ) {
throw new RouteException(
'woocommerce_rest_invalid_address_country',
sprintf(
/* translators: %s country code. */
__( 'Sorry, we do not allow orders from the provided country (%s)', 'woocommerce' ),
$billing_address['country']
),
400,
[
'allowed_countries' => array_keys( wc()->countries->get_allowed_countries() ),
]
);
}
if ( $needs_shipping ) {
$this->validate_address_fields( $shipping_address, 'shipping', $errors );
}
$this->validate_address_fields( $billing_address, 'billing', $errors );
if ( ! $errors->has_errors() ) {
return;
}
$errors_by_code = [];
$error_codes = $errors->get_error_codes();
foreach ( $error_codes as $code ) {
$errors_by_code[ $code ] = $errors->get_error_messages( $code );
}
// Surface errors from first code.
foreach ( $errors_by_code as $code => $error_messages ) {
throw new RouteException(
'woocommerce_rest_invalid_address',
sprintf(
/* translators: %s Address type. */
__( 'There was a problem with the provided %s:', 'woocommerce' ) . ' ' . implode( ', ', $error_messages ),
'shipping' === $code ? __( 'shipping address', 'woocommerce' ) : __( 'billing address', 'woocommerce' )
),
400,
[
'errors' => $errors_by_code,
]
);
}
}
/**
* Check all required address fields are set and return errors if not.
*
* @param string $country Country code.
* @param array $allowed_countries List of valid country codes.
* @return boolean True if valid.
*/
protected function validate_allowed_country( $country, array $allowed_countries ) {
return array_key_exists( $country, $allowed_countries );
}
/**
* Check all required address fields are set and return errors if not.
*
* @param array $address Address array.
* @param string $address_type billing or shipping address, used in error messages.
* @param \WP_Error $errors Error object.
*/
protected function validate_address_fields( $address, $address_type, \WP_Error $errors ) {
$all_locales = wc()->countries->get_country_locale();
$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : [];
/**
* We are not using wc()->counties->get_default_address_fields() here because that is filtered. Instead, this array
* is based on assets/js/base/components/cart-checkout/address-form/default-address-fields.js
*/
$address_fields = [
'first_name' => [
'label' => __( 'First name', 'woocommerce' ),
'required' => true,
],
'last_name' => [
'label' => __( 'Last name', 'woocommerce' ),
'required' => true,
],
'company' => [
'label' => __( 'Company', 'woocommerce' ),
'required' => false,
],
'address_1' => [
'label' => __( 'Address', 'woocommerce' ),
'required' => true,
],
'address_2' => [
'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'required' => false,
],
'country' => [
'label' => __( 'Country/Region', 'woocommerce' ),
'required' => true,
],
'city' => [
'label' => __( 'City', 'woocommerce' ),
'required' => true,
],
'state' => [
'label' => __( 'State/County', 'woocommerce' ),
'required' => true,
],
'postcode' => [
'label' => __( 'Postal code', 'woocommerce' ),
'required' => true,
],
];
if ( $current_locale ) {
foreach ( $current_locale as $key => $field ) {
if ( isset( $address_fields[ $key ] ) ) {
$address_fields[ $key ]['label'] = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label'];
$address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required'];
}
}
}
foreach ( $address_fields as $address_field_key => $address_field ) {
if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) {
/* translators: %s Field label. */
$errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key );
}
}
}
/**
* Check email restrictions of a coupon against the order.
*
* @throws Exception Exception if invalid data is detected.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Order $order Order object.
*/
protected function validate_coupon_email_restriction( \WC_Coupon $coupon, \WC_Order $order ) {
$restrictions = $coupon->get_email_restrictions();
if ( ! empty( $restrictions ) && $order->get_billing_email() && ! wc()->cart->is_coupon_emails_allowed( [ $order->get_billing_email() ], $restrictions ) ) {
throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ) );
}
}
/**
* Check usage restrictions of a coupon against the order.
*
* @throws Exception Exception if invalid data is detected.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Order $order Order object.
*/
protected function validate_coupon_usage_limit( \WC_Coupon $coupon, \WC_Order $order ) {
$coupon_usage_limit = $coupon->get_usage_limit_per_user();
if ( $coupon_usage_limit > 0 ) {
$data_store = $coupon->get_data_store();
$usage_count = $order->get_customer_id() ? $data_store->get_usage_by_user_id( $coupon, $order->get_customer_id() ) : $data_store->get_usage_by_email( $coupon, $order->get_billing_email() );
if ( $usage_count >= $coupon_usage_limit ) {
throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_USAGE_LIMIT_REACHED ) );
}
}
}
/**
* Check there is a shipping method if it requires shipping.
*
* @throws RouteException Exception if invalid data is detected.
* @param boolean $needs_shipping Current order needs shipping.
* @param array $chosen_shipping_methods Array of shipping methods.
*/
public function validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods = array() ) {
if ( ! $needs_shipping || ! is_array( $chosen_shipping_methods ) ) {
return;
}
foreach ( $chosen_shipping_methods as $chosen_shipping_method ) {
if ( false === $chosen_shipping_method ) {
throw new RouteException(
'woocommerce_rest_invalid_shipping_option',
__( 'Sorry, this order requires a shipping option.', 'woocommerce' ),
400,
[]
);
}
}
}
/**
* Validate a given order key against an existing order.
*
* @throws RouteException Exception if invalid data is detected.
* @param integer $order_id Order ID.
* @param string $order_key Order key.
*/
public function validate_order_key( $order_id, $order_key ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $order_key || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) {
throw new RouteException( 'woocommerce_rest_invalid_order', __( 'Invalid order ID or key provided.', 'woocommerce' ), 401 );
}
}
/**
* Get errors for order stock on failed orders.
*
* @throws RouteException Exception if invalid data is detected.
* @param integer $order_id Order ID.
*/
public function get_failed_order_stock_error( $order_id ) {
$order = wc_get_order( $order_id );
// Ensure order items are still stocked if paying for a failed order. Pending orders do not need this check because stock is held.
if ( ! $order->has_status( wc_get_is_pending_statuses() ) ) {
$quantities = array();
foreach ( $order->get_items() as $item_key => $item ) {
if ( $item && is_callable( array( $item, 'get_product' ) ) ) {
$product = $item->get_product();
if ( ! $product ) {
continue;
}
$quantities[ $product->get_stock_managed_by_id() ] = isset( $quantities[ $product->get_stock_managed_by_id() ] ) ? $quantities[ $product->get_stock_managed_by_id() ] + $item->get_quantity() : $item->get_quantity();
}
}
// Stock levels may already have been adjusted for this order (in which case we don't need to worry about checking for low stock).
if ( ! $order->get_data_store()->get_stock_reduced( $order->get_id() ) ) {
foreach ( $order->get_items() as $item_key => $item ) {
if ( $item && is_callable( array( $item, 'get_product' ) ) ) {
$product = $item->get_product();
if ( ! $product ) {
continue;
}
/**
* Filters whether or not the product is in stock for this pay for order.
*
* @param boolean True if in stock.
* @param \WC_Product $product Product.
* @param \WC_Order $order Order.
*
* @since 9.8.0-dev
*/
if ( ! apply_filters( 'woocommerce_pay_order_product_in_stock', $product->is_in_stock(), $product, $order ) ) {
return array(
'code' => 'woocommerce_rest_out_of_stock',
/* translators: %s: product name */
'message' => sprintf( __( 'Sorry, "%s" is no longer in stock so this order cannot be paid for. We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name() ),
);
}
// We only need to check products managing stock, with a limited stock qty.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
}
// Check stock based on all items in the cart and consider any held stock within pending orders.
$held_stock = wc_get_held_stock_quantity( $product, $order->get_id() );
$required_stock = $quantities[ $product->get_stock_managed_by_id() ];
/**
* Filters whether or not the product has enough stock.
*
* @param boolean True if has enough stock.
* @param \WC_Product $product Product.
* @param \WC_Order $order Order.
*
* @since 9.8.0-dev
*/
if ( ! apply_filters( 'woocommerce_pay_order_product_has_enough_stock', ( $product->get_stock_quantity() >= ( $held_stock + $required_stock ) ), $product, $order ) ) {
/* translators: 1: product name 2: quantity in stock */
return array(
'code' => 'woocommerce_rest_out_of_stock',
/* translators: %s: product name */
'message' => sprintf( __( 'Sorry, we do not have enough "%1$s" in stock to fulfill your order (%2$s available). We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity() - $held_stock, $product ) ),
);
}
}
}
}
}
return null;
}
/**
* Changes default order status to draft for orders created via this API.
*
* @return string
*/
public function default_order_status() {
return 'checkout-draft';
}
/**
* Create order line items.
*
* @param \WC_Order $order The order object to update.
*/
protected function update_line_items_from_cart( \WC_Order $order ) {
$cart_controller = new CartController();
$cart = $cart_controller->get_cart_instance();
$cart_hashes = $cart_controller->get_cart_hashes();
if ( $order->get_cart_hash() !== $cart_hashes['line_items'] ) {
$order->set_cart_hash( $cart_hashes['line_items'] );
$order->remove_order_items( 'line_item' );
wc()->checkout->create_order_line_items( $order, $cart );
}
if ( $order->get_meta_data( '_shipping_hash' ) !== $cart_hashes['shipping'] ) {
$order->update_meta_data( '_shipping_hash', $cart_hashes['shipping'] );
$order->remove_order_items( 'shipping' );
wc()->checkout->create_order_shipping_lines( $order, wc()->session->get( 'chosen_shipping_methods' ), wc()->shipping()->get_packages() );
}
if ( $order->get_meta_data( '_coupons_hash' ) !== $cart_hashes['coupons'] ) {
$order->remove_order_items( 'coupon' );
$order->update_meta_data( '_coupons_hash', $cart_hashes['coupons'] );
wc()->checkout->create_order_coupon_lines( $order, $cart );
}
if ( $order->get_meta_data( '_fees_hash' ) !== $cart_hashes['fees'] ) {
$order->update_meta_data( '_fees_hash', $cart_hashes['fees'] );
$order->remove_order_items( 'fee' );
wc()->checkout->create_order_fee_lines( $order, $cart );
}
if ( $order->get_meta_data( '_taxes_hash' ) !== $cart_hashes['taxes'] ) {
$order->update_meta_data( '_taxes_hash', $cart_hashes['taxes'] );
$order->remove_order_items( 'tax' );
wc()->checkout->create_order_tax_lines( $order, $cart );
}
}
/**
* Update address data from cart and/or customer session data.
*
* @param \WC_Order $order The order object to update.
*/
protected function update_addresses_from_cart( \WC_Order $order ) {
$order->set_props(
[
'billing_first_name' => wc()->customer->get_billing_first_name(),
'billing_last_name' => wc()->customer->get_billing_last_name(),
'billing_company' => wc()->customer->get_billing_company(),
'billing_address_1' => wc()->customer->get_billing_address_1(),
'billing_address_2' => wc()->customer->get_billing_address_2(),
'billing_city' => wc()->customer->get_billing_city(),
'billing_state' => wc()->customer->get_billing_state(),
'billing_postcode' => wc()->customer->get_billing_postcode(),
'billing_country' => wc()->customer->get_billing_country(),
'billing_email' => wc()->customer->get_billing_email(),
'billing_phone' => wc()->customer->get_billing_phone(),
'shipping_first_name' => wc()->customer->get_shipping_first_name(),
'shipping_last_name' => wc()->customer->get_shipping_last_name(),
'shipping_company' => wc()->customer->get_shipping_company(),
'shipping_address_1' => wc()->customer->get_shipping_address_1(),
'shipping_address_2' => wc()->customer->get_shipping_address_2(),
'shipping_city' => wc()->customer->get_shipping_city(),
'shipping_state' => wc()->customer->get_shipping_state(),
'shipping_postcode' => wc()->customer->get_shipping_postcode(),
'shipping_country' => wc()->customer->get_shipping_country(),
'shipping_phone' => wc()->customer->get_shipping_phone(),
]
);
}
}
StoreApi/Utilities/Pagination.php 0000644 00000004064 15154173075 0013063 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* Pagination class.
*/
class Pagination {
/**
* Add pagination headers to a response object.
*
* @param \WP_REST_Response $response Reference to the response object.
* @param \WP_REST_Request $request The request object.
* @param int $total_items Total items found.
* @param int $total_pages Total pages found.
* @return \WP_REST_Response
*/
public function add_headers( $response, $request, $total_items, $total_pages ) {
$response->header( 'X-WP-Total', $total_items );
$response->header( 'X-WP-TotalPages', $total_pages );
$current_page = $this->get_current_page( $request );
$link_base = $this->get_link_base( $request );
if ( $current_page > 1 ) {
$previous_page = $current_page - 1;
if ( $previous_page > $total_pages ) {
$previous_page = $total_pages;
}
$this->add_page_link( $response, 'prev', $previous_page, $link_base );
}
if ( $total_pages > $current_page ) {
$this->add_page_link( $response, 'next', ( $current_page + 1 ), $link_base );
}
return $response;
}
/**
* Get current page.
*
* @param \WP_REST_Request $request The request object.
* @return int Get the page from the request object.
*/
protected function get_current_page( $request ) {
return (int) $request->get_param( 'page' );
}
/**
* Get base for links from the request object.
*
* @param \WP_REST_Request $request The request object.
* @return string
*/
protected function get_link_base( $request ) {
return esc_url( add_query_arg( $request->get_query_params(), rest_url( $request->get_route() ) ) );
}
/**
* Add a page link.
*
* @param \WP_REST_Response $response Reference to the response object.
* @param string $name Page link name. e.g. prev.
* @param int $page Page number.
* @param string $link_base Base URL.
*/
protected function add_page_link( &$response, $name, $page, $link_base ) {
$response->link_header( $name, add_query_arg( 'page', $page, $link_base ) );
}
}
StoreApi/Utilities/ProductItemTrait.php 0000644 00000005715 15154173075 0014241 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* ProductItemTrait
*
* Shared functionality for formating product item data.
*/
trait ProductItemTrait {
/**
* Get an array of pricing data.
*
* @param \WC_Product $product Product instance.
* @param string $tax_display_mode If returned prices are incl or excl of tax.
* @return array
*/
protected function prepare_product_price_response( \WC_Product $product, $tax_display_mode = '' ) {
$tax_display_mode = $this->get_tax_display_mode( $tax_display_mode );
$price_function = $this->get_price_function_from_tax_display_mode( $tax_display_mode );
$prices = parent::prepare_product_price_response( $product, $tax_display_mode );
// Add raw prices (prices with greater precision).
$prices['raw_prices'] = [
'precision' => wc_get_rounding_precision(),
'price' => $this->prepare_money_response( $price_function( $product ), wc_get_rounding_precision() ),
'regular_price' => $this->prepare_money_response( $price_function( $product, [ 'price' => $product->get_regular_price() ] ), wc_get_rounding_precision() ),
'sale_price' => $this->prepare_money_response( $price_function( $product, [ 'price' => $product->get_sale_price() ] ), wc_get_rounding_precision() ),
];
return $prices;
}
/**
* Format variation data, for example convert slugs such as attribute_pa_size to Size.
*
* @param array $variation_data Array of data from the cart.
* @param \WC_Product $product Product data.
* @return array
*/
protected function format_variation_data( $variation_data, $product ) {
$return = [];
if ( ! is_iterable( $variation_data ) ) {
return $return;
}
foreach ( $variation_data as $key => $value ) {
$taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $key ) ) );
if ( taxonomy_exists( $taxonomy ) ) {
// If this is a term slug, get the term's nice name.
$term = get_term_by( 'slug', $value, $taxonomy );
if ( ! is_wp_error( $term ) && $term && $term->name ) {
$value = $term->name;
}
$label = wc_attribute_label( $taxonomy );
} else {
/**
* Filters the variation option name.
*
* Filters the variation option name for custom option slugs.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $value The name to display.
* @param null $unused Unused because this is not a variation taxonomy.
* @param string $taxonomy Taxonomy or product attribute name.
* @param \WC_Product $product Product data.
* @return string
*/
$value = apply_filters( 'woocommerce_variation_option_name', $value, null, $taxonomy, $product );
$label = wc_attribute_label( str_replace( 'attribute_', '', $key ), $product );
}
$return[] = [
'attribute' => $this->prepare_html_response( $label ),
'value' => $this->prepare_html_response( $value ),
];
}
return $return;
}
}
StoreApi/Utilities/ProductQuery.php 0000644 00000042477 15154173075 0013452 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use WC_Tax;
/**
* Product Query class.
*
* Helper class to handle product queries for the API.
*/
class ProductQuery {
/**
* Prepare query args to pass to WP_Query for a REST API request.
*
* @param \WP_REST_Request $request Request data.
* @return array
*/
public function prepare_objects_query( $request ) {
$args = [
'offset' => $request['offset'],
'order' => $request['order'],
'orderby' => $request['orderby'],
'paged' => $request['page'],
'post__in' => $request['include'],
'post__not_in' => $request['exclude'],
'posts_per_page' => $request['per_page'] ? $request['per_page'] : -1,
'post_parent__in' => $request['parent'],
'post_parent__not_in' => $request['parent_exclude'],
'search' => $request['search'], // This uses search rather than s intentionally to handle searches internally.
'slug' => $request['slug'],
'fields' => 'ids',
'ignore_sticky_posts' => true,
'post_status' => 'publish',
'date_query' => [],
'post_type' => 'product',
];
// If searching for a specific SKU or slug, allow any post type.
if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) {
$args['post_type'] = [ 'product', 'product_variation' ];
}
// Taxonomy query to filter products by type, category, tag, shipping class, and attribute.
$tax_query = [];
// Filter product type by slug.
if ( ! empty( $request['type'] ) ) {
if ( 'variation' === $request['type'] ) {
$args['post_type'] = 'product_variation';
} else {
$args['post_type'] = 'product';
$tax_query[] = [
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => $request['type'],
];
}
}
if ( 'date' === $args['orderby'] ) {
$args['orderby'] = 'date ID';
}
// Set before into date query. Date query must be specified as an array of an array.
if ( isset( $request['before'] ) ) {
$args['date_query'][0]['before'] = $request['before'];
}
// Set after into date query. Date query must be specified as an array of an array.
if ( isset( $request['after'] ) ) {
$args['date_query'][0]['after'] = $request['after'];
}
// Set date query column. Defaults to post_date.
if ( isset( $request['date_column'] ) && ! empty( $args['date_query'][0] ) ) {
$args['date_query'][0]['column'] = 'post_' . $request['date_column'];
}
// Set custom args to handle later during clauses.
$custom_keys = [
'sku',
'min_price',
'max_price',
'stock_status',
];
foreach ( $custom_keys as $key ) {
if ( ! empty( $request[ $key ] ) ) {
$args[ $key ] = $request[ $key ];
}
}
$operator_mapping = [
'in' => 'IN',
'not_in' => 'NOT IN',
'and' => 'AND',
];
// Gets all registered product taxonomies and prefixes them with `tax_`.
// This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field.
// eg an `sku` taxonomy will be mapped to `tax_sku`.
$all_product_taxonomies = array_map(
function ( $value ) {
return '_unstable_tax_' . $value;
},
get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' )
);
// Map between taxonomy name and arg key.
$default_taxonomies = [
'product_cat' => 'category',
'product_tag' => 'tag',
];
$taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies );
// Set tax_query for each passed arg.
foreach ( $taxonomies as $taxonomy => $key ) {
if ( ! empty( $request[ $key ] ) ) {
$operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN';
$tax_query[] = [
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $request[ $key ],
'operator' => $operator,
];
}
}
// Filter by attributes.
if ( ! empty( $request['attributes'] ) ) {
$att_queries = [];
foreach ( $request['attributes'] as $attribute ) {
if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) {
continue;
}
if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) {
$operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN';
$att_queries[] = [
'taxonomy' => $attribute['attribute'],
'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug',
'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'],
'operator' => $operator,
];
}
}
if ( 1 < count( $att_queries ) ) {
// Add relation arg when using multiple attributes.
$relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN';
$tax_query[] = [
'relation' => $relation,
$att_queries,
];
} else {
$tax_query = array_merge( $tax_query, $att_queries );
}
}
// Build tax_query if taxonomies are set.
if ( ! empty( $tax_query ) ) {
if ( ! empty( $args['tax_query'] ) ) {
$args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore
} else {
$args['tax_query'] = $tax_query; // phpcs:ignore
}
}
// Filter featured.
if ( is_bool( $request['featured'] ) ) {
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => 'featured',
'operator' => true === $request['featured'] ? 'IN' : 'NOT IN',
];
}
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
$on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? [ 0 ] : $on_sale_ids;
$args[ $on_sale_key ] += $on_sale_ids;
}
$catalog_visibility = $request->get_param( 'catalog_visibility' );
$rating = $request->get_param( 'rating' );
$visibility_options = wc_get_product_visibility_options();
if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) {
$exclude_from_catalog = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog';
$exclude_from_search = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search';
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => [ $exclude_from_catalog, $exclude_from_search ],
'operator' => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN',
'rating_filter' => true,
];
}
if ( $rating ) {
$rating_terms = [];
foreach ( $rating as $value ) {
$rating_terms[] = 'rated-' . $value;
}
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => $rating_terms,
];
}
$orderby = $request->get_param( 'orderby' );
$order = $request->get_param( 'order' );
$ordering_args = wc()->query->get_catalog_ordering_args( $orderby, $order );
$args['orderby'] = $ordering_args['orderby'];
$args['order'] = $ordering_args['order'];
if ( 'include' === $orderby ) {
$args['orderby'] = 'post__in';
} elseif ( 'id' === $orderby ) {
$args['orderby'] = 'ID'; // ID must be capitalized.
} elseif ( 'slug' === $orderby ) {
$args['orderby'] = 'name';
}
if ( $ordering_args['meta_key'] ) {
$args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore
}
return $args;
}
/**
* Get results of query.
*
* @param \WP_REST_Request $request Request data.
* @return array
*/
public function get_results( $request ) {
$query_args = $this->prepare_objects_query( $request );
add_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10, 2 );
$query = new \WP_Query();
$results = $query->query( $query_args );
$total_posts = $query->found_posts;
// Out-of-bounds, run the query again without LIMIT for total count.
if ( $total_posts < 1 && $query_args['paged'] > 1 ) {
unset( $query_args['paged'] );
$count_query = new \WP_Query();
$count_query->query( $query_args );
$total_posts = $count_query->found_posts;
}
remove_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10 );
return [
'results' => $results,
'total' => (int) $total_posts,
'pages' => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1,
];
}
/**
* Get objects.
*
* @param \WP_REST_Request $request Request data.
* @return array
*/
public function get_objects( $request ) {
$results = $this->get_results( $request );
return [
'objects' => array_map( 'wc_get_product', $results['results'] ),
'total' => $results['total'],
'pages' => $results['pages'],
];
}
/**
* Get last modified date for all products.
*
* @return int timestamp.
*/
public function get_last_modified() {
global $wpdb;
return strtotime( $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" ) );
}
/**
* Add in conditional search filters for products.
*
* @param array $args Query args.
* @param \WC_Query $wp_query WC_Query object.
* @return array
*/
public function add_query_clauses( $args, $wp_query ) {
global $wpdb;
if ( $wp_query->get( 'search' ) ) {
$search = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%';
$search_query = wc_product_sku_enabled()
? $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR wc_product_meta_lookup.sku LIKE %s ) ", $search, $search )
: $wpdb->prepare( " AND $wpdb->posts.post_title LIKE %s ", $search );
$args['where'] .= $search_query;
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
}
if ( $wp_query->get( 'sku' ) ) {
$skus = explode( ',', $wp_query->get( 'sku' ) );
// Include the current string as a SKU too.
if ( 1 < count( $skus ) ) {
$skus[] = $wp_query->get( 'sku' );
}
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.sku IN ("' . implode( '","', array_map( 'esc_sql', $skus ) ) . '")';
}
if ( $wp_query->get( 'slug' ) ) {
$slugs = explode( ',', $wp_query->get( 'slug' ) );
// Include the current string as a slug too.
if ( 1 < count( $slugs ) ) {
$slugs[] = $wp_query->get( 'slug' );
}
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$post_name__in = implode( '","', array_map( 'esc_sql', $slugs ) );
$args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")";
}
if ( $wp_query->get( 'stock_status' ) ) {
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.stock_status IN ("' . implode( '","', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '")';
} elseif ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.stock_status NOT IN ("outofstock")';
}
if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) {
$args = $this->add_price_filter_clauses( $args, $wp_query );
}
return $args;
}
/**
* Add in conditional price filters.
*
* @param array $args Query args.
* @param \WC_Query $wp_query WC_Query object.
* @return array
*/
protected function add_price_filter_clauses( $args, $wp_query ) {
global $wpdb;
$adjust_for_taxes = $this->adjust_price_filters_for_displayed_taxes();
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
if ( $wp_query->get( 'min_price' ) ) {
$min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'min_price', '>=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price >= %f ', $min_price_filter );
}
}
if ( $wp_query->get( 'max_price' ) ) {
$max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'max_price', '<=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price <= %f ', $max_price_filter );
}
}
return $args;
}
/**
* Get query for price filters when dealing with displayed taxes.
*
* @param float $price_filter Price filter to apply.
* @param string $column Price being filtered (min or max).
* @param string $operator Comparison operator for column.
* @return string Constructed query.
*/
protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) {
global $wpdb;
// Select only used tax classes to avoid unwanted calculations.
$product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" );
if ( empty( $product_tax_classes ) ) {
return '';
}
$or_queries = [];
// We need to adjust the filter for each possible tax class and combine the queries into one.
foreach ( $product_tax_classes as $tax_class ) {
$adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class );
$or_queries[] = $wpdb->prepare(
'( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )',
$tax_class,
$adjusted_price_filter
);
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
return $wpdb->prepare(
' AND (
wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ')
OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )
) ',
$price_filter
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
}
/**
* If price filters need adjustment to work with displayed taxes, this returns true.
*
* This logic is used when prices are stored in the database differently to how they are being displayed, with regards
* to taxes.
*
* @return boolean
*/
protected function adjust_price_filters_for_displayed_taxes() {
$display = get_option( 'woocommerce_tax_display_shop' );
$database = wc_prices_include_tax() ? 'incl' : 'excl';
return $display !== $database;
}
/**
* Converts price filter from subunits to decimal.
*
* @param string|int $price_filter Raw price filter in subunit format.
* @return float Price filter in decimal format.
*/
protected function prepare_price_filter( $price_filter ) {
return floatval( $price_filter / ( 10 ** wc_get_price_decimals() ) );
}
/**
* Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes.
*
* This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core.
*
* @param float $price_filter Price filter amount as entered.
* @param string $tax_class Tax class for adjustment.
* @return float
*/
protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) {
$tax_display = get_option( 'woocommerce_tax_display_shop' );
$tax_rates = WC_Tax::get_rates( $tax_class );
$base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class );
// If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax.
if ( 'incl' === $tax_display ) {
/**
* Filters if taxes should be removed from locations outside the store base location.
*
* The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing
* with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10
* regardless of location and taxes.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param boolean $adjust_non_base_location_prices True by default.
* @return boolean
*/
$taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true );
return $price_filter - array_sum( $taxes );
}
// If prices are shown excl. tax, add taxes to match the prices stored in the DB.
$taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false );
return $price_filter + array_sum( $taxes );
}
/**
* Join wc_product_meta_lookup to posts if not already joined.
*
* @param string $sql SQL join.
* @return string
*/
protected function append_product_sorting_table_join( $sql ) {
global $wpdb;
if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
}
return $sql;
}
}
StoreApi/Utilities/ProductQueryFilters.php 0000644 00000036452 15154173075 0014777 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
use Exception;
use WP_REST_Request;
/**
* Product Query filters class.
*/
class ProductQueryFilters {
/**
* Get filtered min price for current products.
*
* @param \WP_REST_Request $request The request object.
* @return object
*/
public function get_filtered_price( $request ) {
global $wpdb;
// Regenerate the products query without min/max price request params.
unset( $request['min_price'], $request['max_price'] );
// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
$product_query = new ProductQuery();
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
$price_filter_sql = "
SELECT min( min_price ) as min_price, MAX( max_price ) as max_price
FROM {$wpdb->wc_product_meta_lookup}
WHERE product_id IN ( {$product_query_sql} )
";
return $wpdb->get_row( $price_filter_sql ); // phpcs:ignore
}
/**
* Get stock status counts for the current products.
*
* @param \WP_REST_Request $request The request object.
* @return array status=>count pairs.
*/
public function get_stock_status_counts( $request ) {
global $wpdb;
$product_query = new ProductQuery();
$stock_status_options = array_map( 'esc_sql', array_keys( wc_get_product_stock_status_options() ) );
$hide_outofstock_items = get_option( 'woocommerce_hide_out_of_stock_items' );
if ( 'yes' === $hide_outofstock_items ) {
unset( $stock_status_options['outofstock'] );
}
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
unset( $query_args['stock_status'] );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
$stock_status_counts = array();
foreach ( $stock_status_options as $status ) {
$stock_status_count_sql = $this->generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options );
$result = $wpdb->get_row( $stock_status_count_sql ); // phpcs:ignore
$stock_status_counts[ $status ] = $result->status_count;
}
return $stock_status_counts;
}
/**
* Generate calculate query by stock status.
*
* @param string $status status to calculate.
* @param string $product_query_sql product query for current filter state.
* @param array $stock_status_options available stock status options.
*
* @return false|string
*/
private function generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options ) {
if ( ! in_array( $status, $stock_status_options, true ) ) {
return false;
}
global $wpdb;
$status = esc_sql( $status );
return "
SELECT COUNT( DISTINCT posts.ID ) as status_count
FROM {$wpdb->posts} as posts
INNER JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
AND postmeta.meta_key = '_stock_status'
AND postmeta.meta_value = '{$status}'
WHERE posts.ID IN ( {$product_query_sql} )
";
}
/**
* Get terms list for a given taxonomy.
*
* @param string $taxonomy Taxonomy name.
*
* @return array
*/
public function get_terms_list( string $taxonomy ) {
global $wpdb;
return $wpdb->get_results(
$wpdb->prepare(
"SELECT term_id as term_count_id,
count(DISTINCT product_or_parent_id) as term_count
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s
GROUP BY term_id",
$taxonomy
)
);
}
/**
* Get the empty terms list for a given taxonomy.
*
* @param string $taxonomy Taxonomy name.
*
* @return array
*/
public function get_empty_terms_list( string $taxonomy ) {
global $wpdb;
return $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT term_id as term_count_id,
0 as term_count
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s",
$taxonomy
)
);
}
/**
* Get attribute and meta counts.
*
* @param WP_REST_Request $request Request data.
* @param string $filtered_attribute The attribute to count.
*
* @return array
*/
public function get_attribute_counts( $request, $filtered_attribute ) {
if ( is_array( $filtered_attribute ) ) {
wc_deprecated_argument( 'attributes', 'TBD', 'get_attribute_counts does not require an array of attributes as the second parameter anymore. Provide the filtered attribute as a string instead.' );
$filtered_attribute = ! empty( $filtered_attribute[0] ) ? $filtered_attribute[0] : '';
if ( empty( $filtered_attribute ) ) {
return array();
}
}
$attributes_data = $request->get_param( 'attributes' );
$calculate_attribute_counts = $request->get_param( 'calculate_attribute_counts' );
$min_price = $request->get_param( 'min_price' );
$max_price = $request->get_param( 'max_price' );
$rating = $request->get_param( 'rating' );
$stock_status = $request->get_param( 'stock_status' );
$transient_key = 'wc_get_attribute_and_meta_counts_' . md5(
wp_json_encode(
array(
'attributes_data' => $attributes_data,
'calculate_attribute_counts' => $calculate_attribute_counts,
'min_price' => $min_price,
'max_price' => $max_price,
'rating' => $rating,
'stock_status' => $stock_status,
'filtered_attribute' => $filtered_attribute,
)
)
);
$cached_results = get_transient( $transient_key );
if ( ! empty( $cached_results ) && defined( 'WP_DEBUG' ) && ! WP_DEBUG ) {
return $cached_results;
}
if ( empty( $attributes_data ) && empty( $min_price ) && empty( $max_price ) && empty( $rating ) && empty( $stock_status ) ) {
$counts = $this->get_terms_list( $filtered_attribute );
return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
}
$where_clause = '';
if ( ! empty( $min_price ) || ! empty( $max_price ) || ! empty( $rating ) || ! empty( $stock_status ) ) {
$product_metas = [
'min_price' => $min_price,
'max_price' => $max_price,
'average_rating' => $rating,
'stock_status' => $stock_status,
];
$filtered_products_by_metas = $this->get_product_by_metas( $product_metas );
$formatted_filtered_products_by_metas = implode( ',', array_map( 'intval', $filtered_products_by_metas ) );
if ( ! empty( $formatted_filtered_products_by_metas ) ) {
if ( ! empty( $rating ) ) {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_metas );
} else {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_id IN (%1s)', $formatted_filtered_products_by_metas );
}
} else {
$counts = $this->get_empty_terms_list( $filtered_attribute );
return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
}
}
$join_type = 'LEFT';
foreach ( $attributes_data as $attribute ) {
$filtered_terms = $attribute['slug'] ?? '';
if ( empty( $filtered_terms ) ) {
continue;
}
$taxonomy = $attribute['attribute'] ?? '';
$term_ids = [];
if ( in_array( $taxonomy, wc_get_attribute_taxonomy_names(), true ) ) {
foreach ( $filtered_terms as $filtered_term ) {
$term = get_term_by( 'slug', $filtered_term, $taxonomy );
if ( is_object( $term ) ) {
$term_ids[] = $term->term_id;
}
}
}
if ( empty( $term_ids ) ) {
continue;
}
foreach ( $calculate_attribute_counts as $calculate_attribute_count ) {
if ( ! isset( $calculate_attribute_count['taxonomy'] ) && ! isset( $calculate_attribute_count['query_type'] ) ) {
continue;
}
$query_type = $calculate_attribute_count['query_type'];
$filtered_products_by_terms = $this->get_product_by_filtered_terms( $calculate_attribute_count['taxonomy'], $term_ids, $query_type );
$formatted_filtered_products_by_terms = implode( ',', array_map( 'intval', $filtered_products_by_terms ) );
if ( ! empty( $formatted_filtered_products_by_terms ) ) {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_terms );
}
if ( $calculate_attribute_count['taxonomy'] === $filtered_attribute ) {
$join_type = 'or' === $query_type ? 'LEFT' : 'INNER';
}
}
}
global $wpdb;
$counts = $wpdb->get_results(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT attributes.term_id as term_count_id, coalesce(term_count, 0) as term_count
FROM (SELECT DISTINCT term_id
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s) as attributes %1s JOIN (
SELECT COUNT(DISTINCT product_attribute_lookup.product_or_parent_id) as term_count, product_attribute_lookup.term_id
FROM {$wpdb->prefix}wc_product_attributes_lookup product_attribute_lookup
INNER JOIN {$wpdb->posts} posts
ON posts.ID = product_attribute_lookup.product_id
WHERE posts.post_type IN ('product', 'product_variation') AND posts.post_status = 'publish'%1s
GROUP BY product_attribute_lookup.term_id
) summarize
ON attributes.term_id = summarize.term_id
",
$filtered_attribute,
$join_type,
$where_clause
)
);
// phpcs:enable
$results = array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
set_transient( $transient_key, $results, 24 * HOUR_IN_SECONDS );
return $results;
}
/**
* Get rating counts for the current products.
*
* @param \WP_REST_Request $request The request object.
* @return array rating=>count pairs.
*/
public function get_rating_counts( $request ) {
global $wpdb;
// Regenerate the products query without rating request params.
unset( $request['rating'] );
// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
$product_query = new ProductQuery();
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
$rating_count_sql = "
SELECT COUNT( DISTINCT product_id ) as product_count, ROUND( average_rating, 0 ) as rounded_average_rating
FROM {$wpdb->wc_product_meta_lookup}
WHERE product_id IN ( {$product_query_sql} )
AND average_rating > 0
GROUP BY rounded_average_rating
ORDER BY rounded_average_rating ASC
";
$results = $wpdb->get_results( $rating_count_sql ); // phpcs:ignore
return array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) );
}
/**
* Gets product by metas.
*
* @since TBD
* @param array $metas Array of metas to query.
* @return array $results
*/
public function get_product_by_metas( $metas = array() ) {
global $wpdb;
if ( empty( $metas ) ) {
return array();
}
$where = array();
$results = array();
$params = array();
foreach ( $metas as $column => $value ) {
if ( empty( $value ) ) {
continue;
}
if ( 'stock_status' === $column ) {
$stock_product_ids = array();
foreach ( $value as $stock_status ) {
$stock_product_ids[] = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE stock_status = %s",
$stock_status
)
);
}
$where[] = 'product_id IN (' . implode( ',', array_merge( ...$stock_product_ids ) ) . ')';
continue;
}
if ( 'min_price' === $column ) {
$where[] = "{$column} >= %d";
$params[] = intval( $value ) / 100;
continue;
}
if ( 'max_price' === $column ) {
$where[] = "{$column} <= %d";
$params[] = intval( $value ) / 100;
continue;
}
if ( 'average_rating' === $column ) {
$where_rating = array();
foreach ( $value as $rating ) {
$where_rating[] = sprintf( '(average_rating >= %f - 0.5 AND average_rating < %f + 0.5)', $rating, $rating );
}
$where[] = '(' . implode( ' OR ', $where_rating ) . ')';
continue;
}
$where[] = sprintf( "%1s = '%s'", $column, $value );
$params[] = $value;
}
if ( ! empty( $where ) ) {
$where_clause = implode( ' AND ', $where );
$where_clause = sprintf( $where_clause, ...$params );
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE %1s",
$where_clause
)
);
}
// phpcs:enable
return $results;
}
/**
* Gets product by filtered terms.
*
* @since TBD
* @param string $taxonomy Taxonomy name.
* @param array $term_ids Term IDs.
* @param string $query_type or | and.
* @return array Product IDs.
*/
public function get_product_by_filtered_terms( $taxonomy = '', $term_ids = array(), $query_type = 'or' ) {
global $wpdb;
$term_count = count( $term_ids );
$results = array();
$term_ids = implode( ',', array_map( 'intval', $term_ids ) );
if ( 'or' === $query_type ) {
$results = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN (%1s)
",
$taxonomy,
$term_ids
)
// phpcs:enable
);
}
if ( 'and' === $query_type ) {
$results = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN (%1s)
GROUP BY `product_or_parent_id`
HAVING COUNT( DISTINCT `term_id` ) >= %d
",
$taxonomy,
$term_ids,
$term_count
)
// phpcs:enable
);
}
return $results;
}
}
StoreApi/Utilities/QuantityLimits.php 0000644 00000015477 15154173075 0014004 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
/**
* QuantityLimits class.
*
* Returns limits for products and cart items when using the StoreAPI and supporting classes.
*/
final class QuantityLimits {
use DraftOrderTrait;
/**
* Get quantity limits (min, max, step/multiple) for a product or cart item.
*
* @param array $cart_item A cart item array.
* @return array
*/
public function get_cart_item_quantity_limits( $cart_item ) {
$product = $cart_item['data'] ?? false;
if ( ! $product instanceof \WC_Product ) {
return [
'minimum' => 1,
'maximum' => 9999,
'multiple_of' => 1,
'editable' => true,
];
}
$multiple_of = (int) $this->filter_value( 1, 'multiple_of', $cart_item );
$minimum = (int) $this->filter_value( 1, 'minimum', $cart_item );
$maximum = (int) $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $cart_item );
$editable = (bool) $this->filter_value( ! $product->is_sold_individually(), 'editable', $cart_item );
return [
'minimum' => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
'maximum' => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
'multiple_of' => $multiple_of,
'editable' => $editable,
];
}
/**
* Get limits for product add to cart forms.
*
* @param \WC_Product $product Product instance.
* @return array
*/
public function get_add_to_cart_limits( \WC_Product $product ) {
$multiple_of = $this->filter_value( 1, 'multiple_of', $product );
$minimum = $this->filter_value( 1, 'minimum', $product );
$maximum = $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $product );
return [
'minimum' => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
'maximum' => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
'multiple_of' => $multiple_of,
];
}
/**
* Return a number using the closest multiple of another number. Used to enforce step/multiple values.
*
* @param int $number Number to round.
* @param int $multiple_of The multiple.
* @param string $rounding_function ceil, floor, or round.
* @return int
*/
public function limit_to_multiple( int $number, int $multiple_of, string $rounding_function = 'round' ) {
if ( $multiple_of <= 1 ) {
return $number;
}
$rounding_function = in_array( $rounding_function, [ 'ceil', 'floor', 'round' ], true ) ? $rounding_function : 'round';
return $rounding_function( $number / $multiple_of ) * $multiple_of;
}
/**
* Check that a given quantity is valid according to any limits in place.
*
* @param integer $quantity Quantity to validate.
* @param \WC_Product|array $cart_item Cart item.
* @return \WP_Error|true
*/
public function validate_cart_item_quantity( $quantity, $cart_item ) {
$limits = $this->get_cart_item_quantity_limits( $cart_item );
if ( ! $limits['editable'] ) {
return new \WP_Error(
'readonly_quantity',
__( 'This item is already in the cart and its quantity cannot be edited', 'woocommerce' )
);
}
if ( $quantity < $limits['minimum'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The minimum quantity that can be added to the cart is %s', 'woocommerce' ),
$limits['minimum']
)
);
}
if ( $quantity > $limits['maximum'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The maximum quantity that can be added to the cart is %s', 'woocommerce' ),
$limits['maximum']
)
);
}
if ( $quantity % $limits['multiple_of'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The quantity added to the cart must be a multiple of %s', 'woocommerce' ),
$limits['multiple_of']
)
);
}
return true;
}
/**
* Get the limit for the total number of a product allowed in the cart.
*
* This is based on product properties, including remaining stock, and defaults to a maximum of 9999 of any product
* in the cart at once.
*
* @param \WC_Product $product Product instance.
* @return int
*/
protected function get_product_quantity_limit( \WC_Product $product ) {
$limits = [ 9999 ];
if ( $product->is_sold_individually() ) {
$limits[] = 1;
} elseif ( ! $product->backorders_allowed() ) {
$limits[] = $this->get_remaining_stock( $product );
}
/**
* Filters the quantity limit for a product being added to the cart via the Store API.
*
* Filters the variation option name for custom option slugs.
*
* @since 6.8.0
*
* @param integer $quantity_limit Quantity limit which defaults to 9999 unless sold individually.
* @param \WC_Product $product Product instance.
* @return integer
*/
return apply_filters( 'woocommerce_store_api_product_quantity_limit', max( min( array_filter( $limits ) ), 1 ), $product );
}
/**
* Returns the remaining stock for a product if it has stock.
*
* This also factors in draft orders.
*
* @param \WC_Product $product Product instance.
* @return integer|null
*/
protected function get_remaining_stock( \WC_Product $product ) {
if ( is_null( $product->get_stock_quantity() ) ) {
return null;
}
$reserve_stock = new ReserveStock();
$reserved_stock = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
return $product->get_stock_quantity() - $reserved_stock;
}
/**
* Get a quantity for a product or cart item by running it through a filter hook.
*
* @param int|null $value Value to filter.
* @param string $value_type Type of value. Used for filter suffix.
* @param \WC_Product|array $cart_item_or_product Either a cart item or a product instance.
* @return mixed
*/
protected function filter_value( $value, string $value_type, $cart_item_or_product ) {
$is_product = $cart_item_or_product instanceof \WC_Product;
$product = $is_product ? $cart_item_or_product : $cart_item_or_product['data'];
$cart_item = $is_product ? null : $cart_item_or_product;
/**
* Filters the quantity minimum for a cart item in Store API. This allows extensions to control the minimum qty
* of items already within the cart.
*
* The suffix of the hook will vary depending on the value being filtered.
* For example, minimum, maximum, multiple_of, editable.
*
* @since 6.8.0
*
* @param mixed $value The value being filtered.
* @param \WC_Product $product The product object.
* @param array|null $cart_item The cart item if the product exists in the cart, or null.
* @return mixed
*/
return apply_filters( "woocommerce_store_api_product_quantity_{$value_type}", $value, $product, $cart_item );
}
}
StoreApi/Utilities/RateLimits.php 0000644 00000013724 15154173075 0013052 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use WC_Rate_Limiter;
use WC_Cache_Helper;
/**
* RateLimits class.
*/
class RateLimits extends WC_Rate_Limiter {
/**
* Cache group.
*/
const CACHE_GROUP = 'store_api_rate_limit';
/**
* Rate limiting enabled default value.
*
* @var boolean
*/
const ENABLED = false;
/**
* Proxy support enabled default value.
*
* @var boolean
*/
const PROXY_SUPPORT = false;
/**
* Default amount of max requests allowed for the defined timeframe.
*
* @var int
*/
const LIMIT = 25;
/**
* Default time in seconds before rate limits are reset.
*
* @var int
*/
const SECONDS = 10;
/**
* Gets a cache prefix.
*
* @param string $action_id Identifier of the action.
* @return string
*/
protected static function get_cache_key( $action_id ) {
return WC_Cache_Helper::get_cache_prefix( 'store_api_rate_limit' . $action_id );
}
/**
* Get current rate limit row from DB and normalize types. This query is not cached, and returns
* a new rate limit row if none exists.
*
* @param string $action_id Identifier of the action.
* @return object Object containing reset and remaining.
*/
protected static function get_rate_limit_row( $action_id ) {
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare(
"
SELECT rate_limit_expiry as reset, rate_limit_remaining as remaining
FROM {$wpdb->prefix}wc_rate_limits
WHERE rate_limit_key = %s
AND rate_limit_expiry > %s
",
$action_id,
time()
),
'OBJECT'
);
if ( empty( $row ) ) {
$options = self::get_options();
return (object) [
'reset' => (int) $options->seconds + time(),
'remaining' => (int) $options->limit,
];
}
return (object) [
'reset' => (int) $row->reset,
'remaining' => (int) $row->remaining,
];
}
/**
* Returns current rate limit values using cache where possible.
*
* @param string $action_id Identifier of the action.
* @return object
*/
public static function get_rate_limit( $action_id ) {
$current_limit = self::get_cached( $action_id );
if ( false === $current_limit ) {
$current_limit = self::get_rate_limit_row( $action_id );
self::set_cache( $action_id, $current_limit );
}
return $current_limit;
}
/**
* If exceeded, seconds until reset.
*
* @param string $action_id Identifier of the action.
*
* @return bool|int
*/
public static function is_exceeded_retry_after( $action_id ) {
$current_limit = self::get_rate_limit( $action_id );
// Before the next run is allowed, retry forbidden.
if ( time() <= $current_limit->reset && 0 === $current_limit->remaining ) {
return (int) $current_limit->reset - time();
}
// After the next run is allowed, retry allowed.
return false;
}
/**
* Sets the rate limit delay in seconds for action with identifier $id.
*
* @param string $action_id Identifier of the action.
* @return object Current rate limits.
*/
public static function update_rate_limit( $action_id ) {
global $wpdb;
$options = self::get_options();
$rate_limit_expiry = time() + $options->seconds;
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->prefix}wc_rate_limits
(`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`)
VALUES
(%s, %d, %d)
ON DUPLICATE KEY UPDATE
`rate_limit_remaining` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_remaining`), GREATEST(`rate_limit_remaining` - 1, 0)),
`rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`);
",
$action_id,
$rate_limit_expiry,
$options->limit - 1,
time(),
time()
)
);
$current_limit = self::get_rate_limit_row( $action_id );
self::set_cache( $action_id, $current_limit );
return $current_limit;
}
/**
* Retrieve a cached store api rate limit.
*
* @param string $action_id Identifier of the action.
* @return bool|object
*/
protected static function get_cached( $action_id ) {
return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP );
}
/**
* Cache a rate limit.
*
* @param string $action_id Identifier of the action.
* @param object $current_limit Current limit object with expiry and retries remaining.
* @return bool
*/
protected static function set_cache( $action_id, $current_limit ) {
return wp_cache_set( self::get_cache_key( $action_id ), $current_limit, self::CACHE_GROUP );
}
/**
* Return options for Rate Limits, to be returned by the "woocommerce_store_api_rate_limit_options" filter.
*
* @return object Default options.
*/
public static function get_options() {
$default_options = [
/**
* Filters the Store API rate limit check, which is disabled by default.
*
* This can be used also to disable the rate limit check when testing API endpoints via a REST API client.
*/
'enabled' => self::ENABLED,
/**
* Filters whether proxy support is enabled for the Store API rate limit check. This is disabled by default.
*
* If the store is behind a proxy, load balancer, CDN etc. the user can enable this to properly obtain
* the client's IP address through standard transport headers.
*/
'proxy_support' => self::PROXY_SUPPORT,
'limit' => self::LIMIT,
'seconds' => self::SECONDS,
];
return (object) array_merge( // By using array_merge we ensure we get a properly populated options object.
$default_options,
/**
* Filters options for Rate Limits.
*
* @param array $rate_limit_options Array of option values.
* @return array
*
* @since 8.9.0
*/
apply_filters(
'woocommerce_store_api_rate_limit_options',
$default_options
)
);
}
/**
* Gets a single option through provided name.
*
* @param string $option Option name.
*
* @return mixed
*/
public static function get_option( $option ) {
if ( ! is_string( $option ) || ! defined( 'RateLimits::' . strtoupper( $option ) ) ) {
return null;
}
return self::get_options()[ $option ];
}
}
StoreApi/Utilities/ValidationUtils.php 0000644 00000003352 15154173075 0014104 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* ValidationUtils class.
* Helper class which validates and update customer info.
*/
class ValidationUtils {
/**
* Get list of states for a country.
*
* @param string $country Country code.
* @return array Array of state names indexed by state keys.
*/
public function get_states_for_country( $country ) {
return $country ? array_filter( (array) \wc()->countries->get_states( $country ) ) : [];
}
/**
* Validate provided state against a countries list of defined states.
*
* If there are no defined states for a country, any given state is valid.
*
* @param string $state State name or code (sanitized).
* @param string $country Country code.
* @return boolean Valid or not valid.
*/
public function validate_state( $state, $country ) {
$states = $this->get_states_for_country( $country );
if ( count( $states ) && ! in_array( \wc_strtoupper( $state ), array_map( '\wc_strtoupper', array_keys( $states ) ), true ) ) {
return false;
}
return true;
}
/**
* Format a state based on the country. If country has defined states, will return a valid upper case state code.
*
* @param string $state State name or code (sanitized).
* @param string $country Country code.
* @return string
*/
public function format_state( $state, $country ) {
$states = $this->get_states_for_country( $country );
if ( count( $states ) ) {
$state = \wc_strtoupper( $state );
$state_values = array_map( '\wc_strtoupper', array_flip( array_map( '\wc_strtoupper', $states ) ) );
if ( isset( $state_values[ $state ] ) ) {
// Convert to state code if a state name was provided.
return $state_values[ $state ];
}
}
return $state;
}
}
StoreApi/deprecated.php 0000644 00000017737 15154173075 0011132 0 ustar 00 <?php
/**
* Class Aliases for graceful Backwards compatibility.
*
* This file is autoloaded via composer.json and maps the old namespaces to new namespaces.
*/
$class_aliases = [
// Old to new namespaces for utils and exceptions.
Automattic\WooCommerce\StoreApi\Exceptions\RouteException::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException::class,
Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::class => Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi::class,
Automattic\WooCommerce\StoreApi\SchemaController::class => Automattic\WooCommerce\Blocks\StoreApi\SchemaController::class,
Automattic\WooCommerce\StoreApi\RoutesController::class => Automattic\WooCommerce\Blocks\StoreApi\RoutesController::class,
Automattic\WooCommerce\StoreApi\Formatters::class => Automattic\WooCommerce\Blocks\StoreApi\Formatters::class,
Automattic\WooCommerce\StoreApi\Payments\PaymentResult::class => Automattic\WooCommerce\Blocks\Payments\PaymentResult::class,
Automattic\WooCommerce\StoreApi\Payments\PaymentContext::class => Automattic\WooCommerce\Blocks\Payments\PaymentContext::class,
// Old schemas to V1 schemas under new namespace.
Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractAddressSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractAddressSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\BillingAddressSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\BillingAddressSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CartCouponSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartCouponSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CartExtensionsSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartExtensionsSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CartFeeSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartFeeSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartItemSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CartShippingRateSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartShippingRateSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\CheckoutSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ErrorSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ErrorSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ImageAttachmentSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ImageAttachmentSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\OrderCouponSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\OrderCouponSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ProductAttributeSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductAttributeSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ProductCategorySchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductCategorySchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ProductCollectionDataSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductCollectionDataSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ProductReviewSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductReviewSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ProductSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\ShippingAddressSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\ShippingAddressSchema::class,
Automattic\WooCommerce\StoreApi\Schemas\V1\TermSchema::class => Automattic\WooCommerce\Blocks\StoreApi\Schemas\TermSchema::class,
// Old routes to V1 routes under new namespace.
Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\AbstractCartRoute::class,
Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\AbstractRoute::class,
Automattic\WooCommerce\StoreApi\Routes\V1\AbstractTermsRoute::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\AbstractTermsRoute::class,
Automattic\WooCommerce\StoreApi\Routes\V1\Batch::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Batch::class,
Automattic\WooCommerce\StoreApi\Routes\V1\Cart::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Cart::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartAddItem::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartAddItem::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartApplyCoupon::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartApplyCoupon::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartCoupons::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartCoupons::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartCouponsByCode::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartCouponsByCode::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartExtensions::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartExtensions::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartItems::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartItems::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartItemsByKey::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartItemsByKey::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartRemoveCoupon::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartRemoveCoupon::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartRemoveItem::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartRemoveItem::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartSelectShippingRate::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartSelectShippingRate::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartUpdateCustomer::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartUpdateCustomer::class,
Automattic\WooCommerce\StoreApi\Routes\V1\CartUpdateItem::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\CartUpdateItem::class,
Automattic\WooCommerce\StoreApi\Routes\V1\Checkout::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Checkout::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductAttributes::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductAttributes::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductAttributesById::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductAttributesById::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductAttributeTerms::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductAttributeTerms::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductCategories::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductCategories::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductCategoriesById::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductCategoriesById::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductCollectionData::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductCollectionData::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductReviews::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductReviews::class,
Automattic\WooCommerce\StoreApi\Routes\V1\Products::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\Products::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductsById::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductsById::class,
Automattic\WooCommerce\StoreApi\Routes\V1\ProductTags::class => Automattic\WooCommerce\Blocks\StoreApi\Routes\ProductTags::class,
];
foreach ( $class_aliases as $class => $alias ) {
if ( ! class_exists( $alias, false ) ) {
class_alias( $class, $alias );
}
}
unset( $class_aliases );
StoreApi/functions.php 0000644 00000005316 15154173075 0011030 0 ustar 00 <?php
/**
* Helper functions for interacting with the Store API.
*
* This file is autoloaded via composer.json.
*/
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) {
/**
* Register endpoint data under a specified namespace.
*
* @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::register_endpoint_data()
*
* @param array $args Args to pass to register_endpoint_data.
* @returns boolean|\WP_Error True on success, WP_Error on fail.
*/
function woocommerce_store_api_register_endpoint_data( $args ) {
try {
$extend = StoreApi::container()->get( ExtendSchema::class );
$extend->register_endpoint_data( $args );
} catch ( \Exception $error ) {
return new \WP_Error( 'error', $error->getMessage() );
}
return true;
}
}
if ( ! function_exists( 'woocommerce_store_api_register_update_callback' ) ) {
/**
* Add callback functions that can be executed by the cart/extensions endpoint.
*
* @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::register_update_callback()
*
* @param array $args Args to pass to register_update_callback.
* @returns boolean|\WP_Error True on success, WP_Error on fail.
*/
function woocommerce_store_api_register_update_callback( $args ) {
try {
$extend = StoreApi::container()->get( ExtendSchema::class );
$extend->register_update_callback( $args );
} catch ( \Exception $error ) {
return new \WP_Error( 'error', $error->getMessage() );
}
return true;
}
}
if ( ! function_exists( 'woocommerce_store_api_register_payment_requirements' ) ) {
/**
* Registers and validates payment requirements callbacks.
*
* @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::register_payment_requirements()
*
* @param array $args Args to pass to register_payment_requirements.
* @returns boolean|\WP_Error True on success, WP_Error on fail.
*/
function woocommerce_store_api_register_payment_requirements( $args ) {
try {
$extend = StoreApi::container()->get( ExtendSchema::class );
$extend->register_payment_requirements( $args );
} catch ( \Exception $error ) {
return new \WP_Error( 'error', $error->getMessage() );
}
return true;
}
}
if ( ! function_exists( 'woocommerce_store_api_get_formatter' ) ) {
/**
* Returns a formatter instance.
*
* @see Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::get_formatter()
*
* @param string $name Formatter name.
* @return Automattic\WooCommerce\StoreApi\Formatters\FormatterInterface
*/
function woocommerce_store_api_get_formatter( $name ) {
return StoreApi::container()->get( ExtendSchema::class )->get_formatter( $name );
}
}
Templates/AbstractPageTemplate.php 0000644 00000005707 15154173075 0013270 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* AbstractPageTemplate class.
*
* Shared logic for page templates.
*
* @internal
*/
abstract class AbstractPageTemplate {
/**
* Page Template functionality is only initialized when using a block theme.
*/
public function __construct() {
if ( wc_current_theme_is_fse_theme() ) {
$this->init();
}
}
/**
* Initialization method.
*/
protected function init() {
add_filter( 'page_template_hierarchy', array( $this, 'page_template_hierarchy' ), 1 );
add_action( 'current_screen', array( $this, 'page_template_editor_redirect' ) );
add_filter( 'pre_get_document_title', array( $this, 'page_template_title' ) );
}
/**
* Returns the template slug.
*
* @return string
*/
abstract public static function get_slug();
/**
* Returns the page object assigned to this template/page.
*
* @return \WP_Post|null Post object or null.
*/
abstract public static function get_placeholder_page();
/**
* Should return the title of the page.
*
* @return string
*/
abstract public static function get_template_title();
/**
* Should return true on pages/endpoints/routes where the template should be shown.
*
* @return boolean
*/
abstract protected function is_active_template();
/**
* Returns the URL to edit the template.
*
* @return string
*/
protected function get_edit_template_url() {
return admin_url( 'site-editor.php?postType=wp_template&postId=woocommerce%2Fwoocommerce%2F%2F' . $this->get_slug() );
}
/**
* When the page should be displaying the template, add it to the hierarchy.
*
* This places the template name e.g. `cart`, at the beginning of the template hierarchy array. The hook priority
* is 1 to ensure it runs first; other consumers e.g. extensions, could therefore inject their own template instead
* of this one when using the default priority of 10.
*
* @param array $templates Templates that match the pages_template_hierarchy.
*/
public function page_template_hierarchy( $templates ) {
if ( $this->is_active_template() ) {
array_unshift( $templates, $this->get_slug() );
}
return $templates;
}
/**
* Redirect the edit page screen to the template editor.
*
* @param \WP_Screen $current_screen Current screen information.
*/
public function page_template_editor_redirect( \WP_Screen $current_screen ) {
$page = $this->get_placeholder_page();
$edit_page_id = 'page' === $current_screen->id && ! empty( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( $page && $edit_page_id === $page->ID ) {
wp_safe_redirect( $this->get_edit_template_url() );
exit;
}
}
/**
* Filter the page title when the template is active.
*
* @param string $title Page title.
* @return string
*/
public function page_template_title( $title ) {
if ( $this->is_active_template() ) {
return $this->get_template_title();
}
return $title;
}
}
Templates/AbstractTemplateCompatibility.php 0000644 00000013102 15154173075 0015211 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* AbstractTemplateCompatibility class.
*
* To bridge the gap on compatibility with PHP hooks and blockified templates.
*
* @internal
*/
abstract class AbstractTemplateCompatibility {
/**
* The data of supported hooks, containing the hook name, the block name,
* position, and the callbacks.
*
* @var array $hook_data The hook data.
*/
protected $hook_data;
/**
* Initialization method.
*/
public function init() {
if ( ! wc_current_theme_is_fse_theme() ) {
return;
}
$this->set_hook_data();
add_filter(
'render_block_data',
function( $parsed_block, $source_block, $parent_block ) {
/**
* Filter to disable the compatibility layer for the blockified templates.
*
* This hook allows to disable the compatibility layer for the blockified templates.
*
* @since TBD
* @param boolean.
*/
$is_disabled_compatility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false );
if ( $is_disabled_compatility_layer ) {
return $parsed_block;
}
return $this->update_render_block_data( $parsed_block, $source_block, $parent_block );
},
10,
3
);
add_filter(
'render_block',
function ( $block_content, $block ) {
/**
* Filter to disable the compatibility layer for the blockified templates.
*
* This hook allows to disable the compatibility layer for the blockified.
*
* @since TBD
* @param boolean.
*/
$is_disabled_compatility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false );
if ( $is_disabled_compatility_layer ) {
return $block_content;
}
return $this->inject_hooks( $block_content, $block );
},
10,
2
);
}
/**
* Update the render block data to inject our custom attribute needed to
* determine which blocks belong to an inherited Products block.
*
* @param array $parsed_block The block being rendered.
* @param array $source_block An un-modified copy of $parsed_block, as it appeared in the source content.
* @param WP_Block|null $parent_block If this is a nested block, a reference to the parent block.
*
* @return array
*/
abstract public function update_render_block_data( $parsed_block, $source_block, $parent_block );
/**
* Inject hooks to rendered content of corresponding blocks.
*
* @param mixed $block_content The rendered block content.
* @param mixed $block The parsed block data.
* @return string
*/
abstract public function inject_hooks( $block_content, $block );
/**
* The hook data to inject to the rendered content of blocks. This also
* contains hooked functions that will be removed by remove_default_hooks.
*
* The array format:
* [
* <hook-name> => [
* block_names => [ <block-name>, ... ],
* position => before|after,
* hooked => [
* <function-name> => <priority>,
* ...
* ],
* ],
* ]
* Where:
* - hook-name is the name of the hook that will be replaced.
* - block-names is the array block names that hook will be attached to.
* - position is the position of the block relative to the hook.
* - hooked is an array of functions hooked to the hook that will be
* replaced. The key is the function name and the value is the
* priority.
*/
abstract protected function set_hook_data();
/**
* Remove the default callback added by WooCommerce. We replaced these
* callbacks by blocks so we have to remove them to prevent duplicated
* content.
*/
protected function remove_default_hooks() {
foreach ( $this->hook_data as $hook => $data ) {
if ( ! isset( $data['hooked'] ) ) {
continue;
}
foreach ( $data['hooked'] as $callback => $priority ) {
remove_action( $hook, $callback, $priority );
}
}
/**
* When extensions implement their equivalent blocks of the template
* hook functions, they can use this filter to register their old hooked
* data here, so in the blockified template, the old hooked functions
* can be removed in favor of the new blocks while keeping the old
* hooked functions working in classic templates.
*
* Accepts an array of hooked data. The array should be in the following
* format:
* [
* [
* hook => <hook-name>,
* function => <function-name>,
* priority => <priority>,
* ],
* ...
* ]
* Where:
* - hook-name is the name of the hook that have the functions hooked to.
* - function-name is the hooked function name.
* - priority is the priority of the hooked function.
*
* @since 9.5.0
* @param array $data Additional hooked data. Default to empty
*/
$additional_hook_data = apply_filters( 'woocommerce_blocks_hook_compatibility_additional_data', array() );
if ( empty( $additional_hook_data ) || ! is_array( $additional_hook_data ) ) {
return;
}
foreach ( $additional_hook_data as $data ) {
if ( ! isset( $data['hook'], $data['function'], $data['priority'] ) ) {
continue;
}
remove_action( $data['hook'], $data['function'], $data['priority'] );
}
}
/**
* Get the buffer content of the hooks to append/prepend to render content.
*
* @param array $hooks The hooks to be rendered.
* @param string $position The position of the hooks.
*
* @return string
*/
protected function get_hooks_buffer( $hooks, $position ) {
ob_start();
foreach ( $hooks as $hook => $data ) {
if ( $data['position'] === $position ) {
/**
* Action to render the content of a hook.
*
* @since 9.5.0
*/
do_action( $hook );
}
}
return ob_get_clean();
}
}
Templates/ArchiveProductTemplatesCompatibility.php 0000644 00000030620 15154173075 0016557 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* ArchiveProductTemplatesCompatibility class.
*
* To bridge the gap on compatibility with PHP hooks and Product Archive blockified templates.
*
* @internal
*/
class ArchiveProductTemplatesCompatibility extends AbstractTemplateCompatibility {
/**
* The custom ID of the loop item block as the replacement of the core/null block.
*/
const LOOP_ITEM_ID = 'product-loop-item';
/**
* The data of supported hooks, containing the hook name, the block name,
* position, and the callbacks.
*
* @var array $hook_data The hook data.
*/
protected $hook_data;
/**
* Update the render block data to inject our custom attribute needed to
* determine which blocks belong to an inherited Products block.
*
* @param array $parsed_block The block being rendered.
* @param array $source_block An un-modified copy of $parsed_block, as it appeared in the source content.
* @param WP_Block|null $parent_block If this is a nested block, a reference to the parent block.
*
* @return array
*/
public function update_render_block_data( $parsed_block, $source_block, $parent_block ) {
if ( ! $this->is_archive_template() ) {
return $parsed_block;
}
/**
* Custom data can be injected to top level block only, as Gutenberg
* will use this data to render the blocks and its nested blocks.
*/
if ( $parent_block ) {
return $parsed_block;
}
$this->inner_blocks_walker( $parsed_block );
return $parsed_block;
}
/**
* Inject hooks to rendered content of corresponding blocks.
*
* @param mixed $block_content The rendered block content.
* @param mixed $block The parsed block data.
* @return string
*/
public function inject_hooks( $block_content, $block ) {
if ( ! $this->is_archive_template() ) {
return $block_content;
}
/**
* If the block is not inherited, we don't need to inject hooks.
*/
if ( empty( $block['attrs']['isInherited'] ) ) {
return $block_content;
}
$block_name = $block['blockName'];
if ( $this->is_null_post_template( $block ) ) {
$block_name = self::LOOP_ITEM_ID;
}
$block_hooks = array_filter(
$this->hook_data,
function( $hook ) use ( $block_name ) {
return in_array( $block_name, $hook['block_names'], true );
}
);
// We want to inject hooks to the core/post-template or product template block only when the products exist:
// https://github.com/woocommerce/woocommerce-blocks/issues/9463.
if ( $this->is_post_or_product_template( $block_name ) && ! empty( $block_content ) ) {
$this->restore_default_hooks();
$content = sprintf(
'%1$s%2$s%3$s',
$this->get_hooks_buffer( $block_hooks, 'before' ),
$block_content,
$this->get_hooks_buffer( $block_hooks, 'after' )
);
$this->remove_default_hooks();
return $content;
}
$supported_blocks = array_merge(
[],
...array_map(
function( $hook ) {
return $hook['block_names'];
},
array_values( $this->hook_data )
)
);
if ( ! in_array( $block_name, $supported_blocks, true ) ) {
return $block_content;
}
if (
'core/query-no-results' === $block_name
) {
/**
* `core/query-no-result` is a special case because it can return two
* different content depending on the context. We need to check if the
* block content is empty to determine if we need to inject hooks.
*/
if ( empty( trim( $block_content ) ) ) {
return $block_content;
}
$this->restore_default_hooks();
$content = sprintf(
'%1$s%2$s%3$s',
$this->get_hooks_buffer( $block_hooks, 'before' ),
$block_content,
$this->get_hooks_buffer( $block_hooks, 'after' )
);
$this->remove_default_hooks();
return $content;
}
if ( empty( $block_content ) ) {
return $block_content;
}
return sprintf(
'%1$s%2$s%3$s',
$this->get_hooks_buffer( $block_hooks, 'before' ),
$block_content,
$this->get_hooks_buffer( $block_hooks, 'after' )
);
}
/**
* The hook data to inject to the rendered content of blocks. This also
* contains hooked functions that will be removed by remove_default_hooks.
*
* The array format:
* [
* <hook-name> => [
* block_name => <block-name>,
* position => before|after,
* hooked => [
* <function-name> => <priority>,
* ...
* ],
* permanently_removed_actions => [
* <function-name>
* ]
* ],
* ]
* Where:
* - hook-name is the name of the hook that will be replaced.
* - block-name is the name of the block that will replace the hook.
* - position is the position of the block relative to the hook.
* - hooked is an array of functions hooked to the hook that will be
* replaced. The key is the function name and the value is the
* priority.
* - permanently_removed_actions is an array of functions that we do not want to re-add after they have been removed to avoid duplicate content with the Products block and its inner blocks.
*/
protected function set_hook_data() {
$this->hook_data = array(
'woocommerce_before_main_content' => array(
'block_names' => array( 'core/query', 'woocommerce/product-collection' ),
'position' => 'before',
'hooked' => array(
'woocommerce_output_content_wrapper' => 10,
'woocommerce_breadcrumb' => 20,
),
),
'woocommerce_after_main_content' => array(
'block_names' => array( 'core/query', 'woocommerce/product-collection' ),
'position' => 'after',
'hooked' => array(
'woocommerce_output_content_wrapper_end' => 10,
),
),
'woocommerce_before_shop_loop_item_title' => array(
'block_names' => array( 'core/post-title' ),
'position' => 'before',
'hooked' => array(
'woocommerce_show_product_loop_sale_flash' => 10,
'woocommerce_template_loop_product_thumbnail' => 10,
),
),
'woocommerce_shop_loop_item_title' => array(
'block_names' => array( 'core/post-title' ),
'position' => 'after',
'hooked' => array(
'woocommerce_template_loop_product_title' => 10,
),
),
'woocommerce_after_shop_loop_item_title' => array(
'block_names' => array( 'core/post-title' ),
'position' => 'after',
'hooked' => array(
'woocommerce_template_loop_rating' => 5,
'woocommerce_template_loop_price' => 10,
),
),
'woocommerce_before_shop_loop_item' => array(
'block_names' => array( self::LOOP_ITEM_ID ),
'position' => 'before',
'hooked' => array(
'woocommerce_template_loop_product_link_open' => 10,
),
),
'woocommerce_after_shop_loop_item' => array(
'block_names' => array( self::LOOP_ITEM_ID ),
'position' => 'after',
'hooked' => array(
'woocommerce_template_loop_product_link_close' => 5,
'woocommerce_template_loop_add_to_cart' => 10,
),
),
'woocommerce_before_shop_loop' => array(
'block_names' => array( 'core/post-template', 'woocommerce/product-template' ),
'position' => 'before',
'hooked' => array(
'woocommerce_output_all_notices' => 10,
'woocommerce_result_count' => 20,
'woocommerce_catalog_ordering' => 30,
),
'permanently_removed_actions' => array(
'woocommerce_output_all_notices',
'woocommerce_result_count',
'woocommerce_catalog_ordering',
),
),
'woocommerce_after_shop_loop' => array(
'block_names' => array( 'core/post-template', 'woocommerce/product-template' ),
'position' => 'after',
'hooked' => array(
'woocommerce_pagination' => 10,
),
'permanently_removed_actions' => array(
'woocommerce_pagination',
),
),
'woocommerce_no_products_found' => array(
'block_names' => array( 'core/query-no-results' ),
'position' => 'before',
'hooked' => array(
'wc_no_products_found' => 10,
),
'permanently_removed_actions' => array(
'wc_no_products_found',
),
),
'woocommerce_archive_description' => array(
'block_names' => array( 'core/term-description' ),
'position' => 'before',
'hooked' => array(
'woocommerce_taxonomy_archive_description' => 10,
'woocommerce_product_archive_description' => 10,
),
),
);
}
/**
* Check if current page is a product archive template.
*/
private function is_archive_template() {
return is_shop() || is_product_taxonomy();
}
/**
* Loop through inner blocks recursively to find the Products blocks that
* inherits query from template.
*
* @param array $block Parsed block data.
*/
private function inner_blocks_walker( &$block ) {
if (
$this->is_products_block_with_inherit_query( $block ) || $this->is_product_collection_block_with_inherit_query( $block )
) {
$this->inject_attribute( $block );
$this->remove_default_hooks();
}
if ( ! empty( $block['innerBlocks'] ) ) {
array_walk( $block['innerBlocks'], array( $this, 'inner_blocks_walker' ) );
}
}
/**
* Restore default hooks except the ones that are not supposed to be re-added.
*/
private function restore_default_hooks() {
foreach ( $this->hook_data as $hook => $data ) {
if ( ! isset( $data['hooked'] ) ) {
continue;
}
foreach ( $data['hooked'] as $callback => $priority ) {
if ( ! in_array( $callback, $data['permanently_removed_actions'] ?? [], true ) ) {
add_action( $hook, $callback, $priority );
}
}
}
}
/**
* Check if block is within the product-query namespace
*
* @param array $block Parsed block data.
*/
private function is_block_within_namespace( $block ) {
$attributes = $block['attrs'];
return isset( $attributes['__woocommerceNamespace'] ) && 'woocommerce/product-query/product-template' === $attributes['__woocommerceNamespace'];
}
/**
* Check if block has isInherited attribute asigned
*
* @param array $block Parsed block data.
*/
private function is_block_inherited( $block ) {
$attributes = $block['attrs'];
$outcome = isset( $attributes['isInherited'] ) && 1 === $attributes['isInherited'];
return $outcome;
}
/**
* The core/post-template has two different block names:
* - core/post-template when the wrapper is rendered.
* - core/null when the loop item is rendered.
*
* @param array $block Parsed block data.
*/
private function is_null_post_template( $block ) {
$block_name = $block['blockName'];
return 'core/null' === $block_name && ( $this->is_block_inherited( $block ) || $this->is_block_within_namespace( $block ) );
}
/**
* Check if block is a Post template
*
* @param string $block_name Block name.
*/
private function is_post_template( $block_name ) {
return 'core/post-template' === $block_name;
}
/**
* Check if block is a Product Template
*
* @param string $block_name Block name.
*/
private function is_product_template( $block_name ) {
return 'woocommerce/product-template' === $block_name;
}
/**
* Check if block is eaither a Post template or Product Template
*
* @param string $block_name Block name.
*/
private function is_post_or_product_template( $block_name ) {
return $this->is_post_template( $block_name ) || $this->is_product_template( $block_name );
}
/**
* Check if the block is a Products block that inherits query from template.
*
* @param array $block Parsed block data.
*/
private function is_products_block_with_inherit_query( $block ) {
return 'core/query' === $block['blockName'] &&
isset( $block['attrs']['namespace'] ) &&
'woocommerce/product-query' === $block['attrs']['namespace'] &&
isset( $block['attrs']['query']['inherit'] ) &&
$block['attrs']['query']['inherit'];
}
/**
* Check if the block is a Product Collection block that inherits query from template.
*
* @param array $block Parsed block data.
*/
private function is_product_collection_block_with_inherit_query( $block ) {
return 'woocommerce/product-collection' === $block['blockName'] &&
isset( $block['attrs']['query']['inherit'] ) &&
$block['attrs']['query']['inherit'];
}
/**
* Recursively inject the custom attribute to all nested blocks.
*
* @param array $block Parsed block data.
*/
private function inject_attribute( &$block ) {
$block['attrs']['isInherited'] = 1;
if ( ! empty( $block['innerBlocks'] ) ) {
array_walk( $block['innerBlocks'], array( $this, 'inject_attribute' ) );
}
}
}
Templates/CartTemplate.php 0000644 00000002212 15154173075 0011605 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateMigrationUtils;
/**
* CartTemplate class.
*
* @internal
*/
class CartTemplate extends AbstractPageTemplate {
/**
* Template slug.
*
* @return string
*/
public static function get_slug() {
return 'cart';
}
/**
* Returns the page object assigned to this template/page.
*
* @return \WP_Post|null Post object or null.
*/
public static function get_placeholder_page() {
$page_id = wc_get_page_id( 'cart' );
return $page_id ? get_post( $page_id ) : null;
}
/**
* True when viewing the cart page or cart endpoint.
*
* @return boolean
*/
protected function is_active_template() {
if ( ! BlockTemplateMigrationUtils::has_migrated_page( 'cart' ) ) {
return false;
}
global $post;
$placeholder = $this->get_placeholder_page();
return null !== $placeholder && $post instanceof \WP_Post && $placeholder->post_name === $post->post_name;
}
/**
* Should return the title of the page.
*
* @return string
*/
public static function get_template_title() {
return __( 'Cart', 'woocommerce' );
}
}
Templates/CheckoutHeaderTemplate.php 0000644 00000000270 15154173075 0013574 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* CheckoutHeader Template class.
*
* @internal
*/
class CheckoutHeaderTemplate {
const SLUG = 'checkout-header';
}
Templates/CheckoutTemplate.php 0000644 00000002247 15154173075 0012471 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateMigrationUtils;
/**
* CheckoutTemplate class.
*
* @internal
*/
class CheckoutTemplate extends AbstractPageTemplate {
/**
* Template slug.
*
* @return string
*/
public static function get_slug() {
return 'checkout';
}
/**
* Returns the page object assigned to this template/page.
*
* @return \WP_Post|null Post object or null.
*/
public static function get_placeholder_page() {
$page_id = wc_get_page_id( 'checkout' );
return $page_id ? get_post( $page_id ) : null;
}
/**
* Should return the title of the page.
*
* @return string
*/
public static function get_template_title() {
return __( 'Checkout', 'woocommerce' );
}
/**
* True when viewing the checkout page or checkout endpoint.
*
* @return boolean
*/
public function is_active_template() {
if ( ! BlockTemplateMigrationUtils::has_migrated_page( 'checkout' ) ) {
return false;
}
global $post;
$placeholder = $this->get_placeholder_page();
return null !== $placeholder && $post instanceof \WP_Post && $placeholder->post_name === $post->post_name;
}
}
Templates/ClassicTemplatesCompatibility.php 0000644 00000004512 15154173075 0015217 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
/**
* ClassicTemplatesCompatibility class.
*
* To bridge the gap on compatibility with widget blocks and classic PHP core templates.
*
* @internal
*/
class ClassicTemplatesCompatibility {
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
protected $asset_data_registry;
/**
* Constructor.
*
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
*/
public function __construct( AssetDataRegistry $asset_data_registry ) {
$this->asset_data_registry = $asset_data_registry;
$this->init();
}
/**
* Initialization method.
*/
protected function init() {
if ( ! wc_current_theme_is_fse_theme() ) {
add_action( 'template_redirect', array( $this, 'set_classic_template_data' ) );
// We need to set this data on the widgets screen so the filters render previews.
add_action( 'load-widgets.php', array( $this, 'set_filterable_product_data' ) );
}
}
/**
* Executes the methods which set the necessary data needed for filter blocks to work correctly as widgets in Classic templates.
*
* @return void
*/
public function set_classic_template_data() {
$this->set_filterable_product_data();
$this->set_php_template_data();
}
/**
* This method passes the value `has_filterable_products` to the front-end for product archive pages,
* so that widget product filter blocks are aware of the context they are in and can render accordingly.
*
* @return void
*/
public function set_filterable_product_data() {
global $pagenow;
if ( is_shop() || is_product_taxonomy() || 'widgets.php' === $pagenow ) {
$this->asset_data_registry->add( 'hasFilterableProducts', true, true );
}
}
/**
* This method passes the value `is_rendering_php_template` to the front-end of Classic themes,
* so that widget product filter blocks are aware of how to filter the products.
*
* This data only matters on WooCommerce product archive pages.
* On non-archive pages the merchant could be using the All Products block which is not a PHP template.
*
* @return void
*/
public function set_php_template_data() {
if ( is_shop() || is_product_taxonomy() ) {
$this->asset_data_registry->add( 'isRenderingPhpTemplate', true, true );
}
}
}
Templates/MiniCartTemplate.php 0000644 00000000245 15154173075 0012426 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* MiniCartTemplate class.
*
* @internal
*/
class MiniCartTemplate {
const SLUG = 'mini-cart';
}
Templates/OrderConfirmationTemplate.php 0000644 00000001525 15154173075 0014346 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* OrderConfirmationTemplate class.
*
* @internal
*/
class OrderConfirmationTemplate extends AbstractPageTemplate {
/**
* Template slug.
*
* @return string
*/
public static function get_slug() {
return 'order-confirmation';
}
/**
* Returns the page object assigned to this template/page.
*
* @return \WP_Post|null Post object or null.
*/
public static function get_placeholder_page() {
return null;
}
/**
* True when viewing the Order Received endpoint.
*
* @return boolean
*/
protected function is_active_template() {
return is_wc_endpoint_url( 'order-received' );
}
/**
* Should return the title of the page.
*
* @return string
*/
public static function get_template_title() {
return __( 'Order Confirmation', 'woocommerce' );
}
}
Templates/ProductAttributeTemplate.php 0000644 00000001670 15154173075 0014227 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* ProductAttributeTemplate class.
*
* @internal
*/
class ProductAttributeTemplate {
const SLUG = 'taxonomy-product_attribute';
/**
* Constructor.
*/
public function __construct() {
$this->init();
}
/**
* Initialization method.
*/
protected function init() {
add_filter( 'taxonomy_template_hierarchy', array( $this, 'update_taxonomy_template_hierarchy' ), 1, 3 );
}
/**
* Renders the Product by Attribute template for product attributes taxonomy pages.
*
* @param array $templates Templates that match the product attributes taxonomy.
*/
public function update_taxonomy_template_hierarchy( $templates ) {
$queried_object = get_queried_object();
if ( taxonomy_is_product_attribute( $queried_object->taxonomy ) && wc_current_theme_is_fse_theme() ) {
array_splice( $templates, count( $templates ) - 1, 0, self::SLUG );
}
return $templates;
}
}
Templates/ProductSearchResultsTemplate.php 0000644 00000001560 15154173075 0015051 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* ProductSearchResultsTemplate class.
*
* @internal
*/
class ProductSearchResultsTemplate {
const SLUG = 'product-search-results';
/**
* Constructor.
*/
public function __construct() {
$this->init();
}
/**
* Initialization method.
*/
protected function init() {
add_filter( 'search_template_hierarchy', array( $this, 'update_search_template_hierarchy' ), 10, 3 );
}
/**
* When the search is for products and a block theme is active, render the Product Search Template.
*
* @param array $templates Templates that match the search hierarchy.
*/
public function update_search_template_hierarchy( $templates ) {
if ( ( is_search() && is_post_type_archive( 'product' ) ) && wc_current_theme_is_fse_theme() ) {
array_unshift( $templates, self::SLUG );
}
return $templates;
}
}
Templates/SingleProductTemplateCompatibility.php 0000644 00000035173 15154173075 0016244 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Templates;
/**
* SingleProductTemplateCompatibility class.
*
* To bridge the gap on compatibility with PHP hooks and Single Product templates.
*
* @internal
*/
class SingleProductTemplateCompatibility extends AbstractTemplateCompatibility {
const IS_FIRST_BLOCK = '__wooCommerceIsFirstBlock';
const IS_LAST_BLOCK = '__wooCommerceIsLastBlock';
/**
* Inject hooks to rendered content of corresponding blocks.
*
* @param mixed $block_content The rendered block content.
* @param mixed $block The parsed block data.
* @return string
*/
public function inject_hooks( $block_content, $block ) {
if ( ! is_product() ) {
return $block_content;
}
$this->remove_default_hooks();
$block_name = $block['blockName'];
$block_hooks = array_filter(
$this->hook_data,
function( $hook ) use ( $block_name ) {
return in_array( $block_name, $hook['block_names'], true );
}
);
$first_or_last_block_content = $this->inject_hook_to_first_and_last_blocks( $block_content, $block, $block_hooks );
if ( isset( $first_or_last_block_content ) ) {
return $first_or_last_block_content;
}
return sprintf(
'%1$s%2$s%3$s',
$this->get_hooks_buffer( $block_hooks, 'before' ),
$block_content,
$this->get_hooks_buffer( $block_hooks, 'after' )
);
}
/**
* Inject custom hooks to the first and last blocks.
* Since that there is a custom logic for the first and last block, we have to inject the hooks manually.
* The first block supports the following hooks:
* woocommerce_before_single_product
* woocommerce_before_single_product_summary
* woocommerce_single_product_summary
*
* The last block supports the following hooks:
* woocommerce_after_single_product
*
* @param mixed $block_content The rendered block content.
* @param mixed $block The parsed block data.
* @param array $block_hooks The hooks that should be injected to the block.
* @return string
*/
private function inject_hook_to_first_and_last_blocks( $block_content, $block, $block_hooks ) {
$first_block_hook = array(
'before' => array(
'woocommerce_before_main_content' => $this->hook_data['woocommerce_before_main_content'],
'woocommerce_before_single_product' => $this->hook_data['woocommerce_before_single_product'],
'woocommerce_before_single_product_summary' => $this->hook_data['woocommerce_before_single_product_summary'],
'woocommerce_single_product_summary' => $this->hook_data['woocommerce_single_product_summary'],
),
'after' => array(),
);
$last_block_hook = array(
'before' => array(),
'after' => array(
'woocommerce_after_single_product' => $this->hook_data['woocommerce_after_single_product'],
'woocommerce_after_main_content' => $this->hook_data['woocommerce_after_main_content'],
'woocommerce_sidebar' => $this->hook_data['woocommerce_sidebar'],
),
);
if ( isset( $block['attrs'][ self::IS_FIRST_BLOCK ] ) && isset( $block['attrs'][ self::IS_LAST_BLOCK ] ) ) {
return sprintf(
'%1$s%2$s',
$this->inject_hooks_after_the_wrapper(
$block_content,
array_merge(
$first_block_hook['before'],
$block_hooks,
$last_block_hook['before']
)
),
$this->get_hooks_buffer(
array_merge(
$first_block_hook['after'],
$block_hooks,
$last_block_hook['after']
),
'after'
)
);
}
if ( isset( $block['attrs'][ self::IS_FIRST_BLOCK ] ) ) {
return sprintf(
'%1$s%2$s',
$this->inject_hooks_after_the_wrapper(
$block_content,
array_merge(
$first_block_hook['before'],
$block_hooks
)
),
$this->get_hooks_buffer(
array_merge(
$first_block_hook['after'],
$block_hooks
),
'after'
)
);
}
if ( isset( $block['attrs'][ self::IS_LAST_BLOCK ] ) ) {
return sprintf(
'%1$s%2$s%3$s',
$this->get_hooks_buffer(
array_merge(
$last_block_hook['before'],
$block_hooks
),
'before'
),
$block_content,
$this->get_hooks_buffer(
array_merge(
$block_hooks,
$last_block_hook['after']
),
'after'
)
);
}
}
/**
* Update the render block data to inject our custom attribute needed to
* determine which is the first block of the Single Product Template.
*
* @param array $parsed_block The block being rendered.
* @param array $source_block An un-modified copy of $parsed_block, as it appeared in the source content.
* @param WP_Block|null $parent_block If this is a nested block, a reference to the parent block.
*
* @return array
*/
public function update_render_block_data( $parsed_block, $source_block, $parent_block ) {
return $parsed_block;
}
/**
* Set supported hooks.
*/
protected function set_hook_data() {
$this->hook_data = array(
'woocommerce_before_main_content' => array(
'block_names' => array(),
'position' => 'before',
'hooked' => array(
'woocommerce_output_content_wrapper' => 10,
'woocommerce_breadcrumb' => 20,
),
),
'woocommerce_after_main_content' => array(
'block_names' => array(),
'position' => 'after',
'hooked' => array(
'woocommerce_output_content_wrapper_end' => 10,
),
),
'woocommerce_sidebar' => array(
'block_names' => array(),
'position' => 'after',
'hooked' => array(
'woocommerce_get_sidebar' => 10,
),
),
'woocommerce_before_single_product' => array(
'block_names' => array(),
'position' => 'before',
'hooked' => array(
'woocommerce_output_all_notices' => 10,
),
),
'woocommerce_before_single_product_summary' => array(
'block_names' => array(),
'position' => 'before',
'hooked' => array(
'woocommerce_show_product_sale_flash' => 10,
'woocommerce_show_product_images' => 20,
),
),
'woocommerce_single_product_summary' => array(
'block_names' => array(),
'position' => 'before',
'hooked' => array(
'woocommerce_template_single_title' => 5,
'woocommerce_template_single_rating' => 10,
'woocommerce_template_single_price' => 10,
'woocommerce_template_single_excerpt' => 20,
'woocommerce_template_single_add_to_cart' => 30,
'woocommerce_template_single_meta' => 40,
'woocommerce_template_single_sharing' => 50,
),
),
'woocommerce_after_single_product' => array(
'block_names' => array(),
'position' => 'after',
'hooked' => array(),
),
'woocommerce_product_meta_start' => array(
'block_names' => array( 'woocommerce/product-meta' ),
'position' => 'before',
'hooked' => array(),
),
'woocommerce_product_meta_end' => array(
'block_names' => array( 'woocommerce/product-meta' ),
'position' => 'after',
'hooked' => array(),
),
'woocommerce_share' => array(
'block_names' => array( 'woocommerce/product-details' ),
'position' => 'before',
'hooked' => array(),
),
'woocommerce_after_single_product_summary' => array(
'block_names' => array( 'woocommerce/product-details' ),
'position' => 'after',
'hooked' => array(
'woocommerce_output_product_data_tabs' => 10,
// We want to display the upsell products after the last block that belongs to the Single Product.
// 'woocommerce_upsell_display' => 15.
'woocommerce_output_related_products' => 20,
),
),
);
}
/**
* Add compatibility layer to the first and last block of the Single Product Template.
*
* @param string $template_content Template.
* @return string
*/
public static function add_compatibility_layer( $template_content ) {
$parsed_blocks = parse_blocks( $template_content );
if ( ! self::has_single_product_template_blocks( $parsed_blocks ) ) {
$template = self::inject_custom_attributes_to_first_and_last_block_single_product_template( $parsed_blocks );
return self::serialize_blocks( $template );
}
$wrapped_blocks = self::wrap_single_product_template( $template_content );
$template = self::inject_custom_attributes_to_first_and_last_block_single_product_template( $wrapped_blocks );
return self::serialize_blocks( $template );
}
/**
* For compatibility reason, we need to wrap the Single Product template in a div with specific class.
* For more details, see https://github.com/woocommerce/woocommerce-blocks/issues/8314.
*
* @param string $template_content Template Content.
* @return array Wrapped template content inside a div.
*/
private static function wrap_single_product_template( $template_content ) {
$parsed_blocks = parse_blocks( $template_content );
$grouped_blocks = self::group_blocks( $parsed_blocks );
$wrapped_blocks = array_map(
function( $blocks ) {
if ( 'core/template-part' === $blocks[0]['blockName'] ) {
return $blocks;
}
$has_single_product_template_blocks = self::has_single_product_template_blocks( $blocks );
if ( $has_single_product_template_blocks ) {
$wrapped_block = self::create_wrap_block_group( $blocks );
return array( $wrapped_block[0] );
}
return $blocks;
},
$grouped_blocks
);
return $wrapped_blocks;
}
/**
* Add custom attributes to the first group block and last group block that wrap Single Product Template blocks.
*
* @param array $wrapped_blocks Wrapped blocks.
* @return array
*/
private static function inject_custom_attributes_to_first_and_last_block_single_product_template( $wrapped_blocks ) {
$template_with_custom_attributes = array_reduce(
$wrapped_blocks,
function( $carry, $item ) {
$index = $carry['index'];
$carry['index'] = $carry['index'] + 1;
// If the block is a child of a group block, we need to get the first block of the group.
$block = isset( $item[0] ) ? $item[0] : $item;
if ( 'core/template-part' === $block['blockName'] || self::is_custom_html( $block ) ) {
$carry['template'][] = $block;
return $carry;
}
if ( '' === $carry['first_block']['index'] ) {
$block['attrs'][ self::IS_FIRST_BLOCK ] = true;
$carry['first_block']['index'] = $index;
}
if ( '' !== $carry['last_block']['index'] ) {
$index_element = $carry['last_block']['index'];
$carry['last_block']['index'] = $index;
$block['attrs'][ self::IS_LAST_BLOCK ] = true;
unset( $carry['template'][ $index_element ]['attrs'][ self::IS_LAST_BLOCK ] );
$carry['template'][] = $block;
return $carry;
}
$block['attrs'][ self::IS_LAST_BLOCK ] = true;
$carry['last_block']['index'] = $index;
$carry['template'][] = $block;
return $carry;
},
array(
'template' => array(),
'first_block' => array(
'index' => '',
),
'last_block' => array(
'index' => '',
),
'index' => 0,
)
);
return array( $template_with_custom_attributes['template'] );
}
/**
* Wrap all the blocks inside the template in a group block.
*
* @param array $blocks Array of parsed block objects.
* @return array Group block with the blocks inside.
*/
private static function create_wrap_block_group( $blocks ) {
$serialized_blocks = serialize_blocks( $blocks );
$new_block = parse_blocks(
sprintf(
'<!-- wp:group {"className":"woocommerce product"} -->
<div class="wp-block-group woocommerce product">
%1$s
</div>
<!-- /wp:group -->',
$serialized_blocks
)
);
$new_block['innerBlocks'] = $blocks;
return $new_block;
}
/**
* Check if the Single Product template has a single product template block:
* woocommerce/product-gallery-image, woocommerce/product-details, woocommerce/add-to-cart-form]
*
* @param array $parsed_blocks Array of parsed block objects.
* @return bool True if the template has a single product template block, false otherwise.
*/
private static function has_single_product_template_blocks( $parsed_blocks ) {
$single_product_template_blocks = array( 'woocommerce/product-image-gallery', 'woocommerce/product-details', 'woocommerce/add-to-cart-form', 'woocommerce/product-meta', 'woocommerce/product-price', 'woocommerce/breadcrumbs' );
$found = false;
foreach ( $parsed_blocks as $block ) {
if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $single_product_template_blocks, true ) ) {
$found = true;
break;
}
$found = self::has_single_product_template_blocks( $block['innerBlocks'], $single_product_template_blocks );
if ( $found ) {
break;
}
}
return $found;
}
/**
* Group blocks in this way:
* B1 + TP1 + B2 + B3 + B4 + TP2 + B5
* (B = Block, TP = Template Part)
* becomes:
* [[B1], [TP1], [B2, B3, B4], [TP2], [B5]]
*
* @param array $parsed_blocks Array of parsed block objects.
* @return array Array of blocks grouped by template part.
*/
private static function group_blocks( $parsed_blocks ) {
return array_reduce(
$parsed_blocks,
function( $carry, $block ) {
if ( 'core/template-part' === $block['blockName'] ) {
$carry[] = array( $block );
return $carry;
}
$last_element_index = count( $carry ) - 1;
if ( isset( $carry[ $last_element_index ][0]['blockName'] ) && 'core/template-part' !== $carry[ $last_element_index ][0]['blockName'] ) {
$carry[ $last_element_index ][] = $block;
return $carry;
}
$carry[] = array( $block );
return $carry;
},
array()
);
}
/**
* Inject the hooks after the div wrapper.
*
* @param string $block_content Block Content.
* @param array $hooks Hooks to inject.
* @return array
*/
private function inject_hooks_after_the_wrapper( $block_content, $hooks ) {
$closing_tag_position = strpos( $block_content, '>' );
return substr_replace(
$block_content,
$this->get_hooks_buffer(
$hooks,
'before'
),
// Add 1 to the position to inject the content after the closing tag.
$closing_tag_position + 1,
0
);
}
/**
* Plain custom HTML block is parsed as block with an empty blockName with a filled innerHTML.
*
* @param array $block Parse block.
* @return bool
*/
private static function is_custom_html( $block ) {
return empty( $block['blockName'] ) && ! empty( $block['innerHTML'] );
}
/**
* Serialize template.
*
* @param array $parsed_blocks Parsed blocks.
* @return string
*/
private static function serialize_blocks( $parsed_blocks ) {
return array_reduce(
$parsed_blocks,
function( $carry, $item ) {
if ( is_array( $item ) ) {
return $carry . serialize_blocks( $item );
}
return $carry . serialize_block( $item );
},
''
);
}
}
Utils/BlockTemplateMigrationUtils.php 0000644 00000012145 15154173075 0014011 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* Utility methods used for migrating pages to block templates.
* {@internal This class and its methods should only be used within the BlockTemplateController.php and is not intended for public use.}
*/
class BlockTemplateMigrationUtils {
/**
* Check if a page has been migrated to a template.
*
* @param string $page_id Page ID.
* @return boolean
*/
public static function has_migrated_page( $page_id ) {
return (bool) get_option( 'has_migrated_' . $page_id, false );
}
/**
* Stores an option to indicate that a template has been migrated.
*
* @param string $page_id Page ID.
* @param string $status Status of the migration.
*/
public static function set_has_migrated_page( $page_id, $status = 'success' ) {
update_option( 'has_migrated_' . $page_id, $status );
}
/**
* Migrates a page to a template if needed.
*
* @param string $template_slug Template slug.
* @param \WP_Post $page Page object.
*/
public static function migrate_page( $template_slug, $page ) {
// Get the block template for this page. If it exists, we won't migrate because the user already has custom content.
$block_template = BlockTemplateUtils::get_block_template( 'woocommerce/woocommerce//' . $template_slug, 'wp_template' );
// If we were unable to get the block template, bail. Try again later.
if ( ! $block_template ) {
return;
}
// If a custom template is present already, no need to migrate.
if ( $block_template->wp_id ) {
return self::set_has_migrated_page( $template_slug, 'custom-template-exists' );
}
// Use the page template if it exists, which we'll use over our default template if found.
$page_template = self::get_page_template( $page );
$default_template = self::get_default_template( $page );
$template_content = $page_template ?: $default_template;
// If at this point we have no content to migrate, bail.
if ( ! $template_content ) {
return self::set_has_migrated_page( $template_slug, 'no-content' );
}
if ( self::create_custom_template( $block_template, $template_content ) ) {
return self::set_has_migrated_page( $template_slug );
}
}
/**
* Get template for a page following the page hierarchy.
*
* @param \WP_Post|null $page Page object.
* @return string
*/
protected static function get_page_template( $page ) {
$templates = array();
if ( $page && $page->ID ) {
$template = get_page_template_slug( $page->ID );
if ( $template && 0 === validate_file( $template ) ) {
$templates[] = $template;
}
$pagename = $page->post_name;
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}";
}
$templates[] = "page-{$pagename}";
}
}
$block_template = false;
foreach ( $templates as $template ) {
$block_template = BlockTemplateUtils::get_block_template( get_stylesheet() . '//' . $template, 'wp_template' );
if ( $block_template && ! empty( $block_template->content ) ) {
break;
}
}
return $block_template ? $block_template->content : '';
}
/**
* Prepare default page template.
*
* @param \WP_Post $page Page object.
* @return string
*/
protected static function get_default_template( $page ) {
if ( ! $page || empty( $page->post_content ) ) {
return '';
}
$default_template_content = '
<!-- wp:group {"layout":{"inherit":true}} -->
<div class="wp-block-group">
<!-- wp:heading {"level":1} -->
<h1 class="wp-block-heading">' . wp_kses_post( $page->post_title ) . '</h1>
<!-- /wp:heading -->
' . wp_kses_post( $page->post_content ) . '
</div>
<!-- /wp:group -->
';
return self::get_block_template_part( 'header' ) . $default_template_content . self::get_block_template_part( 'footer' );
}
/**
* Create a custom template with given content.
*
* @param \WP_Block_Template|null $template Template object.
* @param string $content Template content.
* @return boolean Success.
*/
protected static function create_custom_template( $template, $content ) {
$term = get_term_by( 'slug', $template->theme, 'wp_theme', ARRAY_A );
if ( ! $term ) {
$term = wp_insert_term( $template->theme, 'wp_theme' );
}
$template_id = wp_insert_post(
[
'post_name' => $template->slug,
'post_type' => 'wp_template',
'post_status' => 'publish',
'tax_input' => array(
'wp_theme' => $template->theme,
),
'meta_input' => array(
'origin' => $template->source,
),
'post_content' => $content,
],
true
);
wp_set_post_terms( $template_id, array( $term['term_id'] ), 'wp_theme' );
return $template_id && ! is_wp_error( $template_id );
}
/**
* Returns the requested template part.
*
* @param string $part The part to return.
* @return string
*/
protected static function get_block_template_part( $part ) {
$template_part = BlockTemplateUtils::get_block_template( get_stylesheet() . '//' . $part, 'wp_template_part' );
if ( ! $template_part || empty( $template_part->content ) ) {
return '';
}
return $template_part->content;
}
}
Utils/BlockTemplateUtils.php 0000644 00000070626 15154173075 0012147 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating;
use Automattic\WooCommerce\Blocks\Options;
use Automattic\WooCommerce\Blocks\Templates\CartTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutHeaderTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutTemplate;
use Automattic\WooCommerce\Blocks\Templates\MiniCartTemplate;
use Automattic\WooCommerce\Blocks\Templates\OrderConfirmationTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductAttributeTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
/**
* Utility methods used for serving block templates from WooCommerce Blocks.
* {@internal This class and its methods should only be used within the BlockTemplateController.php and is not intended for public use.}
*/
class BlockTemplateUtils {
const ELIGIBLE_FOR_ARCHIVE_PRODUCT_FALLBACK = array( 'taxonomy-product_cat', 'taxonomy-product_tag', ProductAttributeTemplate::SLUG );
/**
* Directory names for block templates
*
* Directory names conventions for block templates have changed with Gutenberg 12.1.0,
* however, for backwards-compatibility, we also keep the older conventions, prefixed
* with `DEPRECATED_`.
*
* @var array {
* @var string DEPRECATED_TEMPLATES Old directory name of the block templates directory.
* @var string DEPRECATED_TEMPLATE_PARTS Old directory name of the block template parts directory.
* @var string TEMPLATES_DIR_NAME Directory name of the block templates directory.
* @var string TEMPLATE_PARTS_DIR_NAME Directory name of the block template parts directory.
* }
*/
const DIRECTORY_NAMES = array(
'DEPRECATED_TEMPLATES' => 'block-templates',
'DEPRECATED_TEMPLATE_PARTS' => 'block-template-parts',
'TEMPLATES' => 'templates',
'TEMPLATE_PARTS' => 'parts',
);
/**
* WooCommerce plugin slug
*
* This is used to save templates to the DB which are stored against this value in the wp_terms table.
*
* @var string
*/
const PLUGIN_SLUG = 'woocommerce/woocommerce';
/**
* Deprecated WooCommerce plugin slug
*
* For supporting users who have customized templates under the incorrect plugin slug during the first release.
* More context found here: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5423.
*
* @var string
*/
const DEPRECATED_PLUGIN_SLUG = 'woocommerce';
/**
* Returns an array containing the references of
* the passed blocks and their inner blocks.
*
* @param array $blocks array of blocks.
*
* @return array block references to the passed blocks and their inner blocks.
*/
public static function flatten_blocks( &$blocks ) {
$all_blocks = array();
$queue = array();
foreach ( $blocks as &$block ) {
$queue[] = &$block;
}
$queue_count = count( $queue );
while ( $queue_count > 0 ) {
$block = &$queue[0];
array_shift( $queue );
$all_blocks[] = &$block;
if ( ! empty( $block['innerBlocks'] ) ) {
foreach ( $block['innerBlocks'] as &$inner_block ) {
$queue[] = &$inner_block;
}
}
$queue_count = count( $queue );
}
return $all_blocks;
}
/**
* Parses wp_template content and injects the current theme's
* stylesheet as a theme attribute into each wp_template_part
*
* @param string $template_content serialized wp_template content.
*
* @return string Updated wp_template content.
*/
public static function inject_theme_attribute_in_content( $template_content ) {
$has_updated_content = false;
$new_content = '';
$template_blocks = parse_blocks( $template_content );
$blocks = self::flatten_blocks( $template_blocks );
foreach ( $blocks as &$block ) {
if (
'core/template-part' === $block['blockName'] &&
! isset( $block['attrs']['theme'] )
) {
$block['attrs']['theme'] = wp_get_theme()->get_stylesheet();
$has_updated_content = true;
}
}
if ( $has_updated_content ) {
foreach ( $template_blocks as &$block ) {
$new_content .= serialize_block( $block );
}
return $new_content;
}
return $template_content;
}
/**
* Build a unified template object based a post Object.
* Important: This method is an almost identical duplicate from wp-includes/block-template-utils.php as it was not intended for public use. It has been modified to build templates from plugins rather than themes.
*
* @param \WP_Post $post Template post.
*
* @return \WP_Block_Template|\WP_Error Template.
*/
public static function build_template_result_from_post( $post ) {
$terms = get_the_terms( $post, 'wp_theme' );
if ( is_wp_error( $terms ) ) {
return $terms;
}
if ( ! $terms ) {
return new \WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'woocommerce' ) );
}
$theme = $terms[0]->name;
$has_theme_file = true;
$template = new \WP_Block_Template();
$template->wp_id = $post->ID;
$template->id = $theme . '//' . $post->post_name;
$template->theme = $theme;
$template->content = $post->post_content;
$template->slug = $post->post_name;
$template->source = 'custom';
$template->type = $post->post_type;
$template->description = $post->post_excerpt;
$template->title = $post->post_title;
$template->status = $post->post_status;
$template->has_theme_file = $has_theme_file;
$template->is_custom = false;
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
if ( 'wp_template_part' === $post->post_type ) {
$type_terms = get_the_terms( $post, 'wp_template_part_area' );
if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) {
$template->area = $type_terms[0]->name;
}
}
// We are checking 'woocommerce' to maintain classic templates which are saved to the DB,
// prior to updating to use the correct slug.
// More information found here: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5423.
if ( self::PLUGIN_SLUG === $theme || self::DEPRECATED_PLUGIN_SLUG === strtolower( $theme ) ) {
$template->origin = 'plugin';
}
return $template;
}
/**
* Build a unified template object based on a theme file.
* Important: This method is an almost identical duplicate from wp-includes/block-template-utils.php as it was not intended for public use. It has been modified to build templates from plugins rather than themes.
*
* @param array|object $template_file Theme file.
* @param string $template_type wp_template or wp_template_part.
*
* @return \WP_Block_Template Template.
*/
public static function build_template_result_from_file( $template_file, $template_type ) {
$template_file = (object) $template_file;
// If the theme has an archive-products.html template but does not have product taxonomy templates
// then we will load in the archive-product.html template from the theme to use for product taxonomies on the frontend.
$template_is_from_theme = 'theme' === $template_file->source;
$theme_name = wp_get_theme()->get( 'TextDomain' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$template_content = file_get_contents( $template_file->path );
$template = new \WP_Block_Template();
$template->id = $template_is_from_theme ? $theme_name . '//' . $template_file->slug : self::PLUGIN_SLUG . '//' . $template_file->slug;
$template->theme = $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG;
$template->content = self::inject_theme_attribute_in_content( $template_content );
// Remove the term description block from the archive-product template
// as the Product Catalog/Shop page doesn't have a description.
if ( 'archive-product' === $template_file->slug ) {
$template->content = str_replace( '<!-- wp:term-description {"align":"wide"} /-->', '', $template->content );
}
// Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
$template->source = $template_file->source ? $template_file->source : 'plugin';
$template->slug = $template_file->slug;
$template->type = $template_type;
$template->title = ! empty( $template_file->title ) ? $template_file->title : self::get_block_template_title( $template_file->slug );
$template->description = ! empty( $template_file->description ) ? $template_file->description : self::get_block_template_description( $template_file->slug );
$template->status = 'publish';
$template->has_theme_file = true;
$template->origin = $template_file->source;
$template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
$template->area = 'uncategorized';
// Force the Mini-Cart template part to be in the Mini-Cart template part area.
if ( 'wp_template_part' === $template_type && 'mini-cart' === $template_file->slug ) {
$template->area = 'mini-cart';
}
return $template;
}
/**
* Build a new template object so that we can make Woo Blocks default templates available in the current theme should they not have any.
*
* @param string $template_file Block template file path.
* @param string $template_type wp_template or wp_template_part.
* @param string $template_slug Block template slug e.g. single-product.
* @param bool $template_is_from_theme If the block template file is being loaded from the current theme instead of Woo Blocks.
*
* @return object Block template object.
*/
public static function create_new_block_template_object( $template_file, $template_type, $template_slug, $template_is_from_theme = false ) {
$theme_name = wp_get_theme()->get( 'TextDomain' );
$new_template_item = array(
'slug' => $template_slug,
'id' => $template_is_from_theme ? $theme_name . '//' . $template_slug : self::PLUGIN_SLUG . '//' . $template_slug,
'path' => $template_file,
'type' => $template_type,
'theme' => $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG,
// Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
'source' => $template_is_from_theme ? 'theme' : 'plugin',
'title' => self::get_block_template_title( $template_slug ),
'description' => self::get_block_template_description( $template_slug ),
'post_types' => array(), // Don't appear in any Edit Post template selector dropdown.
);
return (object) $new_template_item;
}
/**
* Finds all nested template part file paths in a theme's directory.
*
* @param string $base_directory The theme's file path.
* @return array $path_list A list of paths to all template part files.
*/
public static function get_template_paths( $base_directory ) {
$path_list = array();
if ( file_exists( $base_directory ) ) {
$nested_files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $base_directory ) );
$nested_html_files = new \RegexIterator( $nested_files, '/^.+\.html$/i', \RecursiveRegexIterator::GET_MATCH );
foreach ( $nested_html_files as $path => $file ) {
$path_list[] = $path;
}
}
return $path_list;
}
/**
* Returns template titles.
*
* @param string $template_slug The templates slug (e.g. single-product).
* @return string Human friendly title.
*/
public static function get_block_template_title( $template_slug ) {
$plugin_template_types = self::get_plugin_block_template_types();
if ( isset( $plugin_template_types[ $template_slug ] ) ) {
return $plugin_template_types[ $template_slug ]['title'];
} else {
// Human friendly title converted from the slug.
return ucwords( preg_replace( '/[\-_]/', ' ', $template_slug ) );
}
}
/**
* Returns template descriptions.
*
* @param string $template_slug The templates slug (e.g. single-product).
* @return string Template description.
*/
public static function get_block_template_description( $template_slug ) {
$plugin_template_types = self::get_plugin_block_template_types();
if ( isset( $plugin_template_types[ $template_slug ] ) ) {
return $plugin_template_types[ $template_slug ]['description'];
}
return '';
}
/**
* Returns a filtered list of plugin template types, containing their
* localized titles and descriptions.
*
* @return array The plugin template types.
*/
public static function get_plugin_block_template_types() {
return array(
'single-product' => array(
'title' => _x( 'Single Product', 'Template name', 'woocommerce' ),
'description' => __( 'Displays a single product.', 'woocommerce' ),
),
'archive-product' => array(
'title' => _x( 'Product Catalog', 'Template name', 'woocommerce' ),
'description' => __( 'Displays your products.', 'woocommerce' ),
),
'taxonomy-product_cat' => array(
'title' => _x( 'Products by Category', 'Template name', 'woocommerce' ),
'description' => __( 'Displays products filtered by a category.', 'woocommerce' ),
),
'taxonomy-product_tag' => array(
'title' => _x( 'Products by Tag', 'Template name', 'woocommerce' ),
'description' => __( 'Displays products filtered by a tag.', 'woocommerce' ),
),
ProductAttributeTemplate::SLUG => array(
'title' => _x( 'Products by Attribute', 'Template name', 'woocommerce' ),
'description' => __( 'Displays products filtered by an attribute.', 'woocommerce' ),
),
ProductSearchResultsTemplate::SLUG => array(
'title' => _x( 'Product Search Results', 'Template name', 'woocommerce' ),
'description' => __( 'Displays search results for your store.', 'woocommerce' ),
),
MiniCartTemplate::SLUG => array(
'title' => _x( 'Mini-Cart', 'Template name', 'woocommerce' ),
'description' => __( 'Template used to display the Mini-Cart drawer.', 'woocommerce' ),
),
CartTemplate::get_slug() => array(
'title' => _x( 'Cart', 'Template name', 'woocommerce' ),
'description' => __( 'The Cart template displays the items selected by the user for purchase, including quantities, prices, and discounts. It allows users to review their choices before proceeding to checkout.', 'woocommerce' ),
),
CheckoutTemplate::get_slug() => array(
'title' => _x( 'Checkout', 'Template name', 'woocommerce' ),
'description' => __( 'The Checkout template guides users through the final steps of the purchase process. It enables users to enter shipping and billing information, select a payment method, and review order details.', 'woocommerce' ),
),
CheckoutHeaderTemplate::SLUG => array(
'title' => _x( 'Checkout Header', 'Template name', 'woocommerce' ),
'description' => __( 'Template used to display the simplified Checkout header.', 'woocommerce' ),
),
OrderConfirmationTemplate::get_slug() => array(
'title' => _x( 'Order Confirmation', 'Template name', 'woocommerce' ),
'description' => __( 'The Order Confirmation template provides customers with a summary of their completed purchase, including ordered items, shipping details, and order total. It serves as a receipt and confirmation of the successful transaction.', 'woocommerce' ),
),
);
}
/**
* Converts template paths into a slug
*
* @param string $path The template's path.
* @return string slug
*/
public static function generate_template_slug_from_path( $path ) {
$template_extension = '.html';
return basename( $path, $template_extension );
}
/**
* Gets the first matching template part within themes directories
*
* Since [Gutenberg 12.1.0](https://github.com/WordPress/gutenberg/releases/tag/v12.1.0), the conventions for
* block templates and parts directory has changed from `block-templates` and `block-templates-parts`
* to `templates` and `parts` respectively.
*
* This function traverses all possible combinations of directory paths where a template or part
* could be located and returns the first one which is readable, prioritizing the new convention
* over the deprecated one, but maintaining that one for backwards compatibility.
*
* @param string $template_slug The slug of the template (i.e. without the file extension).
* @param string $template_type Either `wp_template` or `wp_template_part`.
*
* @return string|null The matched path or `null` if no match was found.
*/
public static function get_theme_template_path( $template_slug, $template_type = 'wp_template' ) {
$template_filename = $template_slug . '.html';
$possible_templates_dir = 'wp_template' === $template_type ? array(
self::DIRECTORY_NAMES['TEMPLATES'],
self::DIRECTORY_NAMES['DEPRECATED_TEMPLATES'],
) : array(
self::DIRECTORY_NAMES['TEMPLATE_PARTS'],
self::DIRECTORY_NAMES['DEPRECATED_TEMPLATE_PARTS'],
);
// Combine the possible root directory names with either the template directory
// or the stylesheet directory for child themes.
$possible_paths = array_reduce(
$possible_templates_dir,
function( $carry, $item ) use ( $template_filename ) {
$filepath = DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . $template_filename;
$carry[] = get_stylesheet_directory() . $filepath;
$carry[] = get_template_directory() . $filepath;
return $carry;
},
array()
);
// Return the first matching.
foreach ( $possible_paths as $path ) {
if ( is_readable( $path ) ) {
return $path;
}
}
return null;
}
/**
* Check if the theme has a template. So we know if to load our own in or not.
*
* @param string $template_name name of the template file without .html extension e.g. 'single-product'.
* @return boolean
*/
public static function theme_has_template( $template_name ) {
return ! ! self::get_theme_template_path( $template_name, 'wp_template' );
}
/**
* Check if the theme has a template. So we know if to load our own in or not.
*
* @param string $template_name name of the template file without .html extension e.g. 'single-product'.
* @return boolean
*/
public static function theme_has_template_part( $template_name ) {
return ! ! self::get_theme_template_path( $template_name, 'wp_template_part' );
}
/**
* Checks to see if they are using a compatible version of WP, or if not they have a compatible version of the Gutenberg plugin installed.
*
* @param string $template_type Optional. Template type: `wp_template` or `wp_template_part`.
* Default `wp_template`.
* @return boolean
*/
public static function supports_block_templates( $template_type = 'wp_template' ) {
if ( 'wp_template_part' === $template_type && ( wc_current_theme_is_fse_theme() || current_theme_supports( 'block-template-parts' ) ) ) {
return true;
} elseif ( 'wp_template' === $template_type && wc_current_theme_is_fse_theme() ) {
return true;
}
return false;
}
/**
* Retrieves a single unified template object using its id.
*
* @param string $id Template unique identifier (example: theme_slug//template_slug).
* @param string $template_type Optional. Template type: `wp_template` or 'wp_template_part`.
* Default `wp_template`.
*
* @return WP_Block_Template|null Template.
*/
public static function get_block_template( $id, $template_type ) {
if ( function_exists( 'get_block_template' ) ) {
return get_block_template( $id, $template_type );
}
if ( function_exists( 'gutenberg_get_block_template' ) ) {
return gutenberg_get_block_template( $id, $template_type );
}
return null;
}
/**
* Checks if we can fall back to the `archive-product` template for a given slug.
*
* `taxonomy-product_cat`, `taxonomy-product_tag`, `taxonomy-product_attribute` templates can
* generally use the `archive-product` as a fallback if there are no specific overrides.
*
* @param string $template_slug Slug to check for fallbacks.
* @return boolean
*/
public static function template_is_eligible_for_product_archive_fallback( $template_slug ) {
return in_array( $template_slug, self::ELIGIBLE_FOR_ARCHIVE_PRODUCT_FALLBACK, true );
}
/**
* Checks if we can fall back to an `archive-product` template stored on the db for a given slug.
*
* @param string $template_slug Slug to check for fallbacks.
* @param array $db_templates Templates that have already been found on the db.
* @return boolean
*/
public static function template_is_eligible_for_product_archive_fallback_from_db( $template_slug, $db_templates ) {
$eligible_for_fallback = self::template_is_eligible_for_product_archive_fallback( $template_slug );
if ( ! $eligible_for_fallback ) {
return false;
}
$array_filter = array_filter(
$db_templates,
function ( $template ) use ( $template_slug ) {
return 'archive-product' === $template->slug;
}
);
return count( $array_filter ) > 0;
}
/**
* Gets the `archive-product` fallback template stored on the db for a given slug.
*
* @param string $template_slug Slug to check for fallbacks.
* @param array $db_templates Templates that have already been found on the db.
* @return boolean|object
*/
public static function get_fallback_template_from_db( $template_slug, $db_templates ) {
$eligible_for_fallback = self::template_is_eligible_for_product_archive_fallback( $template_slug );
if ( ! $eligible_for_fallback ) {
return false;
}
foreach ( $db_templates as $template ) {
if ( 'archive-product' === $template->slug ) {
return $template;
}
}
return false;
}
/**
* Checks if we can fall back to the `archive-product` file template for a given slug in the current theme.
*
* `taxonomy-product_cat`, `taxonomy-product_tag`, `taxonomy-attribute` templates can
* generally use the `archive-product` as a fallback if there are no specific overrides.
*
* @param string $template_slug Slug to check for fallbacks.
* @return boolean
*/
public static function template_is_eligible_for_product_archive_fallback_from_theme( $template_slug ) {
return self::template_is_eligible_for_product_archive_fallback( $template_slug )
&& ! self::theme_has_template( $template_slug )
&& self::theme_has_template( 'archive-product' );
}
/**
* Sets the `has_theme_file` to `true` for templates with fallbacks
*
* There are cases (such as tags, categories and attributes) in which fallback templates
* can be used; so, while *technically* the theme doesn't have a specific file
* for them, it is important that we tell Gutenberg that we do, in fact,
* have a theme file (i.e. the fallback one).
*
* **Note:** this function changes the array that has been passed.
*
* It returns `true` if anything was changed, `false` otherwise.
*
* @param array $query_result Array of template objects.
* @param object $template A specific template object which could have a fallback.
*
* @return boolean
*/
public static function set_has_theme_file_if_fallback_is_available( $query_result, $template ) {
foreach ( $query_result as &$query_result_template ) {
if (
$query_result_template->slug === $template->slug
&& $query_result_template->theme === $template->theme
) {
if ( self::template_is_eligible_for_product_archive_fallback_from_theme( $template->slug ) ) {
$query_result_template->has_theme_file = true;
}
return true;
}
}
return false;
}
/**
* Filter block templates by feature flag.
*
* @param WP_Block_Template[] $block_templates An array of block template objects.
*
* @return WP_Block_Template[] An array of block template objects.
*/
public static function filter_block_templates_by_feature_flag( $block_templates ) {
$feature_gating = new FeatureGating();
$flag = $feature_gating->get_flag();
/**
* An array of block templates with slug as key and flag as value.
*
* @var array
*/
$block_templates_with_feature_gate = array();
return array_filter(
$block_templates,
function( $block_template ) use ( $flag, $block_templates_with_feature_gate ) {
if ( isset( $block_templates_with_feature_gate[ $block_template->slug ] ) ) {
return $block_templates_with_feature_gate[ $block_template->slug ] <= $flag;
}
return true;
}
);
}
/**
* Removes templates that were added to a theme's block-templates directory, but already had a customised version saved in the database.
*
* @param \WP_Block_Template[]|\stdClass[] $templates List of templates to run the filter on.
*
* @return array List of templates with duplicates removed. The customised alternative is preferred over the theme default.
*/
public static function remove_theme_templates_with_custom_alternative( $templates ) {
// Get the slugs of all templates that have been customised and saved in the database.
$customised_template_slugs = array_map(
function( $template ) {
return $template->slug;
},
array_values(
array_filter(
$templates,
function( $template ) {
// This template has been customised and saved as a post.
return 'custom' === $template->source;
}
)
)
);
// Remove theme (i.e. filesystem) templates that have the same slug as a customised one. We don't need to check
// for `woocommerce` in $template->source here because woocommerce templates won't have been added to $templates
// if a saved version was found in the db. This only affects saved templates that were saved BEFORE a theme
// template with the same slug was added.
return array_values(
array_filter(
$templates,
function( $template ) use ( $customised_template_slugs ) {
// This template has been customised and saved as a post, so return it.
return ! ( 'theme' === $template->source && in_array( $template->slug, $customised_template_slugs, true ) );
}
)
);
}
/**
* Returns whether the blockified templates should be used or not.
* First, we need to make sure WordPress version is higher than 6.1 (lowest that supports Products block).
* Then, if the option is not stored on the db, we need to check if the current theme is a block one or not.
*
* @return boolean
*/
public static function should_use_blockified_product_grid_templates() {
$minimum_wp_version = '6.1';
if ( version_compare( $GLOBALS['wp_version'], $minimum_wp_version, '<' ) ) {
return false;
}
$use_blockified_templates = get_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE );
if ( false === $use_blockified_templates ) {
return wc_current_theme_is_fse_theme();
}
return wc_string_to_bool( $use_blockified_templates );
}
/**
* Returns whether the passed `$template` has a title, and it's different from the slug.
*
* @param object $template The template object.
* @return boolean
*/
public static function template_has_title( $template ) {
return ! empty( $template->title ) && $template->title !== $template->slug;
}
/**
* Returns whether the passed `$template` has the legacy template block.
*
* @param object $template The template object.
* @return boolean
*/
public static function template_has_legacy_template_block( $template ) {
return has_block( 'woocommerce/legacy-template', $template->content );
}
/**
* Gets the templates saved in the database.
*
* @param array $slugs An array of slugs to retrieve templates for.
* @param string $template_type wp_template or wp_template_part.
*
* @return int[]|\WP_Post[] An array of found templates.
*/
public static function get_block_templates_from_db( $slugs = array(), $template_type = 'wp_template' ) {
$check_query_args = array(
'post_type' => $template_type,
'posts_per_page' => -1,
'no_found_rows' => true,
'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
array(
'taxonomy' => 'wp_theme',
'field' => 'name',
'terms' => array( self::DEPRECATED_PLUGIN_SLUG, self::PLUGIN_SLUG, get_stylesheet() ),
),
),
);
if ( is_array( $slugs ) && count( $slugs ) > 0 ) {
$check_query_args['post_name__in'] = $slugs;
}
$check_query = new \WP_Query( $check_query_args );
$saved_woo_templates = $check_query->posts;
return array_map(
function( $saved_woo_template ) {
return self::build_template_result_from_post( $saved_woo_template );
},
$saved_woo_templates
);
}
}
Utils/BlocksWpQuery.php 0000644 00000004125 15154173075 0011141 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
use WP_Query;
/**
* BlocksWpQuery query.
*
* Wrapper for WP Query with additional helper methods.
* Allows query args to be set and parsed without doing running it, so that a cache can be used.
*
* @deprecated 2.5.0
*/
class BlocksWpQuery extends WP_Query {
/**
* Constructor.
*
* Sets up the WordPress query, if parameter is not empty.
*
* Unlike the constructor in WP_Query, this does not RUN the query.
*
* @param string|array $query URL query string or array of vars.
*/
public function __construct( $query = '' ) {
if ( ! empty( $query ) ) {
$this->init();
$this->query = wp_parse_args( $query );
$this->query_vars = $this->query;
$this->parse_query_vars();
}
}
/**
* Get cached posts, if a cache exists.
*
* A hash is generated using the array of query_vars. If doing custom queries via filters such as posts_where
* (where the SQL query is manipulated directly) you can still ensure there is a unique hash by injecting custom
* query vars via the parse_query filter. For example:
*
* add_filter( 'parse_query', function( $wp_query ) {
* $wp_query->query_vars['my_custom_query_var'] = true;
* } );
*
* Doing so won't have any negative effect on the query itself, and it will cause the hash to change.
*
* @param string $transient_version Transient version to allow for invalidation.
* @return WP_Post[]|int[] Array of post objects or post IDs.
*/
public function get_cached_posts( $transient_version = '' ) {
$hash = md5( wp_json_encode( $this->query_vars ) );
$transient_name = 'wc_blocks_query_' . $hash;
$transient_value = get_transient( $transient_name );
if ( isset( $transient_value, $transient_value['version'], $transient_value['value'] ) && $transient_value['version'] === $transient_version ) {
return $transient_value['value'];
}
$results = $this->get_posts();
set_transient(
$transient_name,
array(
'version' => $transient_version,
'value' => $results,
),
DAY_IN_SECONDS * 30
);
return $results;
}
}
Utils/CartCheckoutUtils.php 0000644 00000006634 15154173075 0011776 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* Class containing utility methods for dealing with the Cart and Checkout blocks.
*/
class CartCheckoutUtils {
/**
* Checks if the default cart page is using the Cart block.
*
* @return bool true if the WC cart page is using the Cart block.
*/
public static function is_cart_block_default() {
if ( wc_current_theme_is_fse_theme() ) {
// Ignore the pages and check the templates.
$templates_from_db = BlockTemplateUtils::get_block_templates_from_db( array( 'cart' ), 'wp_template' );
// If there is no template file, we're using default which does use the block.
if ( empty( $templates_from_db ) ) {
return true;
}
foreach ( $templates_from_db as $template ) {
if ( has_block( 'woocommerce/cart', $template->content ) ) {
return true;
}
}
}
$cart_page_id = wc_get_page_id( 'cart' );
return $cart_page_id && has_block( 'woocommerce/cart', $cart_page_id );
}
/**
* Checks if the default checkout page is using the Checkout block.
*
* @return bool true if the WC checkout page is using the Checkout block.
*/
public static function is_checkout_block_default() {
if ( wc_current_theme_is_fse_theme() ) {
// Ignore the pages and check the templates.
$templates_from_db = BlockTemplateUtils::get_block_templates_from_db( array( 'checkout' ), 'wp_template' );
// If there is no template file, we're using default which does use the block.
if ( empty( $templates_from_db ) ) {
return true;
}
foreach ( $templates_from_db as $template ) {
if ( has_block( 'woocommerce/checkout', $template->content ) ) {
return true;
}
}
}
$checkout_page_id = wc_get_page_id( 'checkout' );
return $checkout_page_id && has_block( 'woocommerce/checkout', $checkout_page_id );
}
/**
* Gets country codes, names, states, and locale information.
*
* @return array
*/
public static function get_country_data() {
$billing_countries = WC()->countries->get_allowed_countries();
$shipping_countries = WC()->countries->get_shipping_countries();
$country_locales = wc()->countries->get_country_locale();
$country_states = wc()->countries->get_states();
$all_countries = self::deep_sort_with_accents( array_unique( array_merge( $billing_countries, $shipping_countries ) ) );
$country_data = [];
foreach ( array_keys( $all_countries ) as $country_code ) {
$country_data[ $country_code ] = [
'allowBilling' => isset( $billing_countries[ $country_code ] ),
'allowShipping' => isset( $shipping_countries[ $country_code ] ),
'states' => self::deep_sort_with_accents( $country_states[ $country_code ] ?? [] ),
'locale' => $country_locales[ $country_code ] ?? [],
];
}
return $country_data;
}
/**
* Removes accents from an array of values, sorts by the values, then returns the original array values sorted.
*
* @param array $array Array of values to sort.
* @return array Sorted array.
*/
protected static function deep_sort_with_accents( $array ) {
if ( ! is_array( $array ) || empty( $array ) ) {
return $array;
}
$array_without_accents = array_map(
function( $value ) {
return is_array( $value )
? self::deep_sort_with_accents( $value )
: remove_accents( wc_strtolower( html_entity_decode( $value ) ) );
},
$array
);
asort( $array_without_accents );
return array_replace( $array_without_accents, $array );
}
}
Utils/MiniCartUtils.php 0000644 00000002273 15154173075 0011120 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* Utility methods used for the Mini Cart block.
*/
class MiniCartUtils {
/**
* Migrate attributes to color panel component format.
*
* @param array $attributes Any attributes that currently are available from the block.
* @return array Reformatted attributes that are compatible with the color panel component.
*/
public static function migrate_attributes_to_color_panel( $attributes ) {
if ( isset( $attributes['priceColorValue'] ) && ! isset( $attributes['priceColor'] ) ) {
$attributes['priceColor'] = array(
'color' => $attributes['priceColorValue'],
);
unset( $attributes['priceColorValue'] );
}
if ( isset( $attributes['iconColorValue'] ) && ! isset( $attributes['iconColor'] ) ) {
$attributes['iconColor'] = array(
'color' => $attributes['iconColorValue'],
);
unset( $attributes['iconColorValue'] );
}
if ( isset( $attributes['productCountColorValue'] ) && ! isset( $attributes['productCountColor'] ) ) {
$attributes['productCountColor'] = array(
'color' => $attributes['productCountColorValue'],
);
unset( $attributes['productCountColorValue'] );
}
return $attributes;
}
}
Utils/SettingsUtils.php 0000644 00000002726 15154173075 0011215 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
use WC_Admin_Settings;
/**
* WooSettingsUtils class
*/
class SettingsUtils {
/**
* Input field for permalink settings/pages.
*
* @param array $value Input value.
*/
public static function permalink_input_field( $value ) {
$field_description = WC_Admin_Settings::get_field_description( $value );
$description = $field_description['description'];
$tooltip_html = $field_description['tooltip_html'];
?>
<tr valign="top">
<th scope="row" class="titledesc">
<label for="<?php echo esc_attr( $value['id'] ); ?>"><?php echo esc_html( $value['title'] ); ?> <?php echo wp_kses_post( $tooltip_html ); ?></label>
</th>
<td class="forminp forminp-text">
<span class="code" style="width: 400px; display:flex; align-items:center; gap:5px;">
<code class="permalink-custom" style="vertical-align: middle;">
<?php echo esc_html( get_site_url( null, '/' ) ); ?>
</code>
<input
name="<?php echo esc_attr( $value['field_name'] ); ?>"
id="<?php echo esc_attr( $value['id'] ); ?>"
type="text"
required
style="vertical-align: middle;"
value="<?php echo esc_attr( $value['value'] ); ?>"
class="<?php echo esc_attr( $value['class'] ); ?>"
placeholder="<?php echo esc_attr( $value['placeholder'] ); ?>"
/><?php echo esc_html( $value['suffix'] ); ?>
</span>
<?php echo wp_kses_post( $description ); ?>
</td>
</tr>
<?php
}
}
Utils/StyleAttributesUtils.php 0000644 00000044461 15154173075 0012566 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* StyleAttributesUtils class used for getting class and style from attributes.
*/
class StyleAttributesUtils {
/**
* If color value is in preset format, convert it to a CSS var. Else return same value
* For example:
* "var:preset|color|pale-pink" -> "var(--wp--preset--color--pale-pink)"
* "#98b66e" -> "#98b66e"
*
* @param string $color_value value to be processed.
*
* @return (string)
*/
public static function get_color_value( $color_value ) {
if ( is_string( $color_value ) && str_contains( $color_value, 'var:preset|color|' ) ) {
$color_value = str_replace( 'var:preset|color|', '', $color_value );
return sprintf( 'var(--wp--preset--color--%s)', $color_value );
}
return $color_value;
}
/**
* Get CSS value for color preset.
*
* @param string $preset_name Preset name.
*
* @return string CSS value for color preset.
*/
public static function get_preset_value( $preset_name ) {
return "var(--wp--preset--color--$preset_name)";
}
/**
* If spacing value is in preset format, convert it to a CSS var. Else return same value
* For example:
* "var:preset|spacing|50" -> "var(--wp--preset--spacing--50)"
* "50px" -> "50px"
*
* @param string $spacing_value value to be processed.
*
* @return (string)
*/
public static function get_spacing_value( $spacing_value ) {
// Used following code as reference: https://github.com/WordPress/gutenberg/blob/cff6d70d6ff5a26e212958623dc3130569f95685/lib/block-supports/layout.php/#L219-L225.
if ( is_string( $spacing_value ) && str_contains( $spacing_value, 'var:preset|spacing|' ) ) {
$spacing_value = str_replace( 'var:preset|spacing|', '', $spacing_value );
return sprintf( 'var(--wp--preset--spacing--%s)', $spacing_value );
}
return $spacing_value;
}
/**
* Get class and style for align from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_align_class_and_style( $attributes ) {
$align_attribute = $attributes['align'] ?? null;
if ( ! $align_attribute ) {
return null;
}
if ( 'wide' === $align_attribute ) {
return array(
'class' => 'alignwide',
'style' => null,
);
}
if ( 'full' === $align_attribute ) {
return array(
'class' => 'alignfull',
'style' => null,
);
}
if ( 'left' === $align_attribute ) {
return array(
'class' => 'alignleft',
'style' => null,
);
}
if ( 'right' === $align_attribute ) {
return array(
'class' => 'alignright',
'style' => null,
);
}
if ( 'center' === $align_attribute ) {
return array(
'class' => 'aligncenter',
'style' => null,
);
}
return null;
}
/**
* Get class and style for background-color from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_background_color_class_and_style( $attributes ) {
$background_color = $attributes['backgroundColor'] ?? '';
$custom_background_color = $attributes['style']['color']['background'] ?? '';
if ( ! $background_color && '' === $custom_background_color ) {
return null;
}
if ( $background_color ) {
return array(
'class' => sprintf( 'has-background has-%s-background-color', $background_color ),
'style' => null,
'value' => self::get_preset_value( $background_color ),
);
} elseif ( '' !== $custom_background_color ) {
return array(
'class' => null,
'style' => sprintf( 'background-color: %s;', $custom_background_color ),
'value' => $custom_background_color,
);
}
return null;
}
/**
* Get class and style for border-color from attributes.
*
* Data passed to this function is not always consistent. It can be:
* Linked - preset color: $attributes['borderColor'] => 'luminous-vivid-orange'.
* Linked - custom color: $attributes['style']['border']['color'] => '#681228'.
* Unlinked - preset color: $attributes['style']['border']['top']['color'] => 'var:preset|color|luminous-vivid-orange'
* Unlinked - custom color: $attributes['style']['border']['top']['color'] => '#681228'.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_border_color_class_and_style( $attributes ) {
$border_color_linked_preset = $attributes['borderColor'] ?? '';
$border_color_linked_custom = $attributes['style']['border']['color'] ?? '';
$custom_border = $attributes['style']['border'] ?? '';
$border_color_class = '';
$border_color_css = '';
if ( $border_color_linked_preset ) {
// Linked preset color.
$border_color_class = sprintf( 'has-border-color has-%s-border-color', $border_color_linked_preset );
} elseif ( $border_color_linked_custom ) {
// Linked custom color.
$border_color_css .= 'border-color:' . $border_color_linked_custom . ';';
} else {
// Unlinked.
if ( is_array( $custom_border ) ) {
foreach ( $custom_border as $border_color_key => $border_color_value ) {
if ( is_array( $border_color_value ) && array_key_exists( 'color', ( $border_color_value ) ) ) {
$border_color_css .= 'border-' . $border_color_key . '-color:' . self::get_color_value( $border_color_value['color'] ) . ';';
}
}
}
}
if ( ! $border_color_class && ! $border_color_css ) {
return null;
}
return array(
'class' => $border_color_class,
'style' => $border_color_css,
);
}
/**
* Get class and style for border-radius from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_border_radius_class_and_style( $attributes ) {
$custom_border_radius = $attributes['style']['border']['radius'] ?? '';
if ( '' === $custom_border_radius ) {
return null;
}
$border_radius_css = '';
if ( is_string( $custom_border_radius ) ) {
// Linked sides.
$border_radius_css = 'border-radius:' . $custom_border_radius . ';';
} else {
// Unlinked sides.
$border_radius = array();
$border_radius['border-top-left-radius'] = $custom_border_radius['topLeft'] ?? '';
$border_radius['border-top-right-radius'] = $custom_border_radius['topRight'] ?? '';
$border_radius['border-bottom-right-radius'] = $custom_border_radius['bottomRight'] ?? '';
$border_radius['border-bottom-left-radius'] = $custom_border_radius['bottomLeft'] ?? '';
foreach ( $border_radius as $border_radius_side => $border_radius_value ) {
if ( '' !== $border_radius_value ) {
$border_radius_css .= $border_radius_side . ':' . $border_radius_value . ';';
}
}
}
return array(
'class' => null,
'style' => $border_radius_css,
);
}
/**
* Get class and style for border width from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_border_width_class_and_style( $attributes ) {
$custom_border = $attributes['style']['border'] ?? '';
if ( '' === $custom_border ) {
return null;
}
$border_width_css = '';
if ( array_key_exists( 'width', ( $custom_border ) ) && ! empty( $custom_border['width'] ) ) {
// Linked sides.
$border_width_css = 'border-width:' . $custom_border['width'] . ';';
} else {
// Unlinked sides.
foreach ( $custom_border as $border_width_side => $border_width_value ) {
if ( isset( $border_width_value['width'] ) ) {
$border_width_css .= 'border-' . $border_width_side . '-width:' . $border_width_value['width'] . ';';
}
}
}
return array(
'class' => null,
'style' => $border_width_css,
);
}
/**
* Get space-separated classes from block attributes.
*
* @param array $attributes Block attributes.
* @param array $properties Properties to get classes from.
*
* @return string Space-separated classes.
*/
public static function get_classes_by_attributes( $attributes, $properties = array() ) {
$classes_and_styles = self::get_classes_and_styles_by_attributes( $attributes, $properties );
return $classes_and_styles['classes'];
}
/**
* Get class and style for font-family from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_font_family_class_and_style( $attributes ) {
$font_family = $attributes['fontFamily'] ?? '';
if ( $font_family ) {
return array(
'class' => sprintf( 'has-%s-font-family', $font_family ),
'style' => null,
);
}
return null;
}
/**
* Get class and style for font-size from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_font_size_class_and_style( $attributes ) {
$font_size = $attributes['fontSize'] ?? '';
$custom_font_size = $attributes['style']['typography']['fontSize'] ?? '';
if ( ! $font_size && '' === $custom_font_size ) {
return null;
}
if ( $font_size ) {
return array(
'class' => sprintf( 'has-font-size has-%s-font-size', $font_size ),
'style' => null,
);
} elseif ( '' !== $custom_font_size ) {
return array(
'class' => null,
'style' => sprintf( 'font-size: %s;', $custom_font_size ),
);
}
return null;
}
/**
* Get class and style for font-style from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_font_style_class_and_style( $attributes ) {
$custom_font_style = $attributes['style']['typography']['fontStyle'] ?? '';
if ( '' !== $custom_font_style ) {
return array(
'class' => null,
'style' => sprintf( 'font-style: %s;', $custom_font_style ),
);
}
return null;
}
/**
* Get class and style for font-weight from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_font_weight_class_and_style( $attributes ) {
$custom_font_weight = $attributes['style']['typography']['fontWeight'] ?? '';
if ( '' !== $custom_font_weight ) {
return array(
'class' => null,
'style' => sprintf( 'font-weight: %s;', $custom_font_weight ),
);
}
return null;
}
/**
* Get class and style for letter-spacing from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_letter_spacing_class_and_style( $attributes ) {
$custom_letter_spacing = $attributes['style']['typography']['letterSpacing'] ?? '';
if ( '' !== $custom_letter_spacing ) {
return array(
'class' => null,
'style' => sprintf( 'letter-spacing: %s;', $custom_letter_spacing ),
);
}
return null;
}
/**
* Get class and style for line height from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_line_height_class_and_style( $attributes ) {
$line_height = $attributes['style']['typography']['lineHeight'] ?? '';
if ( ! $line_height ) {
return null;
}
return array(
'class' => null,
'style' => sprintf( 'line-height: %s;', $line_height ),
);
}
/**
* Get class and style for link-color from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_link_color_class_and_style( $attributes ) {
if ( ! isset( $attributes['style']['elements']['link']['color']['text'] ) ) {
return null;
}
$link_color = $attributes['style']['elements']['link']['color']['text'];
// If the link color is selected from the theme color picker, the value of $link_color is var:preset|color|slug.
// If the link color is selected from the core color picker, the value of $link_color is an hex value.
// When the link color is a string var:preset|color|slug we parsed it for get the slug, otherwise we use the hex value.
$index_named_link_color = strrpos( $link_color, '|' );
if ( ! empty( $index_named_link_color ) ) {
$parsed_named_link_color = substr( $link_color, $index_named_link_color + 1 );
return array(
'class' => null,
'style' => sprintf( 'color: %s;', self::get_preset_value( $parsed_named_link_color ) ),
'value' => self::get_preset_value( $parsed_named_link_color ),
);
} else {
return array(
'class' => null,
'style' => sprintf( 'color: %s;', $link_color ),
'value' => $link_color,
);
}
}
/**
* Get class and style for margin from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_margin_class_and_style( $attributes ) {
$margin = $attributes['style']['spacing']['margin'] ?? null;
if ( ! $margin ) {
return null;
}
$spacing_values_css = '';
foreach ( $margin as $margin_side => $margin_value ) {
$spacing_values_css .= 'margin-' . $margin_side . ':' . self::get_spacing_value( $margin_value ) . ';';
}
return array(
'class' => null,
'style' => $spacing_values_css,
);
}
/**
* Get class and style for padding from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_padding_class_and_style( $attributes ) {
$padding = $attributes['style']['spacing']['padding'] ?? null;
if ( ! $padding ) {
return null;
}
$spacing_values_css = '';
foreach ( $padding as $padding_side => $padding_value ) {
$spacing_values_css .= 'padding-' . $padding_side . ':' . self::get_spacing_value( $padding_value ) . ';';
}
return array(
'class' => null,
'style' => $spacing_values_css,
);
}
/**
* Get space-separated style rules from block attributes.
*
* @param array $attributes Block attributes.
* @param array $properties Properties to get styles from.
*
* @return string Space-separated style rules.
*/
public static function get_styles_by_attributes( $attributes, $properties = array() ) {
$classes_and_styles = self::get_classes_and_styles_by_attributes( $attributes, $properties );
return $classes_and_styles['styles'];
}
/**
* Get class and style for text align from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_text_align_class_and_style( $attributes ) {
if ( isset( $attributes['textAlign'] ) ) {
return array(
'class' => 'has-text-align-' . $attributes['textAlign'],
'style' => null,
);
}
return null;
}
/**
* Get class and style for text-color from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_text_color_class_and_style( $attributes ) {
$text_color = $attributes['textColor'] ?? '';
$custom_text_color = $attributes['style']['color']['text'] ?? '';
if ( ! $text_color && ! $custom_text_color ) {
return null;
}
if ( $text_color ) {
return array(
'class' => sprintf( 'has-text-color has-%s-color', $text_color ),
'style' => null,
'value' => self::get_preset_value( $text_color ),
);
} elseif ( $custom_text_color ) {
return array(
'class' => null,
'style' => sprintf( 'color: %s;', $custom_text_color ),
'value' => $custom_text_color,
);
}
return null;
}
/**
* Get class and style for text-decoration from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_text_decoration_class_and_style( $attributes ) {
$custom_text_decoration = $attributes['style']['typography']['textDecoration'] ?? '';
if ( '' !== $custom_text_decoration ) {
return array(
'class' => null,
'style' => sprintf( 'text-decoration: %s;', $custom_text_decoration ),
);
}
return null;
}
/**
* Get class and style for text-transform from attributes.
*
* @param array $attributes Block attributes.
*
* @return (array | null)
*/
public static function get_text_transform_class_and_style( $attributes ) {
$custom_text_transform = $attributes['style']['typography']['textTransform'] ?? '';
if ( '' !== $custom_text_transform ) {
return array(
'class' => null,
'style' => sprintf( 'text-transform: %s;', $custom_text_transform ),
);
}
return null;
}
/**
* Get classes and styles from attributes.
*
* @param array $attributes Block attributes.
* @param array $properties Properties to get classes/styles from.
*
* @return array
*/
public static function get_classes_and_styles_by_attributes( $attributes, $properties = array() ) {
$classes_and_styles = array(
'align' => self::get_align_class_and_style( $attributes ),
'background_color' => self::get_background_color_class_and_style( $attributes ),
'border_color' => self::get_border_color_class_and_style( $attributes ),
'border_radius' => self::get_border_radius_class_and_style( $attributes ),
'border_width' => self::get_border_width_class_and_style( $attributes ),
'font_family' => self::get_font_family_class_and_style( $attributes ),
'font_size' => self::get_font_size_class_and_style( $attributes ),
'font_style' => self::get_font_style_class_and_style( $attributes ),
'font_weight' => self::get_font_weight_class_and_style( $attributes ),
'letter_spacing' => self::get_letter_spacing_class_and_style( $attributes ),
'line_height' => self::get_line_height_class_and_style( $attributes ),
'link_color' => self::get_link_color_class_and_style( $attributes ),
'margin' => self::get_margin_class_and_style( $attributes ),
'padding' => self::get_padding_class_and_style( $attributes ),
'text_align' => self::get_text_align_class_and_style( $attributes ),
'text_color' => self::get_text_color_class_and_style( $attributes ),
'text_decoration' => self::get_text_decoration_class_and_style( $attributes ),
'text_transform' => self::get_text_transform_class_and_style( $attributes ),
);
if ( ! empty( $properties ) ) {
foreach ( $classes_and_styles as $key => $value ) {
if ( ! in_array( $key, $properties, true ) ) {
unset( $classes_and_styles[ $key ] );
}
}
}
$classes_and_styles = array_filter( $classes_and_styles );
$classes = array_map(
function( $item ) {
return $item['class'];
},
$classes_and_styles
);
$styles = array_map(
function( $item ) {
return $item['style'];
},
$classes_and_styles
);
$classes = array_filter( $classes );
$styles = array_filter( $styles );
return array(
'classes' => implode( ' ', $classes ),
'styles' => implode( ' ', $styles ),
);
}
}
Utils/Utils.php 0000644 00000001775 15154173075 0007477 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* Utils class
*/
class Utils {
/**
* Compare the current WordPress version with a given version. It's a wrapper around `version-compare`
* that additionally takes into account the suffix (like `-RC1`).
* For example: version 6.3 is considered lower than 6.3-RC2, so you can do
* wp_version_compare( '6.3', '>=' ) and that will return true for 6.3-RC2.
*
* @param string $version The version to compare against.
* @param string|null $operator Optional. The comparison operator. Defaults to null.
* @return bool|int Returns true if the current WordPress version satisfies the comparison, false otherwise.
*/
public static function wp_version_compare( $version, $operator = null ) {
$current_wp_version = get_bloginfo( 'version' );
if ( preg_match( '/^([0-9]+\.[0-9]+)/', $current_wp_version, $matches ) ) {
$current_wp_version = (float) $matches[1];
}
return version_compare( $current_wp_version, $version, $operator );
}
}
Verticals/Client.php 0000644 00000003737 15154173075 0010451 0 ustar 00 <?php
namespace Automattic\WooCommerce\Blocks\Verticals;
/**
* Verticals API client.
*/
class Client {
const ENDPOINT = 'https://public-api.wordpress.com/wpcom/v2/site-verticals';
/**
* Make a request to the Verticals API.
*
* @param string $url The endpoint URL.
*
* @return array|\WP_Error The response body, or WP_Error if the request failed.
*/
private function request( string $url ) {
$response = wp_remote_get( $url );
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = json_decode( wp_remote_retrieve_body( $response ), true );
$error_data = array();
if ( is_wp_error( $response ) ) {
$error_data['code'] = $response->get_error_code();
$error_data['message'] = $response->get_error_message();
}
if ( 200 !== $response_code ) {
$error_data['status'] = $response_code;
if ( isset( $response_body['message'] ) ) {
$error_data['message'] = $response_body['message'];
}
if ( isset( $response_body['code'] ) ) {
$error_data['code'] = $response_body['code'];
}
}
if ( ! empty( $error_data ) ) {
return new \WP_Error( 'verticals_api_error', __( 'Request to the Verticals API failed.', 'woocommerce' ), $error_data );
}
return $response_body;
}
/**
* Returns a list of verticals that have images.
*
* @return array|\WP_Error Array of verticals, or WP_Error if the request failed.
*/
public function get_verticals() {
$response = $this->request( self::ENDPOINT );
if ( is_wp_error( $response ) ) {
return $response;
}
return array_filter(
$response,
function ( $vertical ) {
return $vertical['has_vertical_images'];
}
);
}
/**
* Returns the list of images for the given vertical ID.
*
* @param int $vertical_id The vertical ID.
*
* @return array|\WP_Error Array of images, or WP_Error if the request failed.
*/
public function get_vertical_images( int $vertical_id ) {
return $this->request( self::ENDPOINT . '/' . $vertical_id . '/images' );
}
}
class-config.php 0000644 00000027006 15154562453 0007644 0 ustar 00 <?php
/**
* The base Jetpack configuration class file.
*
* @package automattic/jetpack-config
*/
namespace Automattic\Jetpack;
/*
* The Config package does not require the composer packages that
* contain the package classes shown below. The consumer plugin
* must require the corresponding packages to use these features.
*/
use Automattic\Jetpack\Blaze as Blaze;
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Connection\Plugin;
use Automattic\Jetpack\Import\Main as Import_Main;
use Automattic\Jetpack\JITM as JITM;
use Automattic\Jetpack\JITMS\JITM as JITMS_JITM;
use Automattic\Jetpack\Post_List\Post_List as Post_List;
use Automattic\Jetpack\Publicize\Publicize_Setup as Publicize_Setup;
use Automattic\Jetpack\Search\Initializer as Jetpack_Search_Main;
use Automattic\Jetpack\Stats\Main as Stats_Main;
use Automattic\Jetpack\Stats_Admin\Main as Stats_Admin_Main;
use Automattic\Jetpack\Sync\Main as Sync_Main;
use Automattic\Jetpack\VideoPress\Initializer as VideoPress_Pkg_Initializer;
use Automattic\Jetpack\Waf\Waf_Initializer as Jetpack_Waf_Main;
use Automattic\Jetpack\WordAds\Initializer as Jetpack_WordAds_Main;
use Automattic\Jetpack\Yoast_Promo as Yoast_Promo;
/**
* The configuration class.
*/
class Config {
const FEATURE_ENSURED = 1;
const FEATURE_NOT_AVAILABLE = 0;
const FEATURE_ALREADY_ENSURED = -1;
/**
* The initial setting values.
*
* @var Array
*/
protected $config = array(
'jitm' => false,
'connection' => false,
'sync' => false,
'post_list' => false,
'identity_crisis' => false,
'search' => false,
'publicize' => false,
'wordads' => false,
'waf' => false,
'videopress' => false,
'stats' => false,
'stats_admin' => false,
'blaze' => false,
'yoast_promo' => false,
'import' => false,
);
/**
* Initialization options stored here.
*
* @var array
*/
protected $feature_options = array();
/**
* Creates the configuration class instance.
*/
public function __construct() {
/**
* Adding the config handler to run on priority 2 because the class itself is
* being constructed on priority 1.
*/
add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ), 2 );
}
/**
* Require a feature to be initialized. It's up to the package consumer to actually add
* the package to their composer project. Declaring a requirement using this method
* instructs the class to initialize it.
*
* Run the options method (if exists) every time the method is called.
*
* @param String $feature the feature slug.
* @param array $options Additional options, optional.
*/
public function ensure( $feature, array $options = array() ) {
$this->config[ $feature ] = true;
$this->set_feature_options( $feature, $options );
$method_options = 'ensure_options_' . $feature;
if ( method_exists( $this, $method_options ) ) {
$this->{ $method_options }();
}
}
/**
* Runs on plugins_loaded hook priority with priority 2.
*
* @action plugins_loaded
*/
public function on_plugins_loaded() {
if ( $this->config['connection'] ) {
$this->ensure_class( 'Automattic\Jetpack\Connection\Manager' )
&& $this->ensure_feature( 'connection' );
}
if ( $this->config['sync'] ) {
$this->ensure_class( 'Automattic\Jetpack\Sync\Main' )
&& $this->ensure_feature( 'sync' );
}
if ( $this->config['jitm'] ) {
// Check for the JITM class in both namespaces. The namespace was changed in jetpack-jitm v1.6.
( $this->ensure_class( 'Automattic\Jetpack\JITMS\JITM', false )
|| $this->ensure_class( 'Automattic\Jetpack\JITM' ) )
&& $this->ensure_feature( 'jitm' );
}
if ( $this->config['post_list'] ) {
$this->ensure_class( 'Automattic\Jetpack\Post_List\Post_List' )
&& $this->ensure_feature( 'post_list' );
}
if ( $this->config['identity_crisis'] ) {
$this->ensure_class( 'Automattic\Jetpack\Identity_Crisis' )
&& $this->ensure_feature( 'identity_crisis' );
}
if ( $this->config['search'] ) {
$this->ensure_class( 'Automattic\Jetpack\Search\Initializer' )
&& $this->ensure_feature( 'search' );
}
if ( $this->config['publicize'] ) {
$this->ensure_class( 'Automattic\Jetpack\Publicize\Publicize_UI' ) && $this->ensure_class( 'Automattic\Jetpack\Publicize\Publicize' )
&& $this->ensure_feature( 'publicize' );
}
if ( $this->config['wordads'] ) {
$this->ensure_class( 'Automattic\Jetpack\WordAds\Initializer' )
&& $this->ensure_feature( 'wordads' );
}
if ( $this->config['waf'] ) {
$this->ensure_class( 'Automattic\Jetpack\Waf\Waf_Initializer' )
&& $this->ensure_feature( 'waf' );
}
if ( $this->config['videopress'] ) {
$this->ensure_class( 'Automattic\Jetpack\VideoPress\Initializer' ) && $this->ensure_feature( 'videopress' );
}
if ( $this->config['stats'] ) {
$this->ensure_class( 'Automattic\Jetpack\Stats\Main' ) && $this->ensure_feature( 'stats' );
}
if ( $this->config['stats_admin'] ) {
$this->ensure_class( 'Automattic\Jetpack\Stats_Admin\Main' ) && $this->ensure_feature( 'stats_admin' );
}
if ( $this->config['blaze'] ) {
$this->ensure_class( 'Automattic\Jetpack\Blaze' ) && $this->ensure_feature( 'blaze' );
}
if ( $this->config['yoast_promo'] ) {
$this->ensure_class( 'Automattic\Jetpack\Yoast_Promo' ) && $this->ensure_feature( 'yoast_promo' );
}
if ( $this->config['import'] ) {
$this->ensure_class( 'Automattic\Jetpack\Import\Main' )
&& $this->ensure_feature( 'import' );
}
}
/**
* Returns true if the required class is available and alerts the user if it's not available
* in case the site is in debug mode.
*
* @param String $classname a fully qualified class name.
* @param Boolean $log_notice whether the E_USER_NOTICE should be generated if the class is not found.
*
* @return Boolean whether the class is available.
*/
protected function ensure_class( $classname, $log_notice = true ) {
$available = class_exists( $classname );
if ( $log_notice && ! $available && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
sprintf(
/* translators: %1$s is a PHP class name. */
esc_html__(
'Unable to load class %1$s. Please add the package that contains it using composer and make sure you are requiring the Jetpack autoloader',
'jetpack-config'
),
esc_html( $classname )
),
E_USER_NOTICE
);
}
return $available;
}
/**
* Ensures a feature is enabled, sets it up if it hasn't already been set up.
*
* @param String $feature slug of the feature.
* @return Integer either FEATURE_ENSURED, FEATURE_ALREADY_ENSURED or FEATURE_NOT_AVAILABLE constants.
*/
protected function ensure_feature( $feature ) {
$method = 'enable_' . $feature;
if ( ! method_exists( $this, $method ) ) {
return self::FEATURE_NOT_AVAILABLE;
}
if ( did_action( 'jetpack_feature_' . $feature . '_enabled' ) ) {
return self::FEATURE_ALREADY_ENSURED;
}
$this->{ $method }();
/**
* Fires when a specific Jetpack package feature is initalized using the Config package.
*
* @since 1.1.0
*/
do_action( 'jetpack_feature_' . $feature . '_enabled' );
return self::FEATURE_ENSURED;
}
/**
* Enables the JITM feature.
*/
protected function enable_jitm() {
if ( class_exists( 'Automattic\Jetpack\JITMS\JITM' ) ) {
JITMS_JITM::configure();
} else {
// Provides compatibility with jetpack-jitm <v1.6.
JITM::configure();
}
return true;
}
/**
* Enables the Post_List feature.
*/
protected function enable_post_list() {
Post_List::configure();
return true;
}
/**
* Enables the Sync feature.
*/
protected function enable_sync() {
Sync_Main::configure();
return true;
}
/**
* Enables the Connection feature.
*/
protected function enable_connection() {
Manager::configure();
return true;
}
/**
* Enables the identity-crisis feature.
*/
protected function enable_identity_crisis() {
Identity_Crisis::init();
}
/**
* Enables the search feature.
*/
protected function enable_search() {
Jetpack_Search_Main::init();
}
/**
* Enables the Publicize feature.
*/
protected function enable_publicize() {
Publicize_Setup::configure();
return true;
}
/**
* Handles Publicize options.
*/
protected function ensure_options_publicize() {
$options = $this->get_feature_options( 'publicize' );
if ( ! empty( $options['force_refresh'] ) ) {
Publicize_Setup::$refresh_plan_info = true;
}
}
/**
* Enables WordAds.
*/
protected function enable_wordads() {
Jetpack_WordAds_Main::init();
}
/**
* Enables Waf.
*/
protected function enable_waf() {
Jetpack_Waf_Main::init();
return true;
}
/**
* Enables VideoPress.
*/
protected function enable_videopress() {
VideoPress_Pkg_Initializer::init();
return true;
}
/**
* Enables Stats.
*/
protected function enable_stats() {
Stats_Main::init();
return true;
}
/**
* Enables Stats Admin.
*/
protected function enable_stats_admin() {
Stats_Admin_Main::init();
return true;
}
/**
* Handles VideoPress options
*/
protected function ensure_options_videopress() {
$options = $this->get_feature_options( 'videopress' );
if ( ! empty( $options ) ) {
VideoPress_Pkg_Initializer::update_init_options( $options );
}
return true;
}
/**
* Enables Blaze.
*/
protected function enable_blaze() {
Blaze::init();
return true;
}
/**
* Enables Yoast Promo.
*/
protected function enable_yoast_promo() {
Yoast_Promo::init();
return true;
}
/**
* Enables the Import feature.
*/
protected function enable_import() {
Import_Main::configure();
return true;
}
/**
* Setup the Connection options.
*/
protected function ensure_options_connection() {
$options = $this->get_feature_options( 'connection' );
if ( ! empty( $options['slug'] ) ) {
// The `slug` and `name` are removed from the options because they need to be passed as arguments.
$slug = $options['slug'];
unset( $options['slug'] );
$name = $slug;
if ( ! empty( $options['name'] ) ) {
$name = $options['name'];
unset( $options['name'] );
}
( new Plugin( $slug ) )->add( $name, $options );
}
return true;
}
/**
* Setup the Identity Crisis options.
*
* @return bool
*/
protected function ensure_options_identity_crisis() {
$options = $this->get_feature_options( 'identity_crisis' );
if ( is_array( $options ) && count( $options ) ) {
add_filter(
'jetpack_idc_consumers',
function ( $consumers ) use ( $options ) {
$consumers[] = $options;
return $consumers;
}
);
}
return true;
}
/**
* Setup the Sync options.
*/
protected function ensure_options_sync() {
$options = $this->get_feature_options( 'sync' );
if ( method_exists( 'Automattic\Jetpack\Sync\Main', 'set_sync_data_options' ) ) {
Sync_Main::set_sync_data_options( $options );
}
return true;
}
/**
* Temporary save initialization options for a feature.
*
* @param string $feature The feature slug.
* @param array $options The options.
*
* @return bool
*/
protected function set_feature_options( $feature, array $options ) {
if ( $options ) {
$this->feature_options[ $feature ] = $options;
}
return true;
}
/**
* Get initialization options for a feature from the temporary storage.
*
* @param string $feature The feature slug.
*
* @return array
*/
protected function get_feature_options( $feature ) {
return empty( $this->feature_options[ $feature ] ) ? array() : $this->feature_options[ $feature ];
}
}
class-constants.php 0000644 00000006526 15154614253 0010413 0 ustar 00 <?php
/**
* A constants manager for Jetpack.
*
* @package automattic/jetpack-constants
*/
namespace Automattic\Jetpack;
/**
* Class Automattic\Jetpack\Constants
*
* Testing constants is hard. Once you define a constant, it's defined. Constants Manager is an
* abstraction layer so that unit tests can set "constants" for tests.
*
* To test your code, you'll need to swap out `defined( 'CONSTANT' )` with `Automattic\Jetpack\Constants::is_defined( 'CONSTANT' )`
* and replace `CONSTANT` with `Automattic\Jetpack\Constants::get_constant( 'CONSTANT' )`. Then in the unit test, you can set the
* constant with `Automattic\Jetpack\Constants::set_constant( 'CONSTANT', $value )` and then clean up after each test with something like
* this:
*
* function tearDown() {
* Automattic\Jetpack\Constants::clear_constants();
* }
*/
class Constants {
/**
* A container for all defined constants.
*
* @access public
* @static
*
* @var array.
*/
public static $set_constants = array();
/**
* Checks if a "constant" has been set in constants Manager
* and has a truthy value (e.g. not null, not false, not 0, any string).
*
* @param string $name The name of the constant.
*
* @return bool
*/
public static function is_true( $name ) {
return self::is_defined( $name ) && self::get_constant( $name );
}
/**
* Checks if a "constant" has been set in constants Manager, and if not,
* checks if the constant was defined with define( 'name', 'value ).
*
* @param string $name The name of the constant.
*
* @return bool
*/
public static function is_defined( $name ) {
return array_key_exists( $name, self::$set_constants )
? true
: defined( $name );
}
/**
* Attempts to retrieve the "constant" from constants Manager, and if it hasn't been set,
* then attempts to get the constant with the constant() function. If that also hasn't
* been set, attempts to get a value from filters.
*
* @param string $name The name of the constant.
*
* @return mixed null if the constant does not exist or the value of the constant.
*/
public static function get_constant( $name ) {
if ( array_key_exists( $name, self::$set_constants ) ) {
return self::$set_constants[ $name ];
}
if ( defined( $name ) ) {
return constant( $name );
}
/**
* Filters the value of the constant.
*
* @since 1.2.0
*
* @param null The constant value to be filtered. The default is null.
* @param String $name The constant name.
*/
return apply_filters( 'jetpack_constant_default_value', null, $name );
}
/**
* Sets the value of the "constant" within constants Manager.
*
* @param string $name The name of the constant.
* @param string $value The value of the constant.
*/
public static function set_constant( $name, $value ) {
self::$set_constants[ $name ] = $value;
}
/**
* Will unset a "constant" from constants Manager if the constant exists.
*
* @param string $name The name of the constant.
*
* @return bool Whether the constant was removed.
*/
public static function clear_single_constant( $name ) {
if ( ! array_key_exists( $name, self::$set_constants ) ) {
return false;
}
unset( self::$set_constants[ $name ] );
return true;
}
/**
* Resets all of the constants within constants Manager.
*/
public static function clear_constants() {
self::$set_constants = array();
}
}
class-cache.php 0000644 00000002240 15154630664 0007433 0 ustar 00 <?php
/**
* A static in-process cache for blog data.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack\Status;
/**
* A static in-process cache for blog data.
*
* For internal use only. Do not use this externally.
*/
class Cache {
/**
* Cached data;
*
* @var array[]
*/
private static $cache = array();
/**
* Get a value from the cache.
*
* @param string $key Key to fetch.
* @param mixed $default Default value to return if the key is not set.
* @returns mixed Data.
*/
public static function get( $key, $default = null ) {
$blog_id = get_current_blog_id();
return isset( self::$cache[ $blog_id ] ) && array_key_exists( $key, self::$cache[ $blog_id ] ) ? self::$cache[ $blog_id ][ $key ] : $default;
}
/**
* Set a value in the cache.
*
* @param string $key Key to set.
* @param mixed $value Value to store.
*/
public static function set( $key, $value ) {
$blog_id = get_current_blog_id();
self::$cache[ $blog_id ][ $key ] = $value;
}
/**
* Clear the cache.
*
* This is intended for use in unit tests.
*/
public static function clear() {
self::$cache = array();
}
}
class-cookiestate.php 0000644 00000005370 15154630664 0010711 0 ustar 00 <?php
/**
* Pass state to subsequent requests via cookies.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack;
/**
* Class Automattic\Jetpack\Status
*
* Used to retrieve information about the current status of Jetpack and the site overall.
*/
class CookieState {
/**
* State is passed via cookies from one request to the next, but never to subsequent requests.
* SET: state( $key, $value );
* GET: $value = state( $key );
*
* @param string $key State key.
* @param string $value Value.
* @param bool $restate Reset the cookie (private).
*/
public function state( $key = null, $value = null, $restate = false ) {
static $state = array();
static $path, $domain;
if ( ! isset( $path ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$admin_url = ( new Paths() )->admin_url();
$bits = wp_parse_url( $admin_url );
if ( is_array( $bits ) ) {
$path = ( isset( $bits['path'] ) ) ? dirname( $bits['path'] ) : null;
$domain = ( isset( $bits['host'] ) ) ? $bits['host'] : null;
} else {
$path = null;
$domain = null;
}
}
// Extract state from cookies and delete cookies.
if ( isset( $_COOKIE['jetpackState'] ) && is_array( $_COOKIE['jetpackState'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- User should sanitize if necessary.
$yum = wp_unslash( $_COOKIE['jetpackState'] );
unset( $_COOKIE['jetpackState'] );
foreach ( $yum as $k => $v ) {
if ( strlen( $v ) ) {
$state[ $k ] = $v;
}
setcookie( "jetpackState[$k]", false, 0, $path, $domain, is_ssl(), true );
}
}
if ( $restate ) {
foreach ( $state as $k => $v ) {
setcookie( "jetpackState[$k]", $v, 0, $path, $domain, is_ssl(), true );
}
return;
}
// Get a state variable.
if ( isset( $key ) && ! isset( $value ) ) {
if ( array_key_exists( $key, $state ) ) {
return $state[ $key ];
}
return null;
}
// Set a state variable.
if ( isset( $key ) && isset( $value ) ) {
if ( is_array( $value ) && isset( $value[0] ) ) {
$value = $value[0];
}
$state[ $key ] = $value;
if ( ! headers_sent() ) {
if ( $this->should_set_cookie( $key ) ) {
setcookie( "jetpackState[$key]", $value, 0, $path, $domain, is_ssl(), true );
}
}
}
}
/**
* Determines whether the jetpackState[$key] value should be added to the
* cookie.
*
* @param string $key The state key.
*
* @return boolean Whether the value should be added to the cookie.
*/
public function should_set_cookie( $key ) {
global $current_screen;
$page = isset( $current_screen->base ) ? $current_screen->base : null;
if ( 'toplevel_page_jetpack' === $page && 'display_update_modal' === $key ) {
return false;
}
return true;
}
}
class-errors.php 0000644 00000002277 15154630664 0007716 0 ustar 00 <?php
/**
* An errors utility class for Jetpack.
*
* @package automattic/jetpack-status
*/
// phpcs:disable WordPress.PHP.IniSet.display_errors_Disallowed
// phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
// phpcs:disable WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting
namespace Automattic\Jetpack;
/**
* Erros class.
*/
class Errors {
/**
* Catches PHP errors. Must be used in conjunction with output buffering.
*
* @param bool $catch True to start catching, False to stop.
*
* @static
*/
public function catch_errors( $catch ) {
static $display_errors, $error_reporting;
if ( $catch ) {
$display_errors = @ini_set( 'display_errors', 1 );
$error_reporting = @error_reporting( E_ALL );
if ( class_exists( 'Jetpack' ) ) {
add_action( 'shutdown', array( 'Jetpack', 'catch_errors_on_shutdown' ), 0 );
}
} else {
@ini_set( 'display_errors', $display_errors );
@error_reporting( $error_reporting );
if ( class_exists( 'Jetpack' ) ) {
remove_action( 'shutdown', array( 'Jetpack', 'catch_errors_on_shutdown' ), 0 );
}
}
}
}
class-files.php 0000644 00000002330 15154630664 0007472 0 ustar 00 <?php
/**
* A modules class for Jetpack.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack;
/**
* Class Automattic\Jetpack\Files
*
* Used to retrieve information about files.
*/
class Files {
/**
* Returns an array of all PHP files in the specified absolute path.
* Equivalent to glob( "$absolute_path/*.php" ).
*
* @param string $absolute_path The absolute path of the directory to search.
* @return array Array of absolute paths to the PHP files.
*/
public function glob_php( $absolute_path ) {
if ( function_exists( 'glob' ) ) {
return glob( "$absolute_path/*.php" );
}
$absolute_path = untrailingslashit( $absolute_path );
$files = array();
$dir = @opendir( $absolute_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! $dir ) {
return $files;
}
// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
while ( false !== $file = readdir( $dir ) ) {
if ( '.' === substr( $file, 0, 1 ) || '.php' !== substr( $file, -4 ) ) {
continue;
}
$file = "$absolute_path/$file";
if ( ! is_file( $file ) ) {
continue;
}
$files[] = $file;
}
closedir( $dir );
return $files;
}
}
class-host.php 0000644 00000005625 15154630664 0007357 0 ustar 00 <?php
/**
* A hosting provide class for Jetpack.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack\Status;
use Automattic\Jetpack\Constants;
/**
* Hosting provider class.
*/
class Host {
/**
* Determine if this site is an WordPress.com on Atomic site or not by looking for presence of the wpcomsh plugin.
*
* @since 1.9.0
*
* @return bool
*/
public function is_woa_site() {
$ret = Cache::get( 'is_woa_site' );
if ( null === $ret ) {
$ret = $this->is_atomic_platform() && Constants::is_true( 'WPCOMSH__PLUGIN_FILE' );
Cache::set( 'is_woa_site', $ret );
}
return $ret;
}
/**
* Determine if the site is hosted on the Atomic hosting platform.
*
* @since 1.9.0
*
* @return bool;
*/
public function is_atomic_platform() {
return Constants::is_true( 'ATOMIC_SITE_ID' ) && Constants::is_true( 'ATOMIC_CLIENT_ID' );
}
/**
* Determine if this is a Newspack site.
*
* @return bool
*/
public function is_newspack_site() {
return Constants::is_defined( 'NEWSPACK_PLUGIN_FILE' );
}
/**
* Determine if this is a VIP-hosted site.
*
* @return bool
*/
public function is_vip_site() {
return Constants::is_defined( 'WPCOM_IS_VIP_ENV' ) && true === Constants::get_constant( 'WPCOM_IS_VIP_ENV' );
}
/**
* Determine if this is a Simple platform site.
*
* @return bool
*/
public function is_wpcom_simple() {
return Constants::is_defined( 'IS_WPCOM' ) && true === Constants::get_constant( 'IS_WPCOM' );
}
/**
* Determine if this is a WordPress.com site.
*
* Includes both Simple and WoA platforms.
*
* @return bool
*/
public function is_wpcom_platform() {
return $this->is_wpcom_simple() || $this->is_woa_site();
}
/**
* Add all wordpress.com environments to the safe redirect allowed list.
*
* To be used with a filter of allowed domains for a redirect.
*
* @param array $domains Allowed WP.com Environments.
*/
public static function allow_wpcom_environments( $domains ) {
$domains[] = 'wordpress.com';
$domains[] = 'jetpack.wordpress.com';
$domains[] = 'wpcalypso.wordpress.com';
$domains[] = 'horizon.wordpress.com';
$domains[] = 'calypso.localhost';
return $domains;
}
/**
* Return Calypso environment value; used for developing Jetpack and pairing
* it with different Calypso environments, such as localhost.
*
* @since 1.18.0
*
* @return string Calypso environment
*/
public function get_calypso_env() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Nonce is not required; only used for changing environments.
if ( isset( $_GET['calypso_env'] ) ) {
return sanitize_key( $_GET['calypso_env'] );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( getenv( 'CALYPSO_ENV' ) ) {
return sanitize_key( getenv( 'CALYPSO_ENV' ) );
}
if ( defined( 'CALYPSO_ENV' ) && CALYPSO_ENV ) {
return sanitize_key( CALYPSO_ENV );
}
return '';
}
}
class-modules.php 0000644 00000046572 15154630664 0010060 0 ustar 00 <?php
/**
* A modules class for Jetpack.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack;
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\IP\Utils as IP_Utils;
use Automattic\Jetpack\Status\Host;
/**
* Class Automattic\Jetpack\Modules
*
* Used to retrieve information about the current status of Jetpack modules.
*/
class Modules {
/**
* Check whether or not a Jetpack module is active.
*
* @param string $module The slug of a Jetpack module.
* @return bool
*/
public function is_active( $module ) {
return in_array( $module, self::get_active(), true );
}
/**
* Load module data from module file. Headers differ from WordPress
* plugin headers to avoid them being identified as standalone
* plugins on the WordPress plugins page.
*
* @param string $module The module slug.
*/
public function get( $module ) {
static $modules_details;
// This method relies heavy on auto-generated file found in Jetpack only: module-headings.php
// If it doesn't exist, it's safe to assume none of this will be helpful.
if ( ! function_exists( 'jetpack_has_no_module_info' ) ) {
return false;
}
if ( jetpack_has_no_module_info( $module ) ) {
return false;
}
$file = $this->get_path( $this->get_slug( $module ) );
if ( isset( $modules_details[ $module ] ) ) {
$mod = $modules_details[ $module ];
} else {
$mod = jetpack_get_module_info( $module );
if ( null === $mod ) {
// Try to get the module info from the file as a fallback.
$mod = $this->get_file_data( $file, jetpack_get_all_module_header_names() );
if ( empty( $mod['name'] ) ) {
// No info for this module.
return false;
}
}
$mod['sort'] = empty( $mod['sort'] ) ? 10 : (int) $mod['sort'];
$mod['recommendation_order'] = empty( $mod['recommendation_order'] ) ? 20 : (int) $mod['recommendation_order'];
$mod['deactivate'] = empty( $mod['deactivate'] );
$mod['free'] = empty( $mod['free'] );
$mod['requires_connection'] = ( ! empty( $mod['requires_connection'] ) && 'No' === $mod['requires_connection'] ) ? false : true;
$mod['requires_user_connection'] = ( empty( $mod['requires_user_connection'] ) || 'No' === $mod['requires_user_connection'] ) ? false : true;
if ( empty( $mod['auto_activate'] ) || ! in_array( strtolower( $mod['auto_activate'] ), array( 'yes', 'no', 'public' ), true ) ) {
$mod['auto_activate'] = 'No';
} else {
$mod['auto_activate'] = (string) $mod['auto_activate'];
}
if ( $mod['module_tags'] ) {
$mod['module_tags'] = explode( ',', $mod['module_tags'] );
$mod['module_tags'] = array_map( 'trim', $mod['module_tags'] );
} else {
$mod['module_tags'] = array( 'Other' );
}
if ( $mod['plan_classes'] ) {
$mod['plan_classes'] = explode( ',', $mod['plan_classes'] );
$mod['plan_classes'] = array_map( 'strtolower', array_map( 'trim', $mod['plan_classes'] ) );
} else {
$mod['plan_classes'] = array( 'free' );
}
if ( $mod['feature'] ) {
$mod['feature'] = explode( ',', $mod['feature'] );
$mod['feature'] = array_map( 'trim', $mod['feature'] );
} else {
$mod['feature'] = array( 'Other' );
}
$modules_details[ $module ] = $mod;
}
/**
* Filters the feature array on a module.
*
* This filter allows you to control where each module is filtered: Recommended,
* and the default "Other" listing.
*
* @since-jetpack 3.5.0
*
* @param array $mod['feature'] The areas to feature this module:
* 'Recommended' shows on the main Jetpack admin screen.
* 'Other' should be the default if no other value is in the array.
* @param string $module The slug of the module, e.g. sharedaddy.
* @param array $mod All the currently assembled module data.
*/
$mod['feature'] = apply_filters( 'jetpack_module_feature', $mod['feature'], $module, $mod );
/**
* Filter the returned data about a module.
*
* This filter allows overriding any info about Jetpack modules. It is dangerous,
* so please be careful.
*
* @since-jetpack 3.6.0
*
* @param array $mod The details of the requested module.
* @param string $module The slug of the module, e.g. sharedaddy
* @param string $file The path to the module source file.
*/
return apply_filters( 'jetpack_get_module', $mod, $module, $file );
}
/**
* Like core's get_file_data implementation, but caches the result.
*
* @param string $file Absolute path to the file.
* @param array $headers List of headers, in the format array( 'HeaderKey' => 'Header Name' ).
*/
public function get_file_data( $file, $headers ) {
// Get just the filename from $file (i.e. exclude full path) so that a consistent hash is generated.
$file_name = basename( $file );
if ( ! Constants::is_defined( 'JETPACK__VERSION' ) ) {
return get_file_data( $file, $headers );
}
$cache_key = 'jetpack_file_data_' . JETPACK__VERSION;
$file_data_option = get_transient( $cache_key );
if ( ! is_array( $file_data_option ) ) {
delete_transient( $cache_key );
$file_data_option = false;
}
if ( false === $file_data_option ) {
$file_data_option = array();
}
$key = md5( $file_name . maybe_serialize( $headers ) );
$refresh_cache = is_admin() && isset( $_GET['page'] ) && 'jetpack' === substr( $_GET['page'], 0, 7 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
// If we don't need to refresh the cache, and already have the value, short-circuit!
if ( ! $refresh_cache && isset( $file_data_option[ $key ] ) ) {
return $file_data_option[ $key ];
}
$data = get_file_data( $file, $headers );
$file_data_option[ $key ] = $data;
set_transient( $cache_key, $file_data_option, 29 * DAY_IN_SECONDS );
return $data;
}
/**
* Get a list of activated modules as an array of module slugs.
*/
public function get_active() {
$active = \Jetpack_Options::get_option( 'active_modules' );
if ( ! is_array( $active ) ) {
$active = array();
}
if ( class_exists( 'VaultPress' ) || function_exists( 'vaultpress_contact_service' ) ) {
$active[] = 'vaultpress';
} else {
$active = array_diff( $active, array( 'vaultpress' ) );
}
// If protect is active on the main site of a multisite, it should be active on all sites. Doesn't apply to WP.com.
if ( ! in_array( 'protect', $active, true )
&& ! ( new Host() )->is_wpcom_simple()
&& is_multisite()
&& get_site_option( 'jetpack_protect_active' ) ) {
$active[] = 'protect';
}
// If it's not available, it shouldn't be active.
// We don't delete it from the options though, as it will be active again when a plugin gets reactivated.
$active = array_intersect( $active, $this->get_available() );
/**
* Allow filtering of the active modules.
*
* Gives theme and plugin developers the power to alter the modules that
* are activated on the fly.
*
* @since-jetpack 5.8.0
*
* @param array $active Array of active module slugs.
*/
$active = apply_filters( 'jetpack_active_modules', $active );
return array_unique( $active );
}
/**
* Extract a module's slug from its full path.
*
* @param string $file Full path to a file.
*
* @return string Module slug.
*/
public function get_slug( $file ) {
return str_replace( '.php', '', basename( $file ) );
}
/**
* List available Jetpack modules. Simply lists .php files in /modules/.
* Make sure to tuck away module "library" files in a sub-directory.
*
* @param bool|string $min_version Only return modules introduced in this version or later. Default is false, do not filter.
* @param bool|string $max_version Only return modules introduced before this version. Default is false, do not filter.
* @param bool|null $requires_connection Pass a boolean value to only return modules that require (or do not require) a connection.
* @param bool|null $requires_user_connection Pass a boolean value to only return modules that require (or do not require) a user connection.
*
* @return array $modules Array of module slugs
*/
public function get_available( $min_version = false, $max_version = false, $requires_connection = null, $requires_user_connection = null ) {
static $modules = null;
if ( ! class_exists( 'Jetpack' ) || ! Constants::is_defined( 'JETPACK__VERSION' ) || ! Constants::is_defined( 'JETPACK__PLUGIN_DIR' ) ) {
return array_unique(
/**
* Stand alone plugins need to use this filter to register the modules they interact with.
* This will allow them to activate and deactivate these modules even when Jetpack is not present.
* Note: Standalone plugins can only interact with modules that also exist in the Jetpack plugin, otherwise they'll lose the ability to control it if Jetpack is activated.
*
* @since 1.13.6
*
* @param array $modules The list of available modules as an array of slugs.
* @param bool $requires_connection Whether to list only modules that require a connection to work.
* @param bool $requires_user_connection Whether to list only modules that require a user connection to work.
*/
apply_filters( 'jetpack_get_available_standalone_modules', array(), $requires_connection, $requires_user_connection )
);
}
if ( ! isset( $modules ) ) {
$available_modules_option = \Jetpack_Options::get_option( 'available_modules', array() );
// Use the cache if we're on the front-end and it's available...
if ( ! is_admin() && ! empty( $available_modules_option[ JETPACK__VERSION ] ) ) {
$modules = $available_modules_option[ JETPACK__VERSION ];
} else {
$files = ( new Files() )->glob_php( JETPACK__PLUGIN_DIR . 'modules' );
$modules = array();
foreach ( $files as $file ) {
$slug = $this->get_slug( $file );
$headers = $this->get( $slug );
if ( ! $headers ) {
continue;
}
$modules[ $slug ] = $headers['introduced'];
}
\Jetpack_Options::update_option(
'available_modules',
array(
JETPACK__VERSION => $modules,
)
);
}
}
/**
* Filters the array of modules available to be activated.
*
* @since 2.4.0
*
* @param array $modules Array of available modules.
* @param string $min_version Minimum version number required to use modules.
* @param string $max_version Maximum version number required to use modules.
* @param bool|null $requires_connection Value of the Requires Connection filter.
* @param bool|null $requires_user_connection Value of the Requires User Connection filter.
*/
$mods = apply_filters( 'jetpack_get_available_modules', $modules, $min_version, $max_version, $requires_connection, $requires_user_connection );
if ( ! $min_version && ! $max_version && $requires_connection === null && $requires_user_connection === null ) {
return array_keys( $mods );
}
$r = array();
foreach ( $mods as $slug => $introduced ) {
if ( $min_version && version_compare( $min_version, $introduced, '>=' ) ) {
continue;
}
if ( $max_version && version_compare( $max_version, $introduced, '<' ) ) {
continue;
}
$mod_details = $this->get( $slug );
if ( null !== $requires_connection && (bool) $requires_connection !== $mod_details['requires_connection'] ) {
continue;
}
if ( null !== $requires_user_connection && (bool) $requires_user_connection !== $mod_details['requires_user_connection'] ) {
continue;
}
$r[] = $slug;
}
return $r;
}
/**
* Is slug a valid module.
*
* @param string $module Module slug.
*
* @return bool
*/
public function is_module( $module ) {
return ! empty( $module ) && ! validate_file( $module, $this->get_available() );
}
/**
* Update module status.
*
* @param string $module - module slug.
* @param boolean $active - true to activate, false to deactivate.
* @param bool $exit Should exit be called after deactivation.
* @param bool $redirect Should there be a redirection after activation.
*/
public function update_status( $module, $active, $exit = true, $redirect = true ) {
return $active ? $this->activate( $module, $exit, $redirect ) : $this->deactivate( $module );
}
/**
* Activate a module.
*
* @param string $module Module slug.
* @param bool $exit Should exit be called after deactivation.
* @param bool $redirect Should there be a redirection after activation.
*
* @return bool|void
*/
public function activate( $module, $exit = true, $redirect = true ) {
/**
* Fires before a module is activated.
*
* @since 2.6.0
*
* @param string $module Module slug.
* @param bool $exit Should we exit after the module has been activated. Default to true.
* @param bool $redirect Should the user be redirected after module activation? Default to true.
*/
do_action( 'jetpack_pre_activate_module', $module, $exit, $redirect );
if ( ! strlen( $module ) ) {
return false;
}
// If it's already active, then don't do it again.
$active = $this->get_active();
foreach ( $active as $act ) {
if ( $act === $module ) {
return true;
}
}
if ( ! $this->is_module( $module ) ) {
return false;
}
// Jetpack plugin only
if ( class_exists( 'Jetpack' ) ) {
$module_data = $this->get( $module );
$status = new Status();
$state = new CookieState();
if ( ! \Jetpack::is_connection_ready() ) {
if ( ! $status->is_offline_mode() && ! $status->is_onboarding() ) {
return false;
}
// If we're not connected but in offline mode, make sure the module doesn't require a connection.
if ( $status->is_offline_mode() && $module_data['requires_connection'] ) {
return false;
}
}
if ( class_exists( 'Jetpack_Client_Server' ) ) {
$jetpack = \Jetpack::init();
// Check and see if the old plugin is active.
if ( isset( $jetpack->plugins_to_deactivate[ $module ] ) ) {
// Deactivate the old plugins.
$deactivated = array();
foreach ( $jetpack->plugins_to_deactivate[ $module ] as $idx => $deactivate_me ) {
if ( \Jetpack_Client_Server::deactivate_plugin( $deactivate_me[0], $deactivate_me[1] ) ) {
// If we deactivated the old plugin, remembere that with ::state() and redirect back to this page to activate the module
// We can't activate the module on this page load since the newly deactivated old plugin is still loaded on this page load.
$deactivated[] = "$module:$idx";
}
}
if ( $deactivated ) {
$state->state( 'deactivated_plugins', implode( ',', $deactivated ) );
wp_safe_redirect( add_query_arg( 'jetpack_restate', 1 ) );
exit;
}
}
}
// Protect won't work with mis-configured IPs.
if ( 'protect' === $module ) {
if ( ! IP_Utils::get_ip() ) {
$state->state( 'message', 'protect_misconfigured_ip' );
return false;
}
}
if ( ! Jetpack_Plan::supports( $module ) ) {
return false;
}
// Check the file for fatal errors, a la wp-admin/plugins.php::activate.
$errors = new Errors();
$state->state( 'module', $module );
$state->state( 'error', 'module_activation_failed' ); // we'll override this later if the plugin can be included without fatal error.
$errors->catch_errors( true );
ob_start();
$module_path = $this->get_path( $module );
if ( file_exists( $module_path ) ) {
require $this->get_path( $module ); // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
}
$active[] = $module;
$this->update_active( $active );
$state->state( 'error', false ); // the override.
ob_end_clean();
$errors->catch_errors( false );
} else { // Not a Jetpack plugin.
$active[] = $module;
$this->update_active( $active );
}
if ( $redirect ) {
wp_safe_redirect( ( new Paths() )->admin_url( 'page=jetpack' ) );
}
if ( $exit ) {
exit;
}
return true;
}
/**
* Deactivate module.
*
* @param string $module Module slug.
*
* @return bool
*/
public function deactivate( $module ) {
/**
* Fires when a module is deactivated.
*
* @since 1.9.0
*
* @param string $module Module slug.
*/
do_action( 'jetpack_pre_deactivate_module', $module );
$active = $this->get_active();
$new = array_filter( array_diff( $active, (array) $module ) );
return $this->update_active( $new );
}
/**
* Generate a module's path from its slug.
*
* @param string $slug Module slug.
*/
public function get_path( $slug ) {
if ( ! Constants::is_defined( 'JETPACK__PLUGIN_DIR' ) ) {
return '';
}
/**
* Filters the path of a modules.
*
* @since 7.4.0
*
* @param array $return The absolute path to a module's root php file
* @param string $slug The module slug
*/
return apply_filters( 'jetpack_get_module_path', JETPACK__PLUGIN_DIR . "modules/$slug.php", $slug );
}
/**
* Saves all the currently active modules to options.
* Also fires Action hooks for each newly activated and deactivated module.
*
* @param array $modules Array of active modules to be saved in options.
*
* @return $success bool true for success, false for failure.
*/
public function update_active( $modules ) {
$current_modules = \Jetpack_Options::get_option( 'active_modules', array() );
$active_modules = $this->get_active();
$new_active_modules = array_diff( $modules, $current_modules );
$new_inactive_modules = array_diff( $active_modules, $modules );
$new_current_modules = array_diff( array_merge( $current_modules, $new_active_modules ), $new_inactive_modules );
$reindexed_modules = array_values( $new_current_modules );
$success = \Jetpack_Options::update_option( 'active_modules', array_unique( $reindexed_modules ) );
// Let's take `pre_update_option_jetpack_active_modules` filter into account
// and actually decide for which modules we need to fire hooks by comparing
// the 'active_modules' option before and after the update.
$current_modules_post_update = \Jetpack_Options::get_option( 'active_modules', array() );
$new_inactive_modules = array_diff( $current_modules, $current_modules_post_update );
$new_inactive_modules = array_unique( $new_inactive_modules );
$new_inactive_modules = array_values( $new_inactive_modules );
$new_active_modules = array_diff( $current_modules_post_update, $current_modules );
$new_active_modules = array_unique( $new_active_modules );
$new_active_modules = array_values( $new_active_modules );
foreach ( $new_active_modules as $module ) {
/**
* Fires when a specific module is activated.
*
* @since 1.9.0
*
* @param string $module Module slug.
* @param boolean $success whether the module was activated. @since 4.2
*/
do_action( 'jetpack_activate_module', $module, $success );
/**
* Fires when a module is activated.
* The dynamic part of the filter, $module, is the module slug.
*
* @since 1.9.0
*
* @param string $module Module slug.
*/
do_action( "jetpack_activate_module_$module", $module );
}
foreach ( $new_inactive_modules as $module ) {
/**
* Fired after a module has been deactivated.
*
* @since 4.2.0
*
* @param string $module Module slug.
* @param boolean $success whether the module was deactivated.
*/
do_action( 'jetpack_deactivate_module', $module, $success );
/**
* Fires when a module is deactivated.
* The dynamic part of the filter, $module, is the module slug.
*
* @since 1.9.0
*
* @param string $module Module slug.
*/
do_action( "jetpack_deactivate_module_$module", $module );
}
return $success;
}
}
class-paths.php 0000644 00000001040 15154630664 0007504 0 ustar 00 <?php
/**
* A Path & URL utility class for Jetpack.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack;
/**
* Class Automattic\Jetpack\Paths
*
* Used to retrieve information about files.
*/
class Paths {
/**
* Jetpack Admin URL.
*
* @param array $args Query string args.
*
* @return string Jetpack admin URL.
*/
public function admin_url( $args = null ) {
$args = wp_parse_args( $args, array( 'page' => 'jetpack' ) );
$url = add_query_arg( $args, admin_url( 'admin.php' ) );
return $url;
}
}
class-status.php 0000644 00000030014 15154630664 0007713 0 ustar 00 <?php
/**
* A status class for Jetpack.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack;
use Automattic\Jetpack\Status\Cache;
use Automattic\Jetpack\Status\Host;
use WPCOM_Masterbar;
/**
* Class Automattic\Jetpack\Status
*
* Used to retrieve information about the current status of Jetpack and the site overall.
*/
class Status {
/**
* Is Jetpack in development (offline) mode?
*
* @deprecated 1.3.0 Use Status->is_offline_mode().
*
* @return bool Whether Jetpack's offline mode is active.
*/
public function is_development_mode() {
_deprecated_function( __FUNCTION__, '1.3.0', 'Automattic\Jetpack\Status->is_offline_mode' );
return $this->is_offline_mode();
}
/**
* Is Jetpack in offline mode?
*
* This was formerly called "Development Mode", but sites "in development" aren't always offline/localhost.
*
* @since 1.3.0
*
* @return bool Whether Jetpack's offline mode is active.
*/
public function is_offline_mode() {
$cached = Cache::get( 'is_offline_mode' );
if ( null !== $cached ) {
return $cached;
}
$offline_mode = false;
if ( defined( '\\JETPACK_DEV_DEBUG' ) ) {
$offline_mode = constant( '\\JETPACK_DEV_DEBUG' );
} elseif ( defined( '\\WP_LOCAL_DEV' ) ) {
$offline_mode = constant( '\\WP_LOCAL_DEV' );
} elseif ( $this->is_local_site() ) {
$offline_mode = true;
}
/**
* Filters Jetpack's offline mode.
*
* @see https://jetpack.com/support/development-mode/
* @todo Update documentation ^^.
*
* @since 1.1.1
* @since-jetpack 2.2.1
* @deprecated 1.3.0
*
* @param bool $offline_mode Is Jetpack's offline mode active.
*/
$offline_mode = (bool) apply_filters_deprecated( 'jetpack_development_mode', array( $offline_mode ), '1.3.0', 'jetpack_offline_mode' );
/**
* Filters Jetpack's offline mode.
*
* @see https://jetpack.com/support/development-mode/
* @todo Update documentation ^^.
*
* @since 1.3.0
*
* @param bool $offline_mode Is Jetpack's offline mode active.
*/
$offline_mode = (bool) apply_filters( 'jetpack_offline_mode', $offline_mode );
Cache::set( 'is_offline_mode', $offline_mode );
return $offline_mode;
}
/**
* Is Jetpack in "No User test mode"?
*
* This will make Jetpack act as if there were no connected users, but only a site connection (aka blog token)
*
* @since 1.6.0
* @deprecated 1.7.5 Since this version, Jetpack connection is considered active after registration, making no_user_testing_mode obsolete.
*
* @return bool Whether Jetpack's No User Testing Mode is active.
*/
public function is_no_user_testing_mode() {
_deprecated_function( __METHOD__, '1.7.5' );
return true;
}
/**
* Whether this is a system with a multiple networks.
* Implemented since there is no core is_multi_network function.
* Right now there is no way to tell which network is the dominant network on the system.
*
* @return boolean
*/
public function is_multi_network() {
global $wpdb;
$cached = Cache::get( 'is_multi_network' );
if ( null !== $cached ) {
return $cached;
}
// If we don't have a multi site setup no need to do any more.
if ( ! is_multisite() ) {
Cache::set( 'is_multi_network', false );
return false;
}
$num_sites = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->site}" );
if ( $num_sites > 1 ) {
Cache::set( 'is_multi_network', true );
return true;
}
Cache::set( 'is_multi_network', false );
return false;
}
/**
* Whether the current site is single user site.
*
* @return bool
*/
public function is_single_user_site() {
global $wpdb;
$ret = Cache::get( 'is_single_user_site' );
if ( null === $ret ) {
$some_users = get_transient( 'jetpack_is_single_user' );
if ( false === $some_users ) {
$some_users = $wpdb->get_var( "SELECT COUNT(*) FROM (SELECT user_id FROM $wpdb->usermeta WHERE meta_key = '{$wpdb->prefix}capabilities' LIMIT 2) AS someusers" );
set_transient( 'jetpack_is_single_user', (int) $some_users, 12 * HOUR_IN_SECONDS );
}
$ret = 1 === (int) $some_users;
Cache::set( 'is_single_user_site', $ret );
}
return $ret;
}
/**
* If the site is a local site.
*
* @since 1.3.0
*
* @return bool
*/
public function is_local_site() {
$cached = Cache::get( 'is_local_site' );
if ( null !== $cached ) {
return $cached;
}
$site_url = site_url();
// Check for localhost and sites using an IP only first.
$is_local = $site_url && false === strpos( $site_url, '.' );
// Use Core's environment check, if available.
if ( 'local' === wp_get_environment_type() ) {
$is_local = true;
}
// Then check for usual usual domains used by local dev tools.
$known_local = array(
'#\.local$#i',
'#\.localhost$#i',
'#\.test$#i',
'#\.docksal$#i', // Docksal.
'#\.docksal\.site$#i', // Docksal.
'#\.dev\.cc$#i', // ServerPress.
'#\.lndo\.site$#i', // Lando.
'#^https?://127\.0\.0\.1$#',
);
if ( ! $is_local ) {
foreach ( $known_local as $url ) {
if ( preg_match( $url, $site_url ) ) {
$is_local = true;
break;
}
}
}
/**
* Filters is_local_site check.
*
* @since 1.3.0
*
* @param bool $is_local If the current site is a local site.
*/
$is_local = apply_filters( 'jetpack_is_local_site', $is_local );
Cache::set( 'is_local_site', $is_local );
return $is_local;
}
/**
* If is a staging site.
*
* @todo Add IDC detection to a package.
*
* @return bool
*/
public function is_staging_site() {
$cached = Cache::get( 'is_staging_site' );
if ( null !== $cached ) {
return $cached;
}
/*
* Core's wp_get_environment_type allows for a few specific options.
* We should default to bowing out gracefully for anything other than production or local.
*/
$is_staging = ! in_array( wp_get_environment_type(), array( 'production', 'local' ), true );
$known_staging = array(
'urls' => array(
'#\.staging\.wpengine\.com$#i', // WP Engine. This is their legacy staging URL structure. Their new platform does not have a common URL. https://github.com/Automattic/jetpack/issues/21504
'#\.staging\.kinsta\.com$#i', // Kinsta.com.
'#\.kinsta\.cloud$#i', // Kinsta.com.
'#\.stage\.site$#i', // DreamPress.
'#\.newspackstaging\.com$#i', // Newspack.
'#^(?!live-)([a-zA-Z0-9-]+)\.pantheonsite\.io$#i', // Pantheon.
'#\.flywheelsites\.com$#i', // Flywheel.
'#\.flywheelstaging\.com$#i', // Flywheel.
'#\.cloudwaysapps\.com$#i', // Cloudways.
'#\.azurewebsites\.net$#i', // Azure.
'#\.wpserveur\.net$#i', // WPServeur.
'#\-liquidwebsites\.com$#i', // Liquidweb.
),
'constants' => array(
'IS_WPE_SNAPSHOT', // WP Engine. This is used on their legacy staging environment. Their new platform does not have a constant. https://github.com/Automattic/jetpack/issues/21504
'KINSTA_DEV_ENV', // Kinsta.com.
'WPSTAGECOACH_STAGING', // WP Stagecoach.
'JETPACK_STAGING_MODE', // Generic.
'WP_LOCAL_DEV', // Generic.
),
);
/**
* Filters the flags of known staging sites.
*
* @since 1.1.1
* @since-jetpack 3.9.0
*
* @param array $known_staging {
* An array of arrays that each are used to check if the current site is staging.
* @type array $urls URLs of staging sites in regex to check against site_url.
* @type array $constants PHP constants of known staging/developement environments.
* }
*/
$known_staging = apply_filters( 'jetpack_known_staging', $known_staging );
if ( isset( $known_staging['urls'] ) ) {
$site_url = site_url();
foreach ( $known_staging['urls'] as $url ) {
if ( preg_match( $url, wp_parse_url( $site_url, PHP_URL_HOST ) ) ) {
$is_staging = true;
break;
}
}
}
if ( isset( $known_staging['constants'] ) ) {
foreach ( $known_staging['constants'] as $constant ) {
if ( defined( $constant ) && constant( $constant ) ) {
$is_staging = true;
}
}
}
// Last, let's check if sync is erroring due to an IDC. If so, set the site to staging mode.
if ( ! $is_staging && method_exists( 'Automattic\\Jetpack\\Identity_Crisis', 'validate_sync_error_idc_option' ) && \Automattic\Jetpack\Identity_Crisis::validate_sync_error_idc_option() ) {
$is_staging = true;
}
/**
* Filters is_staging_site check.
*
* @since 1.1.1
* @since-jetpack 3.9.0
*
* @param bool $is_staging If the current site is a staging site.
*/
$is_staging = apply_filters( 'jetpack_is_staging_site', $is_staging );
Cache::set( 'is_staging_site', $is_staging );
return $is_staging;
}
/**
* Whether the site is currently onboarding or not.
* A site is considered as being onboarded if it currently has an onboarding token.
*
* @since-jetpack 5.8
*
* @access public
* @static
*
* @return bool True if the site is currently onboarding, false otherwise
*/
public function is_onboarding() {
return \Jetpack_Options::get_option( 'onboarding' ) !== false;
}
/**
* Whether the site is currently private or not.
* On WordPress.com and WoA, sites can be marked as private
*
* @since 1.16.0
*
* @return bool True if the site is private.
*/
public function is_private_site() {
$ret = Cache::get( 'is_private_site' );
if ( null === $ret ) {
$is_private_site = '-1' === get_option( 'blog_public' );
/**
* Filters the is_private_site check.
*
* @since 1.16.1
*
* @param bool $is_private_site True if the site is private.
*/
$is_private_site = apply_filters( 'jetpack_is_private_site', $is_private_site );
Cache::set( 'is_private_site', $is_private_site );
return $is_private_site;
}
return $ret;
}
/**
* Whether the site is currently unlaunched or not.
* On WordPress.com and WoA, sites can be marked as "coming soon", aka unlaunched
*
* @since 1.16.0
*
* @return bool True if the site is not launched.
*/
public function is_coming_soon() {
$ret = Cache::get( 'is_coming_soon' );
if ( null === $ret ) {
$is_coming_soon = (bool) ( function_exists( 'site_is_coming_soon' ) && \site_is_coming_soon() )
|| get_option( 'wpcom_public_coming_soon' );
/**
* Filters the is_coming_soon check.
*
* @since 1.16.1
*
* @param bool $is_coming_soon True if the site is coming soon (i.e. unlaunched).
*/
$is_coming_soon = apply_filters( 'jetpack_is_coming_soon', $is_coming_soon );
Cache::set( 'is_coming_soon', $is_coming_soon );
return $is_coming_soon;
}
return $ret;
}
/**
* Returns the site slug suffix to be used as part of Calypso URLs.
*
* Strips http:// or https:// from a url, replaces forward slash with ::.
*
* @since 1.6.0
*
* @param string $url Optional. URL to build the site suffix from. Default: Home URL.
*
* @return string
*/
public function get_site_suffix( $url = '' ) {
// On WordPress.com, site suffixes are a bit different.
if ( method_exists( 'WPCOM_Masterbar', 'get_calypso_site_slug' ) ) {
return WPCOM_Masterbar::get_calypso_site_slug( get_current_blog_id() );
}
// Grab the 'site_url' option for WoA sites to avoid plugins to interfere with the site
// identifier (e.g. i18n plugins may change the main url to '<DOMAIN>/<LOCALE>', but we
// want to exclude the locale since it's not part of the site suffix).
if ( ( new Host() )->is_woa_site() ) {
$url = \site_url();
}
if ( empty( $url ) ) {
// WordPress can be installed in subdirectories (e.g. make.wordpress.org/plugins)
// where the 'site_url' option points to the root domain (e.g. make.wordpress.org)
// which could collide with another site in the same domain but with WordPress
// installed in a different subdirectory (e.g. make.wordpress.org/core). To avoid
// such collision, we identify the site with the 'home_url' option.
$url = \home_url();
}
$url = preg_replace( '#^.*?://#', '', $url );
$url = str_replace( '/', '::', $url );
return rtrim( $url, ':' );
}
}
class-visitor.php 0000644 00000002033 15154630664 0010067 0 ustar 00 <?php
/**
* Status and information regarding the site visitor.
*
* @package automattic/jetpack-status
*/
namespace Automattic\Jetpack\Status;
/**
* Visitor class.
*/
class Visitor {
/**
* Gets current user IP address.
*
* @param bool $check_all_headers Check all headers? Default is `false`.
*
* @return string Current user IP address.
*/
public function get_ip( $check_all_headers = false ) {
if ( $check_all_headers ) {
foreach ( array(
'HTTP_CF_CONNECTING_IP',
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'HTTP_VIA',
) as $key ) {
if ( ! empty( $_SERVER[ $key ] ) ) {
// @todo Some of these might actually be lists of IPs (e.g. HTTP_X_FORWARDED_FOR) or something else entirely (HTTP_VIA).
return filter_var( wp_unslash( $_SERVER[ $key ] ) );
}
}
}
return ! empty( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
}
}
class-client.php 0000644 00000035762 15154644771 0007671 0 ustar 00 <?php
/**
* The Connection Client class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
/**
* The Client class that is used to connect to WordPress.com Jetpack API.
*/
class Client {
const WPCOM_JSON_API_VERSION = '1.1';
/**
* Makes an authorized remote request using Jetpack_Signature
*
* @param array $args the arguments for the remote request.
* @param array|String $body the request body.
* @return array|WP_Error WP HTTP response on success
*/
public static function remote_request( $args, $body = null ) {
if ( isset( $args['url'] ) ) {
/**
* Filters the remote request url.
*
* @since 1.30.12
*
* @param string The remote request url.
*/
$args['url'] = apply_filters( 'jetpack_remote_request_url', $args['url'] );
}
$result = self::build_signed_request( $args, $body );
if ( ! $result || is_wp_error( $result ) ) {
return $result;
}
$response = self::_wp_remote_request( $result['url'], $result['request'] );
Error_Handler::get_instance()->check_api_response_for_errors(
$response,
$result['auth'],
empty( $args['url'] ) ? '' : $args['url'],
empty( $args['method'] ) ? 'POST' : $args['method'],
'rest'
);
/**
* Fired when the remote request response has been received.
*
* @since 1.30.8
*
* @param array|WP_Error The HTTP response.
*/
do_action( 'jetpack_received_remote_request_response', $response );
return $response;
}
/**
* Adds authorization signature to a remote request using Jetpack_Signature
*
* @param array $args the arguments for the remote request.
* @param array|String $body the request body.
* @return WP_Error|array {
* An array containing URL and request items.
*
* @type String $url The request URL.
* @type array $request Request arguments.
* @type array $auth Authorization data.
* }
*/
public static function build_signed_request( $args, $body = null ) {
add_filter(
'jetpack_constant_default_value',
__NAMESPACE__ . '\Utils::jetpack_api_constant_filter',
10,
2
);
$defaults = array(
'url' => '',
'user_id' => 0,
'blog_id' => 0,
'auth_location' => Constants::get_constant( 'JETPACK_CLIENT__AUTH_LOCATION' ),
'method' => 'POST',
'timeout' => 10,
'redirection' => 0,
'headers' => array(),
'stream' => false,
'filename' => null,
'sslverify' => true,
);
$args = wp_parse_args( $args, $defaults );
$args['blog_id'] = (int) $args['blog_id'];
if ( 'header' !== $args['auth_location'] ) {
$args['auth_location'] = 'query_string';
}
$token = ( new Tokens() )->get_access_token( $args['user_id'] );
if ( ! $token ) {
return new \WP_Error( 'missing_token' );
}
$method = strtoupper( $args['method'] );
$timeout = (int) $args['timeout'];
$redirection = $args['redirection'];
$stream = $args['stream'];
$filename = $args['filename'];
$sslverify = $args['sslverify'];
$request = compact( 'method', 'body', 'timeout', 'redirection', 'stream', 'filename', 'sslverify' );
@list( $token_key, $secret ) = explode( '.', $token->secret ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( empty( $token ) || empty( $secret ) ) {
return new \WP_Error( 'malformed_token' );
}
$token_key = sprintf(
'%s:%d:%d',
$token_key,
Constants::get_constant( 'JETPACK__API_VERSION' ),
$token->external_user_id
);
$time_diff = (int) \Jetpack_Options::get_option( 'time_diff' );
$jetpack_signature = new \Jetpack_Signature( $token->secret, $time_diff );
$timestamp = time() + $time_diff;
if ( function_exists( 'wp_generate_password' ) ) {
$nonce = wp_generate_password( 10, false );
} else {
$nonce = substr( sha1( wp_rand( 0, 1000000 ) ), 0, 10 );
}
// Kind of annoying. Maybe refactor Jetpack_Signature to handle body-hashing.
if ( $body === null ) {
$body_hash = '';
} else {
// Allow arrays to be used in passing data.
$body_to_hash = $body;
if ( is_array( $body ) ) {
// We cast this to a new variable, because the array form of $body needs to be
// maintained so it can be passed into the request later on in the code.
if ( count( $body ) > 0 ) {
$body_to_hash = wp_json_encode( self::_stringify_data( $body ) );
} else {
$body_to_hash = '';
}
}
if ( ! is_string( $body_to_hash ) ) {
return new \WP_Error( 'invalid_body', 'Body is malformed.' );
}
$body_hash = base64_encode( sha1( $body_to_hash, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
$auth = array(
'token' => $token_key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'body-hash' => $body_hash,
);
if ( false !== strpos( $args['url'], 'xmlrpc.php' ) ) {
$url_args = array(
'for' => 'jetpack',
'wpcom_blog_id' => \Jetpack_Options::get_option( 'id' ),
);
} else {
$url_args = array();
}
if ( 'header' !== $args['auth_location'] ) {
$url_args += $auth;
}
$url = add_query_arg( urlencode_deep( $url_args ), $args['url'] );
$signature = $jetpack_signature->sign_request( $token_key, $timestamp, $nonce, $body_hash, $method, $url, $body, false );
if ( ! $signature || is_wp_error( $signature ) ) {
return $signature;
}
// Send an Authorization header so various caches/proxies do the right thing.
$auth['signature'] = $signature;
$auth['version'] = Constants::get_constant( 'JETPACK__VERSION' );
$header_pieces = array();
foreach ( $auth as $key => $value ) {
$header_pieces[] = sprintf( '%s="%s"', $key, $value );
}
$request['headers'] = array_merge(
$args['headers'],
array(
'Authorization' => 'X_JETPACK ' . join( ' ', $header_pieces ),
)
);
if ( 'header' !== $args['auth_location'] ) {
$url = add_query_arg( 'signature', rawurlencode( $signature ), $url );
}
return compact( 'url', 'request', 'auth' );
}
/**
* Wrapper for wp_remote_request(). Turns off SSL verification for certain SSL errors.
* This is lame, but many, many, many hosts have misconfigured SSL.
*
* When Jetpack is registered, the jetpack_fallback_no_verify_ssl_certs option is set to the current time if:
* 1. a certificate error is found AND
* 2. not verifying the certificate works around the problem.
*
* The option is checked on each request.
*
* @internal
*
* @param String $url the request URL.
* @param array $args request arguments.
* @param Boolean $set_fallback whether to allow flagging this request to use a fallback certficate override.
* @return array|WP_Error WP HTTP response on success
*/
public static function _wp_remote_request( $url, $args, $set_fallback = false ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
$fallback = \Jetpack_Options::get_option( 'fallback_no_verify_ssl_certs' );
if ( false === $fallback ) {
\Jetpack_Options::update_option( 'fallback_no_verify_ssl_certs', 0 );
}
/**
* SSL verification (`sslverify`) for the JetpackClient remote request
* defaults to off, use this filter to force it on.
*
* Return `true` to ENABLE SSL verification, return `false`
* to DISABLE SSL verification.
*
* @since 1.7.0
* @since-jetpack 3.6.0
*
* @param bool Whether to force `sslverify` or not.
*/
if ( apply_filters( 'jetpack_client_verify_ssl_certs', false ) ) {
return wp_remote_request( $url, $args );
}
if ( (int) $fallback ) {
// We're flagged to fallback.
$args['sslverify'] = false;
}
$response = wp_remote_request( $url, $args );
if (
! $set_fallback // We're not allowed to set the flag on this request, so whatever happens happens.
||
isset( $args['sslverify'] ) && ! $args['sslverify'] // No verification - no point in doing it again.
||
! is_wp_error( $response ) // Let it ride.
) {
self::set_time_diff( $response, $set_fallback );
return $response;
}
// At this point, we're not flagged to fallback and we are allowed to set the flag on this request.
$message = $response->get_error_message();
// Is it an SSL Certificate verification error?
if (
false === strpos( $message, '14090086' ) // OpenSSL SSL3 certificate error.
&&
false === strpos( $message, '1407E086' ) // OpenSSL SSL2 certificate error.
&&
false === strpos( $message, 'error setting certificate verify locations' ) // cURL CA bundle not found.
&&
false === strpos( $message, 'Peer certificate cannot be authenticated with' ) // cURL CURLE_SSL_CACERT: CA bundle found, but not helpful
// Different versions of curl have different error messages
// this string should catch them all.
&&
false === strpos( $message, 'Problem with the SSL CA cert' ) // cURL CURLE_SSL_CACERT_BADFILE: probably access rights.
) {
// No, it is not.
return $response;
}
// Redo the request without SSL certificate verification.
$args['sslverify'] = false;
$response = wp_remote_request( $url, $args );
if ( ! is_wp_error( $response ) ) {
// The request went through this time, flag for future fallbacks.
\Jetpack_Options::update_option( 'fallback_no_verify_ssl_certs', time() );
self::set_time_diff( $response, $set_fallback );
}
return $response;
}
/**
* Sets the time difference for correct signature computation.
*
* @param HTTP_Response $response the response object.
* @param Boolean $force_set whether to force setting the time difference.
*/
public static function set_time_diff( &$response, $force_set = false ) {
$code = wp_remote_retrieve_response_code( $response );
// Only trust the Date header on some responses.
if ( 200 != $code && 304 != $code && 400 != $code && 401 != $code ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
return;
}
$date = wp_remote_retrieve_header( $response, 'date' );
if ( ! $date ) {
return;
}
$time = (int) strtotime( $date );
if ( 0 >= $time ) {
return;
}
$time_diff = $time - time();
if ( $force_set ) { // During register.
\Jetpack_Options::update_option( 'time_diff', $time_diff );
} else { // Otherwise.
$old_diff = \Jetpack_Options::get_option( 'time_diff' );
if ( false === $old_diff || abs( $time_diff - (int) $old_diff ) > 10 ) {
\Jetpack_Options::update_option( 'time_diff', $time_diff );
}
}
}
/**
* Validate and build arguments for a WordPress.com REST API request.
*
* @param string $path REST API path.
* @param string $version REST API version. Default is `2`.
* @param array $args Arguments to {@see WP_Http}. Default is `array()`.
* @param string $base_api_path REST API root. Default is `wpcom`.
*
* @return array|WP_Error $response Response data, else {@see WP_Error} on failure.
*/
public static function validate_args_for_wpcom_json_api_request(
$path,
$version = '2',
$args = array(),
$base_api_path = 'wpcom'
) {
$base_api_path = trim( $base_api_path, '/' );
$version = ltrim( $version, 'v' );
$path = ltrim( $path, '/' );
$filtered_args = array_intersect_key(
$args,
array(
'headers' => 'array',
'method' => 'string',
'timeout' => 'int',
'redirection' => 'int',
'stream' => 'boolean',
'filename' => 'string',
'sslverify' => 'boolean',
)
);
// Use GET by default whereas `remote_request` uses POST.
$request_method = isset( $filtered_args['method'] ) ? strtoupper( $filtered_args['method'] ) : 'GET';
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
$base_api_path,
$version,
$path
);
$validated_args = array_merge(
$filtered_args,
array(
'url' => $url,
'method' => $request_method,
)
);
return $validated_args;
}
/**
* Queries the WordPress.com REST API with a user token.
*
* @param string $path REST API path.
* @param string $version REST API version. Default is `2`.
* @param array $args Arguments to {@see WP_Http}. Default is `array()`.
* @param string $body Body passed to {@see WP_Http}. Default is `null`.
* @param string $base_api_path REST API root. Default is `wpcom`.
*
* @return array|WP_Error $response Response data, else {@see WP_Error} on failure.
*/
public static function wpcom_json_api_request_as_user(
$path,
$version = '2',
$args = array(),
$body = null,
$base_api_path = 'wpcom'
) {
$args = self::validate_args_for_wpcom_json_api_request( $path, $version, $args, $base_api_path );
$args['user_id'] = get_current_user_id();
if ( isset( $body ) && ! isset( $args['headers'] ) && in_array( $args['method'], array( 'POST', 'PUT', 'PATCH' ), true ) ) {
$args['headers'] = array( 'Content-Type' => 'application/json' );
}
if ( isset( $body ) && ! is_string( $body ) ) {
$body = wp_json_encode( $body );
}
return self::remote_request( $args, $body );
}
/**
* Query the WordPress.com REST API using the blog token
*
* @param String $path The API endpoint relative path.
* @param String $version The API version.
* @param array $args Request arguments.
* @param String $body Request body.
* @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
* @return array|WP_Error $response Data.
*/
public static function wpcom_json_api_request_as_blog(
$path,
$version = self::WPCOM_JSON_API_VERSION,
$args = array(),
$body = null,
$base_api_path = 'rest'
) {
$validated_args = self::validate_args_for_wpcom_json_api_request( $path, $version, $args, $base_api_path );
$validated_args['blog_id'] = (int) \Jetpack_Options::get_option( 'id' );
// For Simple sites get the response directly without any HTTP requests.
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
add_filter( 'is_jetpack_authorized_for_site', '__return_true' );
require_lib( 'wpcom-api-direct' );
return \WPCOM_API_Direct::do_request( $validated_args, $body );
}
return self::remote_request( $validated_args, $body );
}
/**
* Takes an array or similar structure and recursively turns all values into strings. This is used to
* make sure that body hashes are made ith the string version, which is what will be seen after a
* server pulls up the data in the $_POST array.
*
* @param array|Mixed $data the data that needs to be stringified.
*
* @return array|string
*/
public static function _stringify_data( $data ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
// Booleans are special, lets just makes them and explicit 1/0 instead of the 0 being an empty string.
if ( is_bool( $data ) ) {
return $data ? '1' : '0';
}
// Cast objects into arrays.
if ( is_object( $data ) ) {
$data = (array) $data;
}
// Non arrays at this point should be just converted to strings.
if ( ! is_array( $data ) ) {
return (string) $data;
}
foreach ( $data as &$value ) {
$value = self::_stringify_data( $value );
}
return $data;
}
}
class-connection-notice.php 0000644 00000017660 15154644771 0012026 0 ustar 00 <?php
/**
* Admin connection notices.
*
* @package automattic/jetpack-admin-ui
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Tracking;
/**
* Admin connection notices.
*/
class Connection_Notice {
/**
* Whether the class has been initialized.
*
* @var bool
*/
private static $is_initialized = false;
/**
* The constructor.
*/
public function __construct() {
if ( ! static::$is_initialized ) {
add_action( 'current_screen', array( $this, 'initialize_notices' ) );
static::$is_initialized = true;
}
}
/**
* Initialize the notices if needed.
*
* @param \WP_Screen $screen WP Core's screen object.
*
* @return void
*/
public function initialize_notices( $screen ) {
if ( ! in_array(
$screen->id,
array(
'jetpack_page_akismet-key-config',
'admin_page_jetpack_modules',
),
true
) ) {
add_action( 'admin_notices', array( $this, 'delete_user_update_connection_owner_notice' ) );
}
}
/**
* This is an entire admin notice dedicated to messaging and handling of the case where a user is trying to delete
* the connection owner.
*/
public function delete_user_update_connection_owner_notice() {
global $current_screen;
/*
* phpcs:disable WordPress.Security.NonceVerification.Recommended
*
* This function is firing within wp-admin and checks (below) if it is in the midst of a deletion on the users
* page. Nonce will be already checked by WordPress, so we do not need to check ourselves.
*/
if ( ! isset( $current_screen->base ) || 'users' !== $current_screen->base ) {
return;
}
if ( ! isset( $_REQUEST['action'] ) || 'delete' !== $_REQUEST['action'] ) {
return;
}
// Get connection owner or bail.
$connection_manager = new Manager();
$connection_owner_id = $connection_manager->get_connection_owner_id();
if ( ! $connection_owner_id ) {
return;
}
$connection_owner_userdata = get_userdata( $connection_owner_id );
// Bail if we're not trying to delete connection owner.
$user_ids_to_delete = array();
if ( isset( $_REQUEST['users'] ) ) {
$user_ids_to_delete = array_map( 'sanitize_text_field', wp_unslash( $_REQUEST['users'] ) );
} elseif ( isset( $_REQUEST['user'] ) ) {
$user_ids_to_delete[] = sanitize_text_field( wp_unslash( $_REQUEST['user'] ) );
}
// phpcs:enable
$user_ids_to_delete = array_map( 'absint', $user_ids_to_delete );
$deleting_connection_owner = in_array( $connection_owner_id, (array) $user_ids_to_delete, true );
if ( ! $deleting_connection_owner ) {
return;
}
// Bail if they're trying to delete themselves to avoid confusion.
if ( get_current_user_id() === $connection_owner_id ) {
return;
}
$tracking = new Tracking();
// Track it!
if ( method_exists( $tracking, 'record_user_event' ) ) {
$tracking->record_user_event( 'delete_connection_owner_notice_view' );
}
$connected_admins = $connection_manager->get_connected_users( 'jetpack_disconnect' );
$user = is_a( $connection_owner_userdata, 'WP_User' ) ? esc_html( $connection_owner_userdata->data->user_login ) : '';
echo "<div class='notice notice-warning' id='jetpack-notice-switch-connection-owner'>";
echo '<h2>' . esc_html__( 'Important notice about your Jetpack connection:', 'jetpack-connection' ) . '</h2>';
echo '<p>' . sprintf(
/* translators: WordPress User, if available. */
esc_html__( 'Warning! You are about to delete the Jetpack connection owner (%s) for this site, which may cause some of your Jetpack features to stop working.', 'jetpack-connection' ),
esc_html( $user )
) . '</p>';
if ( ! empty( $connected_admins ) && count( $connected_admins ) > 1 ) {
echo '<form id="jp-switch-connection-owner" action="" method="post">';
echo "<label for='owner'>" . esc_html__( 'You can choose to transfer connection ownership to one of these already-connected admins:', 'jetpack-connection' ) . ' </label>';
$connected_admin_ids = array_map(
function ( $connected_admin ) {
return $connected_admin->ID;
},
$connected_admins
);
wp_dropdown_users(
array(
'name' => 'owner',
'include' => array_diff( $connected_admin_ids, array( $connection_owner_id ) ),
'show' => 'display_name_with_login',
)
);
echo '<p>';
submit_button( esc_html__( 'Set new connection owner', 'jetpack-connection' ), 'primary', 'jp-switch-connection-owner-submit', false );
echo '</p>';
echo "<div id='jp-switch-user-results'></div>";
echo '</form>';
?>
<script type="text/javascript">
( function() {
const switchOwnerButton = document.getElementById('jp-switch-connection-owner');
if ( ! switchOwnerButton ) {
return;
}
switchOwnerButton.addEventListener( 'submit', function ( e ) {
e.preventDefault();
const submitBtn = document.getElementById('jp-switch-connection-owner-submit');
submitBtn.disabled = true;
const results = document.getElementById('jp-switch-user-results');
results.innerHTML = '';
results.classList.remove( 'error-message' );
const handleAPIError = ( message ) => {
submitBtn.disabled = false;
results.classList.add( 'error-message' );
results.innerHTML = message || "<?php esc_html_e( 'Something went wrong. Please try again.', 'jetpack-connection' ); ?>";
}
fetch(
<?php echo wp_json_encode( esc_url_raw( get_rest_url() . 'jetpack/v4/connection/owner' ), JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
{
method: 'POST',
headers: {
'X-WP-Nonce': <?php echo wp_json_encode( wp_create_nonce( 'wp_rest' ), JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
},
body: new URLSearchParams( new FormData( this ) ),
}
)
.then( response => response.json() )
.then( data => {
if ( data.hasOwnProperty( 'code' ) && data.code === 'success' ) {
// Owner successfully changed.
results.innerHTML = <?php echo wp_json_encode( esc_html__( 'Success!', 'jetpack-connection' ), JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
setTimeout(function () {
document.getElementById( 'jetpack-notice-switch-connection-owner' ).style.display = 'none';
}, 1000);
return;
}
handleAPIError( data?.message );
} )
.catch( () => handleAPIError() );
});
} )();
</script>
<?php
} else {
echo '<p>' . esc_html__( 'Every Jetpack site needs at least one connected admin for the features to work properly. Please connect to your WordPress.com account via the button below. Once you connect, you may refresh this page to see an option to change the connection owner.', 'jetpack-connection' ) . '</p>';
$connect_url = $connection_manager->get_authorization_url();
$connect_url = add_query_arg( 'from', 'delete_connection_owner_notice', $connect_url );
echo "<a href='" . esc_url( $connect_url ) . "' target='_blank' rel='noopener noreferrer' class='button-primary'>" . esc_html__( 'Connect to WordPress.com', 'jetpack-connection' ) . '</a>';
}
echo '<p>';
printf(
wp_kses(
/* translators: URL to Jetpack support doc regarding the primary user. */
__( "<a href='%s' target='_blank' rel='noopener noreferrer'>Learn more</a> about the connection owner and what will break if you do not have one.", 'jetpack-connection' ),
array(
'a' => array(
'href' => true,
'target' => true,
'rel' => true,
),
)
),
esc_url( Redirect::get_url( 'jetpack-support-primary-user' ) )
);
echo '</p>';
echo '<p>';
printf(
wp_kses(
/* translators: URL to contact Jetpack support. */
__( 'As always, feel free to <a href="%s" target="_blank" rel="noopener noreferrer">contact our support team</a> if you have any questions.', 'jetpack-connection' ),
array(
'a' => array(
'href' => true,
'target' => true,
'rel' => true,
),
)
),
esc_url( Redirect::get_url( 'jetpack-contact-support' ) )
);
echo '</p>';
echo '</div>';
}
}
class-error-handler.php 0000644 00000050472 15154644771 0011152 0 ustar 00 <?php
/**
* The Jetpack Connection error class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Jetpack Connection Errors that handles errors
*
* This class handles the following workflow:
*
* 1. A XML-RCP request with an invalid signature triggers a error
* 2. Applies a gate to only process each error code once an hour to avoid overflow
* 3. It stores the error on the database, but we don't know yet if this is a valid error, because
* we can't confirm it came from WP.com.
* 4. It encrypts the error details and send it to thw wp.com server
* 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
* 6. This endpoint add this error to the Verified errors in the database
* 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
*
* Errors are stored in the database as options in the following format:
*
* [
* $error_code => [
* $user_id => [
* $error_details
* ]
* ]
* ]
*
* For each error code we store a maximum of 5 errors for 5 different user ids.
*
* An user ID can be
* * 0 for blog tokens
* * positive integer for user tokens
* * 'invalid' for malformed tokens
*
* @since 1.14.2
*/
class Error_Handler {
/**
* The name of the option that stores the errors
*
* @since 1.14.2
*
* @var string
*/
const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
/**
* The name of the option that stores the errors
*
* @since 1.14.2
*
* @var string
*/
const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
/**
* The prefix of the transient that controls the gate for each error code
*
* @since 1.14.2
*
* @var string
*/
const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
/**
* Time in seconds a test should live in the database before being discarded
*
* @since 1.14.2
*/
const ERROR_LIFE_TIME = DAY_IN_SECONDS;
/**
* The error code for event tracking purposes.
* If there are many, only the first error code will be tracked.
*
* @var string
*/
private $error_code;
/**
* List of known errors. Only error codes in this list will be handled
*
* @since 1.14.2
*
* @var array
*/
public $known_errors = array(
'malformed_token',
'malformed_user_id',
'unknown_user',
'no_user_tokens',
'empty_master_user_option',
'no_token_for_user',
'token_malformed',
'user_id_mismatch',
'no_possible_tokens',
'no_valid_user_token',
'no_valid_blog_token',
'unknown_token',
'could_not_sign',
'invalid_scheme',
'invalid_secret',
'invalid_token',
'token_mismatch',
'invalid_body',
'invalid_signature',
'invalid_body_hash',
'invalid_nonce',
'signature_mismatch',
);
/**
* Holds the instance of this singleton class
*
* @since 1.14.2
*
* @var Error_Handler $instance
*/
public static $instance = null;
/**
* Initialize instance, hookds and load verified errors handlers
*
* @since 1.14.2
*/
private function __construct() {
defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
$this->handle_verified_errors();
// If the site gets reconnected, clear errors.
add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_errors' ) );
add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
}
/**
* Gets the list of verified errors and act upon them
*
* @since 1.14.2
*
* @return void
*/
public function handle_verified_errors() {
$verified_errors = $this->get_verified_errors();
foreach ( array_keys( $verified_errors ) as $error_code ) {
switch ( $error_code ) {
case 'malformed_token':
case 'token_malformed':
case 'no_possible_tokens':
case 'no_valid_user_token':
case 'no_valid_blog_token':
case 'unknown_token':
case 'could_not_sign':
case 'invalid_token':
case 'token_mismatch':
case 'invalid_signature':
case 'signature_mismatch':
case 'no_user_tokens':
case 'no_token_for_user':
add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
add_action( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ) );
$this->error_code = $error_code;
// Since we are only generically handling errors, we don't need to trigger error messages for each one of them.
break 2;
}
}
}
/**
* Gets the instance of this singleton class
*
* @since 1.14.2
*
* @return Error_Handler $instance
*/
public static function get_instance() {
if ( self::$instance === null ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Keep track of a connection error that was encountered
*
* @param \WP_Error $error The error object.
* @param boolean $force Force the report, even if should_report_error is false.
* @param boolean $skip_wpcom_verification Set to 'true' to verify the error locally and skip the WP.com verification.
*
* @return void
* @since 1.14.2
*/
public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
if ( in_array( $error->get_error_code(), $this->known_errors, true ) && $this->should_report_error( $error ) || $force ) {
$stored_error = $this->store_error( $error );
if ( $stored_error ) {
$skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
}
}
}
/**
* Checks the status of the gate
*
* This protects the site (and WPCOM) against over loads.
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @return boolean $should_report True if gate is open and the error should be reported.
*/
public function should_report_error( \WP_Error $error ) {
if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
return true;
}
/**
* Whether to bypass the gate for the error handling
*
* By default, we only process errors once an hour for each error code.
* This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
*
* This filter is useful for unit testing
*
* @since 1.14.2
*
* @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
*/
$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
if ( true === $bypass_gate ) {
return true;
}
$transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
if ( get_transient( $transient ) ) {
return false;
}
set_transient( $transient, true, HOUR_IN_SECONDS );
return true;
}
/**
* Stores the error in the database so we know there is an issue and can inform the user
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
*/
public function store_error( \WP_Error $error ) {
$stored_errors = $this->get_stored_errors();
$error_array = $this->wp_error_to_array( $error );
$error_code = $error->get_error_code();
$user_id = $error_array['user_id'];
if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
$stored_errors[ $error_code ] = array();
}
$stored_errors[ $error_code ][ $user_id ] = $error_array;
// Let's store a maximum of 5 different user ids for each error code.
if ( count( $stored_errors[ $error_code ] ) > 5 ) {
// array_shift will destroy keys here because they are numeric, so manually remove first item.
$keys = array_keys( $stored_errors[ $error_code ] );
unset( $stored_errors[ $error_code ][ $keys[0] ] );
}
if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
return $error_array;
}
return false;
}
/**
* Converts a WP_Error object in the array representation we store in the database
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @return boolean|array False if error is invalid or the error array
*/
public function wp_error_to_array( \WP_Error $error ) {
$data = $error->get_error_data();
if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
return false;
}
$signature_details = $data['signature_details'];
if ( ! isset( $signature_details['token'] ) || empty( $signature_details['token'] ) ) {
return false;
}
$user_id = $this->get_user_id_from_token( $signature_details['token'] );
$error_array = array(
'error_code' => $error->get_error_code(),
'user_id' => $user_id,
'error_message' => $error->get_error_message(),
'error_data' => $signature_details,
'timestamp' => time(),
'nonce' => wp_generate_password( 10, false ),
'error_type' => empty( $data['error_type'] ) ? '' : $data['error_type'],
);
return $error_array;
}
/**
* Sends the error to WP.com to be verified
*
* @since 1.14.2
*
* @param array $error_array The array representation of the error as it is stored in the database.
* @return bool
*/
public function send_error_to_wpcom( $error_array ) {
$blog_id = \Jetpack_Options::get_option( 'id' );
$encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
if ( false === $encrypted_data ) {
return false;
}
$args = array(
'body' => array(
'error_data' => $encrypted_data,
),
);
// send encrypted data to WP.com Public-API v2.
wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
return true;
}
/**
* Encrypt data to be sent over to WP.com
*
* @since 1.14.2
*
* @param array|string $data the data to be encoded.
* @return boolean|string The encoded string on success, false on failure
*/
public function encrypt_data_to_wpcom( $data ) {
try {
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
} catch ( \SodiumException $e ) {
// error encrypting data.
return false;
}
return $encrypted_data;
}
/**
* Extracts the user ID from a token
*
* @since 1.14.2
*
* @param string $token the token used to make the request.
* @return string $the user id or `invalid` if user id not present.
*/
public function get_user_id_from_token( $token ) {
$parsed_token = explode( ':', wp_unslash( $token ) );
if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
$user_id = $parsed_token[2];
} else {
$user_id = 'invalid';
}
return $user_id;
}
/**
* Gets the reported errors stored in the database
*
* @since 1.14.2
*
* @return array $errors
*/
public function get_stored_errors() {
$stored_errors = get_option( self::STORED_ERRORS_OPTION );
if ( ! is_array( $stored_errors ) ) {
$stored_errors = array();
}
$stored_errors = $this->garbage_collector( $stored_errors );
return $stored_errors;
}
/**
* Gets the verified errors stored in the database
*
* @since 1.14.2
*
* @return array $errors
*/
public function get_verified_errors() {
$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
if ( ! is_array( $verified_errors ) ) {
$verified_errors = array();
}
$verified_errors = $this->garbage_collector( $verified_errors );
return $verified_errors;
}
/**
* Removes expired errors from the array
*
* This method is called by get_stored_errors and get_verified errors and filters their result
* Whenever a new error is stored to the database or verified, this will be triggered and the
* expired error will be permantently removed from the database
*
* @since 1.14.2
*
* @param array $errors array of errors as stored in the database.
* @return array
*/
private function garbage_collector( $errors ) {
foreach ( $errors as $error_code => $users ) {
foreach ( $users as $user_id => $error ) {
if ( self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
unset( $errors[ $error_code ][ $user_id ] );
}
}
}
// Clear empty error codes.
$errors = array_filter(
$errors,
function ( $user_errors ) {
return ! empty( $user_errors );
}
);
return $errors;
}
/**
* Delete all stored and verified errors from the database
*
* @since 1.14.2
*
* @return void
*/
public function delete_all_errors() {
$this->delete_stored_errors();
$this->delete_verified_errors();
}
/**
* Delete all stored and verified errors from the database and returns unfiltered value
*
* This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
*
* @since 8.9.0
*
* @param mixed $check The input sent by the filter.
* @return boolean
*/
public function delete_all_errors_and_return_unfiltered_value( $check ) {
$this->delete_all_errors();
return $check;
}
/**
* Delete the reported errors stored in the database
*
* @since 1.14.2
*
* @return boolean True, if option is successfully deleted. False on failure.
*/
public function delete_stored_errors() {
return delete_option( self::STORED_ERRORS_OPTION );
}
/**
* Delete the verified errors stored in the database
*
* @since 1.14.2
*
* @return boolean True, if option is successfully deleted. False on failure.
*/
public function delete_verified_errors() {
return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
}
/**
* Gets an error based on the nonce
*
* Receives a nonce and finds the related error.
*
* @since 1.14.2
*
* @param string $nonce The nonce created for the error we want to get.
* @return null|array Returns the error array representation or null if error not found.
*/
public function get_error_by_nonce( $nonce ) {
$errors = $this->get_stored_errors();
foreach ( $errors as $user_group ) {
foreach ( $user_group as $error ) {
if ( $error['nonce'] === $nonce ) {
return $error;
}
}
}
return null;
}
/**
* Adds an error to the verified error list
*
* @since 1.14.2
*
* @param array $error The error array, as it was saved in the unverified errors list.
* @return void
*/
public function verify_error( $error ) {
$verified_errors = $this->get_verified_errors();
$error_code = $error['error_code'];
$user_id = $error['user_id'];
if ( ! isset( $verified_errors[ $error_code ] ) ) {
$verified_errors[ $error_code ] = array();
}
$verified_errors[ $error_code ][ $user_id ] = $error;
update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
}
/**
* Register REST API end point for error hanlding.
*
* @since 1.14.2
*
* @return void
*/
public function register_verify_error_endpoint() {
register_rest_route(
'jetpack/v4',
'/verify_xmlrpc_error',
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'verify_xml_rpc_error' ),
'permission_callback' => '__return_true',
'args' => array(
'nonce' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
/**
* Handles verification that a xml rpc error is legit and came from WordPres.com
*
* @since 1.14.2
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return boolean
*/
public function verify_xml_rpc_error( \WP_REST_Request $request ) {
$error = $this->get_error_by_nonce( $request['nonce'] );
if ( $error ) {
$this->verify_error( $error );
return new \WP_REST_Response( true, 200 );
}
return new \WP_REST_Response( false, 200 );
}
/**
* Prints a generic error notice for all connection errors
*
* @since 8.9.0
*
* @return void
*/
public function generic_admin_notice_error() {
// do not add admin notice to the jetpack dashboard.
global $pagenow;
if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
return;
}
if ( ! current_user_can( 'jetpack_connect' ) ) {
return;
}
/**
* Filters the message to be displayed in the admin notices area when there's a connection error.
*
* By default we don't display any errors.
*
* Return an empty value to disable the message.
*
* @since 8.9.0
*
* @param string $message The error message.
* @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
*/
$message = apply_filters( 'jetpack_connection_error_notice_message', '', $this->get_verified_errors() );
/**
* Fires inside the admin_notices hook just before displaying the error message for a broken connection.
*
* If you want to disable the default message from being displayed, return an emtpy value in the jetpack_connection_error_notice_message filter.
*
* @since 8.9.0
*
* @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
*/
do_action( 'jetpack_connection_error_notice', $this->get_verified_errors() );
if ( empty( $message ) ) {
return;
}
?>
<div class="notice notice-error is-dismissible jetpack-message jp-connect" style="display:block !important;">
<p><?php echo esc_html( $message ); ?></p>
</div>
<?php
}
/**
* Adds the error message to the Jetpack React Dashboard
*
* @since 8.9.0
*
* @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
* @return array
*/
public function jetpack_react_dashboard_error( $errors ) {
$errors[] = array(
'code' => 'connection_error',
'message' => __( 'Your connection with WordPress.com seems to be broken. If you\'re experiencing issues, please try reconnecting.', 'jetpack-connection' ),
'action' => 'reconnect',
'data' => array( 'api_error_code' => $this->error_code ),
);
return $errors;
}
/**
* Check REST API response for errors, and report them to WP.com if needed.
*
* @see wp_remote_request() For more information on the $http_response array format.
* @param array|\WP_Error $http_response The response or WP_Error on failure.
* @param array $auth_data Auth data, allowed keys: `token`, `timestamp`, `nonce`, `body-hash`.
* @param string $url Request URL.
* @param string $method Request method.
* @param string $error_type The source of an error: 'xmlrpc' or 'rest'.
*
* @return void
*/
public function check_api_response_for_errors( $http_response, $auth_data, $url, $method, $error_type ) {
if ( 200 === wp_remote_retrieve_response_code( $http_response ) || ! is_array( $auth_data ) || ! $url || ! $method ) {
return;
}
$body_raw = wp_remote_retrieve_body( $http_response );
if ( ! $body_raw ) {
return;
}
$body = json_decode( $body_raw, true );
if ( empty( $body['error'] ) || ( ! is_string( $body['error'] ) && ! is_int( $body['error'] ) ) ) {
return;
}
$error = new \WP_Error(
$body['error'],
empty( $body['message'] ) ? '' : $body['message'],
array(
'signature_details' => array(
'token' => empty( $auth_data['token'] ) ? '' : $auth_data['token'],
'timestamp' => empty( $auth_data['timestamp'] ) ? '' : $auth_data['timestamp'],
'nonce' => empty( $auth_data['nonce'] ) ? '' : $auth_data['nonce'],
'body_hash' => empty( $auth_data['body_hash'] ) ? '' : $auth_data['body_hash'],
'method' => $method,
'url' => $url,
),
'error_type' => in_array( $error_type, array( 'xmlrpc', 'rest' ), true ) ? $error_type : '',
)
);
$this->report_error( $error, false, true );
}
}
class-heartbeat.php 0000644 00000014374 15154644771 0010346 0 ustar 00 <?php
/**
* Jetpack Heartbeat package.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
use Jetpack_Options;
use WP_CLI;
/**
* Heartbeat sends a batch of stats to wp.com once a day
*/
class Heartbeat {
/**
* Holds the singleton instance of this class
*
* @since 1.0.0
* @since-jetpack 2.3.3
* @var Heartbeat
*/
private static $instance = false;
/**
* Cronjob identifier
*
* @var string
*/
private $cron_name = 'jetpack_v2_heartbeat';
/**
* Singleton
*
* @since 1.0.0
* @since-jetpack 2.3.3
* @static
* @return Heartbeat
*/
public static function init() {
if ( ! self::$instance ) {
self::$instance = new Heartbeat();
}
return self::$instance;
}
/**
* Constructor for singleton
*
* @since 1.0.0
* @since-jetpack 2.3.3
*/
private function __construct() {
// Schedule the task.
add_action( $this->cron_name, array( $this, 'cron_exec' ) );
if ( ! wp_next_scheduled( $this->cron_name ) ) {
// Deal with the old pre-3.0 weekly one.
$timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
}
wp_schedule_event( time(), 'daily', $this->cron_name );
}
add_filter( 'jetpack_xmlrpc_unauthenticated_methods', array( __CLASS__, 'jetpack_xmlrpc_methods' ) );
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'jetpack-heartbeat', array( $this, 'cli_callback' ) );
}
}
/**
* Method that gets executed on the wp-cron call
*
* @since 1.0.0
* @since-jetpack 2.3.3
* @global string $wp_version
*/
public function cron_exec() {
$a8c_mc_stats = new A8c_Mc_Stats();
/*
* This should run daily. Figuring in for variances in
* WP_CRON, don't let it run more than every 23 hours at most.
*
* i.e. if it ran less than 23 hours ago, fail out.
*/
$last = (int) Jetpack_Options::get_option( 'last_heartbeat' );
if ( $last && ( $last + DAY_IN_SECONDS - HOUR_IN_SECONDS > time() ) ) {
return;
}
/*
* Check for an identity crisis
*
* If one exists:
* - Bump stat for ID crisis
* - Email site admin about potential ID crisis
*/
// Coming Soon!
foreach ( self::generate_stats_array( 'v2-' ) as $key => $value ) {
if ( is_array( $value ) ) {
foreach ( $value as $v ) {
$a8c_mc_stats->add( $key, (string) $v );
}
} else {
$a8c_mc_stats->add( $key, (string) $value );
}
}
Jetpack_Options::update_option( 'last_heartbeat', time() );
$a8c_mc_stats->do_server_side_stats();
/**
* Fires when we synchronize all registered options on heartbeat.
*
* @since 3.3.0
*/
do_action( 'jetpack_heartbeat' );
}
/**
* Generates heartbeat stats data.
*
* @param string $prefix Prefix to add before stats identifier.
*
* @return array The stats array.
*/
public static function generate_stats_array( $prefix = '' ) {
/**
* This filter is used to build the array of stats that are bumped once a day by Jetpack Heartbeat.
*
* Filter the array and add key => value pairs where
* * key is the stat group name
* * value is the stat name.
*
* Example:
* add_filter( 'jetpack_heartbeat_stats_array', function( $stats ) {
* $stats['is-https'] = is_ssl() ? 'https' : 'http';
* });
*
* This will bump the stats for the 'is-https/https' or 'is-https/http' stat.
*
* @param array $stats The stats to be filtered.
* @param string $prefix The prefix that will automatically be added at the begining at each stat group name.
*/
$stats = apply_filters( 'jetpack_heartbeat_stats_array', array(), $prefix );
$return = array();
// Apply prefix to stats.
foreach ( $stats as $stat => $value ) {
$return[ "$prefix$stat" ] = $value;
}
return $return;
}
/**
* Registers jetpack.getHeartbeatData xmlrpc method
*
* @param array $methods The list of methods to be filtered.
* @return array $methods
*/
public static function jetpack_xmlrpc_methods( $methods ) {
$methods['jetpack.getHeartbeatData'] = array( __CLASS__, 'xmlrpc_data_response' );
return $methods;
}
/**
* Handles the response for the jetpack.getHeartbeatData xmlrpc method
*
* @param array $params The parameters received in the request.
* @return array $params all the stats that heartbeat handles.
*/
public static function xmlrpc_data_response( $params = array() ) {
// The WordPress XML-RPC server sets a default param of array()
// if no argument is passed on the request and the method handlers get this array in $params.
// generate_stats_array() needs a string as first argument.
$params = empty( $params ) ? '' : $params;
return self::generate_stats_array( $params );
}
/**
* Clear scheduled events
*
* @return void
*/
public function deactivate() {
// Deal with the old pre-3.0 weekly one.
$timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
}
$timestamp = wp_next_scheduled( $this->cron_name );
wp_unschedule_event( $timestamp, $this->cron_name );
}
/**
* Interact with the Heartbeat
*
* ## OPTIONS
*
* inspect (default): Gets the list of data that is going to be sent in the heartbeat and the date/time of the last heartbeat
*
* @param array $args Arguments passed via CLI.
*
* @return void
*/
public function cli_callback( $args ) {
$allowed_args = array(
'inspect',
);
if ( isset( $args[0] ) && ! in_array( $args[0], $allowed_args, true ) ) {
/* translators: %s is a command like "prompt" */
WP_CLI::error( sprintf( __( '%s is not a valid command.', 'jetpack-connection' ), $args[0] ) );
}
$stats = self::generate_stats_array();
$formatted_stats = array();
foreach ( $stats as $stat_name => $bin ) {
$formatted_stats[] = array(
'Stat name' => $stat_name,
'Bin' => $bin,
);
}
WP_CLI\Utils\format_items( 'table', $formatted_stats, array( 'Stat name', 'Bin' ) );
$last_heartbeat = Jetpack_Options::get_option( 'last_heartbeat' );
if ( $last_heartbeat ) {
$last_date = gmdate( 'Y-m-d H:i:s', $last_heartbeat );
/* translators: %s is the full datetime of the last heart beat e.g. 2020-01-01 12:21:23 */
WP_CLI::line( sprintf( __( 'Last heartbeat sent at: %s', 'jetpack-connection' ), $last_date ) );
}
}
}
class-initial-state.php 0000644 00000002624 15154644771 0011151 0 ustar 00 <?php
/**
* The React initial state.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Status;
/**
* The React initial state.
*/
class Initial_State {
/**
* Whether the initial state was already rendered
*
* @var boolean
*/
private static $rendered = false;
/**
* Get the initial state data.
*
* @return array
*/
private static function get_data() {
global $wp_version;
return array(
'apiRoot' => esc_url_raw( rest_url() ),
'apiNonce' => wp_create_nonce( 'wp_rest' ),
'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ),
'connectionStatus' => REST_Connector::connection_status( false ),
'userConnectionData' => REST_Connector::get_user_connection_data( false ),
'connectedPlugins' => REST_Connector::get_connection_plugins( false ),
'wpVersion' => $wp_version,
'siteSuffix' => ( new Status() )->get_site_suffix(),
'connectionErrors' => Error_Handler::get_instance()->get_verified_errors(),
);
}
/**
* Render the initial state into a JavaScript variable.
*
* @return string
*/
public static function render() {
if ( self::$rendered ) {
return null;
}
self::$rendered = true;
return 'var JP_CONNECTION_INITIAL_STATE=JSON.parse(decodeURIComponent("' . rawurlencode( wp_json_encode( self::get_data() ) ) . '"));';
}
}
class-manager.php 0000644 00000230577 15154644771 0010026 0 ustar 00 <?php
/**
* The Jetpack Connection manager class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\A8c_Mc_Stats as A8c_Mc_Stats;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Heartbeat;
use Automattic\Jetpack\Roles;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Terms_Of_Service;
use Automattic\Jetpack\Tracking;
use Jetpack_IXR_Client;
use Jetpack_Options;
use WP_Error;
use WP_User;
/**
* The Jetpack Connection Manager class that is used as a single gateway between WordPress.com
* and Jetpack.
*/
class Manager {
/**
* A copy of the raw POST data for signature verification purposes.
*
* @var String
*/
protected $raw_post_data;
/**
* Verification data needs to be stored to properly verify everything.
*
* @var Object
*/
private $xmlrpc_verification = null;
/**
* Plugin management object.
*
* @var Plugin
*/
private $plugin = null;
/**
* Error handler object.
*
* @var Error_Handler
*/
public $error_handler = null;
/**
* Jetpack_XMLRPC_Server object
*
* @var Jetpack_XMLRPC_Server
*/
public $xmlrpc_server = null;
/**
* Holds extra parameters that will be sent along in the register request body.
*
* Use Manager::add_register_request_param to add values to this array.
*
* @since 1.26.0
* @var array
*/
private static $extra_register_params = array();
/**
* Initialize the object.
* Make sure to call the "Configure" first.
*
* @param string $plugin_slug Slug of the plugin using the connection (optional, but encouraged).
*
* @see \Automattic\Jetpack\Config
*/
public function __construct( $plugin_slug = null ) {
if ( $plugin_slug && is_string( $plugin_slug ) ) {
$this->set_plugin_instance( new Plugin( $plugin_slug ) );
}
}
/**
* Initializes required listeners. This is done separately from the constructors
* because some objects sometimes need to instantiate separate objects of this class.
*
* @todo Implement a proper nonce verification.
*/
public static function configure() {
$manager = new self();
add_filter(
'jetpack_constant_default_value',
__NAMESPACE__ . '\Utils::jetpack_api_constant_filter',
10,
2
);
$manager->setup_xmlrpc_handlers(
$_GET, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$manager->has_connected_owner(),
$manager->verify_xml_rpc_signature()
);
$manager->error_handler = Error_Handler::get_instance();
if ( $manager->is_connected() ) {
add_filter( 'xmlrpc_methods', array( $manager, 'public_xmlrpc_methods' ) );
add_filter( 'shutdown', array( new Package_Version_Tracker(), 'maybe_update_package_versions' ) );
}
add_action( 'rest_api_init', array( $manager, 'initialize_rest_api_registration_connector' ) );
( new Nonce_Handler() )->init_schedule();
add_action( 'plugins_loaded', __NAMESPACE__ . '\Plugin_Storage::configure', 100 );
add_filter( 'map_meta_cap', array( $manager, 'jetpack_connection_custom_caps' ), 1, 4 );
Heartbeat::init();
add_filter( 'jetpack_heartbeat_stats_array', array( $manager, 'add_stats_to_heartbeat' ) );
Webhooks::init( $manager );
// Set up package version hook.
add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
if ( defined( 'JETPACK__SANDBOX_DOMAIN' ) && JETPACK__SANDBOX_DOMAIN ) {
( new Server_Sandbox() )->init();
}
// Initialize connection notices.
new Connection_Notice();
}
/**
* Sets up the XMLRPC request handlers.
*
* @since 1.25.0 Deprecate $is_active param.
*
* @param array $request_params incoming request parameters.
* @param bool $has_connected_owner Whether the site has a connected owner.
* @param bool $is_signed whether the signature check has been successful.
* @param \Jetpack_XMLRPC_Server $xmlrpc_server (optional) an instance of the server to use instead of instantiating a new one.
*/
public function setup_xmlrpc_handlers(
$request_params,
$has_connected_owner,
$is_signed,
\Jetpack_XMLRPC_Server $xmlrpc_server = null
) {
add_filter( 'xmlrpc_blog_options', array( $this, 'xmlrpc_options' ), 1000, 2 );
if (
! isset( $request_params['for'] )
|| 'jetpack' !== $request_params['for']
) {
return false;
}
// Alternate XML-RPC, via ?for=jetpack&jetpack=comms.
if (
isset( $request_params['jetpack'] )
&& 'comms' === $request_params['jetpack']
) {
if ( ! Constants::is_defined( 'XMLRPC_REQUEST' ) ) {
// Use the real constant here for WordPress' sake.
define( 'XMLRPC_REQUEST', true );
}
add_action( 'template_redirect', array( $this, 'alternate_xmlrpc' ) );
add_filter( 'xmlrpc_methods', array( $this, 'remove_non_jetpack_xmlrpc_methods' ), 1000 );
}
if ( ! Constants::get_constant( 'XMLRPC_REQUEST' ) ) {
return false;
}
// Display errors can cause the XML to be not well formed.
@ini_set( 'display_errors', false ); // phpcs:ignore
if ( $xmlrpc_server ) {
$this->xmlrpc_server = $xmlrpc_server;
} else {
$this->xmlrpc_server = new \Jetpack_XMLRPC_Server();
}
$this->require_jetpack_authentication();
if ( $is_signed ) {
// If the site is connected either at a site or user level and the request is signed, expose the methods.
// The callback is responsible to determine whether the request is signed with blog or user token and act accordingly.
// The actual API methods.
$callback = array( $this->xmlrpc_server, 'xmlrpc_methods' );
// Hack to preserve $HTTP_RAW_POST_DATA.
add_filter( 'xmlrpc_methods', array( $this, 'xmlrpc_methods' ) );
} elseif ( $has_connected_owner && ! $is_signed ) {
// The jetpack.authorize method should be available for unauthenticated users on a site with an
// active Jetpack connection, so that additional users can link their account.
$callback = array( $this->xmlrpc_server, 'authorize_xmlrpc_methods' );
} else {
// Any other unsigned request should expose the bootstrap methods.
$callback = array( $this->xmlrpc_server, 'bootstrap_xmlrpc_methods' );
new XMLRPC_Connector( $this );
}
add_filter( 'xmlrpc_methods', $callback );
// Now that no one can authenticate, and we're whitelisting all XML-RPC methods, force enable_xmlrpc on.
add_filter( 'pre_option_enable_xmlrpc', '__return_true' );
return true;
}
/**
* Initializes the REST API connector on the init hook.
*/
public function initialize_rest_api_registration_connector() {
new REST_Connector( $this );
}
/**
* Since a lot of hosts use a hammer approach to "protecting" WordPress sites,
* and just blanket block all requests to /xmlrpc.php, or apply other overly-sensitive
* security/firewall policies, we provide our own alternate XML RPC API endpoint
* which is accessible via a different URI. Most of the below is copied directly
* from /xmlrpc.php so that we're replicating it as closely as possible.
*
* @todo Tighten $wp_xmlrpc_server_class a bit to make sure it doesn't do bad things.
*/
public function alternate_xmlrpc() {
// Some browser-embedded clients send cookies. We don't want them.
$_COOKIE = array();
include_once ABSPATH . 'wp-admin/includes/admin.php';
include_once ABSPATH . WPINC . '/class-IXR.php';
include_once ABSPATH . WPINC . '/class-wp-xmlrpc-server.php';
/**
* Filters the class used for handling XML-RPC requests.
*
* @since 1.7.0
* @since-jetpack 3.1.0
*
* @param string $class The name of the XML-RPC server class.
*/
$wp_xmlrpc_server_class = apply_filters( 'wp_xmlrpc_server_class', 'wp_xmlrpc_server' );
$wp_xmlrpc_server = new $wp_xmlrpc_server_class();
// Fire off the request.
nocache_headers();
$wp_xmlrpc_server->serve_request();
exit;
}
/**
* Removes all XML-RPC methods that are not `jetpack.*`.
* Only used in our alternate XML-RPC endpoint, where we want to
* ensure that Core and other plugins' methods are not exposed.
*
* @param array $methods a list of registered WordPress XMLRPC methods.
* @return array filtered $methods
*/
public function remove_non_jetpack_xmlrpc_methods( $methods ) {
$jetpack_methods = array();
foreach ( $methods as $method => $callback ) {
if ( 0 === strpos( $method, 'jetpack.' ) ) {
$jetpack_methods[ $method ] = $callback;
}
}
return $jetpack_methods;
}
/**
* Removes all other authentication methods not to allow other
* methods to validate unauthenticated requests.
*/
public function require_jetpack_authentication() {
// Don't let anyone authenticate.
$_COOKIE = array();
remove_all_filters( 'authenticate' );
remove_all_actions( 'wp_login_failed' );
if ( $this->is_connected() ) {
// Allow Jetpack authentication.
add_filter( 'authenticate', array( $this, 'authenticate_jetpack' ), 10, 3 );
}
}
/**
* Authenticates XML-RPC and other requests from the Jetpack Server
*
* @param WP_User|Mixed $user user object if authenticated.
* @param String $username username.
* @param String $password password string.
* @return WP_User|Mixed authenticated user or error.
*/
public function authenticate_jetpack( $user, $username, $password ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( is_a( $user, '\\WP_User' ) ) {
return $user;
}
$token_details = $this->verify_xml_rpc_signature();
if ( ! $token_details ) {
return $user;
}
if ( 'user' !== $token_details['type'] ) {
return $user;
}
if ( ! $token_details['user_id'] ) {
return $user;
}
nocache_headers();
return new \WP_User( $token_details['user_id'] );
}
/**
* Verifies the signature of the current request.
*
* @return false|array
*/
public function verify_xml_rpc_signature() {
if ( $this->xmlrpc_verification === null ) {
$this->xmlrpc_verification = $this->internal_verify_xml_rpc_signature();
if ( is_wp_error( $this->xmlrpc_verification ) ) {
/**
* Action for logging XMLRPC signature verification errors. This data is sensitive.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param WP_Error $signature_verification_error The verification error
*/
do_action( 'jetpack_verify_signature_error', $this->xmlrpc_verification );
Error_Handler::get_instance()->report_error( $this->xmlrpc_verification );
}
}
return is_wp_error( $this->xmlrpc_verification ) ? false : $this->xmlrpc_verification;
}
/**
* Verifies the signature of the current request.
*
* This function has side effects and should not be used. Instead,
* use the memoized version `->verify_xml_rpc_signature()`.
*
* @internal
* @todo Refactor to use proper nonce verification.
*/
private function internal_verify_xml_rpc_signature() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// It's not for us.
if ( ! isset( $_GET['token'] ) || empty( $_GET['signature'] ) ) {
return false;
}
$signature_details = array(
'token' => isset( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '',
'timestamp' => isset( $_GET['timestamp'] ) ? wp_unslash( $_GET['timestamp'] ) : '',
'nonce' => isset( $_GET['nonce'] ) ? wp_unslash( $_GET['nonce'] ) : '',
'body_hash' => isset( $_GET['body-hash'] ) ? wp_unslash( $_GET['body-hash'] ) : '',
'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? wp_unslash( $_SERVER['REQUEST_METHOD'] ) : null,
'url' => wp_unslash( ( isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : null ) . ( isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : null ) ), // Temp - will get real signature URL later.
'signature' => isset( $_GET['signature'] ) ? wp_unslash( $_GET['signature'] ) : '',
);
$error_type = 'xmlrpc';
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@list( $token_key, $version, $user_id ) = explode( ':', wp_unslash( $_GET['token'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$jetpack_api_version = Constants::get_constant( 'JETPACK__API_VERSION' );
if (
empty( $token_key )
||
empty( $version ) || (string) $jetpack_api_version !== $version ) {
return new \WP_Error( 'malformed_token', 'Malformed token in request', compact( 'signature_details', 'error_type' ) );
}
if ( '0' === $user_id ) {
$token_type = 'blog';
$user_id = 0;
} else {
$token_type = 'user';
if ( empty( $user_id ) || ! ctype_digit( $user_id ) ) {
return new \WP_Error(
'malformed_user_id',
'Malformed user_id in request',
compact( 'signature_details', 'error_type' )
);
}
$user_id = (int) $user_id;
$user = new \WP_User( $user_id );
if ( ! $user || ! $user->exists() ) {
return new \WP_Error(
'unknown_user',
sprintf( 'User %d does not exist', $user_id ),
compact( 'signature_details', 'error_type' )
);
}
}
$token = $this->get_tokens()->get_access_token( $user_id, $token_key, false );
if ( is_wp_error( $token ) ) {
$token->add_data( compact( 'signature_details', 'error_type' ) );
return $token;
} elseif ( ! $token ) {
return new \WP_Error(
'unknown_token',
sprintf( 'Token %s:%s:%d does not exist', $token_key, $version, $user_id ),
compact( 'signature_details', 'error_type' )
);
}
$jetpack_signature = new \Jetpack_Signature( $token->secret, (int) \Jetpack_Options::get_option( 'time_diff' ) );
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( isset( $_POST['_jetpack_is_multipart'] ) ) {
$post_data = $_POST;
$file_hashes = array();
foreach ( $post_data as $post_data_key => $post_data_value ) {
if ( 0 !== strpos( $post_data_key, '_jetpack_file_hmac_' ) ) {
continue;
}
$post_data_key = substr( $post_data_key, strlen( '_jetpack_file_hmac_' ) );
$file_hashes[ $post_data_key ] = $post_data_value;
}
foreach ( $file_hashes as $post_data_key => $post_data_value ) {
unset( $post_data[ "_jetpack_file_hmac_{$post_data_key}" ] );
$post_data[ $post_data_key ] = $post_data_value;
}
ksort( $post_data );
$body = http_build_query( stripslashes_deep( $post_data ) );
} elseif ( $this->raw_post_data === null ) {
$body = file_get_contents( 'php://input' );
} else {
$body = null;
}
// phpcs:enable
$signature = $jetpack_signature->sign_current_request(
array( 'body' => $body === null ? $this->raw_post_data : $body )
);
$signature_details['url'] = $jetpack_signature->current_request_url;
if ( ! $signature ) {
return new \WP_Error(
'could_not_sign',
'Unknown signature error',
compact( 'signature_details', 'error_type' )
);
} elseif ( is_wp_error( $signature ) ) {
return $signature;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$timestamp = (int) $_GET['timestamp'];
$nonce = wp_unslash( (string) $_GET['nonce'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WP Core doesn't sanitize nonces either.
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// Use up the nonce regardless of whether the signature matches.
if ( ! ( new Nonce_Handler() )->add( $timestamp, $nonce ) ) {
return new \WP_Error(
'invalid_nonce',
'Could not add nonce',
compact( 'signature_details', 'error_type' )
);
}
// Be careful about what you do with this debugging data.
// If a malicious requester has access to the expected signature,
// bad things might be possible.
$signature_details['expected'] = $signature;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! hash_equals( $signature, wp_unslash( $_GET['signature'] ) ) ) {
return new \WP_Error(
'signature_mismatch',
'Signature mismatch',
compact( 'signature_details', 'error_type' )
);
}
/**
* Action for additional token checking.
*
* @since 1.7.0
* @since-jetpack 7.7.0
*
* @param array $post_data request data.
* @param array $token_data token data.
*/
return apply_filters(
'jetpack_signature_check_token',
array(
'type' => $token_type,
'token_key' => $token_key,
'user_id' => $token->external_user_id,
),
$token,
$this->raw_post_data
);
}
/**
* Returns true if the current site is connected to WordPress.com and has the minimum requirements to enable Jetpack UI.
*
* This method is deprecated since version 1.25.0 of this package. Please use has_connected_owner instead.
*
* Since this method has a wide spread use, we decided not to throw any deprecation warnings for now.
*
* @deprecated 1.25.0
* @see Manager::has_connected_owner
* @return Boolean is the site connected?
*/
public function is_active() {
return (bool) $this->get_tokens()->get_access_token( true );
}
/**
* Obtains an instance of the Tokens class.
*
* @return Tokens the Tokens object
*/
public function get_tokens() {
return new Tokens();
}
/**
* Returns true if the site has both a token and a blog id, which indicates a site has been registered.
*
* @access public
* @deprecated 1.12.1 Use is_connected instead
* @see Manager::is_connected
*
* @return bool
*/
public function is_registered() {
_deprecated_function( __METHOD__, '1.12.1' );
return $this->is_connected();
}
/**
* Returns true if the site has both a token and a blog id, which indicates a site has been connected.
*
* @access public
* @since 1.21.1
*
* @return bool
*/
public function is_connected() {
$has_blog_id = (bool) \Jetpack_Options::get_option( 'id' );
$has_blog_token = (bool) $this->get_tokens()->get_access_token();
return $has_blog_id && $has_blog_token;
}
/**
* Returns true if the site has at least one connected administrator.
*
* @access public
* @since 1.21.1
*
* @return bool
*/
public function has_connected_admin() {
return (bool) count( $this->get_connected_users( 'manage_options' ) );
}
/**
* Returns true if the site has any connected user.
*
* @access public
* @since 1.21.1
*
* @return bool
*/
public function has_connected_user() {
return (bool) count( $this->get_connected_users( 'any', 1 ) );
}
/**
* Returns an array of users that have user tokens for communicating with wpcom.
* Able to select by specific capability.
*
* @since 9.9.1 Added $limit parameter.
*
* @param string $capability The capability of the user.
* @param int|null $limit How many connected users to get before returning.
* @return WP_User[] Array of WP_User objects if found.
*/
public function get_connected_users( $capability = 'any', $limit = null ) {
$connected_users = array();
$user_tokens = $this->get_tokens()->get_user_tokens();
if ( ! is_array( $user_tokens ) || empty( $user_tokens ) ) {
return $connected_users;
}
$connected_user_ids = array_keys( $user_tokens );
if ( ! empty( $connected_user_ids ) ) {
foreach ( $connected_user_ids as $id ) {
// Check for capability.
if ( 'any' !== $capability && ! user_can( $id, $capability ) ) {
continue;
}
$user_data = get_userdata( $id );
if ( $user_data instanceof \WP_User ) {
$connected_users[] = $user_data;
if ( $limit && count( $connected_users ) >= $limit ) {
return $connected_users;
}
}
}
}
return $connected_users;
}
/**
* Returns true if the site has a connected Blog owner (master_user).
*
* @access public
* @since 1.21.1
*
* @return bool
*/
public function has_connected_owner() {
return (bool) $this->get_connection_owner_id();
}
/**
* Returns true if the site is connected only at a site level.
*
* Note that we are explicitly checking for the existence of the master_user option in order to account for cases where we don't have any user tokens (user-level connection) but the master_user option is set, which could be the result of a problematic user connection.
*
* @access public
* @since 1.25.0
* @deprecated 1.27.0
*
* @return bool
*/
public function is_userless() {
_deprecated_function( __METHOD__, '1.27.0', 'Automattic\\Jetpack\\Connection\\Manager::is_site_connection' );
return $this->is_site_connection();
}
/**
* Returns true if the site is connected only at a site level.
*
* Note that we are explicitly checking for the existence of the master_user option in order to account for cases where we don't have any user tokens (user-level connection) but the master_user option is set, which could be the result of a problematic user connection.
*
* @access public
* @since 1.27.0
*
* @return bool
*/
public function is_site_connection() {
return $this->is_connected() && ! $this->has_connected_user() && ! \Jetpack_Options::get_option( 'master_user' );
}
/**
* Checks to see if the connection owner of the site is missing.
*
* @return bool
*/
public function is_missing_connection_owner() {
$connection_owner = $this->get_connection_owner_id();
if ( ! get_user_by( 'id', $connection_owner ) ) {
return true;
}
return false;
}
/**
* Returns true if the user with the specified identifier is connected to
* WordPress.com.
*
* @param int $user_id the user identifier. Default is the current user.
* @return bool Boolean is the user connected?
*/
public function is_user_connected( $user_id = false ) {
$user_id = false === $user_id ? get_current_user_id() : absint( $user_id );
if ( ! $user_id ) {
return false;
}
return (bool) $this->get_tokens()->get_access_token( $user_id );
}
/**
* Returns the local user ID of the connection owner.
*
* @return bool|int Returns the ID of the connection owner or False if no connection owner found.
*/
public function get_connection_owner_id() {
$owner = $this->get_connection_owner();
return $owner instanceof \WP_User ? $owner->ID : false;
}
/**
* Get the wpcom user data of the current|specified connected user.
*
* @todo Refactor to properly load the XMLRPC client independently.
*
* @param Integer $user_id the user identifier.
* @return bool|array An array with the WPCOM user data on success, false otherwise.
*/
public function get_connected_user_data( $user_id = null ) {
if ( ! $user_id ) {
$user_id = get_current_user_id();
}
// Check if the user is connected and return false otherwise.
if ( ! $this->is_user_connected( $user_id ) ) {
return false;
}
$transient_key = "jetpack_connected_user_data_$user_id";
$cached_user_data = get_transient( $transient_key );
if ( $cached_user_data ) {
return $cached_user_data;
}
$xml = new Jetpack_IXR_Client(
array(
'user_id' => $user_id,
)
);
$xml->query( 'wpcom.getUser' );
if ( ! $xml->isError() ) {
$user_data = $xml->getResponse();
set_transient( $transient_key, $xml->getResponse(), DAY_IN_SECONDS );
return $user_data;
}
return false;
}
/**
* Returns a user object of the connection owner.
*
* @return WP_User|false False if no connection owner found.
*/
public function get_connection_owner() {
$user_id = \Jetpack_Options::get_option( 'master_user' );
if ( ! $user_id ) {
return false;
}
// Make sure user is connected.
$user_token = $this->get_tokens()->get_access_token( $user_id );
$connection_owner = false;
if ( $user_token && is_object( $user_token ) && isset( $user_token->external_user_id ) ) {
$connection_owner = get_userdata( $user_token->external_user_id );
}
return $connection_owner;
}
/**
* Returns true if the provided user is the Jetpack connection owner.
* If user ID is not specified, the current user will be used.
*
* @param Integer|Boolean $user_id the user identifier. False for current user.
* @return Boolean True the user the connection owner, false otherwise.
*/
public function is_connection_owner( $user_id = false ) {
if ( ! $user_id ) {
$user_id = get_current_user_id();
}
return ( (int) $user_id ) === $this->get_connection_owner_id();
}
/**
* Connects the user with a specified ID to a WordPress.com user using the
* remote login flow.
*
* @access public
*
* @param Integer $user_id (optional) the user identifier, defaults to current user.
* @param String $redirect_url the URL to redirect the user to for processing, defaults to
* admin_url().
* @return WP_Error only in case of a failed user lookup.
*/
public function connect_user( $user_id = null, $redirect_url = null ) {
$user = null;
if ( null === $user_id ) {
$user = wp_get_current_user();
} else {
$user = get_user_by( 'ID', $user_id );
}
if ( empty( $user ) ) {
return new \WP_Error( 'user_not_found', 'Attempting to connect a non-existent user.' );
}
if ( null === $redirect_url ) {
$redirect_url = admin_url();
}
// Using wp_redirect intentionally because we're redirecting outside.
wp_redirect( $this->get_authorization_url( $user, $redirect_url ) ); // phpcs:ignore WordPress.Security.SafeRedirect
exit();
}
/**
* Unlinks the current user from the linked WordPress.com user.
*
* @access public
* @static
*
* @todo Refactor to properly load the XMLRPC client independently.
*
* @param Integer $user_id the user identifier.
* @param bool $can_overwrite_primary_user Allow for the primary user to be disconnected.
* @param bool $force_disconnect_locally Disconnect user locally even if we were unable to disconnect them from WP.com.
* @return Boolean Whether the disconnection of the user was successful.
*/
public function disconnect_user( $user_id = null, $can_overwrite_primary_user = false, $force_disconnect_locally = false ) {
$user_id = empty( $user_id ) ? get_current_user_id() : (int) $user_id;
$is_primary_user = Jetpack_Options::get_option( 'master_user' ) === $user_id;
if ( $is_primary_user && ! $can_overwrite_primary_user ) {
return false;
}
// Attempt to disconnect the user from WordPress.com.
$is_disconnected_from_wpcom = $this->unlink_user_from_wpcom( $user_id );
$is_disconnected_locally = false;
if ( $is_disconnected_from_wpcom || $force_disconnect_locally ) {
// Disconnect the user locally.
$is_disconnected_locally = $this->get_tokens()->disconnect_user( $user_id );
if ( $is_disconnected_locally ) {
// Delete cached connected user data.
$transient_key = "jetpack_connected_user_data_$user_id";
delete_transient( $transient_key );
/**
* Fires after the current user has been unlinked from WordPress.com.
*
* @since 1.7.0
* @since-jetpack 4.1.0
*
* @param int $user_id The current user's ID.
*/
do_action( 'jetpack_unlinked_user', $user_id );
if ( $is_primary_user ) {
Jetpack_Options::delete_option( 'master_user' );
}
}
}
return $is_disconnected_from_wpcom && $is_disconnected_locally;
}
/**
* Request to wpcom for a user to be unlinked from their WordPress.com account
*
* @param int $user_id The user identifier.
*
* @return bool Whether the disconnection of the user was successful.
*/
public function unlink_user_from_wpcom( $user_id ) {
// Attempt to disconnect the user from WordPress.com.
$xml = new Jetpack_IXR_Client();
$xml->query( 'jetpack.unlink_user', $user_id );
if ( $xml->isError() ) {
return false;
}
return (bool) $xml->getResponse();
}
/**
* Update the connection owner.
*
* @since 1.29.0
*
* @param Integer $new_owner_id The ID of the user to become the connection owner.
*
* @return true|WP_Error True if owner successfully changed, WP_Error otherwise.
*/
public function update_connection_owner( $new_owner_id ) {
$roles = new Roles();
if ( ! user_can( $new_owner_id, $roles->translate_role_to_cap( 'administrator' ) ) ) {
return new WP_Error(
'new_owner_not_admin',
__( 'New owner is not admin', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
$old_owner_id = $this->get_connection_owner_id();
if ( $old_owner_id === $new_owner_id ) {
return new WP_Error(
'new_owner_is_existing_owner',
__( 'New owner is same as existing owner', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
if ( ! $this->is_user_connected( $new_owner_id ) ) {
return new WP_Error(
'new_owner_not_connected',
__( 'New owner is not connected', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
// Notify WPCOM about the connection owner change.
$owner_updated_wpcom = $this->update_connection_owner_wpcom( $new_owner_id );
if ( $owner_updated_wpcom ) {
// Update the connection owner in Jetpack only if they were successfully updated on WPCOM.
// This will ensure consistency with WPCOM.
\Jetpack_Options::update_option( 'master_user', $new_owner_id );
// Track it.
( new Tracking() )->record_user_event( 'set_connection_owner_success' );
return true;
}
return new WP_Error(
'error_setting_new_owner',
__( 'Could not confirm new owner.', 'jetpack-connection' ),
array( 'status' => 500 )
);
}
/**
* Request to WPCOM to update the connection owner.
*
* @since 1.29.0
*
* @param Integer $new_owner_id The ID of the user to become the connection owner.
*
* @return Boolean Whether the ownership transfer was successful.
*/
public function update_connection_owner_wpcom( $new_owner_id ) {
// Notify WPCOM about the connection owner change.
$xml = new Jetpack_IXR_Client(
array(
'user_id' => get_current_user_id(),
)
);
$xml->query(
'jetpack.switchBlogOwner',
array(
'new_blog_owner' => $new_owner_id,
)
);
if ( $xml->isError() ) {
return false;
}
return (bool) $xml->getResponse();
}
/**
* Returns the requested Jetpack API URL.
*
* @param String $relative_url the relative API path.
* @return String API URL.
*/
public function api_url( $relative_url ) {
$api_base = Constants::get_constant( 'JETPACK__API_BASE' );
$api_version = '/' . Constants::get_constant( 'JETPACK__API_VERSION' ) . '/';
/**
* Filters the API URL that Jetpack uses for server communication.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param String $url the generated URL.
* @param String $relative_url the relative URL that was passed as an argument.
* @param String $api_base the API base string that is being used.
* @param String $api_version the API version string that is being used.
*/
return apply_filters(
'jetpack_api_url',
rtrim( $api_base . $relative_url, '/\\' ) . $api_version,
$relative_url,
$api_base,
$api_version
);
}
/**
* Returns the Jetpack XMLRPC WordPress.com API endpoint URL.
*
* @return String XMLRPC API URL.
*/
public function xmlrpc_api_url() {
$base = preg_replace(
'#(https?://[^?/]+)(/?.*)?$#',
'\\1',
Constants::get_constant( 'JETPACK__API_BASE' )
);
return untrailingslashit( $base ) . '/xmlrpc.php';
}
/**
* Attempts Jetpack registration which sets up the site for connection. Should
* remain public because the call to action comes from the current site, not from
* WordPress.com.
*
* @param String $api_endpoint (optional) an API endpoint to use, defaults to 'register'.
* @return true|WP_Error The error object.
*/
public function register( $api_endpoint = 'register' ) {
add_action( 'pre_update_jetpack_option_register', array( '\\Jetpack_Options', 'delete_option' ) );
$secrets = ( new Secrets() )->generate( 'register', get_current_user_id(), 600 );
if ( false === $secrets ) {
return new WP_Error( 'cannot_save_secrets', __( 'Jetpack experienced an issue trying to save options (cannot_save_secrets). We suggest that you contact your hosting provider, and ask them for help checking that the options table is writable on your site.', 'jetpack-connection' ) );
}
if (
empty( $secrets['secret_1'] ) ||
empty( $secrets['secret_2'] ) ||
empty( $secrets['exp'] )
) {
return new \WP_Error( 'missing_secrets' );
}
// Better to try (and fail) to set a higher timeout than this system
// supports than to have register fail for more users than it should.
$timeout = $this->set_min_time_limit( 60 ) / 2;
$gmt_offset = get_option( 'gmt_offset' );
if ( ! $gmt_offset ) {
$gmt_offset = 0;
}
$stats_options = get_option( 'stats_options' );
$stats_id = isset( $stats_options['blog_id'] )
? $stats_options['blog_id']
: null;
/* This action is documented in src/class-package-version-tracker.php */
$package_versions = apply_filters( 'jetpack_package_versions', array() );
$active_plugins_using_connection = Plugin_Storage::get_all();
/**
* Filters the request body for additional property addition.
*
* @since 1.7.0
* @since-jetpack 7.7.0
*
* @param array $post_data request data.
* @param Array $token_data token data.
*/
$body = apply_filters(
'jetpack_register_request_body',
array_merge(
array(
'siteurl' => Urls::site_url(),
'home' => Urls::home_url(),
'gmt_offset' => $gmt_offset,
'timezone_string' => (string) get_option( 'timezone_string' ),
'site_name' => (string) get_option( 'blogname' ),
'secret_1' => $secrets['secret_1'],
'secret_2' => $secrets['secret_2'],
'site_lang' => get_locale(),
'timeout' => $timeout,
'stats_id' => $stats_id,
'state' => get_current_user_id(),
'site_created' => $this->get_assumed_site_creation_date(),
'jetpack_version' => Constants::get_constant( 'JETPACK__VERSION' ),
'ABSPATH' => Constants::get_constant( 'ABSPATH' ),
'current_user_email' => wp_get_current_user()->user_email,
'connect_plugin' => $this->get_plugin() ? $this->get_plugin()->get_slug() : null,
'package_versions' => $package_versions,
'active_connected_plugins' => $active_plugins_using_connection,
),
self::$extra_register_params
)
);
$args = array(
'method' => 'POST',
'body' => $body,
'headers' => array(
'Accept' => 'application/json',
),
'timeout' => $timeout,
);
$args['body'] = $this->apply_activation_source_to_args( $args['body'] );
// TODO: fix URLs for bad hosts.
$response = Client::_wp_remote_request(
$this->api_url( $api_endpoint ),
$args,
true
);
// Make sure the response is valid and does not contain any Jetpack errors.
$registration_details = $this->validate_remote_register_response( $response );
if ( is_wp_error( $registration_details ) ) {
return $registration_details;
} elseif ( ! $registration_details ) {
return new \WP_Error(
'unknown_error',
'Unknown error registering your Jetpack site.',
wp_remote_retrieve_response_code( $response )
);
}
if ( empty( $registration_details->jetpack_secret ) || ! is_string( $registration_details->jetpack_secret ) ) {
return new \WP_Error(
'jetpack_secret',
'Unable to validate registration of your Jetpack site.',
wp_remote_retrieve_response_code( $response )
);
}
if ( isset( $registration_details->jetpack_public ) ) {
$jetpack_public = (int) $registration_details->jetpack_public;
} else {
$jetpack_public = false;
}
\Jetpack_Options::update_options(
array(
'id' => (int) $registration_details->jetpack_id,
'public' => $jetpack_public,
)
);
update_option( Package_Version_Tracker::PACKAGE_VERSION_OPTION, $package_versions );
$this->get_tokens()->update_blog_token( (string) $registration_details->jetpack_secret );
$alternate_authorization_url = isset( $registration_details->alternate_authorization_url ) ? $registration_details->alternate_authorization_url : '';
add_filter(
'jetpack_register_site_rest_response',
function ( $response ) use ( $alternate_authorization_url ) {
$response['alternateAuthorizeUrl'] = $alternate_authorization_url;
return $response;
}
);
/**
* Fires when a site is registered on WordPress.com.
*
* @since 1.7.0
* @since-jetpack 3.7.0
*
* @param int $json->jetpack_id Jetpack Blog ID.
* @param string $json->jetpack_secret Jetpack Blog Token.
* @param int|bool $jetpack_public Is the site public.
*/
do_action(
'jetpack_site_registered',
$registration_details->jetpack_id,
$registration_details->jetpack_secret,
$jetpack_public
);
if ( isset( $registration_details->token ) ) {
/**
* Fires when a user token is sent along with the registration data.
*
* @since 1.7.0
* @since-jetpack 7.6.0
*
* @param object $token the administrator token for the newly registered site.
*/
do_action( 'jetpack_site_registered_user_token', $registration_details->token );
}
return true;
}
/**
* Attempts Jetpack registration.
*
* @param bool $tos_agree Whether the user agreed to TOS.
*
* @return bool|WP_Error
*/
public function try_registration( $tos_agree = true ) {
if ( $tos_agree ) {
$terms_of_service = new Terms_Of_Service();
$terms_of_service->agree();
}
/**
* Action fired when the user attempts the registration.
*
* @since 1.26.0
*/
$pre_register = apply_filters( 'jetpack_pre_register', null );
if ( is_wp_error( $pre_register ) ) {
return $pre_register;
}
$tracking_data = array();
if ( null !== $this->get_plugin() ) {
$tracking_data['plugin_slug'] = $this->get_plugin()->get_slug();
}
$tracking = new Tracking();
$tracking->record_user_event( 'jpc_register_begin', $tracking_data );
add_filter( 'jetpack_register_request_body', array( Utils::class, 'filter_register_request_body' ) );
$result = $this->register();
remove_filter( 'jetpack_register_request_body', array( Utils::class, 'filter_register_request_body' ) );
// If there was an error with registration and the site was not registered, record this so we can show a message.
if ( ! $result || is_wp_error( $result ) ) {
return $result;
}
return true;
}
/**
* Adds a parameter to the register request body
*
* @since 1.26.0
*
* @param string $name The name of the parameter to be added.
* @param string $value The value of the parameter to be added.
*
* @throws \InvalidArgumentException If supplied arguments are not strings.
* @return void
*/
public function add_register_request_param( $name, $value ) {
if ( ! is_string( $name ) || ! is_string( $value ) ) {
throw new \InvalidArgumentException( 'name and value must be strings' );
}
self::$extra_register_params[ $name ] = $value;
}
/**
* Takes the response from the Jetpack register new site endpoint and
* verifies it worked properly.
*
* @since 1.7.0
* @since-jetpack 2.6.0
*
* @param Mixed $response the response object, or the error object.
* @return string|WP_Error A JSON object on success or WP_Error on failures
**/
protected function validate_remote_register_response( $response ) {
if ( is_wp_error( $response ) ) {
return new \WP_Error(
'register_http_request_failed',
$response->get_error_message()
);
}
$code = wp_remote_retrieve_response_code( $response );
$entity = wp_remote_retrieve_body( $response );
if ( $entity ) {
$registration_response = json_decode( $entity );
} else {
$registration_response = false;
}
$code_type = (int) ( $code / 100 );
if ( 5 === $code_type ) {
return new \WP_Error( 'wpcom_5??', $code );
} elseif ( 408 === $code ) {
return new \WP_Error( 'wpcom_408', $code );
} elseif ( ! empty( $registration_response->error ) ) {
if (
'xml_rpc-32700' === $registration_response->error
&& ! function_exists( 'xml_parser_create' )
) {
$error_description = __( "PHP's XML extension is not available. Jetpack requires the XML extension to communicate with WordPress.com. Please contact your hosting provider to enable PHP's XML extension.", 'jetpack-connection' );
} else {
$error_description = isset( $registration_response->error_description )
? (string) $registration_response->error_description
: '';
}
return new \WP_Error(
(string) $registration_response->error,
$error_description,
$code
);
} elseif ( 200 !== $code ) {
return new \WP_Error( 'wpcom_bad_response', $code );
}
// Jetpack ID error block.
if ( empty( $registration_response->jetpack_id ) ) {
return new \WP_Error(
'jetpack_id',
/* translators: %s is an error message string */
sprintf( __( 'Error Details: Jetpack ID is empty. Do not publicly post this error message! %s', 'jetpack-connection' ), $entity ),
$entity
);
} elseif ( ! is_scalar( $registration_response->jetpack_id ) ) {
return new \WP_Error(
'jetpack_id',
/* translators: %s is an error message string */
sprintf( __( 'Error Details: Jetpack ID is not a scalar. Do not publicly post this error message! %s', 'jetpack-connection' ), $entity ),
$entity
);
} elseif ( preg_match( '/[^0-9]/', $registration_response->jetpack_id ) ) {
return new \WP_Error(
'jetpack_id',
/* translators: %s is an error message string */
sprintf( __( 'Error Details: Jetpack ID begins with a numeral. Do not publicly post this error message! %s', 'jetpack-connection' ), $entity ),
$entity
);
}
return $registration_response;
}
/**
* Adds a used nonce to a list of known nonces.
*
* @param int $timestamp the current request timestamp.
* @param string $nonce the nonce value.
* @return bool whether the nonce is unique or not.
*
* @deprecated since 1.24.0
* @see Nonce_Handler::add()
*/
public function add_nonce( $timestamp, $nonce ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Nonce_Handler::add' );
return ( new Nonce_Handler() )->add( $timestamp, $nonce );
}
/**
* Cleans nonces that were saved when calling ::add_nonce.
*
* @todo Properly prepare the query before executing it.
*
* @param bool $all whether to clean even non-expired nonces.
*
* @deprecated since 1.24.0
* @see Nonce_Handler::clean_all()
*/
public function clean_nonces( $all = false ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Nonce_Handler::clean_all' );
( new Nonce_Handler() )->clean_all( $all ? PHP_INT_MAX : ( time() - Nonce_Handler::LIFETIME ) );
}
/**
* Sets the Connection custom capabilities.
*
* @param string[] $caps Array of the user's capabilities.
* @param string $cap Capability name.
* @param int $user_id The user ID.
* @param array $args Adds the context to the cap. Typically the object ID.
*/
public function jetpack_connection_custom_caps( $caps, $cap, $user_id, $args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
switch ( $cap ) {
case 'jetpack_connect':
case 'jetpack_reconnect':
$is_offline_mode = ( new Status() )->is_offline_mode();
if ( $is_offline_mode ) {
$caps = array( 'do_not_allow' );
break;
}
// Pass through. If it's not offline mode, these should match disconnect.
// Let users disconnect if it's offline mode, just in case things glitch.
case 'jetpack_disconnect':
/**
* Filters the jetpack_disconnect capability.
*
* @since 1.14.2
*
* @param array An array containing the capability name.
*/
$caps = apply_filters( 'jetpack_disconnect_cap', array( 'manage_options' ) );
break;
case 'jetpack_connect_user':
$is_offline_mode = ( new Status() )->is_offline_mode();
if ( $is_offline_mode ) {
$caps = array( 'do_not_allow' );
break;
}
// With site connections in mind, non-admin users can connect their account only if a connection owner exists.
$caps = $this->has_connected_owner() ? array( 'read' ) : array( 'manage_options' );
break;
}
return $caps;
}
/**
* Builds the timeout limit for queries talking with the wpcom servers.
*
* Based on local php max_execution_time in php.ini
*
* @since 1.7.0
* @since-jetpack 5.4.0
* @return int
**/
public function get_max_execution_time() {
$timeout = (int) ini_get( 'max_execution_time' );
// Ensure exec time set in php.ini.
if ( ! $timeout ) {
$timeout = 30;
}
return $timeout;
}
/**
* Sets a minimum request timeout, and returns the current timeout
*
* @since 1.7.0
* @since-jetpack 5.4.0
* @param Integer $min_timeout the minimum timeout value.
**/
public function set_min_time_limit( $min_timeout ) {
$timeout = $this->get_max_execution_time();
if ( $timeout < $min_timeout ) {
$timeout = $min_timeout;
set_time_limit( $timeout );
}
return $timeout;
}
/**
* Get our assumed site creation date.
* Calculated based on the earlier date of either:
* - Earliest admin user registration date.
* - Earliest date of post of any post type.
*
* @since 1.7.0
* @since-jetpack 7.2.0
*
* @return string Assumed site creation date and time.
*/
public function get_assumed_site_creation_date() {
$cached_date = get_transient( 'jetpack_assumed_site_creation_date' );
if ( ! empty( $cached_date ) ) {
return $cached_date;
}
$earliest_registered_users = get_users(
array(
'role' => 'administrator',
'orderby' => 'user_registered',
'order' => 'ASC',
'fields' => array( 'user_registered' ),
'number' => 1,
)
);
$earliest_registration_date = $earliest_registered_users[0]->user_registered;
$earliest_posts = get_posts(
array(
'posts_per_page' => 1,
'post_type' => 'any',
'post_status' => 'any',
'orderby' => 'date',
'order' => 'ASC',
)
);
// If there are no posts at all, we'll count only on user registration date.
if ( $earliest_posts ) {
$earliest_post_date = $earliest_posts[0]->post_date;
} else {
$earliest_post_date = PHP_INT_MAX;
}
$assumed_date = min( $earliest_registration_date, $earliest_post_date );
set_transient( 'jetpack_assumed_site_creation_date', $assumed_date );
return $assumed_date;
}
/**
* Adds the activation source string as a parameter to passed arguments.
*
* @todo Refactor to use rawurlencode() instead of urlencode().
*
* @param array $args arguments that need to have the source added.
* @return array $amended arguments.
*/
public static function apply_activation_source_to_args( $args ) {
list( $activation_source_name, $activation_source_keyword ) = get_option( 'jetpack_activation_source' );
if ( $activation_source_name ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode
$args['_as'] = urlencode( $activation_source_name );
}
if ( $activation_source_keyword ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode
$args['_ak'] = urlencode( $activation_source_keyword );
}
return $args;
}
/**
* Generates two secret tokens and the end of life timestamp for them.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @param Integer $exp Expiration time in seconds.
*/
public function generate_secrets( $action, $user_id = false, $exp = 600 ) {
return ( new Secrets() )->generate( $action, $user_id, $exp );
}
/**
* Returns two secret tokens and the end of life timestamp for them.
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->get() instead.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @return string|array an array of secrets or an error string.
*/
public function get_secrets( $action, $user_id ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->get' );
return ( new Secrets() )->get( $action, $user_id );
}
/**
* Deletes secret tokens in case they, for example, have expired.
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->delete() instead.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
*/
public function delete_secrets( $action, $user_id ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->delete' );
( new Secrets() )->delete( $action, $user_id );
}
/**
* Deletes all connection tokens and transients from the local Jetpack site.
* If the plugin object has been provided in the constructor, the function first checks
* whether it's the only active connection.
* If there are any other connections, the function will do nothing and return `false`
* (unless `$ignore_connected_plugins` is set to `true`).
*
* @param bool $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
*
* @return bool True if disconnected successfully, false otherwise.
*/
public function delete_all_connection_tokens( $ignore_connected_plugins = false ) {
// refuse to delete if we're not the last Jetpack plugin installed.
if ( ! $ignore_connected_plugins && null !== $this->plugin && ! $this->plugin->is_only() ) {
return false;
}
/**
* Fires upon the disconnect attempt.
* Return `false` to prevent the disconnect.
*
* @since 1.14.2
*/
if ( ! apply_filters( 'jetpack_connection_delete_all_tokens', true ) ) {
return false;
}
\Jetpack_Options::delete_option(
array(
'master_user',
'time_diff',
'fallback_no_verify_ssl_certs',
)
);
( new Secrets() )->delete_all();
$this->get_tokens()->delete_all();
// Delete cached connected user data.
$transient_key = 'jetpack_connected_user_data_' . get_current_user_id();
delete_transient( $transient_key );
// Delete all XML-RPC errors.
Error_Handler::get_instance()->delete_all_errors();
return true;
}
/**
* Tells WordPress.com to disconnect the site and clear all tokens from cached site.
* If the plugin object has been provided in the constructor, the function first check
* whether it's the only active connection.
* If there are any other connections, the function will do nothing and return `false`
* (unless `$ignore_connected_plugins` is set to `true`).
*
* @param bool $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
*
* @return bool True if disconnected successfully, false otherwise.
*/
public function disconnect_site_wpcom( $ignore_connected_plugins = false ) {
if ( ! $ignore_connected_plugins && null !== $this->plugin && ! $this->plugin->is_only() ) {
return false;
}
/**
* Fires upon the disconnect attempt.
* Return `false` to prevent the disconnect.
*
* @since 1.14.2
*/
if ( ! apply_filters( 'jetpack_connection_disconnect_site_wpcom', true, $this ) ) {
return false;
}
$xml = new Jetpack_IXR_Client();
$xml->query( 'jetpack.deregister', get_current_user_id() );
return true;
}
/**
* Disconnect the plugin and remove the tokens.
* This function will automatically perform "soft" or "hard" disconnect depending on whether other plugins are using the connection.
* This is a proxy method to simplify the Connection package API.
*
* @see Manager::disconnect_site()
*
* @param boolean $disconnect_wpcom Should disconnect_site_wpcom be called.
* @param bool $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
* @return bool
*/
public function remove_connection( $disconnect_wpcom = true, $ignore_connected_plugins = false ) {
$this->disconnect_site( $disconnect_wpcom, $ignore_connected_plugins );
return true;
}
/**
* Completely clearing up the connection, and initiating reconnect.
*
* @return true|WP_Error True if reconnected successfully, a `WP_Error` object otherwise.
*/
public function reconnect() {
( new Tracking() )->record_user_event( 'restore_connection_reconnect' );
$this->disconnect_site_wpcom( true );
$this->delete_all_connection_tokens( true );
return $this->register();
}
/**
* Validate the tokens, and refresh the invalid ones.
*
* @return string|bool|WP_Error True if connection restored or string indicating what's to be done next. A `WP_Error` object or false otherwise.
*/
public function restore() {
// If this is a site connection we need to trigger a full reconnection as our only secure means of
// communication with WPCOM, aka the blog token, is compromised.
if ( $this->is_site_connection() ) {
return $this->reconnect();
}
$validate_tokens_response = $this->get_tokens()->validate();
// If token validation failed, trigger a full reconnection.
if ( is_array( $validate_tokens_response ) &&
isset( $validate_tokens_response['blog_token']['is_healthy'] ) &&
isset( $validate_tokens_response['user_token']['is_healthy'] ) ) {
$blog_token_healthy = $validate_tokens_response['blog_token']['is_healthy'];
$user_token_healthy = $validate_tokens_response['user_token']['is_healthy'];
} else {
$blog_token_healthy = false;
$user_token_healthy = false;
}
// Tokens are both valid, or both invalid. We can't fix the problem we don't see, so the full reconnection is needed.
if ( $blog_token_healthy === $user_token_healthy ) {
$result = $this->reconnect();
return ( true === $result ) ? 'authorize' : $result;
}
if ( ! $blog_token_healthy ) {
return $this->refresh_blog_token();
}
if ( ! $user_token_healthy ) {
return ( true === $this->refresh_user_token() ) ? 'authorize' : false;
}
return false;
}
/**
* Responds to a WordPress.com call to register the current site.
* Should be changed to protected.
*
* @param array $registration_data Array of [ secret_1, user_id ].
*/
public function handle_registration( array $registration_data ) {
list( $registration_secret_1, $registration_user_id ) = $registration_data;
if ( empty( $registration_user_id ) ) {
return new \WP_Error( 'registration_state_invalid', __( 'Invalid Registration State', 'jetpack-connection' ), 400 );
}
return ( new Secrets() )->verify( 'register', $registration_secret_1, (int) $registration_user_id );
}
/**
* Perform the API request to validate the blog and user tokens.
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->validate_tokens() instead.
*
* @param int|null $user_id ID of the user we need to validate token for. Current user's ID by default.
*
* @return array|false|WP_Error The API response: `array( 'blog_token_is_healthy' => true|false, 'user_token_is_healthy' => true|false )`.
*/
public function validate_tokens( $user_id = null ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->validate' );
return $this->get_tokens()->validate( $user_id );
}
/**
* Verify a Previously Generated Secret.
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->verify() instead.
*
* @param string $action The type of secret to verify.
* @param string $secret_1 The secret string to compare to what is stored.
* @param int $user_id The user ID of the owner of the secret.
* @return \WP_Error|string WP_Error on failure, secret_2 on success.
*/
public function verify_secrets( $action, $secret_1, $user_id ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->verify' );
return ( new Secrets() )->verify( $action, $secret_1, $user_id );
}
/**
* Responds to a WordPress.com call to authorize the current user.
* Should be changed to protected.
*/
public function handle_authorization() {
}
/**
* Obtains the auth token.
*
* @param array $data The request data.
* @return object|\WP_Error Returns the auth token on success.
* Returns a \WP_Error on failure.
*/
public function get_token( $data ) {
return $this->get_tokens()->get( $data, $this->api_url( 'token' ) );
}
/**
* Builds a URL to the Jetpack connection auth page.
*
* @param WP_User $user (optional) defaults to the current logged in user.
* @param String $redirect (optional) a redirect URL to use instead of the default.
* @return string Connect URL.
*/
public function get_authorization_url( $user = null, $redirect = null ) {
if ( empty( $user ) ) {
$user = wp_get_current_user();
}
$roles = new Roles();
$role = $roles->translate_user_to_role( $user );
$signed_role = $this->get_tokens()->sign_role( $role );
/**
* Filter the URL of the first time the user gets redirected back to your site for connection
* data processing.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param string $redirect_url Defaults to the site admin URL.
*/
$processing_url = apply_filters( 'jetpack_connect_processing_url', admin_url( 'admin.php' ) );
/**
* Filter the URL to redirect the user back to when the authorization process
* is complete.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param string $redirect_url Defaults to the site URL.
*/
$redirect = apply_filters( 'jetpack_connect_redirect_url', $redirect );
$secrets = ( new Secrets() )->generate( 'authorize', $user->ID, 2 * HOUR_IN_SECONDS );
/**
* Filter the type of authorization.
* 'calypso' completes authorization on wordpress.com/jetpack/connect
* while 'jetpack' ( or any other value ) completes the authorization at jetpack.wordpress.com.
*
* @since 1.7.0
* @since-jetpack 4.3.3
*
* @param string $auth_type Defaults to 'calypso', can also be 'jetpack'.
*/
$auth_type = apply_filters( 'jetpack_auth_type', 'calypso' );
/**
* Filters the user connection request data for additional property addition.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param array $request_data request data.
*/
$body = apply_filters(
'jetpack_connect_request_body',
array(
'response_type' => 'code',
'client_id' => \Jetpack_Options::get_option( 'id' ),
'redirect_uri' => add_query_arg(
array(
'handler' => 'jetpack-connection-webhooks',
'action' => 'authorize',
'_wpnonce' => wp_create_nonce( "jetpack-authorize_{$role}_{$redirect}" ),
'redirect' => $redirect ? rawurlencode( $redirect ) : false,
),
esc_url( $processing_url )
),
'state' => $user->ID,
'scope' => $signed_role,
'user_email' => $user->user_email,
'user_login' => $user->user_login,
'is_active' => $this->has_connected_owner(), // TODO Deprecate this.
'jp_version' => (string) Constants::get_constant( 'JETPACK__VERSION' ),
'auth_type' => $auth_type,
'secret' => $secrets['secret_1'],
'blogname' => get_option( 'blogname' ),
'site_url' => Urls::site_url(),
'home_url' => Urls::home_url(),
'site_icon' => get_site_icon_url(),
'site_lang' => get_locale(),
'site_created' => $this->get_assumed_site_creation_date(),
'allow_site_connection' => ! $this->has_connected_owner(),
)
);
$body = $this->apply_activation_source_to_args( urlencode_deep( $body ) );
$api_url = $this->api_url( 'authorize' );
return add_query_arg( $body, $api_url );
}
/**
* Authorizes the user by obtaining and storing the user token.
*
* @param array $data The request data.
* @return string|\WP_Error Returns a string on success.
* Returns a \WP_Error on failure.
*/
public function authorize( $data = array() ) {
/**
* Action fired when user authorization starts.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*/
do_action( 'jetpack_authorize_starting' );
$roles = new Roles();
$role = $roles->translate_current_user_to_role();
if ( ! $role ) {
return new \WP_Error( 'no_role', 'Invalid request.', 400 );
}
$cap = $roles->translate_role_to_cap( $role );
if ( ! $cap ) {
return new \WP_Error( 'no_cap', 'Invalid request.', 400 );
}
if ( ! empty( $data['error'] ) ) {
return new \WP_Error( $data['error'], 'Error included in the request.', 400 );
}
if ( ! isset( $data['state'] ) ) {
return new \WP_Error( 'no_state', 'Request must include state.', 400 );
}
if ( ! ctype_digit( $data['state'] ) ) {
return new \WP_Error( $data['error'], 'State must be an integer.', 400 );
}
$current_user_id = get_current_user_id();
if ( $current_user_id !== (int) $data['state'] ) {
return new \WP_Error( 'wrong_state', 'State does not match current user.', 400 );
}
if ( empty( $data['code'] ) ) {
return new \WP_Error( 'no_code', 'Request must include an authorization code.', 400 );
}
$token = $this->get_tokens()->get( $data, $this->api_url( 'token' ) );
if ( is_wp_error( $token ) ) {
$code = $token->get_error_code();
if ( empty( $code ) ) {
$code = 'invalid_token';
}
return new \WP_Error( $code, $token->get_error_message(), 400 );
}
if ( ! $token ) {
return new \WP_Error( 'no_token', 'Error generating token.', 400 );
}
$is_connection_owner = ! $this->has_connected_owner();
$this->get_tokens()->update_user_token( $current_user_id, sprintf( '%s.%d', $token, $current_user_id ), $is_connection_owner );
/**
* Fires after user has successfully received an auth token.
*
* @since 1.7.0
* @since-jetpack 3.9.0
*/
do_action( 'jetpack_user_authorized' );
if ( ! $is_connection_owner ) {
/**
* Action fired when a secondary user has been authorized.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*/
do_action( 'jetpack_authorize_ending_linked' );
return 'linked';
}
/**
* Action fired when the master user has been authorized.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param array $data The request data.
*/
do_action( 'jetpack_authorize_ending_authorized', $data );
\Jetpack_Options::delete_raw_option( 'jetpack_last_connect_url_check' );
( new Nonce_Handler() )->reschedule();
return 'authorized';
}
/**
* Disconnects from the Jetpack servers.
* Forgets all connection details and tells the Jetpack servers to do the same.
*
* @param boolean $disconnect_wpcom Should disconnect_site_wpcom be called.
* @param bool $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
*/
public function disconnect_site( $disconnect_wpcom = true, $ignore_connected_plugins = true ) {
if ( ! $ignore_connected_plugins && null !== $this->plugin && ! $this->plugin->is_only() ) {
return false;
}
wp_clear_scheduled_hook( 'jetpack_clean_nonces' );
( new Nonce_Handler() )->clean_all();
/**
* Fires when a site is disconnected.
*
* @since 1.36.3
*/
do_action( 'jetpack_site_before_disconnected' );
// If the site is in an IDC because sync is not allowed,
// let's make sure to not disconnect the production site.
if ( $disconnect_wpcom ) {
$tracking = new Tracking();
$tracking->record_user_event( 'disconnect_site', array() );
$this->disconnect_site_wpcom( $ignore_connected_plugins );
}
$this->delete_all_connection_tokens( $ignore_connected_plugins );
// Remove tracked package versions, since they depend on the Jetpack Connection.
delete_option( Package_Version_Tracker::PACKAGE_VERSION_OPTION );
$jetpack_unique_connection = \Jetpack_Options::get_option( 'unique_connection' );
if ( $jetpack_unique_connection ) {
// Check then record unique disconnection if site has never been disconnected previously.
if ( - 1 === $jetpack_unique_connection['disconnected'] ) {
$jetpack_unique_connection['disconnected'] = 1;
} else {
if ( 0 === $jetpack_unique_connection['disconnected'] ) {
$a8c_mc_stats_instance = new A8c_Mc_Stats();
$a8c_mc_stats_instance->add( 'connections', 'unique-disconnect' );
$a8c_mc_stats_instance->do_server_side_stats();
}
// increment number of times disconnected.
$jetpack_unique_connection['disconnected'] += 1;
}
\Jetpack_Options::update_option( 'unique_connection', $jetpack_unique_connection );
}
/**
* Fires when a site is disconnected.
*
* @since 1.30.1
*/
do_action( 'jetpack_site_disconnected' );
}
/**
* The Base64 Encoding of the SHA1 Hash of the Input.
*
* @param string $text The string to hash.
* @return string
*/
public function sha1_base64( $text ) {
return base64_encode( sha1( $text, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* This function mirrors Jetpack_Data::is_usable_domain() in the WPCOM codebase.
*
* @param string $domain The domain to check.
*
* @return bool|WP_Error
*/
public function is_usable_domain( $domain ) {
// If it's empty, just fail out.
if ( ! $domain ) {
return new \WP_Error(
'fail_domain_empty',
/* translators: %1$s is a domain name. */
sprintf( __( 'Domain `%1$s` just failed is_usable_domain check as it is empty.', 'jetpack-connection' ), $domain )
);
}
/**
* Skips the usuable domain check when connecting a site.
*
* Allows site administrators with domains that fail gethostname-based checks to pass the request to WP.com
*
* @since 1.7.0
* @since-jetpack 4.1.0
*
* @param bool If the check should be skipped. Default false.
*/
if ( apply_filters( 'jetpack_skip_usuable_domain_check', false ) ) {
return true;
}
// None of the explicit localhosts.
$forbidden_domains = array(
'wordpress.com',
'localhost',
'localhost.localdomain',
'127.0.0.1',
'local.wordpress.test', // VVV pattern.
'local.wordpress-trunk.test', // VVV pattern.
'src.wordpress-develop.test', // VVV pattern.
'build.wordpress-develop.test', // VVV pattern.
);
if ( in_array( $domain, $forbidden_domains, true ) ) {
return new \WP_Error(
'fail_domain_forbidden',
sprintf(
/* translators: %1$s is a domain name. */
__(
'Domain `%1$s` just failed is_usable_domain check as it is in the forbidden array.',
'jetpack-connection'
),
$domain
)
);
}
// No .test or .local domains.
if ( preg_match( '#\.(test|local)$#i', $domain ) ) {
return new \WP_Error(
'fail_domain_tld',
sprintf(
/* translators: %1$s is a domain name. */
__(
'Domain `%1$s` just failed is_usable_domain check as it uses an invalid top level domain.',
'jetpack-connection'
),
$domain
)
);
}
// No WPCOM subdomains.
if ( preg_match( '#\.WordPress\.com$#i', $domain ) ) {
return new \WP_Error(
'fail_subdomain_wpcom',
sprintf(
/* translators: %1$s is a domain name. */
__(
'Domain `%1$s` just failed is_usable_domain check as it is a subdomain of WordPress.com.',
'jetpack-connection'
),
$domain
)
);
}
// If PHP was compiled without support for the Filter module (very edge case).
if ( ! function_exists( 'filter_var' ) ) {
// Just pass back true for now, and let wpcom sort it out.
return true;
}
return true;
}
/**
* Gets the requested token.
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->get_access_token() instead.
*
* @param int|false $user_id false: Return the Blog Token. int: Return that user's User Token.
* @param string|false $token_key If provided, check that the token matches the provided input.
* @param bool|true $suppress_errors If true, return a falsy value when the token isn't found; When false, return a descriptive WP_Error when the token isn't found.
*
* @return object|false
*
* @see $this->get_tokens()->get_access_token()
*/
public function get_access_token( $user_id = false, $token_key = false, $suppress_errors = true ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->get_access_token' );
return $this->get_tokens()->get_access_token( $user_id, $token_key, $suppress_errors );
}
/**
* In some setups, $HTTP_RAW_POST_DATA can be emptied during some IXR_Server paths
* since it is passed by reference to various methods.
* Capture it here so we can verify the signature later.
*
* @param array $methods an array of available XMLRPC methods.
* @return array the same array, since this method doesn't add or remove anything.
*/
public function xmlrpc_methods( $methods ) {
$this->raw_post_data = isset( $GLOBALS['HTTP_RAW_POST_DATA'] ) ? $GLOBALS['HTTP_RAW_POST_DATA'] : null;
return $methods;
}
/**
* Resets the raw post data parameter for testing purposes.
*/
public function reset_raw_post_data() {
$this->raw_post_data = null;
}
/**
* Registering an additional method.
*
* @param array $methods an array of available XMLRPC methods.
* @return array the amended array in case the method is added.
*/
public function public_xmlrpc_methods( $methods ) {
if ( array_key_exists( 'wp.getOptions', $methods ) ) {
$methods['wp.getOptions'] = array( $this, 'jetpack_get_options' );
}
return $methods;
}
/**
* Handles a getOptions XMLRPC method call.
*
* @param array $args method call arguments.
* @return an amended XMLRPC server options array.
*/
public function jetpack_get_options( $args ) {
global $wp_xmlrpc_server;
$wp_xmlrpc_server->escape( $args );
$username = $args[1];
$password = $args[2];
$user = $wp_xmlrpc_server->login( $username, $password );
if ( ! $user ) {
return $wp_xmlrpc_server->error;
}
$options = array();
$user_data = $this->get_connected_user_data();
if ( is_array( $user_data ) ) {
$options['jetpack_user_id'] = array(
'desc' => __( 'The WP.com user ID of the connected user', 'jetpack-connection' ),
'readonly' => true,
'value' => $user_data['ID'],
);
$options['jetpack_user_login'] = array(
'desc' => __( 'The WP.com username of the connected user', 'jetpack-connection' ),
'readonly' => true,
'value' => $user_data['login'],
);
$options['jetpack_user_email'] = array(
'desc' => __( 'The WP.com user email of the connected user', 'jetpack-connection' ),
'readonly' => true,
'value' => $user_data['email'],
);
$options['jetpack_user_site_count'] = array(
'desc' => __( 'The number of sites of the connected WP.com user', 'jetpack-connection' ),
'readonly' => true,
'value' => $user_data['site_count'],
);
}
$wp_xmlrpc_server->blog_options = array_merge( $wp_xmlrpc_server->blog_options, $options );
$args = stripslashes_deep( $args );
return $wp_xmlrpc_server->wp_getOptions( $args );
}
/**
* Adds Jetpack-specific options to the output of the XMLRPC options method.
*
* @param array $options standard Core options.
* @return array amended options.
*/
public function xmlrpc_options( $options ) {
$jetpack_client_id = false;
if ( $this->is_connected() ) {
$jetpack_client_id = \Jetpack_Options::get_option( 'id' );
}
$options['jetpack_version'] = array(
'desc' => __( 'Jetpack Plugin Version', 'jetpack-connection' ),
'readonly' => true,
'value' => Constants::get_constant( 'JETPACK__VERSION' ),
);
$options['jetpack_client_id'] = array(
'desc' => __( 'The Client ID/WP.com Blog ID of this site', 'jetpack-connection' ),
'readonly' => true,
'value' => $jetpack_client_id,
);
return $options;
}
/**
* Resets the saved authentication state in between testing requests.
*/
public function reset_saved_auth_state() {
$this->xmlrpc_verification = null;
}
/**
* Sign a user role with the master access token.
* If not specified, will default to the current user.
*
* @access public
*
* @param string $role User role.
* @param int $user_id ID of the user.
* @return string Signed user role.
*/
public function sign_role( $role, $user_id = null ) {
return $this->get_tokens()->sign_role( $role, $user_id );
}
/**
* Set the plugin instance.
*
* @param Plugin $plugin_instance The plugin instance.
*
* @return $this
*/
public function set_plugin_instance( Plugin $plugin_instance ) {
$this->plugin = $plugin_instance;
return $this;
}
/**
* Retrieve the plugin management object.
*
* @return Plugin|null
*/
public function get_plugin() {
return $this->plugin;
}
/**
* Get all connected plugins information, excluding those disconnected by user.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @return array|WP_Error
*/
public function get_connected_plugins() {
$maybe_plugins = Plugin_Storage::get_all();
if ( $maybe_plugins instanceof WP_Error ) {
return $maybe_plugins;
}
return $maybe_plugins;
}
/**
* Force plugin disconnect. After its called, the plugin will not be allowed to use the connection.
* Note: this method does not remove any access tokens.
*
* @deprecated since 1.39.0
* @return bool
*/
public function disable_plugin() {
return null;
}
/**
* Force plugin reconnect after user-initiated disconnect.
* After its called, the plugin will be allowed to use the connection again.
* Note: this method does not initialize access tokens.
*
* @deprecated since 1.39.0.
* @return bool
*/
public function enable_plugin() {
return null;
}
/**
* Whether the plugin is allowed to use the connection, or it's been disconnected by user.
* If no plugin slug was passed into the constructor, always returns true.
*
* @deprecated 1.42.0 This method no longer has a purpose after the removal of the soft disconnect feature.
*
* @return bool
*/
public function is_plugin_enabled() {
return true;
}
/**
* Perform the API request to refresh the blog token.
* Note that we are making this request on behalf of the Jetpack master user,
* given they were (most probably) the ones that registered the site at the first place.
*
* @return WP_Error|bool The result of updating the blog_token option.
*/
public function refresh_blog_token() {
( new Tracking() )->record_user_event( 'restore_connection_refresh_blog_token' );
$blog_id = \Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-refresh-blog-token'
);
$method = 'POST';
$user_id = get_current_user_id();
$response = Client::remote_request( compact( 'url', 'method', 'user_id' ) );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'refresh_blog_token_http_request_failed', $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$entity = wp_remote_retrieve_body( $response );
if ( $entity ) {
$json = json_decode( $entity );
} else {
$json = false;
}
if ( 200 !== $code ) {
if ( empty( $json->code ) ) {
return new WP_Error( 'unknown', '', $code );
}
/* translators: Error description string. */
$error_description = isset( $json->message ) ? sprintf( __( 'Error Details: %s', 'jetpack-connection' ), (string) $json->message ) : '';
return new WP_Error( (string) $json->code, $error_description, $code );
}
if ( empty( $json->jetpack_secret ) || ! is_scalar( $json->jetpack_secret ) ) {
return new WP_Error( 'jetpack_secret', '', $code );
}
Error_Handler::get_instance()->delete_all_errors();
return $this->get_tokens()->update_blog_token( (string) $json->jetpack_secret );
}
/**
* Disconnect the user from WP.com, and initiate the reconnect process.
*
* @return bool
*/
public function refresh_user_token() {
( new Tracking() )->record_user_event( 'restore_connection_refresh_user_token' );
$this->disconnect_user( null, true, true );
return true;
}
/**
* Fetches a signed token.
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->get_signed_token() instead.
*
* @param object $token the token.
* @return WP_Error|string a signed token
*/
public function get_signed_token( $token ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->get_signed_token' );
return $this->get_tokens()->get_signed_token( $token );
}
/**
* If the site-level connection is active, add the list of plugins using connection to the heartbeat (except Jetpack itself)
*
* @param array $stats The Heartbeat stats array.
* @return array $stats
*/
public function add_stats_to_heartbeat( $stats ) {
if ( ! $this->is_connected() ) {
return $stats;
}
$active_plugins_using_connection = Plugin_Storage::get_all();
foreach ( array_keys( $active_plugins_using_connection ) as $plugin_slug ) {
if ( 'jetpack' !== $plugin_slug ) {
$stats_group = isset( $active_plugins_using_connection['jetpack'] ) ? 'combined-connection' : 'standalone-connection';
$stats[ $stats_group ][] = $plugin_slug;
}
}
return $stats;
}
/**
* Get the WPCOM or self-hosted site ID.
*
* @return int|WP_Error
*/
public static function get_site_id() {
$is_wpcom = ( defined( 'IS_WPCOM' ) && IS_WPCOM );
$site_id = $is_wpcom ? get_current_blog_id() : \Jetpack_Options::get_option( 'id' );
if ( ! $site_id ) {
return new \WP_Error(
'unavailable_site_id',
__( 'Sorry, something is wrong with your Jetpack connection.', 'jetpack-connection' ),
403
);
}
return (int) $site_id;
}
}
class-nonce-handler.php 0000644 00000013264 15154644771 0011121 0 ustar 00 <?php
/**
* The nonce handler.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The nonce handler.
*/
class Nonce_Handler {
/**
* How long the scheduled cleanup can run (in seconds).
* Can be modified using the filter `jetpack_connection_nonce_scheduled_cleanup_limit`.
*/
const SCHEDULED_CLEANUP_TIME_LIMIT = 5;
/**
* How many nonces should be removed per batch during the `clean_all()` run.
*/
const CLEAN_ALL_LIMIT_PER_BATCH = 1000;
/**
* Nonce lifetime in seconds.
*/
const LIFETIME = HOUR_IN_SECONDS;
/**
* The nonces used during the request are stored here to keep them valid.
* The property is static to keep the nonces accessible between the `Nonce_Handler` instances.
*
* @var array
*/
private static $nonces_used_this_request = array();
/**
* The database object.
*
* @var \wpdb
*/
private $db;
/**
* Initializing the object.
*/
public function __construct() {
global $wpdb;
$this->db = $wpdb;
}
/**
* Scheduling the WP-cron cleanup event.
*/
public function init_schedule() {
add_action( 'jetpack_clean_nonces', array( __CLASS__, 'clean_scheduled' ) );
if ( ! wp_next_scheduled( 'jetpack_clean_nonces' ) ) {
wp_schedule_event( time(), 'hourly', 'jetpack_clean_nonces' );
}
}
/**
* Reschedule the WP-cron cleanup event to make it start sooner.
*/
public function reschedule() {
wp_clear_scheduled_hook( 'jetpack_clean_nonces' );
wp_schedule_event( time(), 'hourly', 'jetpack_clean_nonces' );
}
/**
* Adds a used nonce to a list of known nonces.
*
* @param int $timestamp the current request timestamp.
* @param string $nonce the nonce value.
*
* @return bool whether the nonce is unique or not.
*/
public function add( $timestamp, $nonce ) {
if ( isset( static::$nonces_used_this_request[ "$timestamp:$nonce" ] ) ) {
return static::$nonces_used_this_request[ "$timestamp:$nonce" ];
}
// This should always have gone through Jetpack_Signature::sign_request() first to check $timestamp and $nonce.
$timestamp = (int) $timestamp;
$nonce = esc_sql( $nonce );
// Raw query so we can avoid races: add_option will also update.
$show_errors = $this->db->hide_errors();
// Running `try...finally` to make sure that we re-enable errors in case of an exception.
try {
$old_nonce = $this->db->get_row(
$this->db->prepare( "SELECT 1 FROM `{$this->db->options}` WHERE option_name = %s", "jetpack_nonce_{$timestamp}_{$nonce}" )
);
if ( $old_nonce === null ) {
$return = (bool) $this->db->query(
$this->db->prepare(
"INSERT INTO `{$this->db->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s)",
"jetpack_nonce_{$timestamp}_{$nonce}",
time(),
'no'
)
);
} else {
$return = false;
}
} finally {
$this->db->show_errors( $show_errors );
}
static::$nonces_used_this_request[ "$timestamp:$nonce" ] = $return;
return $return;
}
/**
* Removing all existing nonces, or at least as many as possible.
* Capped at 20 seconds to avoid breaking the site.
*
* @param int $cutoff_timestamp All nonces added before this timestamp will be removed.
* @param int $time_limit How long the cleanup can run (in seconds).
*
* @return true
*/
public function clean_all( $cutoff_timestamp = PHP_INT_MAX, $time_limit = 20 ) {
// phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
for ( $end_time = time() + $time_limit; time() < $end_time; ) {
$result = $this->delete( static::CLEAN_ALL_LIMIT_PER_BATCH, $cutoff_timestamp );
if ( ! $result ) {
break;
}
}
return true;
}
/**
* Scheduled clean up of the expired nonces.
*/
public static function clean_scheduled() {
/**
* Adjust the time limit for the scheduled cleanup.
*
* @since 9.5.0
*
* @param int $time_limit How long the cleanup can run (in seconds).
*/
$time_limit = apply_filters( 'jetpack_connection_nonce_cleanup_runtime_limit', static::SCHEDULED_CLEANUP_TIME_LIMIT );
( new static() )->clean_all( time() - static::LIFETIME, $time_limit );
}
/**
* Delete the nonces.
*
* @param int $limit How many nonces to delete.
* @param null|int $cutoff_timestamp All nonces added before this timestamp will be removed.
*
* @return int|false Number of removed nonces, or `false` if nothing to remove (or in case of a database error).
*/
public function delete( $limit = 10, $cutoff_timestamp = null ) {
global $wpdb;
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_id FROM `{$wpdb->options}`"
. " WHERE `option_name` >= 'jetpack_nonce_' AND `option_name` < %s"
. ' LIMIT %d',
'jetpack_nonce_' . $cutoff_timestamp,
$limit
)
);
if ( ! is_array( $ids ) ) {
// There's an error and we can't proceed.
return false;
}
// Removing zeroes in case AUTO_INCREMENT of the options table is broken, and all ID's are zeroes.
$ids = array_filter( $ids );
if ( ! count( $ids ) ) {
// There's nothing to remove.
return false;
}
$ids_fill = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$args = $ids;
$args[] = 'jetpack_nonce_%';
// The Code Sniffer is unable to understand what's going on...
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return $wpdb->query( $wpdb->prepare( "DELETE FROM `{$wpdb->options}` WHERE `option_id` IN ( {$ids_fill} ) AND option_name LIKE %s", $args ) );
}
/**
* Clean the cached nonces valid during the current request, therefore making them invalid.
*
* @return bool
*/
public static function invalidate_request_nonces() {
static::$nonces_used_this_request = array();
return true;
}
}
class-package-version-tracker.php 0000644 00000006277 15154644771 0013121 0 ustar 00 <?php
/**
* The Package_Version_Tracker class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Package_Version_Tracker class.
*/
class Package_Version_Tracker {
const PACKAGE_VERSION_OPTION = 'jetpack_package_versions';
/**
* The cache key for storing a failed request to update remote package versions.
* The caching logic is that when a failed request occurs, we cache it temporarily
* with a set expiration time.
* Only after the key has expired, we'll be able to repeat a remote request.
* This also implies that the cached value is redundant, however we chose the datetime
* of the failed request to avoid using booleans.
*/
const CACHED_FAILED_REQUEST_KEY = 'jetpack_failed_update_remote_package_versions';
/**
* The min time difference in seconds for attempting to
* update remote tracked package versions after a failed remote request.
*/
const CACHED_FAILED_REQUEST_EXPIRATION = 1 * HOUR_IN_SECONDS;
/**
* Uses the jetpack_package_versions filter to obtain the package versions from packages that need
* version tracking. If the package versions have changed, updates the option and notifies WPCOM.
*/
public function maybe_update_package_versions() {
/**
* Obtains the package versions.
*
* @since 1.30.2
*
* @param array An associative array of Jetpack package slugs and their corresponding versions as key/value pairs.
*/
$filter_versions = apply_filters( 'jetpack_package_versions', array() );
if ( ! is_array( $filter_versions ) ) {
return;
}
$option_versions = get_option( self::PACKAGE_VERSION_OPTION, array() );
foreach ( $filter_versions as $package => $version ) {
if ( ! is_string( $package ) || ! is_string( $version ) ) {
unset( $filter_versions[ $package ] );
}
}
if ( ! is_array( $option_versions )
|| count( array_diff_assoc( $filter_versions, $option_versions ) )
|| count( array_diff_assoc( $option_versions, $filter_versions ) )
) {
$this->update_package_versions_option( $filter_versions );
}
}
/**
* Updates the package versions:
* - Sends the updated package versions to wpcom.
* - Updates the 'jetpack_package_versions' option.
*
* @param array $package_versions The package versions.
*/
protected function update_package_versions_option( $package_versions ) {
$connection = new Manager();
if ( ! $connection->is_connected() ) {
return;
}
$site_id = \Jetpack_Options::get_option( 'id' );
$last_failed_attempt_within_hour = get_transient( self::CACHED_FAILED_REQUEST_KEY );
if ( $last_failed_attempt_within_hour ) {
return;
}
$body = wp_json_encode(
array(
'package_versions' => $package_versions,
)
);
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-package-versions', $site_id ),
'2',
array(
'headers' => array( 'content-type' => 'application/json' ),
'method' => 'POST',
),
$body,
'wpcom'
);
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
update_option( self::PACKAGE_VERSION_OPTION, $package_versions );
} else {
set_transient( self::CACHED_FAILED_REQUEST_KEY, time(), self::CACHED_FAILED_REQUEST_EXPIRATION );
}
}
}
class-package-version.php 0000644 00000001210 15154644771 0011446 0 ustar 00 <?php
/**
* The Package_Version class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Package_Version class.
*/
class Package_Version {
const PACKAGE_VERSION = '1.51.7';
const PACKAGE_SLUG = 'connection';
/**
* Adds the package slug and version to the package version tracker's data.
*
* @param array $package_versions The package version array.
*
* @return array The packge version array.
*/
public static function send_package_version_to_tracker( $package_versions ) {
$package_versions[ self::PACKAGE_SLUG ] = self::PACKAGE_VERSION;
return $package_versions;
}
}
class-plugin-storage.php 0000644 00000016313 15154644771 0011342 0 ustar 00 <?php
/**
* Storage for plugin connection information.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use WP_Error;
/**
* The class serves a single purpose - to store the data which plugins use the connection, along with some auxiliary information.
*/
class Plugin_Storage {
const ACTIVE_PLUGINS_OPTION_NAME = 'jetpack_connection_active_plugins';
/**
* Options where disabled plugins were stored
*
* @deprecated since 1.39.0.
* @var string
*/
const PLUGINS_DISABLED_OPTION_NAME = 'jetpack_connection_disabled_plugins';
/**
* Whether this class was configured for the first time or not.
*
* @var boolean
*/
private static $configured = false;
/**
* Refresh list of connected plugins upon intialization.
*
* @var boolean
*/
private static $refresh_connected_plugins = false;
/**
* Connected plugins.
*
* @var array
*/
private static $plugins = array();
/**
* The blog ID the storage is setup for.
* The data will be refreshed if the blog ID changes.
* Used for the multisite networks.
*
* @var int
*/
private static $current_blog_id = null;
/**
* Add or update the plugin information in the storage.
*
* @param string $slug Plugin slug.
* @param array $args Plugin arguments, optional.
*
* @return bool
*/
public static function upsert( $slug, array $args = array() ) {
self::$plugins[ $slug ] = $args;
// if plugin is not in the list of active plugins, refresh the list.
if ( ! array_key_exists( $slug, (array) get_option( self::ACTIVE_PLUGINS_OPTION_NAME, array() ) ) ) {
self::$refresh_connected_plugins = true;
}
return true;
}
/**
* Retrieve the plugin information by slug.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @param string $slug The plugin slug.
*
* @return array|null|WP_Error
*/
public static function get_one( $slug ) {
$plugins = self::get_all();
if ( $plugins instanceof WP_Error ) {
return $plugins;
}
return empty( $plugins[ $slug ] ) ? null : $plugins[ $slug ];
}
/**
* Retrieve info for all plugins that use the connection.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @since 1.39.0 deprecated the $connected_only argument.
*
* @param null $deprecated null plugins that were explicitly disconnected. Deprecated, there's no such a thing as disconnecting only specific plugins anymore.
*
* @return array|WP_Error
*/
public static function get_all( $deprecated = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$maybe_error = self::ensure_configured();
if ( $maybe_error instanceof WP_Error ) {
return $maybe_error;
}
return self::$plugins;
}
/**
* Remove the plugin connection info from Jetpack.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @param string $slug The plugin slug.
*
* @return bool|WP_Error
*/
public static function delete( $slug ) {
$maybe_error = self::ensure_configured();
if ( $maybe_error instanceof WP_Error ) {
return $maybe_error;
}
if ( array_key_exists( $slug, self::$plugins ) ) {
unset( self::$plugins[ $slug ] );
}
return true;
}
/**
* The method makes sure that `Jetpack\Config` has finished, and it's now safe to retrieve the list of plugins.
*
* @return bool|WP_Error
*/
private static function ensure_configured() {
if ( ! self::$configured ) {
return new WP_Error( 'too_early', __( 'You cannot call this method until Jetpack Config is configured', 'jetpack-connection' ) );
}
if ( is_multisite() && get_current_blog_id() !== self::$current_blog_id ) {
self::$plugins = (array) get_option( self::ACTIVE_PLUGINS_OPTION_NAME, array() );
self::$current_blog_id = get_current_blog_id();
}
return true;
}
/**
* Called once to configure this class after plugins_loaded.
*
* @return void
*/
public static function configure() {
if ( self::$configured ) {
return;
}
if ( is_multisite() ) {
self::$current_blog_id = get_current_blog_id();
}
// If a plugin was activated or deactivated.
// self::$plugins is populated in Config::ensure_options_connection().
$number_of_plugins_differ = count( self::$plugins ) !== count( (array) get_option( self::ACTIVE_PLUGINS_OPTION_NAME, array() ) );
if ( $number_of_plugins_differ || true === self::$refresh_connected_plugins ) {
self::update_active_plugins_option();
}
self::$configured = true;
}
/**
* Updates the active plugins option with current list of active plugins.
*
* @return void
*/
public static function update_active_plugins_option() {
// Note: Since this options is synced to wpcom, if you change its structure, you have to update the sanitizer at wpcom side.
update_option( self::ACTIVE_PLUGINS_OPTION_NAME, self::$plugins );
if ( ! class_exists( 'Automattic\Jetpack\Sync\Settings' ) || ! \Automattic\Jetpack\Sync\Settings::is_sync_enabled() ) {
self::update_active_plugins_wpcom_no_sync_fallback();
}
}
/**
* Add the plugin to the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @param string $slug Plugin slug.
*
* @return bool
*/
public static function disable_plugin( $slug ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return true;
}
/**
* Remove the plugin from the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @param string $slug Plugin slug.
*
* @return bool
*/
public static function enable_plugin( $slug ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return true;
}
/**
* Get all plugins that were disconnected by user.
*
* @deprecated since 1.39.0.
*
* @return array
*/
public static function get_all_disabled_plugins() { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return array();
}
/**
* Update active plugins option with current list of active plugins on WPCOM.
* This is a fallback to ensure this option is always up to date on WPCOM in case
* Sync is not present or disabled.
*
* @since 1.34.0
*/
private static function update_active_plugins_wpcom_no_sync_fallback() {
$connection = new Manager();
if ( ! $connection->is_connected() ) {
return;
}
$site_id = \Jetpack_Options::get_option( 'id' );
$body = wp_json_encode(
array(
'active_connected_plugins' => self::$plugins,
)
);
Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-active-connected-plugins', $site_id ),
'2',
array(
'headers' => array( 'content-type' => 'application/json' ),
'method' => 'POST',
),
$body,
'wpcom'
);
}
}
class-plugin.php 0000644 00000004516 15154644771 0007702 0 ustar 00 <?php
/**
* Plugin connection management class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* Plugin connection management class.
* The class represents a single plugin that uses Jetpack connection.
* Its functionality has been pretty simplistic so far: add to the storage (`Plugin_Storage`), remove it from there,
* and determine whether it's the last active connection. As the component grows, there'll be more functionality added.
*/
class Plugin {
/**
* List of the keys allowed as arguments
*
* @var array
*/
private $arguments_whitelist = array(
'url_info',
);
/**
* Plugin slug.
*
* @var string
*/
private $slug;
/**
* Initialize the plugin manager.
*
* @param string $slug Plugin slug.
*/
public function __construct( $slug ) {
$this->slug = $slug;
}
/**
* Get the plugin slug.
*
* @return string
*/
public function get_slug() {
return $this->slug;
}
/**
* Add the plugin connection info into Jetpack.
*
* @param string $name Plugin name, required.
* @param array $args Plugin arguments, optional.
*
* @return $this
* @see $this->arguments_whitelist
*/
public function add( $name, array $args = array() ) {
$args = compact( 'name' ) + array_intersect_key( $args, array_flip( $this->arguments_whitelist ) );
Plugin_Storage::upsert( $this->slug, $args );
return $this;
}
/**
* Remove the plugin connection info from Jetpack.
*
* @return $this
*/
public function remove() {
Plugin_Storage::delete( $this->slug );
return $this;
}
/**
* Determine if this plugin connection is the only one active at the moment, if any.
*
* @return bool
*/
public function is_only() {
$plugins = Plugin_Storage::get_all();
return ! $plugins || ( array_key_exists( $this->slug, $plugins ) && 1 === count( $plugins ) );
}
/**
* Add the plugin to the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @return bool
*/
public function disable() {
return true;
}
/**
* Remove the plugin from the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @return bool
*/
public function enable() {
return true;
}
/**
* Whether this plugin is allowed to use the connection.
*
* @deprecated since 11.0
* @return bool
*/
public function is_enabled() {
return true;
}
}
class-rest-authentication.php 0000644 00000013436 15154644771 0012377 0 ustar 00 <?php
/**
* The Jetpack Connection Rest Authentication file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Jetpack Connection Rest Authentication class.
*/
class Rest_Authentication {
/**
* The rest authentication status.
*
* @since 1.17.0
* @var boolean
*/
private $rest_authentication_status = null;
/**
* The rest authentication type.
* Can be either 'user' or 'blog' depending on whether the request
* is signed with a user or a blog token.
*
* @since 1.29.0
* @var string
*/
private $rest_authentication_type = null;
/**
* The Manager object.
*
* @since 1.17.0
* @var Object
*/
private $connection_manager = null;
/**
* Holds the singleton instance of this class
*
* @since 1.17.0
* @var Object
*/
private static $instance = false;
/**
* Flag used to avoid determine_current_user filter to enter an infinite loop
*
* @since 1.26.0
* @var boolean
*/
private $doing_determine_current_user_filter = false;
/**
* The constructor.
*/
private function __construct() {
$this->connection_manager = new Manager();
}
/**
* Controls the single instance of this class.
*
* @static
*/
public static function init() {
if ( ! self::$instance ) {
self::$instance = new self();
add_filter( 'determine_current_user', array( self::$instance, 'wp_rest_authenticate' ) );
add_filter( 'rest_authentication_errors', array( self::$instance, 'wp_rest_authentication_errors' ) );
}
return self::$instance;
}
/**
* Authenticates requests from Jetpack server to WP REST API endpoints.
* Uses the existing XMLRPC request signing implementation.
*
* @param int|bool $user User ID if one has been determined, false otherwise.
*
* @return int|null The user id or null if the request was authenticated via blog token, or not authenticated at all.
*/
public function wp_rest_authenticate( $user ) {
if ( $this->doing_determine_current_user_filter ) {
return $user;
}
$this->doing_determine_current_user_filter = true;
try {
if ( ! empty( $user ) ) {
// Another authentication method is in effect.
return $user;
}
add_filter(
'jetpack_constant_default_value',
__NAMESPACE__ . '\Utils::jetpack_api_constant_filter',
10,
2
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['_for'] ) || 'jetpack' !== $_GET['_for'] ) {
// Nothing to do for this authentication method.
return null;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['token'] ) && ! isset( $_GET['signature'] ) ) {
// Nothing to do for this authentication method.
return null;
}
if ( ! isset( $_SERVER['REQUEST_METHOD'] ) ) {
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_request',
__( 'The request method is missing.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
}
// Only support specific request parameters that have been tested and
// are known to work with signature verification. A different method
// can be passed to the WP REST API via the '?_method=' parameter if
// needed.
if ( 'GET' !== $_SERVER['REQUEST_METHOD'] && 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_request',
__( 'This request method is not supported.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
}
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] && ! empty( file_get_contents( 'php://input' ) ) ) {
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_request',
__( 'This request method does not support body parameters.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
}
$verified = $this->connection_manager->verify_xml_rpc_signature();
if (
$verified &&
isset( $verified['type'] ) &&
'blog' === $verified['type']
) {
// Site-level authentication successful.
$this->rest_authentication_status = true;
$this->rest_authentication_type = 'blog';
return null;
}
if (
$verified &&
isset( $verified['type'] ) &&
'user' === $verified['type'] &&
! empty( $verified['user_id'] )
) {
// User-level authentication successful.
$this->rest_authentication_status = true;
$this->rest_authentication_type = 'user';
return $verified['user_id'];
}
// Something else went wrong. Probably a signature error.
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_signature',
__( 'The request is not signed correctly.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
} finally {
$this->doing_determine_current_user_filter = false;
}
}
/**
* Report authentication status to the WP REST API.
*
* @param WP_Error|mixed $value Error from another authentication handler, null if we should handle it, or another value if not.
* @return WP_Error|boolean|null {@see WP_JSON_Server::check_authentication}
*/
public function wp_rest_authentication_errors( $value ) {
if ( null !== $value ) {
return $value;
}
return $this->rest_authentication_status;
}
/**
* Resets the saved authentication state in between testing requests.
*/
public function reset_saved_auth_state() {
$this->rest_authentication_status = null;
$this->connection_manager->reset_saved_auth_state();
}
/**
* Whether the request was signed with a blog token.
*
* @since 1.29.0
*
* @return bool True if the request was signed with a valid blog token, false otherwise.
*/
public static function is_signed_with_blog_token() {
$instance = self::init();
return true === $instance->rest_authentication_status && 'blog' === $instance->rest_authentication_type;
}
}
class-rest-connector.php 0000644 00000062424 15154644771 0011353 0 ustar 00 <?php
/**
* Sets up the Connection REST API endpoints.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Jetpack_XMLRPC_Server;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Registers the REST routes for Connections.
*/
class REST_Connector {
/**
* The Connection Manager.
*
* @var Manager
*/
private $connection;
/**
* This property stores the localized "Insufficient Permissions" error message.
*
* @var string Generic error message when user is not allowed to perform an action.
*/
private static $user_permissions_error_msg;
const JETPACK__DEBUGGER_PUBLIC_KEY = "\r\n" . '-----BEGIN PUBLIC KEY-----' . "\r\n"
. 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm+uLLVoxGCY71LS6KFc6' . "\r\n"
. '1UnF6QGBAsi5XF8ty9kR3/voqfOkpW+gRerM2Kyjy6DPCOmzhZj7BFGtxSV2ZoMX' . "\r\n"
. '9ZwWxzXhl/Q/6k8jg8BoY1QL6L2K76icXJu80b+RDIqvOfJruaAeBg1Q9NyeYqLY' . "\r\n"
. 'lEVzN2vIwcFYl+MrP/g6Bc2co7Jcbli+tpNIxg4Z+Hnhbs7OJ3STQLmEryLpAxQO' . "\r\n"
. 'q8cbhQkMx+FyQhxzSwtXYI/ClCUmTnzcKk7SgGvEjoKGAmngILiVuEJ4bm7Q1yok' . "\r\n"
. 'xl9+wcfW6JAituNhml9dlHCWnn9D3+j8pxStHihKy2gVMwiFRjLEeD8K/7JVGkb/' . "\r\n"
. 'EwIDAQAB' . "\r\n"
. '-----END PUBLIC KEY-----' . "\r\n";
/**
* Constructor.
*
* @param Manager $connection The Connection Manager.
*/
public function __construct( Manager $connection ) {
$this->connection = $connection;
self::$user_permissions_error_msg = esc_html__(
'You do not have the correct user permissions to perform this action.
Please contact your site admin if you think this is a mistake.',
'jetpack-connection'
);
$jp_version = Constants::get_constant( 'JETPACK__VERSION' );
if ( ! $this->connection->has_connected_owner() ) {
// Register a site.
register_rest_route(
'jetpack/v4',
'/verify_registration',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'verify_registration' ),
'permission_callback' => '__return_true',
)
);
}
// Authorize a remote user.
register_rest_route(
'jetpack/v4',
'/remote_authorize',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::remote_authorize',
'permission_callback' => '__return_true',
)
);
// Get current connection status of Jetpack.
register_rest_route(
'jetpack/v4',
'/connection',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::connection_status',
'permission_callback' => '__return_true',
)
);
// Disconnect site.
register_rest_route(
'jetpack/v4',
'/connection',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::disconnect_site',
'permission_callback' => __CLASS__ . '::disconnect_site_permission_check',
'args' => array(
'isActive' => array(
'description' => __( 'Set to false will trigger the site to disconnect.', 'jetpack-connection' ),
'validate_callback' => function ( $value ) {
if ( false !== $value ) {
return new WP_Error(
'rest_invalid_param',
__( 'The isActive argument should be set to false.', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
return true;
},
'required' => true,
),
),
)
);
// We are only registering this route if Jetpack-the-plugin is not active or it's version is ge 10.0-alpha.
// The reason for doing so is to avoid conflicts between the Connection package and
// older versions of Jetpack, registering the same route twice.
if ( empty( $jp_version ) || version_compare( $jp_version, '10.0-alpha', '>=' ) ) {
// Get current user connection data.
register_rest_route(
'jetpack/v4',
'/connection/data',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::get_user_connection_data',
'permission_callback' => __CLASS__ . '::user_connection_data_permission_check',
)
);
}
// Get list of plugins that use the Jetpack connection.
register_rest_route(
'jetpack/v4',
'/connection/plugins',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_connection_plugins' ),
'permission_callback' => __CLASS__ . '::connection_plugins_permission_check',
)
);
// Full or partial reconnect in case of connection issues.
register_rest_route(
'jetpack/v4',
'/connection/reconnect',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'connection_reconnect' ),
'permission_callback' => __CLASS__ . '::jetpack_reconnect_permission_check',
)
);
// Register the site (get `blog_token`).
register_rest_route(
'jetpack/v4',
'/connection/register',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'connection_register' ),
'permission_callback' => __CLASS__ . '::jetpack_register_permission_check',
'args' => array(
'from' => array(
'description' => __( 'Indicates where the registration action was triggered for tracking/segmentation purposes', 'jetpack-connection' ),
'type' => 'string',
),
'registration_nonce' => array(
'description' => __( 'The registration nonce', 'jetpack-connection' ),
'type' => 'string',
'required' => true,
),
'redirect_uri' => array(
'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
'type' => 'string',
),
'plugin_slug' => array(
'description' => __( 'Indicates from what plugin the request is coming from', 'jetpack-connection' ),
'type' => 'string',
),
),
)
);
// Get authorization URL.
register_rest_route(
'jetpack/v4',
'/connection/authorize_url',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'connection_authorize_url' ),
'permission_callback' => __CLASS__ . '::user_connection_data_permission_check',
'args' => array(
'redirect_uri' => array(
'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
'type' => 'string',
),
),
)
);
register_rest_route(
'jetpack/v4',
'/user-token',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( static::class, 'update_user_token' ),
'permission_callback' => array( static::class, 'update_user_token_permission_check' ),
'args' => array(
'user_token' => array(
'description' => __( 'New user token', 'jetpack-connection' ),
'type' => 'string',
'required' => true,
),
'is_connection_owner' => array(
'description' => __( 'Is connection owner', 'jetpack-connection' ),
'type' => 'boolean',
),
),
),
)
);
// Set the connection owner.
register_rest_route(
'jetpack/v4',
'/connection/owner',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( static::class, 'set_connection_owner' ),
'permission_callback' => array( static::class, 'set_connection_owner_permission_check' ),
'args' => array(
'owner' => array(
'description' => __( 'New owner', 'jetpack-connection' ),
'type' => 'integer',
'required' => true,
),
),
)
);
}
/**
* Handles verification that a site is registered.
*
* @since 1.7.0
* @since-jetpack 5.4.0
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return string|WP_Error
*/
public function verify_registration( WP_REST_Request $request ) {
$registration_data = array( $request['secret_1'], $request['state'] );
return $this->connection->handle_registration( $registration_data );
}
/**
* Handles verification that a site is registered
*
* @since 1.7.0
* @since-jetpack 5.4.0
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return array|wp-error
*/
public static function remote_authorize( $request ) {
$xmlrpc_server = new Jetpack_XMLRPC_Server();
$result = $xmlrpc_server->remote_authorize( $request );
if ( is_a( $result, 'IXR_Error' ) ) {
$result = new WP_Error( $result->code, $result->message );
}
return $result;
}
/**
* Get connection status for this Jetpack site.
*
* @since 1.7.0
* @since-jetpack 4.3.0
*
* @param bool $rest_response Should we return a rest response or a simple array. Default to rest response.
*
* @return WP_REST_Response|array Connection information.
*/
public static function connection_status( $rest_response = true ) {
$status = new Status();
$connection = new Manager();
$connection_status = array(
'isActive' => $connection->has_connected_owner(), // TODO deprecate this.
'isStaging' => $status->is_staging_site(),
'isRegistered' => $connection->is_connected(),
'isUserConnected' => $connection->is_user_connected(),
'hasConnectedOwner' => $connection->has_connected_owner(),
'offlineMode' => array(
'isActive' => $status->is_offline_mode(),
'constant' => defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG,
'url' => $status->is_local_site(),
/** This filter is documented in packages/status/src/class-status.php */
'filter' => ( apply_filters( 'jetpack_development_mode', false ) || apply_filters( 'jetpack_offline_mode', false ) ), // jetpack_development_mode is deprecated.
'wpLocalConstant' => defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV,
),
'isPublic' => '1' == get_option( 'blog_public' ), // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
);
/**
* Filters the connection status data.
*
* @since 1.25.0
*
* @param array An array containing the connection status data.
*/
$connection_status = apply_filters( 'jetpack_connection_status', $connection_status );
if ( $rest_response ) {
return rest_ensure_response(
$connection_status
);
} else {
return $connection_status;
}
}
/**
* Get plugins connected to the Jetpack.
*
* @param bool $rest_response Should we return a rest response or a simple array. Default to rest response.
*
* @since 1.13.1
* @since 1.38.0 Added $rest_response param.
*
* @return WP_REST_Response|WP_Error Response or error object, depending on the request result.
*/
public static function get_connection_plugins( $rest_response = true ) {
$plugins = ( new Manager() )->get_connected_plugins();
if ( is_wp_error( $plugins ) ) {
return $plugins;
}
array_walk(
$plugins,
function ( &$data, $slug ) {
$data['slug'] = $slug;
}
);
if ( $rest_response ) {
return rest_ensure_response( array_values( $plugins ) );
}
return array_values( $plugins );
}
/**
* Verify that user can view Jetpack admin page and can activate plugins.
*
* @since 1.15.0
*
* @return bool|WP_Error Whether user has the capability 'activate_plugins'.
*/
public static function activate_plugins_permission_check() {
if ( current_user_can( 'activate_plugins' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_activate_plugins', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Permission check for the connection_plugins endpoint
*
* @return bool|WP_Error
*/
public static function connection_plugins_permission_check() {
if ( true === static::activate_plugins_permission_check() ) {
return true;
}
if ( true === static::is_request_signed_by_jetpack_debugger() ) {
return true;
}
return new WP_Error( 'invalid_user_permission_activate_plugins', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Permission check for the disconnect site endpoint.
*
* @since 1.30.1
*
* @return bool|WP_Error True if user is able to disconnect the site.
*/
public static function disconnect_site_permission_check() {
if ( current_user_can( 'jetpack_disconnect' ) ) {
return true;
}
return new WP_Error(
'invalid_user_permission_jetpack_disconnect',
self::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Get miscellaneous user data related to the connection. Similar data available in old "My Jetpack".
* Information about the master/primary user.
* Information about the current user.
*
* @param bool $rest_response Should we return a rest response or a simple array. Default to rest response.
*
* @since 1.30.1
*
* @return \WP_REST_Response|array
*/
public static function get_user_connection_data( $rest_response = true ) {
$blog_id = \Jetpack_Options::get_option( 'id' );
$connection = new Manager();
$current_user = wp_get_current_user();
$connection_owner = $connection->get_connection_owner();
$owner_display_name = false === $connection_owner ? null : $connection_owner->display_name;
$is_user_connected = $connection->is_user_connected();
$is_master_user = false === $connection_owner ? false : ( $current_user->ID === $connection_owner->ID );
$wpcom_user_data = $connection->get_connected_user_data();
// Add connected user gravatar to the returned wpcom_user_data.
// Probably we shouldn't do this when $wpcom_user_data is false, but we have been since 2016 so
// clients probably expect that by now.
if ( false === $wpcom_user_data ) {
$wpcom_user_data = array();
}
$wpcom_user_data['avatar'] = ( ! empty( $wpcom_user_data['email'] ) ?
get_avatar_url(
$wpcom_user_data['email'],
array(
'size' => 64,
'default' => 'mysteryman',
)
)
: false );
$current_user_connection_data = array(
'isConnected' => $is_user_connected,
'isMaster' => $is_master_user,
'username' => $current_user->user_login,
'id' => $current_user->ID,
'blogId' => $blog_id,
'wpcomUser' => $wpcom_user_data,
'gravatar' => get_avatar_url( $current_user->ID, 64, 'mm', '', array( 'force_display' => true ) ),
'permissions' => array(
'connect' => current_user_can( 'jetpack_connect' ),
'connect_user' => current_user_can( 'jetpack_connect_user' ),
'disconnect' => current_user_can( 'jetpack_disconnect' ),
),
);
/**
* Filters the current user connection data.
*
* @since 1.30.1
*
* @param array An array containing the current user connection data.
*/
$current_user_connection_data = apply_filters( 'jetpack_current_user_connection_data', $current_user_connection_data );
$response = array(
'currentUser' => $current_user_connection_data,
'connectionOwner' => $owner_display_name,
);
if ( $rest_response ) {
return rest_ensure_response( $response );
}
return $response;
}
/**
* Permission check for the connection/data endpoint
*
* @return bool|WP_Error
*/
public static function user_connection_data_permission_check() {
if ( current_user_can( 'jetpack_connect_user' ) ) {
return true;
}
return new WP_Error(
'invalid_user_permission_user_connection_data',
self::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Verifies if the request was signed with the Jetpack Debugger key
*
* @param string|null $pub_key The public key used to verify the signature. Default is the Jetpack Debugger key. This is used for testing purposes.
*
* @return bool
*/
public static function is_request_signed_by_jetpack_debugger( $pub_key = null ) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['signature'], $_GET['timestamp'], $_GET['url'], $_GET['rest_route'] ) ) {
return false;
}
// signature timestamp must be within 5min of current time.
if ( abs( time() - (int) $_GET['timestamp'] ) > 300 ) {
return false;
}
$signature = base64_decode( filter_var( wp_unslash( $_GET['signature'] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$signature_data = wp_json_encode(
array(
'rest_route' => filter_var( wp_unslash( $_GET['rest_route'] ) ),
'timestamp' => (int) $_GET['timestamp'],
'url' => filter_var( wp_unslash( $_GET['url'] ) ),
)
);
if (
! function_exists( 'openssl_verify' )
|| 1 !== openssl_verify(
$signature_data,
$signature,
$pub_key ? $pub_key : static::JETPACK__DEBUGGER_PUBLIC_KEY
)
) {
return false;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return true;
}
/**
* Verify that user is allowed to disconnect Jetpack.
*
* @since 1.15.0
*
* @return bool|WP_Error Whether user has the capability 'jetpack_disconnect'.
*/
public static function jetpack_reconnect_permission_check() {
if ( current_user_can( 'jetpack_reconnect' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Returns generic error message when user is not allowed to perform an action.
*
* @return string The error message.
*/
public static function get_user_permissions_error_msg() {
return self::$user_permissions_error_msg;
}
/**
* The endpoint tried to partially or fully reconnect the website to WP.com.
*
* @since 1.15.0
*
* @return \WP_REST_Response|WP_Error
*/
public function connection_reconnect() {
$response = array();
$next = null;
$result = $this->connection->restore();
if ( is_wp_error( $result ) ) {
$response = $result;
} elseif ( is_string( $result ) ) {
$next = $result;
} else {
$next = true === $result ? 'completed' : 'failed';
}
switch ( $next ) {
case 'authorize':
$response['status'] = 'in_progress';
$response['authorizeUrl'] = $this->connection->get_authorization_url();
break;
case 'completed':
$response['status'] = 'completed';
/**
* Action fired when reconnection has completed successfully.
*
* @since 1.18.1
*/
do_action( 'jetpack_reconnection_completed' );
break;
case 'failed':
$response = new WP_Error( 'Reconnect failed' );
break;
}
return rest_ensure_response( $response );
}
/**
* Verify that user is allowed to connect Jetpack.
*
* @since 1.26.0
*
* @return bool|WP_Error Whether user has the capability 'jetpack_connect'.
*/
public static function jetpack_register_permission_check() {
if ( current_user_can( 'jetpack_connect' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_jetpack_connect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* The endpoint tried to partially or fully reconnect the website to WP.com.
*
* @since 1.7.0
* @since-jetpack 7.7.0
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public function connection_register( $request ) {
if ( ! wp_verify_nonce( $request->get_param( 'registration_nonce' ), 'jetpack-registration-nonce' ) ) {
return new WP_Error( 'invalid_nonce', __( 'Unable to verify your request.', 'jetpack-connection' ), array( 'status' => 403 ) );
}
if ( isset( $request['from'] ) ) {
$this->connection->add_register_request_param( 'from', (string) $request['from'] );
}
if ( ! empty( $request['plugin_slug'] ) ) {
// If `plugin_slug` matches a plugin using the connection, let's inform the plugin that is establishing the connection.
$connected_plugin = Plugin_Storage::get_one( (string) $request['plugin_slug'] );
if ( ! is_wp_error( $connected_plugin ) && ! empty( $connected_plugin ) ) {
$this->connection->set_plugin_instance( new Plugin( (string) $request['plugin_slug'] ) );
}
}
$result = $this->connection->try_registration();
if ( is_wp_error( $result ) ) {
return $result;
}
$redirect_uri = $request->get_param( 'redirect_uri' ) ? admin_url( $request->get_param( 'redirect_uri' ) ) : null;
if ( class_exists( 'Jetpack' ) ) {
$authorize_url = \Jetpack::build_authorize_url( $redirect_uri );
} else {
$authorize_url = $this->connection->get_authorization_url( null, $redirect_uri );
}
/**
* Filters the response of jetpack/v4/connection/register endpoint
*
* @param array $response Array response
* @since 1.27.0
*/
$response_body = apply_filters(
'jetpack_register_site_rest_response',
array()
);
// We manipulate the alternate URLs after the filter is applied, so they can not be overwritten.
$response_body['authorizeUrl'] = $authorize_url;
if ( ! empty( $response_body['alternateAuthorizeUrl'] ) ) {
$response_body['alternateAuthorizeUrl'] = Redirect::get_url( $response_body['alternateAuthorizeUrl'] );
}
return rest_ensure_response( $response_body );
}
/**
* Get the authorization URL.
*
* @since 1.27.0
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public function connection_authorize_url( $request ) {
$redirect_uri = $request->get_param( 'redirect_uri' ) ? admin_url( $request->get_param( 'redirect_uri' ) ) : null;
$authorize_url = $this->connection->get_authorization_url( null, $redirect_uri );
return rest_ensure_response(
array(
'authorizeUrl' => $authorize_url,
)
);
}
/**
* The endpoint tried to partially or fully reconnect the website to WP.com.
*
* @since 1.29.0
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public static function update_user_token( $request ) {
$token_parts = explode( '.', $request['user_token'] );
if ( count( $token_parts ) !== 3 || ! (int) $token_parts[2] || ! ctype_digit( $token_parts[2] ) ) {
return new WP_Error( 'invalid_argument_user_token', esc_html__( 'Invalid user token is provided', 'jetpack-connection' ) );
}
$user_id = (int) $token_parts[2];
if ( false === get_userdata( $user_id ) ) {
return new WP_Error( 'invalid_argument_user_id', esc_html__( 'Invalid user id is provided', 'jetpack-connection' ) );
}
$connection = new Manager();
if ( ! $connection->is_connected() ) {
return new WP_Error( 'site_not_connected', esc_html__( 'Site is not connected', 'jetpack-connection' ) );
}
$is_connection_owner = isset( $request['is_connection_owner'] )
? (bool) $request['is_connection_owner']
: ( new Manager() )->get_connection_owner_id() === $user_id;
( new Tokens() )->update_user_token( $user_id, $request['user_token'], $is_connection_owner );
/**
* Fires when the user token gets successfully replaced.
*
* @since 1.29.0
*
* @param int $user_id User ID.
* @param string $token New user token.
*/
do_action( 'jetpack_updated_user_token', $user_id, $request['user_token'] );
return rest_ensure_response(
array(
'success' => true,
)
);
}
/**
* Disconnects Jetpack from the WordPress.com Servers
*
* @since 1.30.1
*
* @return bool|WP_Error True if Jetpack successfully disconnected.
*/
public static function disconnect_site() {
$connection = new Manager();
if ( $connection->is_connected() ) {
$connection->disconnect_site();
return rest_ensure_response( array( 'code' => 'success' ) );
}
return new WP_Error(
'disconnect_failed',
esc_html__( 'Failed to disconnect the site as it appears already disconnected.', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
/**
* Verify that the API client is allowed to replace user token.
*
* @since 1.29.0
*
* @return bool|WP_Error.
*/
public static function update_user_token_permission_check() {
return Rest_Authentication::is_signed_with_blog_token()
? true
: new WP_Error( 'invalid_permission_update_user_token', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Change the connection owner.
*
* @since 1.29.0
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public static function set_connection_owner( $request ) {
$new_owner_id = $request['owner'];
$owner_set = ( new Manager() )->update_connection_owner( $new_owner_id );
if ( is_wp_error( $owner_set ) ) {
return $owner_set;
}
return rest_ensure_response(
array(
'code' => 'success',
)
);
}
/**
* Check that user has permission to change the master user.
*
* @since 1.7.0
* @since-jetpack 6.2.0
* @since-jetpack 7.7.0 Update so that any user with jetpack_disconnect privs can set owner.
*
* @return bool|WP_Error True if user is able to change master user.
*/
public static function set_connection_owner_permission_check() {
if ( current_user_can( 'jetpack_disconnect' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_set_connection_owner', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
}
class-secrets.php 0000644 00000020437 15154644771 0010054 0 ustar 00 <?php
/**
* The Jetpack Connection Secrets class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Jetpack_Options;
use WP_Error;
/**
* The Jetpack Connection Secrets class that is used to manage secrets.
*/
class Secrets {
const SECRETS_MISSING = 'secrets_missing';
const SECRETS_EXPIRED = 'secrets_expired';
const LEGACY_SECRETS_OPTION_NAME = 'jetpack_secrets';
/**
* Deletes all connection secrets from the local Jetpack site.
*/
public function delete_all() {
Jetpack_Options::delete_raw_option( 'jetpack_secrets' );
}
/**
* Runs the wp_generate_password function with the required parameters. This is the
* default implementation of the secret callable, can be overridden using the
* jetpack_connection_secret_generator filter.
*
* @return String $secret value.
*/
private function secret_callable_method() {
$secret = wp_generate_password( 32, false );
// Some sites may hook into the random_password filter and make the password shorter, let's make sure our secret has the required length.
$attempts = 1;
$secret_length = strlen( $secret );
while ( $secret_length < 32 && $attempts < 32 ) {
++$attempts;
$secret .= wp_generate_password( 32, false );
$secret_length = strlen( $secret );
}
return (string) substr( $secret, 0, 32 );
}
/**
* Generates two secret tokens and the end of life timestamp for them.
*
* @param String $action The action name.
* @param Integer|bool $user_id The user identifier. Defaults to `false`.
* @param Integer $exp Expiration time in seconds.
*/
public function generate( $action, $user_id = false, $exp = 600 ) {
if ( false === $user_id ) {
$user_id = get_current_user_id();
}
$callable = apply_filters( 'jetpack_connection_secret_generator', array( get_called_class(), 'secret_callable_method' ) );
$secrets = Jetpack_Options::get_raw_option(
self::LEGACY_SECRETS_OPTION_NAME,
array()
);
$secret_name = 'jetpack_' . $action . '_' . $user_id;
if (
isset( $secrets[ $secret_name ] ) &&
$secrets[ $secret_name ]['exp'] > time()
) {
return $secrets[ $secret_name ];
}
$secret_value = array(
'secret_1' => call_user_func( $callable ),
'secret_2' => call_user_func( $callable ),
'exp' => time() + $exp,
);
$secrets[ $secret_name ] = $secret_value;
$res = Jetpack_Options::update_raw_option( self::LEGACY_SECRETS_OPTION_NAME, $secrets );
return $res ? $secrets[ $secret_name ] : false;
}
/**
* Returns two secret tokens and the end of life timestamp for them.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @return string|array an array of secrets or an error string.
*/
public function get( $action, $user_id ) {
$secret_name = 'jetpack_' . $action . '_' . $user_id;
$secrets = Jetpack_Options::get_raw_option(
self::LEGACY_SECRETS_OPTION_NAME,
array()
);
if ( ! isset( $secrets[ $secret_name ] ) ) {
return self::SECRETS_MISSING;
}
if ( $secrets[ $secret_name ]['exp'] < time() ) {
$this->delete( $action, $user_id );
return self::SECRETS_EXPIRED;
}
return $secrets[ $secret_name ];
}
/**
* Deletes secret tokens in case they, for example, have expired.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
*/
public function delete( $action, $user_id ) {
$secret_name = 'jetpack_' . $action . '_' . $user_id;
$secrets = Jetpack_Options::get_raw_option(
self::LEGACY_SECRETS_OPTION_NAME,
array()
);
if ( isset( $secrets[ $secret_name ] ) ) {
unset( $secrets[ $secret_name ] );
Jetpack_Options::update_raw_option( self::LEGACY_SECRETS_OPTION_NAME, $secrets );
}
}
/**
* Verify a Previously Generated Secret.
*
* @param string $action The type of secret to verify.
* @param string $secret_1 The secret string to compare to what is stored.
* @param int $user_id The user ID of the owner of the secret.
* @return WP_Error|string WP_Error on failure, secret_2 on success.
*/
public function verify( $action, $secret_1, $user_id ) {
$allowed_actions = array( 'register', 'authorize', 'publicize' );
if ( ! in_array( $action, $allowed_actions, true ) ) {
return new WP_Error( 'unknown_verification_action', 'Unknown Verification Action', 400 );
}
$user = get_user_by( 'id', $user_id );
/**
* We've begun verifying the previously generated secret.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param string $action The type of secret to verify.
* @param \WP_User $user The user object.
*/
do_action( 'jetpack_verify_secrets_begin', $action, $user );
$return_error = function ( WP_Error $error ) use ( $action, $user ) {
/**
* Verifying of the previously generated secret has failed.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param string $action The type of secret to verify.
* @param \WP_User $user The user object.
* @param WP_Error $error The error object.
*/
do_action( 'jetpack_verify_secrets_fail', $action, $user, $error );
return $error;
};
$stored_secrets = $this->get( $action, $user_id );
$this->delete( $action, $user_id );
$error = null;
if ( empty( $secret_1 ) ) {
$error = $return_error(
new WP_Error(
'verify_secret_1_missing',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is missing.', 'jetpack-connection' ), 'secret_1' ),
400
)
);
} elseif ( ! is_string( $secret_1 ) ) {
$error = $return_error(
new WP_Error(
'verify_secret_1_malformed',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is malformed.', 'jetpack-connection' ), 'secret_1' ),
400
)
);
} elseif ( empty( $user_id ) ) {
// $user_id is passed around during registration as "state".
$error = $return_error(
new WP_Error(
'state_missing',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is missing.', 'jetpack-connection' ), 'state' ),
400
)
);
} elseif ( ! ctype_digit( (string) $user_id ) ) {
$error = $return_error(
new WP_Error(
'state_malformed',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is malformed.', 'jetpack-connection' ), 'state' ),
400
)
);
} elseif ( self::SECRETS_MISSING === $stored_secrets ) {
$error = $return_error(
new WP_Error(
'verify_secrets_missing',
__( 'Verification secrets not found', 'jetpack-connection' ),
400
)
);
} elseif ( self::SECRETS_EXPIRED === $stored_secrets ) {
$error = $return_error(
new WP_Error(
'verify_secrets_expired',
__( 'Verification took too long', 'jetpack-connection' ),
400
)
);
} elseif ( ! $stored_secrets ) {
$error = $return_error(
new WP_Error(
'verify_secrets_empty',
__( 'Verification secrets are empty', 'jetpack-connection' ),
400
)
);
} elseif ( is_wp_error( $stored_secrets ) ) {
$stored_secrets->add_data( 400 );
$error = $return_error( $stored_secrets );
} elseif ( empty( $stored_secrets['secret_1'] ) || empty( $stored_secrets['secret_2'] ) || empty( $stored_secrets['exp'] ) ) {
$error = $return_error(
new WP_Error(
'verify_secrets_incomplete',
__( 'Verification secrets are incomplete', 'jetpack-connection' ),
400
)
);
} elseif ( ! hash_equals( $secret_1, $stored_secrets['secret_1'] ) ) {
$error = $return_error(
new WP_Error(
'verify_secrets_mismatch',
__( 'Secret mismatch', 'jetpack-connection' ),
400
)
);
}
// Something went wrong during the checks, returning the error.
if ( ! empty( $error ) ) {
return $error;
}
/**
* We've succeeded at verifying the previously generated secret.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param string $action The type of secret to verify.
* @param \WP_User $user The user object.
*/
do_action( 'jetpack_verify_secrets_success', $action, $user );
return $stored_secrets['secret_2'];
}
}
class-server-sandbox.php 0000644 00000017266 15154644771 0011354 0 ustar 00 <?php
/**
* The Server_Sandbox class.
*
* This feature is only useful for Automattic developers.
* It configures Jetpack to talk to staging/sandbox servers
* on WordPress.com instead of production servers.
*
* @package automattic/jetpack-sandbox
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
/**
* The Server_Sandbox class.
*/
class Server_Sandbox {
/**
* Sets up the action hooks for the server sandbox.
*/
public function init() {
if ( did_action( 'jetpack_server_sandbox_init' ) ) {
return;
}
add_action( 'requests-requests.before_request', array( $this, 'server_sandbox' ), 10, 4 );
add_action( 'admin_bar_menu', array( $this, 'admin_bar_add_sandbox_item' ), 999 );
/**
* Fires when the server sandbox is initialized. This action is used to ensure that
* the server sandbox action hooks are set up only once.
*
* @since 1.30.7
*/
do_action( 'jetpack_server_sandbox_init' );
}
/**
* Returns the new url and host values.
*
* @param string $sandbox Sandbox domain.
* @param string $url URL of request about to be made.
* @param array $headers Headers of request about to be made.
* @param string $data The body of request about to be made.
* @param string $method The method of request about to be made.
*
* @return array [ 'url' => new URL, 'host' => new Host, 'new_signature => New signature if url was changed ]
*/
public function server_sandbox_request_parameters( $sandbox, $url, $headers, $data = null, $method = 'GET' ) {
$host = '';
$new_signature = '';
if ( ! is_string( $sandbox ) || ! is_string( $url ) ) {
return array(
'url' => $url,
'host' => $host,
'new_signature' => $new_signature,
);
}
$url_host = wp_parse_url( $url, PHP_URL_HOST );
switch ( $url_host ) {
case 'public-api.wordpress.com':
case 'jetpack.wordpress.com':
case 'jetpack.com':
case 'dashboard.wordpress.com':
$host = isset( $headers['Host'] ) ? $headers['Host'] : $url_host;
$original_url = $url;
$url = preg_replace(
'@^(https?://)' . preg_quote( $url_host, '@' ) . '(?=[/?#].*|$)@',
'${1}' . $sandbox,
$url,
1
);
/**
* Whether to add the X Debug query parameter to the request made to the Sandbox
*
* @since 1.36.0
*
* @param bool $add_parameter Whether to add the parameter to the request or not. Default is to false.
* @param string $url The URL of the request being made.
* @param string $host The host of the request being made.
*/
if ( apply_filters( 'jetpack_sandbox_add_profile_parameter', false, $url, $host ) ) {
$url = add_query_arg( 'XDEBUG_PROFILE', 1, $url );
// URL has been modified since the signature was created. We'll need a new one.
$original_url = add_query_arg( 'XDEBUG_PROFILE', 1, $original_url );
$new_signature = $this->get_new_signature( $original_url, $headers, $data, $method );
}
}
return compact( 'url', 'host', 'new_signature' );
}
/**
* Gets a new signature for the request
*
* @param string $url The new URL to be signed.
* @param array $headers The headers of the request about to be made.
* @param string $data The body of request about to be made.
* @param string $method The method of the request about to be made.
* @return string|null
*/
private function get_new_signature( $url, $headers, $data, $method ) {
if ( ! empty( $headers['Authorization'] ) ) {
$a_headers = $this->extract_authorization_headers( $headers );
if ( ! empty( $a_headers ) ) {
$token_details = explode( ':', $a_headers['token'] );
if ( count( $token_details ) === 3 ) {
$user_id = $token_details[2];
$token = ( new Tokens() )->get_access_token( $user_id );
$time_diff = (int) \Jetpack_Options::get_option( 'time_diff' );
$jetpack_signature = new \Jetpack_Signature( $token->secret, $time_diff );
$signature = $jetpack_signature->sign_request(
$a_headers['token'],
$a_headers['timestamp'],
$a_headers['nonce'],
$a_headers['body-hash'],
$method,
$url,
$data,
false
);
if ( $signature && ! is_wp_error( $signature ) ) {
return $signature;
} elseif ( is_wp_error( $signature ) ) {
$this->log_new_signature_error( $signature->get_error_message() );
}
} else {
$this->log_new_signature_error( 'Malformed token on Authorization Header' );
}
} else {
$this->log_new_signature_error( 'Error extracting Authorization Header' );
}
} else {
$this->log_new_signature_error( 'Empty Authorization Header' );
}
}
/**
* Logs error if the attempt to create a new signature fails
*
* @param string $message The error message.
* @return void
*/
private function log_new_signature_error( $message ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf( "SANDBOXING: Error re-signing the request. '%s'", $message ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
/**
* Extract the values in the Authorization header into an array
*
* @param array $headers The headers of the request about to be made.
* @return array|null
*/
public function extract_authorization_headers( $headers ) {
if ( ! empty( $headers['Authorization'] ) && is_string( $headers['Authorization'] ) ) {
$header = str_replace( 'X_JETPACK ', '', $headers['Authorization'] );
$vars = explode( ' ', $header );
$result = array();
foreach ( $vars as $var ) {
$elements = explode( '"', $var );
if ( count( $elements ) === 3 ) {
$result[ substr( $elements[0], 0, -1 ) ] = $elements[1];
}
}
return $result;
}
}
/**
* Modifies parameters of request in order to send the request to the
* server specified by `JETPACK__SANDBOX_DOMAIN`.
*
* Attached to the `requests-requests.before_request` filter.
*
* @param string $url URL of request about to be made.
* @param array $headers Headers of request about to be made.
* @param array|string $data Data of request about to be made.
* @param string $type Type of request about to be made.
* @return void
*/
public function server_sandbox( &$url, &$headers, &$data = null, &$type = null ) {
if ( ! Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ) ) {
return;
}
$original_url = $url;
$request_parameters = $this->server_sandbox_request_parameters( Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ), $url, $headers, $data, $type );
$url = $request_parameters['url'];
if ( $request_parameters['host'] ) {
$headers['Host'] = $request_parameters['host'];
if ( $request_parameters['new_signature'] ) {
$headers['Authorization'] = preg_replace( '/signature=\"[^\"]+\"/', 'signature="' . $request_parameters['new_signature'] . '"', $headers['Authorization'] );
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf( "SANDBOXING via '%s': '%s'", Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ), $original_url ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
}
/**
* Adds a "Jetpack API Sandboxed" item to the admin bar if the JETPACK__SANDBOX_DOMAIN
* constant is set.
*
* Attached to the `admin_bar_menu` action.
*
* @param WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
*/
public function admin_bar_add_sandbox_item( $wp_admin_bar ) {
if ( ! Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ) ) {
return;
}
$node = array(
'id' => 'jetpack-connection-api-sandbox',
'title' => 'Jetpack API Sandboxed',
'meta' => array(
'title' => 'Sandboxing via ' . Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ),
),
);
$wp_admin_bar->add_menu( $node );
}
}
class-terms-of-service.php 0000644 00000005357 15154644771 0011602 0 ustar 00 <?php
/**
* A Terms of Service class for Jetpack.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
/**
* Class Terms_Of_Service
*
* Helper class that is responsible for the state of agreement of the terms of service.
*/
class Terms_Of_Service {
/**
* Jetpack option name where the terms of service state is stored.
*
* @var string
*/
const OPTION_NAME = 'tos_agreed';
/**
* Allow the site to agree to the terms of service.
*/
public function agree() {
$this->set_agree();
/**
* Acton fired when the master user has agreed to the terms of service.
*
* @since 1.0.4
* @since-jetpack 7.9.0
*/
do_action( 'jetpack_agreed_to_terms_of_service' );
}
/**
* Allow the site to reject to the terms of service.
*/
public function reject() {
$this->set_reject();
/**
* Acton fired when the master user has revoked their agreement to the terms of service.
*
* @since 1.0.4
* @since-jetpack 7.9.1
*/
do_action( 'jetpack_reject_terms_of_service' );
}
/**
* Returns whether the master user has agreed to the terms of service.
*
* The following conditions have to be met in order to agree to the terms of service.
* 1. The master user has gone though the connect flow.
* 2. The site is not in dev mode.
* 3. The master user of the site is still connected (deprecated @since 1.4.0).
*
* @return bool
*/
public function has_agreed() {
if ( $this->is_offline_mode() ) {
return false;
}
/**
* Before 1.4.0 we used to also check if the master user of the site is connected
* by calling the Connection related `is_active` method.
* As of 1.4.0 we have removed this check in order to resolve the
* circular dependencies it was introducing to composer packages.
*
* @since 1.4.0
*/
return $this->get_raw_has_agreed();
}
/**
* Abstracted for testing purposes.
* Tells us if the site is in dev mode.
*
* @return bool
*/
protected function is_offline_mode() {
return ( new Status() )->is_offline_mode();
}
/**
* Gets just the Jetpack Option that contains the terms of service state.
* Abstracted for testing purposes.
*
* @return bool
*/
protected function get_raw_has_agreed() {
return \Jetpack_Options::get_option( self::OPTION_NAME, false );
}
/**
* Sets the correct Jetpack Option to mark the that the site has agreed to the terms of service.
* Abstracted for testing purposes.
*/
protected function set_agree() {
\Jetpack_Options::update_option( self::OPTION_NAME, true );
}
/**
* Sets the correct Jetpack Option to mark that the site has rejected the terms of service.
* Abstracted for testing purposes.
*/
protected function set_reject() {
\Jetpack_Options::update_option( self::OPTION_NAME, false );
}
}
class-tokens.php 0000644 00000051543 15154644771 0007711 0 ustar 00 <?php
/**
* The Jetpack Connection Tokens class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Roles;
use DateInterval;
use DateTime;
use Exception;
use Jetpack_Options;
use WP_Error;
/**
* The Jetpack Connection Tokens class that manages tokens.
*/
class Tokens {
const MAGIC_NORMAL_TOKEN_KEY = ';normal;';
/**
* Datetime format.
*/
const DATE_FORMAT_ATOM = 'Y-m-d\TH:i:sP';
/**
* Deletes all connection tokens and transients from the local Jetpack site.
*/
public function delete_all() {
Jetpack_Options::delete_option(
array(
'blog_token',
'user_token',
'user_tokens',
)
);
$this->remove_lock();
}
/**
* Perform the API request to validate the blog and user tokens.
*
* @param int|null $user_id ID of the user we need to validate token for. Current user's ID by default.
*
* @return array|false|WP_Error The API response: `array( 'blog_token_is_healthy' => true|false, 'user_token_is_healthy' => true|false )`.
*/
public function validate( $user_id = null ) {
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-token-health'
);
$user_token = $this->get_access_token( $user_id ? $user_id : get_current_user_id() );
$blog_token = $this->get_access_token();
// Cannot validate non-existent tokens.
if ( false === $user_token || false === $blog_token ) {
return false;
}
$method = 'POST';
$body = array(
'user_token' => $this->get_signed_token( $user_token ),
'blog_token' => $this->get_signed_token( $blog_token ),
);
$response = Client::_wp_remote_request( $url, compact( 'body', 'method' ) );
if ( is_wp_error( $response ) || ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return $body ? $body : false;
}
/**
* Perform the API request to validate only the blog.
*
* @return bool|WP_Error Boolean with the test result. WP_Error if test cannot be performed.
*/
public function validate_blog_token() {
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-token-health/blog'
);
$method = 'GET';
$response = Client::remote_request( compact( 'url', 'method' ) );
if ( is_wp_error( $response ) || ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return is_array( $body ) && isset( $body['is_healthy'] ) && true === $body['is_healthy'];
}
/**
* Obtains the auth token.
*
* @param array $data The request data.
* @param string $token_api_url The URL of the Jetpack "token" API.
* @return object|WP_Error Returns the auth token on success.
* Returns a WP_Error on failure.
*/
public function get( $data, $token_api_url ) {
$roles = new Roles();
$role = $roles->translate_current_user_to_role();
if ( ! $role ) {
return new WP_Error( 'role', __( 'An administrator for this blog must set up the Jetpack connection.', 'jetpack-connection' ) );
}
$client_secret = $this->get_access_token();
if ( ! $client_secret ) {
return new WP_Error( 'client_secret', __( 'You need to register your Jetpack before connecting it.', 'jetpack-connection' ) );
}
/**
* Filter the URL of the first time the user gets redirected back to your site for connection
* data processing.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param string $redirect_url Defaults to the site admin URL.
*/
$processing_url = apply_filters( 'jetpack_token_processing_url', admin_url( 'admin.php' ) );
$redirect = isset( $data['redirect'] ) ? esc_url_raw( (string) $data['redirect'] ) : '';
/**
* Filter the URL to redirect the user back to when the authentication process
* is complete.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param string $redirect_url Defaults to the site URL.
*/
$redirect = apply_filters( 'jetpack_token_redirect_url', $redirect );
$redirect_uri = ( 'calypso' === $data['auth_type'] )
? $data['redirect_uri']
: add_query_arg(
array(
'handler' => 'jetpack-connection-webhooks',
'action' => 'authorize',
'_wpnonce' => wp_create_nonce( "jetpack-authorize_{$role}_{$redirect}" ),
'redirect' => $redirect ? rawurlencode( $redirect ) : false,
),
esc_url( $processing_url )
);
/**
* Filters the token request data.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param array $request_data request data.
*/
$body = apply_filters(
'jetpack_token_request_body',
array(
'client_id' => Jetpack_Options::get_option( 'id' ),
'client_secret' => $client_secret->secret,
'grant_type' => 'authorization_code',
'code' => $data['code'],
'redirect_uri' => $redirect_uri,
)
);
$args = array(
'method' => 'POST',
'body' => $body,
'headers' => array(
'Accept' => 'application/json',
),
);
add_filter( 'http_request_timeout', array( $this, 'return_30' ), PHP_INT_MAX - 1 );
$response = Client::_wp_remote_request( $token_api_url, $args );
remove_filter( 'http_request_timeout', array( $this, 'return_30' ), PHP_INT_MAX - 1 );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'token_http_request_failed', $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$entity = wp_remote_retrieve_body( $response );
if ( $entity ) {
$json = json_decode( $entity );
} else {
$json = false;
}
if ( 200 !== $code || ! empty( $json->error ) ) {
if ( empty( $json->error ) ) {
return new WP_Error( 'unknown', '', $code );
}
/* translators: Error description string. */
$error_description = isset( $json->error_description ) ? sprintf( __( 'Error Details: %s', 'jetpack-connection' ), (string) $json->error_description ) : '';
return new WP_Error( (string) $json->error, $error_description, $code );
}
if ( empty( $json->access_token ) || ! is_scalar( $json->access_token ) ) {
return new WP_Error( 'access_token', '', $code );
}
if ( empty( $json->token_type ) || 'X_JETPACK' !== strtoupper( $json->token_type ) ) {
return new WP_Error( 'token_type', '', $code );
}
if ( empty( $json->scope ) ) {
return new WP_Error( 'scope', 'No Scope', $code );
}
// TODO: get rid of the error silencer.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@list( $role, $hmac ) = explode( ':', $json->scope );
if ( empty( $role ) || empty( $hmac ) ) {
return new WP_Error( 'scope', 'Malformed Scope', $code );
}
if ( $this->sign_role( $role ) !== $json->scope ) {
return new WP_Error( 'scope', 'Invalid Scope', $code );
}
$cap = $roles->translate_role_to_cap( $role );
if ( ! $cap ) {
return new WP_Error( 'scope', 'No Cap', $code );
}
if ( ! current_user_can( $cap ) ) {
return new WP_Error( 'scope', 'current_user_cannot', $code );
}
return (string) $json->access_token;
}
/**
* Enters a user token into the user_tokens option
*
* @param int $user_id The user id.
* @param string $token The user token.
* @param bool $is_master_user Whether the user is the master user.
* @return bool
*/
public function update_user_token( $user_id, $token, $is_master_user ) {
// Not designed for concurrent updates.
$user_tokens = $this->get_user_tokens();
if ( ! is_array( $user_tokens ) ) {
$user_tokens = array();
}
$user_tokens[ $user_id ] = $token;
if ( $is_master_user ) {
$master_user = $user_id;
$options = compact( 'user_tokens', 'master_user' );
} else {
$options = compact( 'user_tokens' );
}
return Jetpack_Options::update_options( $options );
}
/**
* Sign a user role with the master access token.
* If not specified, will default to the current user.
*
* @access public
*
* @param string $role User role.
* @param int $user_id ID of the user.
* @return string Signed user role.
*/
public function sign_role( $role, $user_id = null ) {
if ( empty( $user_id ) ) {
$user_id = (int) get_current_user_id();
}
if ( ! $user_id ) {
return false;
}
$token = $this->get_access_token();
if ( ! $token || is_wp_error( $token ) ) {
return false;
}
return $role . ':' . hash_hmac( 'md5', "{$role}|{$user_id}", $token->secret );
}
/**
* Increases the request timeout value to 30 seconds.
*
* @return int Returns 30.
*/
public function return_30() {
return 30;
}
/**
* Gets the requested token.
*
* Tokens are one of two types:
* 1. Blog Tokens: These are the "main" tokens. Each site typically has one Blog Token,
* though some sites can have multiple "Special" Blog Tokens (see below). These tokens
* are not associated with a user account. They represent the site's connection with
* the Jetpack servers.
* 2. User Tokens: These are "sub-"tokens. Each connected user account has one User Token.
*
* All tokens look like "{$token_key}.{$private}". $token_key is a public ID for the
* token, and $private is a secret that should never be displayed anywhere or sent
* over the network; it's used only for signing things.
*
* Blog Tokens can be "Normal" or "Special".
* * Normal: The result of a normal connection flow. They look like
* "{$random_string_1}.{$random_string_2}"
* That is, $token_key and $private are both random strings.
* Sites only have one Normal Blog Token. Normal Tokens are found in either
* Jetpack_Options::get_option( 'blog_token' ) (usual) or the JETPACK_BLOG_TOKEN
* constant (rare).
* * Special: A connection token for sites that have gone through an alternative
* connection flow. They look like:
* ";{$special_id}{$special_version};{$wpcom_blog_id};.{$random_string}"
* That is, $private is a random string and $token_key has a special structure with
* lots of semicolons.
* Most sites have zero Special Blog Tokens. Special tokens are only found in the
* JETPACK_BLOG_TOKEN constant.
*
* In particular, note that Normal Blog Tokens never start with ";" and that
* Special Blog Tokens always do.
*
* When searching for a matching Blog Tokens, Blog Tokens are examined in the following
* order:
* 1. Defined Special Blog Tokens (via the JETPACK_BLOG_TOKEN constant)
* 2. Stored Normal Tokens (via Jetpack_Options::get_option( 'blog_token' ))
* 3. Defined Normal Tokens (via the JETPACK_BLOG_TOKEN constant)
*
* @param int|false $user_id false: Return the Blog Token. int: Return that user's User Token.
* @param string|false $token_key If provided, check that the token matches the provided input.
* @param bool|true $suppress_errors If true, return a falsy value when the token isn't found; When false, return a descriptive WP_Error when the token isn't found.
*
* @return object|false|WP_Error
*/
public function get_access_token( $user_id = false, $token_key = false, $suppress_errors = true ) {
if ( $this->is_locked() ) {
$this->delete_all();
return false;
}
$possible_special_tokens = array();
$possible_normal_tokens = array();
$user_tokens = $this->get_user_tokens();
if ( $user_id ) {
if ( ! $user_tokens ) {
return $suppress_errors ? false : new WP_Error( 'no_user_tokens', __( 'No user tokens found', 'jetpack-connection' ) );
}
if ( true === $user_id ) { // connection owner.
$user_id = Jetpack_Options::get_option( 'master_user' );
if ( ! $user_id ) {
return $suppress_errors ? false : new WP_Error( 'empty_master_user_option', __( 'No primary user defined', 'jetpack-connection' ) );
}
}
if ( ! isset( $user_tokens[ $user_id ] ) || ! $user_tokens[ $user_id ] ) {
// translators: %s is the user ID.
return $suppress_errors ? false : new WP_Error( 'no_token_for_user', sprintf( __( 'No token for user %d', 'jetpack-connection' ), $user_id ) );
}
$user_token_chunks = explode( '.', $user_tokens[ $user_id ] );
if ( empty( $user_token_chunks[1] ) || empty( $user_token_chunks[2] ) ) {
// translators: %s is the user ID.
return $suppress_errors ? false : new WP_Error( 'token_malformed', sprintf( __( 'Token for user %d is malformed', 'jetpack-connection' ), $user_id ) );
}
if ( $user_token_chunks[2] !== (string) $user_id ) {
// translators: %1$d is the ID of the requested user. %2$d is the user ID found in the token.
return $suppress_errors ? false : new WP_Error( 'user_id_mismatch', sprintf( __( 'Requesting user_id %1$d does not match token user_id %2$d', 'jetpack-connection' ), $user_id, $user_token_chunks[2] ) );
}
$possible_normal_tokens[] = "{$user_token_chunks[0]}.{$user_token_chunks[1]}";
} else {
$stored_blog_token = Jetpack_Options::get_option( 'blog_token' );
if ( $stored_blog_token ) {
$possible_normal_tokens[] = $stored_blog_token;
}
$defined_tokens_string = Constants::get_constant( 'JETPACK_BLOG_TOKEN' );
if ( $defined_tokens_string ) {
$defined_tokens = explode( ',', $defined_tokens_string );
foreach ( $defined_tokens as $defined_token ) {
if ( ';' === $defined_token[0] ) {
$possible_special_tokens[] = $defined_token;
} else {
$possible_normal_tokens[] = $defined_token;
}
}
}
}
if ( self::MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
$possible_tokens = $possible_normal_tokens;
} else {
$possible_tokens = array_merge( $possible_special_tokens, $possible_normal_tokens );
}
if ( ! $possible_tokens ) {
// If no user tokens were found, it would have failed earlier, so this is about blog token.
return $suppress_errors ? false : new WP_Error( 'no_possible_tokens', __( 'No blog token found', 'jetpack-connection' ) );
}
$valid_token = false;
if ( false === $token_key ) {
// Use first token.
$valid_token = $possible_tokens[0];
} elseif ( self::MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
// Use first normal token.
$valid_token = $possible_tokens[0]; // $possible_tokens only contains normal tokens because of earlier check.
} else {
// Use the token matching $token_key or false if none.
// Ensure we check the full key.
$token_check = rtrim( $token_key, '.' ) . '.';
foreach ( $possible_tokens as $possible_token ) {
if ( hash_equals( substr( $possible_token, 0, strlen( $token_check ) ), $token_check ) ) {
$valid_token = $possible_token;
break;
}
}
}
if ( ! $valid_token ) {
if ( $user_id ) {
// translators: %d is the user ID.
return $suppress_errors ? false : new WP_Error( 'no_valid_user_token', sprintf( __( 'Invalid token for user %d', 'jetpack-connection' ), $user_id ) );
} else {
return $suppress_errors ? false : new WP_Error( 'no_valid_blog_token', __( 'Invalid blog token', 'jetpack-connection' ) );
}
}
return (object) array(
'secret' => $valid_token,
'external_user_id' => (int) $user_id,
);
}
/**
* Updates the blog token to a new value.
*
* @access public
*
* @param string $token the new blog token value.
* @return Boolean Whether updating the blog token was successful.
*/
public function update_blog_token( $token ) {
return Jetpack_Options::update_option( 'blog_token', $token );
}
/**
* Unlinks the current user from the linked WordPress.com user.
*
* @access public
* @static
*
* @todo Refactor to properly load the XMLRPC client independently.
*
* @param int $user_id The user identifier.
*
* @return bool Whether the disconnection of the user was successful.
*/
public function disconnect_user( $user_id ) {
$tokens = $this->get_user_tokens();
if ( ! $tokens ) {
return false;
}
if ( ! isset( $tokens[ $user_id ] ) ) {
return false;
}
unset( $tokens[ $user_id ] );
$this->update_user_tokens( $tokens );
return true;
}
/**
* Returns an array of user_id's that have user tokens for communicating with wpcom.
* Able to select by specific capability.
*
* @deprecated 1.30.0
* @see Manager::get_connected_users
*
* @param string $capability The capability of the user.
* @param int|null $limit How many connected users to get before returning.
* @return array Array of WP_User objects if found.
*/
public function get_connected_users( $capability = 'any', $limit = null ) {
_deprecated_function( __METHOD__, '1.30.0' );
return ( new Manager( 'jetpack' ) )->get_connected_users( $capability, $limit );
}
/**
* Fetches a signed token.
*
* @param object $token the token.
* @return WP_Error|string a signed token
*/
public function get_signed_token( $token ) {
if ( ! isset( $token->secret ) || empty( $token->secret ) ) {
return new WP_Error( 'invalid_token' );
}
list( $token_key, $token_secret ) = explode( '.', $token->secret );
$token_key = sprintf(
'%s:%d:%d',
$token_key,
Constants::get_constant( 'JETPACK__API_VERSION' ),
$token->external_user_id
);
$timestamp = time();
if ( function_exists( 'wp_generate_password' ) ) {
$nonce = wp_generate_password( 10, false );
} else {
$nonce = substr( sha1( wp_rand( 0, 1000000 ) ), 0, 10 );
}
$normalized_request_string = join(
"\n",
array(
$token_key,
$timestamp,
$nonce,
)
) . "\n";
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$signature = base64_encode( hash_hmac( 'sha1', $normalized_request_string, $token_secret, true ) );
$auth = array(
'token' => $token_key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'signature' => $signature,
);
$header_pieces = array();
foreach ( $auth as $key => $value ) {
$header_pieces[] = sprintf( '%s="%s"', $key, $value );
}
return join( ' ', $header_pieces );
}
/**
* Gets the list of user tokens
*
* @since 1.30.0
*
* @return bool|array An array of user tokens where keys are user IDs and values are the tokens. False if no user token is found.
*/
public function get_user_tokens() {
return Jetpack_Options::get_option( 'user_tokens' );
}
/**
* Updates the option that stores the user tokens
*
* @since 1.30.0
*
* @param array $tokens An array of user tokens where keys are user IDs and values are the tokens.
* @return bool Was the option successfully updated?
*
* @todo add validate the input.
*/
public function update_user_tokens( $tokens ) {
return Jetpack_Options::update_option( 'user_tokens', $tokens );
}
/**
* Lock the tokens to the current site URL.
*
* @param int $timespan How long the tokens should be locked, in seconds.
*
* @return bool
*/
public function set_lock( $timespan = HOUR_IN_SECONDS ) {
try {
$expires = ( new DateTime() )->add( DateInterval::createFromDateString( (int) $timespan . ' seconds' ) );
} catch ( Exception $e ) {
return false;
}
if ( false === $expires ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return Jetpack_Options::update_option( 'token_lock', $expires->format( static::DATE_FORMAT_ATOM ) . '|||' . base64_encode( Urls::site_url() ) );
}
/**
* Remove the site lock from tokens.
*
* @return bool
*/
public function remove_lock() {
Jetpack_Options::delete_option( 'token_lock' );
return true;
}
/**
* Check if the domain is locked, remove the lock if needed.
* Possible scenarios:
* - lock expired, site URL matches the lock URL: remove the lock, return false.
* - lock not expired, site URL matches the lock URL: return false.
* - site URL does not match the lock URL (expiration date is ignored): return true, do not remove the lock.
*
* @return bool
*/
public function is_locked() {
$the_lock = Jetpack_Options::get_option( 'token_lock' );
if ( ! $the_lock ) {
// Not locked.
return false;
}
$the_lock = explode( '|||', $the_lock, 2 );
if ( count( $the_lock ) !== 2 ) {
// Something's wrong with the lock.
$this->remove_lock();
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$locked_site_url = base64_decode( $the_lock[1] );
$expires = $the_lock[0];
$expiration_date = DateTime::createFromFormat( static::DATE_FORMAT_ATOM, $expires );
if ( false === $expiration_date || ! $locked_site_url ) {
// Something's wrong with the lock.
$this->remove_lock();
return false;
}
if ( Urls::site_url() === $locked_site_url ) {
if ( new DateTime() > $expiration_date ) {
// Site lock expired.
// Site URL matches, removing the lock.
$this->remove_lock();
}
return false;
}
// Site URL doesn't match.
return true;
}
}
class-tracking.php 0000644 00000023322 15154644771 0010202 0 ustar 00 <?php
/**
* Nosara Tracks for Jetpack
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
/**
* The Tracking class, used to record events in wpcom
*/
class Tracking {
/**
* The assets version.
*
* @since 1.13.1
* @deprecated since 1.40.1
*
* @var string Assets version.
*/
const ASSETS_VERSION = '1.0.0';
/**
* Slug of the product that we are tracking.
*
* @var string
*/
private $product_name;
/**
* Connection manager object.
*
* @var Object
*/
private $connection;
/**
* Creates the Tracking object.
*
* @param String $product_name the slug of the product that we are tracking.
* @param Automattic\Jetpack\Connection\Manager $connection the connection manager object.
*/
public function __construct( $product_name = 'jetpack', $connection = null ) {
$this->product_name = $product_name;
$this->connection = $connection;
if ( $this->connection === null ) {
// TODO We should always pass a Connection.
$this->connection = new Connection\Manager();
}
if ( ! did_action( 'jetpack_set_tracks_ajax_hook' ) ) {
add_action( 'wp_ajax_jetpack_tracks', array( $this, 'ajax_tracks' ) );
/**
* Fires when the Tracking::ajax_tracks() callback has been hooked to the
* wp_ajax_jetpack_tracks action. This action is used to ensure that
* the callback is hooked only once.
*
* @since 1.13.11
*/
do_action( 'jetpack_set_tracks_ajax_hook' );
}
}
/**
* Universal method for for all tracking events triggered via the JavaScript client.
*
* @access public
*/
public function ajax_tracks() {
// Check for nonce.
if (
empty( $_REQUEST['tracksNonce'] )
|| ! wp_verify_nonce( $_REQUEST['tracksNonce'], 'jp-tracks-ajax-nonce' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- WP core doesn't pre-sanitize nonces either.
) {
wp_send_json_error(
__( 'You aren’t authorized to do that.', 'jetpack-connection' ),
403
);
}
if ( ! isset( $_REQUEST['tracksEventName'] ) || ! isset( $_REQUEST['tracksEventType'] ) ) {
wp_send_json_error(
__( 'No valid event name or type.', 'jetpack-connection' ),
403
);
}
$tracks_data = array();
if ( 'click' === $_REQUEST['tracksEventType'] && isset( $_REQUEST['tracksEventProp'] ) ) {
if ( is_array( $_REQUEST['tracksEventProp'] ) ) {
$tracks_data = array_map( 'filter_var', wp_unslash( $_REQUEST['tracksEventProp'] ) );
} else {
$tracks_data = array( 'clicked' => filter_var( wp_unslash( $_REQUEST['tracksEventProp'] ) ) );
}
}
$this->record_user_event( filter_var( wp_unslash( $_REQUEST['tracksEventName'] ) ), $tracks_data, null, false );
wp_send_json_success();
}
/**
* Register script necessary for tracking.
*
* @param boolean $enqueue Also enqueue? defaults to false.
*/
public static function register_tracks_functions_scripts( $enqueue = false ) {
// Register jp-tracks as it is a dependency.
wp_register_script(
'jp-tracks',
'//stats.wp.com/w.js',
array(),
gmdate( 'YW' ),
true
);
Assets::register_script(
'jp-tracks-functions',
'../dist/tracks-callables.js',
__FILE__,
array(
'dependencies' => array( 'jp-tracks' ),
'enqueue' => $enqueue,
'in_footer' => true,
)
);
}
/**
* Enqueue script necessary for tracking.
*/
public function enqueue_tracks_scripts() {
Assets::register_script(
'jptracks',
'../dist/tracks-ajax.js',
__FILE__,
array(
'dependencies' => array( 'jquery' ),
'enqueue' => true,
'in_footer' => true,
)
);
wp_localize_script(
'jptracks',
'jpTracksAJAX',
array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'jpTracksAJAX_nonce' => wp_create_nonce( 'jp-tracks-ajax-nonce' ),
)
);
}
/**
* Send an event in Tracks.
*
* @param string $event_type Type of the event.
* @param array $data Data to send with the event.
* @param mixed $user Username, user_id, or WP_user object.
* @param bool $use_product_prefix Whether to use the object's product name as a prefix to the event type. If
* set to false, the prefix will be 'jetpack_'.
*/
public function record_user_event( $event_type, $data = array(), $user = null, $use_product_prefix = true ) {
if ( ! $user ) {
$user = wp_get_current_user();
}
$site_url = get_option( 'siteurl' );
$data['_via_ua'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$data['_via_ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$data['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';
$data['blog_url'] = $site_url;
$data['blog_id'] = \Jetpack_Options::get_option( 'id' );
// Top level events should not be namespaced.
if ( '_aliasUser' !== $event_type ) {
$prefix = $use_product_prefix ? $this->product_name : 'jetpack';
$event_type = $prefix . '_' . $event_type;
}
$data['jetpack_version'] = defined( 'JETPACK__VERSION' ) ? JETPACK__VERSION : '0';
return $this->tracks_record_event( $user, $event_type, $data );
}
/**
* Record an event in Tracks - this is the preferred way to record events from PHP.
*
* @param mixed $user username, user_id, or WP_user object.
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
* @param int $event_timestamp_millis The time in millis since 1970-01-01 00:00:00 when the event occurred.
*
* @return bool true for success | \WP_Error if the event pixel could not be fired
*/
public function tracks_record_event( $user, $event_name, $properties = array(), $event_timestamp_millis = false ) {
// We don't want to track user events during unit tests/CI runs.
if ( $user instanceof \WP_User && 'wptests_capabilities' === $user->cap_key ) {
return false;
}
$terms_of_service = new Terms_Of_Service();
$status = new Status();
// Don't track users who have not agreed to our TOS.
if ( ! $this->should_enable_tracking( $terms_of_service, $status ) ) {
return false;
}
$event_obj = $this->tracks_build_event_obj( $user, $event_name, $properties, $event_timestamp_millis );
if ( is_wp_error( $event_obj->error ) ) {
return $event_obj->error;
}
return $event_obj->record();
}
/**
* Determines whether tracking should be enabled.
*
* @param Automattic\Jetpack\Terms_Of_Service $terms_of_service A Terms_Of_Service object.
* @param Automattic\Jetpack\Status $status A Status object.
*
* @return boolean True if tracking should be enabled, else false.
*/
public function should_enable_tracking( $terms_of_service, $status ) {
if ( $status->is_offline_mode() ) {
return false;
}
return $terms_of_service->has_agreed() || $this->connection->is_user_connected();
}
/**
* Procedurally build a Tracks Event Object.
* NOTE: Use this only when the simpler Automattic\Jetpack\Tracking->jetpack_tracks_record_event() function won't work for you.
*
* @param WP_user $user WP_user object.
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
* @param int $event_timestamp_millis The time in millis since 1970-01-01 00:00:00 when the event occurred.
*
* @return \Jetpack_Tracks_Event|\WP_Error
*/
private function tracks_build_event_obj( $user, $event_name, $properties = array(), $event_timestamp_millis = false ) {
$identity = $this->tracks_get_identity( $user->ID );
$properties['user_lang'] = $user->get( 'WPLANG' );
$blog_details = array(
'blog_lang' => isset( $properties['blog_lang'] ) ? $properties['blog_lang'] : get_bloginfo( 'language' ),
);
$timestamp = ( false !== $event_timestamp_millis ) ? $event_timestamp_millis : round( microtime( true ) * 1000 );
$timestamp_string = is_string( $timestamp ) ? $timestamp : number_format( $timestamp, 0, '', '' );
return new \Jetpack_Tracks_Event(
array_merge(
$blog_details,
(array) $properties,
$identity,
array(
'_en' => $event_name,
'_ts' => $timestamp_string,
)
)
);
}
/**
* Get the identity to send to tracks.
*
* @param int $user_id The user id of the local user.
*
* @return array $identity
*/
public function tracks_get_identity( $user_id ) {
// Meta is set, and user is still connected. Use WPCOM ID.
$wpcom_id = get_user_meta( $user_id, 'jetpack_tracks_wpcom_id', true );
if ( $wpcom_id && $this->connection->is_user_connected( $user_id ) ) {
return array(
'_ut' => 'wpcom:user_id',
'_ui' => $wpcom_id,
);
}
// User is connected, but no meta is set yet. Use WPCOM ID and set meta.
if ( $this->connection->is_user_connected( $user_id ) ) {
$wpcom_user_data = $this->connection->get_connected_user_data( $user_id );
update_user_meta( $user_id, 'jetpack_tracks_wpcom_id', $wpcom_user_data['ID'] );
return array(
'_ut' => 'wpcom:user_id',
'_ui' => $wpcom_user_data['ID'],
);
}
// User isn't linked at all. Fall back to anonymous ID.
$anon_id = get_user_meta( $user_id, 'jetpack_tracks_anon_id', true );
if ( ! $anon_id ) {
$anon_id = \Jetpack_Tracks_Client::get_anon_id();
add_user_meta( $user_id, 'jetpack_tracks_anon_id', $anon_id, false );
}
if ( ! isset( $_COOKIE['tk_ai'] ) && ! headers_sent() ) {
setcookie( 'tk_ai', $anon_id, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false ); // phpcs:ignore Jetpack.Functions.SetCookie -- This is a random string and should be fine.
}
return array(
'_ut' => 'anon',
'_ui' => $anon_id,
);
}
}
class-urls.php 0000644 00000011611 15154644771 0007363 0 ustar 00 <?php
/**
* The Jetpack Connection package Urls class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
/**
* Provides Url methods for the Connection package.
*/
class Urls {
const HTTPS_CHECK_OPTION_PREFIX = 'jetpack_sync_https_history_';
const HTTPS_CHECK_HISTORY = 5;
/**
* Return URL from option or PHP constant.
*
* @param string $option_name (e.g. 'home').
*
* @return mixed|null URL.
*/
public static function get_raw_url( $option_name ) {
$value = null;
$constant = ( 'home' === $option_name )
? 'WP_HOME'
: 'WP_SITEURL';
// Since we disregard the constant for multisites in ms-default-filters.php,
// let's also use the db value if this is a multisite.
if ( ! is_multisite() && Constants::is_defined( $constant ) ) {
$value = Constants::get_constant( $constant );
} else {
// Let's get the option from the database so that we can bypass filters. This will help
// ensure that we get more uniform values.
$value = \Jetpack_Options::get_raw_option( $option_name );
}
return $value;
}
/**
* Normalize domains by removing www unless declared in the site's option.
*
* @param string $option Option value from the site.
* @param callable $url_function Function retrieving the URL to normalize.
* @return mixed|string URL.
*/
public static function normalize_www_in_url( $option, $url_function ) {
$url = wp_parse_url( call_user_func( $url_function ) );
$option_url = wp_parse_url( get_option( $option ) );
if ( ! $option_url || ! $url ) {
return $url;
}
if ( "www.{$option_url[ 'host' ]}" === $url['host'] ) {
// remove www if not present in option URL.
$url['host'] = $option_url['host'];
}
if ( "www.{$url[ 'host' ]}" === $option_url['host'] ) {
// add www if present in option URL.
$url['host'] = $option_url['host'];
}
$normalized_url = "{$url['scheme']}://{$url['host']}";
if ( isset( $url['path'] ) ) {
$normalized_url .= "{$url['path']}";
}
if ( isset( $url['query'] ) ) {
$normalized_url .= "?{$url['query']}";
}
return $normalized_url;
}
/**
* Return URL with a normalized protocol.
*
* @param callable $callable Function to retrieve URL option.
* @param string $new_value URL Protocol to set URLs to.
* @return string Normalized URL.
*/
public static function get_protocol_normalized_url( $callable, $new_value ) {
$option_key = self::HTTPS_CHECK_OPTION_PREFIX . $callable;
$parsed_url = wp_parse_url( $new_value );
if ( ! $parsed_url ) {
return $new_value;
}
if ( array_key_exists( 'scheme', $parsed_url ) ) {
$scheme = $parsed_url['scheme'];
} else {
$scheme = '';
}
$scheme_history = get_option( $option_key, array() );
$scheme_history[] = $scheme;
// Limit length to self::HTTPS_CHECK_HISTORY.
$scheme_history = array_slice( $scheme_history, ( self::HTTPS_CHECK_HISTORY * -1 ) );
update_option( $option_key, $scheme_history );
$forced_scheme = in_array( 'https', $scheme_history, true ) ? 'https' : 'http';
return set_url_scheme( $new_value, $forced_scheme );
}
/**
* Helper function that is used when getting home or siteurl values. Decides
* whether to get the raw or filtered value.
*
* @param string $url_type URL to get, home or siteurl.
* @return string
*/
public static function get_raw_or_filtered_url( $url_type ) {
$url_function = ( 'home' === $url_type )
? 'home_url'
: 'site_url';
if (
! Constants::is_defined( 'JETPACK_SYNC_USE_RAW_URL' ) ||
Constants::get_constant( 'JETPACK_SYNC_USE_RAW_URL' )
) {
$scheme = is_ssl() ? 'https' : 'http';
$url = (string) self::get_raw_url( $url_type );
$url = set_url_scheme( $url, $scheme );
} else {
$url = self::normalize_www_in_url( $url_type, $url_function );
}
return self::get_protocol_normalized_url( $url_function, $url );
}
/**
* Return the escaped home_url.
*
* @return string
*/
public static function home_url() {
$url = self::get_raw_or_filtered_url( 'home' );
/**
* Allows overriding of the home_url value that is synced back to WordPress.com.
*
* @since 1.7.0
* @since-jetpack 5.2.0
*
* @param string $home_url
*/
return esc_url_raw( apply_filters( 'jetpack_sync_home_url', $url ) );
}
/**
* Return the escaped siteurl.
*
* @return string
*/
public static function site_url() {
$url = self::get_raw_or_filtered_url( 'siteurl' );
/**
* Allows overriding of the site_url value that is synced back to WordPress.com.
*
* @since 1.7.0
* @since-jetpack 5.2.0
*
* @param string $site_url
*/
return esc_url_raw( apply_filters( 'jetpack_sync_site_url', $url ) );
}
/**
* Return main site URL with a normalized protocol.
*
* @return string
*/
public static function main_network_site_url() {
return self::get_protocol_normalized_url( 'main_network_site_url', network_site_url() );
}
}
class-utils.php 0000644 00000004556 15154644771 0007550 0 ustar 00 <?php
/**
* The Jetpack Connection package Utils class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Tracking;
/**
* Provides utility methods for the Connection package.
*/
class Utils {
const DEFAULT_JETPACK__API_VERSION = 1;
const DEFAULT_JETPACK__API_BASE = 'https://jetpack.wordpress.com/jetpack.';
const DEFAULT_JETPACK__WPCOM_JSON_API_BASE = 'https://public-api.wordpress.com';
/**
* Enters a user token into the user_tokens option
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->update_user_token() instead.
*
* @param int $user_id The user id.
* @param string $token The user token.
* @param bool $is_master_user Whether the user is the master user.
* @return bool
*/
public static function update_user_token( $user_id, $token, $is_master_user ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->update_user_token' );
return ( new Tokens() )->update_user_token( $user_id, $token, $is_master_user );
}
/**
* Filters the value of the api constant.
*
* @param String $constant_value The constant value.
* @param String $constant_name The constant name.
* @return mixed | null
*/
public static function jetpack_api_constant_filter( $constant_value, $constant_name ) {
if ( $constant_value !== null ) {
// If the constant value was already set elsewhere, use that value.
return $constant_value;
}
if ( defined( "self::DEFAULT_$constant_name" ) ) {
return constant( "self::DEFAULT_$constant_name" );
}
return null;
}
/**
* Add a filter to initialize default values of the constants.
*/
public static function init_default_constants() {
add_filter(
'jetpack_constant_default_value',
array( __CLASS__, 'jetpack_api_constant_filter' ),
10,
2
);
}
/**
* Filters the registration request body to include tracking properties.
*
* @param array $properties Already prepared tracking properties.
* @return array amended properties.
*/
public static function filter_register_request_body( $properties ) {
$tracking = new Tracking();
$tracks_identity = $tracking->tracks_get_identity( get_current_user_id() );
return array_merge(
$properties,
array(
'_ui' => $tracks_identity['_ui'],
'_ut' => $tracks_identity['_ut'],
)
);
}
}
class-webhooks.php 0000644 00000014426 15154644771 0010226 0 ustar 00 <?php
/**
* Connection Webhooks class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\CookieState;
use Automattic\Jetpack\Roles;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\Tracking;
use Jetpack_Options;
/**
* Connection Webhooks class.
*/
class Webhooks {
/**
* The Connection Manager object.
*
* @var Manager
*/
private $connection;
/**
* Webhooks constructor.
*
* @param Manager $connection The Connection Manager object.
*/
public function __construct( $connection ) {
$this->connection = $connection;
}
/**
* Initialize the webhooks.
*
* @param Manager $connection The Connection Manager object.
*/
public static function init( $connection ) {
$webhooks = new static( $connection );
add_action( 'init', array( $webhooks, 'controller' ) );
add_action( 'load-toplevel_page_jetpack', array( $webhooks, 'fallback_jetpack_controller' ) );
}
/**
* Jetpack plugin used to trigger this webhooks in Jetpack::admin_page_load()
*
* The Jetpack toplevel menu is still accessible for stand-alone plugins, and while there's no content for that page, there are still
* actions from Calypso and WPCOM that reach that route regardless of the site having the Jetpack plugin or not. That's why we are still handling it here.
*/
public function fallback_jetpack_controller() {
$this->controller( true );
}
/**
* The "controller" decides which handler we need to run.
*
* @param bool $force Do not check if it's a webhook request and just run the controller.
*/
public function controller( $force = false ) {
if ( ! $force ) {
// The nonce is verified in specific handlers.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['handler'] ) || 'jetpack-connection-webhooks' !== $_GET['handler'] ) {
return;
}
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['connect_url_redirect'] ) ) {
$this->handle_connect_url_redirect();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['action'] ) ) {
return;
}
// The nonce is verified in specific handlers.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
switch ( $_GET['action'] ) {
case 'authorize':
$this->handle_authorize();
$this->do_exit();
break;
case 'authorize_redirect':
$this->handle_authorize_redirect();
$this->do_exit();
break;
// Class Jetpack::admin_page_load() still handles other cases.
}
}
/**
* Perform the authorization action.
*/
public function handle_authorize() {
if ( $this->connection->is_connected() && $this->connection->is_user_connected() ) {
$redirect_url = apply_filters( 'jetpack_client_authorize_already_authorized_url', admin_url() );
wp_safe_redirect( $redirect_url );
return;
}
do_action( 'jetpack_client_authorize_processing' );
$data = stripslashes_deep( $_GET );
$data['auth_type'] = 'client';
$roles = new Roles();
$role = $roles->translate_current_user_to_role();
$redirect = isset( $data['redirect'] ) ? esc_url_raw( (string) $data['redirect'] ) : '';
check_admin_referer( "jetpack-authorize_{$role}_{$redirect}" );
$tracking = new Tracking();
$result = $this->connection->authorize( $data );
if ( is_wp_error( $result ) ) {
do_action( 'jetpack_client_authorize_error', $result );
$tracking->record_user_event(
'jpc_client_authorize_fail',
array(
'error_code' => $result->get_error_code(),
'error_message' => $result->get_error_message(),
)
);
} else {
/**
* Fires after the Jetpack client is authorized to communicate with WordPress.com.
*
* @param int Jetpack Blog ID.
*
* @since 1.7.0
* @since-jetpack 4.2.0
*/
do_action( 'jetpack_client_authorized', Jetpack_Options::get_option( 'id' ) );
$tracking->record_user_event( 'jpc_client_authorize_success' );
}
$fallback_redirect = apply_filters( 'jetpack_client_authorize_fallback_url', admin_url() );
$redirect = wp_validate_redirect( $redirect ) ? $redirect : $fallback_redirect;
wp_safe_redirect( $redirect );
}
/**
* The authorhize_redirect webhook handler
*/
public function handle_authorize_redirect() {
$authorize_redirect_handler = new Webhooks\Authorize_Redirect( $this->connection );
$authorize_redirect_handler->handle();
}
/**
* The `exit` is wrapped into a method so we could mock it.
*/
protected function do_exit() {
exit;
}
/**
* Handle the `connect_url_redirect` action,
* which is usually called to repeat an attempt for user to authorize the connection.
*
* @return void
*/
public function handle_connect_url_redirect() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
$from = ! empty( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : 'iframe';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- no site changes, sanitization happens in get_authorization_url()
$redirect = ! empty( $_GET['redirect_after_auth'] ) ? wp_unslash( $_GET['redirect_after_auth'] ) : false;
add_filter( 'allowed_redirect_hosts', array( Host::class, 'allow_wpcom_environments' ) );
if ( ! $this->connection->is_user_connected() ) {
if ( ! $this->connection->is_connected() ) {
$this->connection->register();
}
$connect_url = add_query_arg( 'from', $from, $this->connection->get_authorization_url( null, $redirect ) );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
if ( isset( $_GET['notes_iframe'] ) ) {
$connect_url .= '¬es_iframe';
}
wp_safe_redirect( $connect_url );
$this->do_exit();
} elseif ( ! isset( $_GET['calypso_env'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
( new CookieState() )->state( 'message', 'already_authorized' );
wp_safe_redirect( $redirect );
$this->do_exit();
} else {
$connect_url = add_query_arg(
array(
'from' => $from,
'already_authorized' => true,
),
$this->connection->get_authorization_url()
);
wp_safe_redirect( $connect_url );
$this->do_exit();
}
}
}
class-xmlrpc-async-call.php 0000644 00000005117 15154644771 0011733 0 ustar 00 <?php
/**
* XMLRPC Async Call class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Jetpack_IXR_ClientMulticall;
/**
* Make XMLRPC async calls to WordPress.com
*
* This class allows you to enqueue XMLRPC calls that will be grouped and sent
* at once in a multi-call request at shutdown.
*
* Usage:
*
* XMLRPC_Async_Call::add_call( 'methodName', get_current_user_id(), $arg1, $arg2, etc... )
*
* See XMLRPC_Async_Call::add_call for details
*/
class XMLRPC_Async_Call {
/**
* Hold the IXR Clients that will be dispatched at shutdown
*
* Clients are stored in the following schema:
* [
* $blog_id => [
* $user_id => [
* arrat of Jetpack_IXR_ClientMulticall
* ]
* ]
* ]
*
* @var array
*/
public static $clients = array();
/**
* Adds a new XMLRPC call to the queue to be processed on shutdown
*
* @param string $method The XML-RPC method.
* @param integer $user_id The user ID used to make the request (will use this user's token); Use 0 for the blog token.
* @param mixed ...$args This function accepts any number of additional arguments, that will be passed to the call.
* @return void
*/
public static function add_call( $method, $user_id = 0, ...$args ) {
global $blog_id;
$client_blog_id = is_multisite() ? $blog_id : 0;
if ( ! isset( self::$clients[ $client_blog_id ] ) ) {
self::$clients[ $client_blog_id ] = array();
}
if ( ! isset( self::$clients[ $client_blog_id ][ $user_id ] ) ) {
self::$clients[ $client_blog_id ][ $user_id ] = new Jetpack_IXR_ClientMulticall( array( 'user_id' => $user_id ) );
}
if ( function_exists( 'ignore_user_abort' ) ) {
ignore_user_abort( true );
}
array_unshift( $args, $method );
call_user_func_array( array( self::$clients[ $client_blog_id ][ $user_id ], 'addCall' ), $args );
if ( false === has_action( 'shutdown', array( 'Automattic\Jetpack\Connection\XMLRPC_Async_Call', 'do_calls' ) ) ) {
add_action( 'shutdown', array( 'Automattic\Jetpack\Connection\XMLRPC_Async_Call', 'do_calls' ) );
}
}
/**
* Trigger the calls at shutdown
*
* @return void
*/
public static function do_calls() {
foreach ( self::$clients as $client_blog_id => $blog_clients ) {
if ( $client_blog_id > 0 ) {
$switch_success = switch_to_blog( $client_blog_id, true );
if ( ! $switch_success ) {
continue;
}
}
foreach ( $blog_clients as $client ) {
if ( empty( $client->calls ) ) {
continue;
}
flush();
$client->query();
}
if ( $client_blog_id > 0 ) {
restore_current_blog();
}
}
}
}
class-xmlrpc-connector.php 0000644 00000003502 15154644771 0011673 0 ustar 00 <?php
/**
* Sets up the Connection XML-RPC methods.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* Registers the XML-RPC methods for Connections.
*/
class XMLRPC_Connector {
/**
* The Connection Manager.
*
* @var Manager
*/
private $connection;
/**
* Constructor.
*
* @param Manager $connection The Connection Manager.
*/
public function __construct( Manager $connection ) {
$this->connection = $connection;
// Adding the filter late to avoid being overwritten by Jetpack's XMLRPC server.
add_filter( 'xmlrpc_methods', array( $this, 'xmlrpc_methods' ), 20 );
}
/**
* Attached to the `xmlrpc_methods` filter.
*
* @param array $methods The already registered XML-RPC methods.
* @return array
*/
public function xmlrpc_methods( $methods ) {
return array_merge(
$methods,
array(
'jetpack.verifyRegistration' => array( $this, 'verify_registration' ),
)
);
}
/**
* Handles verification that a site is registered.
*
* @param array $registration_data The data sent by the XML-RPC client:
* [ $secret_1, $user_id ].
*
* @return string|IXR_Error
*/
public function verify_registration( $registration_data ) {
return $this->output( $this->connection->handle_registration( $registration_data ) );
}
/**
* Normalizes output for XML-RPC.
*
* @param mixed $data The data to output.
*/
private function output( $data ) {
if ( is_wp_error( $data ) ) {
$code = $data->get_error_data();
if ( ! $code ) {
$code = -10520;
}
if ( ! class_exists( \IXR_Error::class ) ) {
require_once ABSPATH . WPINC . '/class-IXR.php';
}
return new \IXR_Error(
$code,
sprintf( 'Jetpack: [%s] %s', $data->get_error_code(), $data->get_error_message() )
);
}
return $data;
}
}
interface-manager.php 0000644 00000000452 15154644771 0010644 0 ustar 00 <?php
/**
* The Jetpack Connection Interface file.
* No longer used.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* This interface is no longer used and is now deprecated.
*
* @deprecated since jetpack 7.8
*/
interface Manager_Interface {
}
webhooks/class-authorize-redirect.php 0000644 00000014112 15154644771 0014027 0 ustar 00 <?php
/**
* Authorize_Redirect Webhook handler class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection\Webhooks;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Licensing;
use Automattic\Jetpack\Tracking;
use GP_Locales;
use Jetpack_Network;
/**
* Authorize_Redirect Webhook handler class.
*/
class Authorize_Redirect {
/**
* Constructs the object
*
* @param Automattic\Jetpack\Connection\Manager $connection The Connection Manager object.
*/
public function __construct( $connection ) {
$this->connection = $connection;
}
/**
* Handle the webhook
*
* This method implements what's in Jetpack::admin_page_load when the Jetpack plugin is not present
*/
public function handle() {
add_filter(
'allowed_redirect_hosts',
function ( $domains ) {
$domains[] = 'jetpack.com';
$domains[] = 'jetpack.wordpress.com';
$domains[] = 'wordpress.com';
// Calypso envs.
$domains[] = 'calypso.localhost';
$domains[] = 'wpcalypso.wordpress.com';
$domains[] = 'horizon.wordpress.com';
return array_unique( $domains );
}
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$dest_url = empty( $_GET['dest_url'] ) ? null : esc_url_raw( wp_unslash( $_GET['dest_url'] ) );
if ( ! $dest_url || ( 0 === stripos( $dest_url, 'https://jetpack.com/' ) && 0 === stripos( $dest_url, 'https://wordpress.com/' ) ) ) {
// The destination URL is missing or invalid, nothing to do here.
exit;
}
// The user is either already connected, or finished the connection process.
if ( $this->connection->is_connected() && $this->connection->is_user_connected() ) {
if ( class_exists( '\Automattic\Jetpack\Licensing' ) && method_exists( '\Automattic\Jetpack\Licensing', 'handle_user_connected_redirect' ) ) {
Licensing::instance()->handle_user_connected_redirect( $dest_url );
}
wp_safe_redirect( $dest_url );
exit;
} elseif ( ! empty( $_GET['done'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// The user decided not to proceed with setting up the connection.
wp_safe_redirect( Admin_Menu::get_top_level_menu_item_url() );
exit;
}
$redirect_args = array(
'page' => 'jetpack',
'action' => 'authorize_redirect',
'dest_url' => rawurlencode( $dest_url ),
'done' => '1',
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['from'] ) && 'jetpack_site_only_checkout' === $_GET['from'] ) {
$redirect_args['from'] = 'jetpack_site_only_checkout';
}
wp_safe_redirect( $this->build_authorize_url( add_query_arg( $redirect_args, admin_url( 'admin.php' ) ) ) );
exit;
}
/**
* Create the Jetpack authorization URL. Copied from Jetpack class.
*
* @param bool|string $redirect URL to redirect to.
*
* @todo Update default value for redirect since the called function expects a string.
*
* @return mixed|void
*/
public function build_authorize_url( $redirect = false ) {
add_filter( 'jetpack_connect_request_body', array( __CLASS__, 'filter_connect_request_body' ) );
add_filter( 'jetpack_connect_redirect_url', array( __CLASS__, 'filter_connect_redirect_url' ) );
$url = $this->connection->get_authorization_url( wp_get_current_user(), $redirect );
remove_filter( 'jetpack_connect_request_body', array( __CLASS__, 'filter_connect_request_body' ) );
remove_filter( 'jetpack_connect_redirect_url', array( __CLASS__, 'filter_connect_redirect_url' ) );
/**
* This filter is documented in plugins/jetpack/class-jetpack.php
*/
return apply_filters( 'jetpack_build_authorize_url', $url );
}
/**
* Filters the redirection URL that is used for connect requests. The redirect
* URL should return the user back to the Jetpack console.
* Copied from Jetpack class.
*
* @param String $redirect the default redirect URL used by the package.
* @return String the modified URL.
*/
public static function filter_connect_redirect_url( $redirect ) {
$jetpack_admin_page = esc_url_raw( admin_url( 'admin.php?page=jetpack' ) );
$redirect = $redirect
? wp_validate_redirect( esc_url_raw( $redirect ), $jetpack_admin_page )
: $jetpack_admin_page;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['is_multisite'] ) ) {
$redirect = Jetpack_Network::init()->get_url( 'network_admin_page' );
}
return $redirect;
}
/**
* Filters the connection URL parameter array.
* Copied from Jetpack class.
*
* @param array $args default URL parameters used by the package.
* @return array the modified URL arguments array.
*/
public static function filter_connect_request_body( $args ) {
if (
Constants::is_defined( 'JETPACK__GLOTPRESS_LOCALES_PATH' )
&& include_once Constants::get_constant( 'JETPACK__GLOTPRESS_LOCALES_PATH' )
) {
$gp_locale = GP_Locales::by_field( 'wp_locale', get_locale() );
$args['locale'] = isset( $gp_locale ) && isset( $gp_locale->slug )
? $gp_locale->slug
: '';
}
$tracking = new Tracking();
$tracks_identity = $tracking->tracks_get_identity( $args['state'] );
$args = array_merge(
$args,
array(
'_ui' => $tracks_identity['_ui'],
'_ut' => $tracks_identity['_ut'],
)
);
$calypso_env = self::get_calypso_env();
if ( ! empty( $calypso_env ) ) {
$args['calypso_env'] = $calypso_env;
}
return $args;
}
/**
* Return Calypso environment value; used for developing Jetpack and pairing
* it with different Calypso enrionments, such as localhost.
* Copied from Jetpack class.
*
* @since 1.37.1
*
* @return string Calypso environment
*/
public static function get_calypso_env() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['calypso_env'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return sanitize_key( $_GET['calypso_env'] );
}
if ( getenv( 'CALYPSO_ENV' ) ) {
return sanitize_key( getenv( 'CALYPSO_ENV' ) );
}
if ( defined( 'CALYPSO_ENV' ) && CALYPSO_ENV ) {
return sanitize_key( CALYPSO_ENV );
}
return '';
}
}
AutoloadFileWriter.php 0000644 00000005636 15154650006 0011036 0 ustar 00 <?php // phpcs:ignore WordPress.Files.FileName
/**
* Autoloader file writer.
*
* @package automattic/jetpack-autoloader
*/
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
// phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export
namespace Automattic\Jetpack\Autoloader;
/**
* Class AutoloadFileWriter.
*/
class AutoloadFileWriter {
/**
* The file comment to use.
*/
const COMMENT = <<<AUTOLOADER_COMMENT
/**
* This file was automatically generated by automattic/jetpack-autoloader.
*
* @package automattic/jetpack-autoloader
*/
AUTOLOADER_COMMENT;
/**
* Copies autoloader files and replaces any placeholders in them.
*
* @param IOInterface|null $io An IO for writing to.
* @param string $outDir The directory to place the autoloader files in.
* @param string $suffix The suffix to use in the autoloader's namespace.
*/
public static function copyAutoloaderFiles( $io, $outDir, $suffix ) {
$renameList = array(
'autoload.php' => '../autoload_packages.php',
);
$ignoreList = array(
'AutoloadGenerator.php',
'AutoloadProcessor.php',
'CustomAutoloaderPlugin.php',
'ManifestGenerator.php',
'AutoloadFileWriter.php',
);
// Copy all of the autoloader files.
$files = scandir( __DIR__ );
foreach ( $files as $file ) {
// Only PHP files will be copied.
if ( substr( $file, -4 ) !== '.php' ) {
continue;
}
if ( in_array( $file, $ignoreList, true ) ) {
continue;
}
$newFile = isset( $renameList[ $file ] ) ? $renameList[ $file ] : $file;
$content = self::prepareAutoloaderFile( $file, $suffix );
$written = file_put_contents( $outDir . '/' . $newFile, $content );
if ( $io ) {
if ( $written ) {
$io->writeError( " <info>Generated: $newFile</info>" );
} else {
$io->writeError( " <error>Error: $newFile</error>" );
}
}
}
}
/**
* Prepares an autoloader file to be written to the destination.
*
* @param String $filename a file to prepare.
* @param String $suffix Unique suffix used in the namespace.
*
* @return string
*/
private static function prepareAutoloaderFile( $filename, $suffix ) {
$header = self::COMMENT;
$header .= PHP_EOL;
$header .= 'namespace Automattic\Jetpack\Autoloader\jp' . $suffix . ';';
$header .= PHP_EOL . PHP_EOL;
$sourceLoader = fopen( __DIR__ . '/' . $filename, 'r' );
$file_contents = stream_get_contents( $sourceLoader );
return str_replace(
'/* HEADER */',
$header,
$file_contents
);
}
}
AutoloadGenerator.php 0000644 00000034237 15154650006 0010707 0 ustar 00 <?php // phpcs:ignore WordPress.Files.FileName
/**
* Autoloader Generator.
*
* @package automattic/jetpack-autoloader
*/
// phpcs:disable PHPCompatibility.Keywords.NewKeywords.t_useFound
// phpcs:disable PHPCompatibility.LanguageConstructs.NewLanguageConstructs.t_ns_separatorFound
// phpcs:disable PHPCompatibility.FunctionDeclarations.NewClosure.Found
// phpcs:disable PHPCompatibility.Keywords.NewKeywords.t_namespaceFound
// phpcs:disable PHPCompatibility.Keywords.NewKeywords.t_dirFound
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export
// phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
namespace Automattic\Jetpack\Autoloader;
use Composer\Composer;
use Composer\Config;
use Composer\Installer\InstallationManager;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Repository\InstalledRepositoryInterface;
use Composer\Util\Filesystem;
use Composer\Util\PackageSorter;
/**
* Class AutoloadGenerator.
*/
class AutoloadGenerator {
/**
* IO object.
*
* @var IOInterface IO object.
*/
private $io;
/**
* The filesystem utility.
*
* @var Filesystem
*/
private $filesystem;
/**
* Instantiate an AutoloadGenerator object.
*
* @param IOInterface $io IO object.
*/
public function __construct( IOInterface $io = null ) {
$this->io = $io;
$this->filesystem = new Filesystem();
}
/**
* Dump the Jetpack autoloader files.
*
* @param Composer $composer The Composer object.
* @param Config $config Config object.
* @param InstalledRepositoryInterface $localRepo Installed Repository object.
* @param PackageInterface $mainPackage Main Package object.
* @param InstallationManager $installationManager Manager for installing packages.
* @param string $targetDir Path to the current target directory.
* @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap.
* @param string $suffix The autoloader suffix.
*/
public function dump(
Composer $composer,
Config $config,
InstalledRepositoryInterface $localRepo,
PackageInterface $mainPackage,
InstallationManager $installationManager,
$targetDir,
$scanPsrPackages = false,
$suffix = null
) {
$this->filesystem->ensureDirectoryExists( $config->get( 'vendor-dir' ) );
$packageMap = $composer->getAutoloadGenerator()->buildPackageMap( $installationManager, $mainPackage, $localRepo->getCanonicalPackages() );
$autoloads = $this->parseAutoloads( $packageMap, $mainPackage );
// Convert the autoloads into a format that the manifest generator can consume more easily.
$basePath = $this->filesystem->normalizePath( realpath( getcwd() ) );
$vendorPath = $this->filesystem->normalizePath( realpath( $config->get( 'vendor-dir' ) ) );
$processedAutoloads = $this->processAutoloads( $autoloads, $scanPsrPackages, $vendorPath, $basePath );
unset( $packageMap, $autoloads );
// Make sure none of the legacy files remain that can lead to problems with the autoloader.
$this->removeLegacyFiles( $vendorPath );
// Write all of the files now that we're done.
$this->writeAutoloaderFiles( $vendorPath . '/jetpack-autoloader/', $suffix );
$this->writeManifests( $vendorPath . '/' . $targetDir, $processedAutoloads );
if ( ! $scanPsrPackages ) {
$this->io->writeError( '<warning>You are generating an unoptimized autoloader. If this is a production build, consider using the -o option.</warning>' );
}
}
/**
* Compiles an ordered list of namespace => path mappings
*
* @param array $packageMap Array of array(package, installDir-relative-to-composer.json).
* @param PackageInterface $mainPackage Main package instance.
*
* @return array The list of path mappings.
*/
public function parseAutoloads( array $packageMap, PackageInterface $mainPackage ) {
$rootPackageMap = array_shift( $packageMap );
$sortedPackageMap = $this->sortPackageMap( $packageMap );
$sortedPackageMap[] = $rootPackageMap;
array_unshift( $packageMap, $rootPackageMap );
$psr0 = $this->parseAutoloadsType( $packageMap, 'psr-0', $mainPackage );
$psr4 = $this->parseAutoloadsType( $packageMap, 'psr-4', $mainPackage );
$classmap = $this->parseAutoloadsType( array_reverse( $sortedPackageMap ), 'classmap', $mainPackage );
$files = $this->parseAutoloadsType( $sortedPackageMap, 'files', $mainPackage );
krsort( $psr0 );
krsort( $psr4 );
return array(
'psr-0' => $psr0,
'psr-4' => $psr4,
'classmap' => $classmap,
'files' => $files,
);
}
/**
* Sorts packages by dependency weight
*
* Packages of equal weight retain the original order
*
* @param array $packageMap The package map.
*
* @return array
*/
protected function sortPackageMap( array $packageMap ) {
$packages = array();
$paths = array();
foreach ( $packageMap as $item ) {
list( $package, $path ) = $item;
$name = $package->getName();
$packages[ $name ] = $package;
$paths[ $name ] = $path;
}
$sortedPackages = PackageSorter::sortPackages( $packages );
$sortedPackageMap = array();
foreach ( $sortedPackages as $package ) {
$name = $package->getName();
$sortedPackageMap[] = array( $packages[ $name ], $paths[ $name ] );
}
return $sortedPackageMap;
}
/**
* Returns the file identifier.
*
* @param PackageInterface $package The package instance.
* @param string $path The path.
*/
protected function getFileIdentifier( PackageInterface $package, $path ) {
return md5( $package->getName() . ':' . $path );
}
/**
* Returns the path code for the given path.
*
* @param Filesystem $filesystem The filesystem instance.
* @param string $basePath The base path.
* @param string $vendorPath The vendor path.
* @param string $path The path.
*
* @return string The path code.
*/
protected function getPathCode( Filesystem $filesystem, $basePath, $vendorPath, $path ) {
if ( ! $filesystem->isAbsolutePath( $path ) ) {
$path = $basePath . '/' . $path;
}
$path = $filesystem->normalizePath( $path );
$baseDir = '';
if ( 0 === strpos( $path . '/', $vendorPath . '/' ) ) {
$path = substr( $path, strlen( $vendorPath ) );
$baseDir = '$vendorDir';
if ( false !== $path ) {
$baseDir .= ' . ';
}
} else {
$path = $filesystem->normalizePath( $filesystem->findShortestPath( $basePath, $path, true ) );
if ( ! $filesystem->isAbsolutePath( $path ) ) {
$baseDir = '$baseDir . ';
$path = '/' . $path;
}
}
if ( strpos( $path, '.phar' ) !== false ) {
$baseDir = "'phar://' . " . $baseDir;
}
return $baseDir . ( ( false !== $path ) ? var_export( $path, true ) : '' );
}
/**
* This function differs from the composer parseAutoloadsType in that beside returning the path.
* It also return the path and the version of a package.
*
* Supports PSR-4, PSR-0, and classmap parsing.
*
* @param array $packageMap Map of all the packages.
* @param string $type Type of autoloader to use.
* @param PackageInterface $mainPackage Instance of the Package Object.
*
* @return array
*/
protected function parseAutoloadsType( array $packageMap, $type, PackageInterface $mainPackage ) {
$autoloads = array();
foreach ( $packageMap as $item ) {
list($package, $installPath) = $item;
$autoload = $package->getAutoload();
if ( $package === $mainPackage ) {
$autoload = array_merge_recursive( $autoload, $package->getDevAutoload() );
}
if ( null !== $package->getTargetDir() && $package !== $mainPackage ) {
$installPath = substr( $installPath, 0, -strlen( '/' . $package->getTargetDir() ) );
}
if ( in_array( $type, array( 'psr-4', 'psr-0' ), true ) && isset( $autoload[ $type ] ) && is_array( $autoload[ $type ] ) ) {
foreach ( $autoload[ $type ] as $namespace => $paths ) {
$paths = is_array( $paths ) ? $paths : array( $paths );
foreach ( $paths as $path ) {
$relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path;
$autoloads[ $namespace ][] = array(
'path' => $relativePath,
'version' => $package->getVersion(), // Version of the class comes from the package - should we try to parse it?
);
}
}
}
if ( 'classmap' === $type && isset( $autoload['classmap'] ) && is_array( $autoload['classmap'] ) ) {
foreach ( $autoload['classmap'] as $paths ) {
$paths = is_array( $paths ) ? $paths : array( $paths );
foreach ( $paths as $path ) {
$relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path;
$autoloads[] = array(
'path' => $relativePath,
'version' => $package->getVersion(), // Version of the class comes from the package - should we try to parse it?
);
}
}
}
if ( 'files' === $type && isset( $autoload['files'] ) && is_array( $autoload['files'] ) ) {
foreach ( $autoload['files'] as $paths ) {
$paths = is_array( $paths ) ? $paths : array( $paths );
foreach ( $paths as $path ) {
$relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path;
$autoloads[ $this->getFileIdentifier( $package, $path ) ] = array(
'path' => $relativePath,
'version' => $package->getVersion(), // Version of the file comes from the package - should we try to parse it?
);
}
}
}
}
return $autoloads;
}
/**
* Given Composer's autoloads this will convert them to a version that we can use to generate the manifests.
*
* When the $scanPsrPackages argument is true, PSR-4 namespaces are converted to classmaps. When $scanPsrPackages
* is false, PSR-4 namespaces are not converted to classmaps.
*
* PSR-0 namespaces are always converted to classmaps.
*
* @param array $autoloads The autoloads we want to process.
* @param bool $scanPsrPackages Whether or not PSR-4 packages should be converted to a classmap.
* @param string $vendorPath The path to the vendor directory.
* @param string $basePath The path to the current directory.
*
* @return array $processedAutoloads
*/
private function processAutoloads( $autoloads, $scanPsrPackages, $vendorPath, $basePath ) {
$processor = new AutoloadProcessor(
function ( $path, $excludedClasses, $namespace ) use ( $basePath ) {
$dir = $this->filesystem->normalizePath(
$this->filesystem->isAbsolutePath( $path ) ? $path : $basePath . '/' . $path
);
// Composer 2.4 changed the name of the class.
if ( class_exists( \Composer\ClassMapGenerator\ClassMapGenerator::class ) ) {
if ( ! is_dir( $dir ) && ! is_file( $dir ) ) {
return array();
}
$generator = new \Composer\ClassMapGenerator\ClassMapGenerator();
$generator->scanPaths( $dir, $excludedClasses, 'classmap', empty( $namespace ) ? null : $namespace );
return $generator->getClassMap()->getMap();
}
return \Composer\Autoload\ClassMapGenerator::createMap(
$dir,
$excludedClasses,
null, // Don't pass the IOInterface since the normal autoload generation will have reported already.
empty( $namespace ) ? null : $namespace
);
},
function ( $path ) use ( $basePath, $vendorPath ) {
return $this->getPathCode( $this->filesystem, $basePath, $vendorPath, $path );
}
);
return array(
'psr-4' => $processor->processPsr4Packages( $autoloads, $scanPsrPackages ),
'classmap' => $processor->processClassmap( $autoloads, $scanPsrPackages ),
'files' => $processor->processFiles( $autoloads ),
);
}
/**
* Removes all of the legacy autoloader files so they don't cause any problems.
*
* @param string $outDir The directory legacy files are written to.
*/
private function removeLegacyFiles( $outDir ) {
$files = array(
'autoload_functions.php',
'class-autoloader-handler.php',
'class-classes-handler.php',
'class-files-handler.php',
'class-plugins-handler.php',
'class-version-selector.php',
);
foreach ( $files as $file ) {
$this->filesystem->remove( $outDir . '/' . $file );
}
}
/**
* Writes all of the autoloader files to disk.
*
* @param string $outDir The directory to write to.
* @param string $suffix The unique autoloader suffix.
*/
private function writeAutoloaderFiles( $outDir, $suffix ) {
$this->io->writeError( "<info>Generating jetpack autoloader ($outDir)</info>" );
// We will remove all autoloader files to generate this again.
$this->filesystem->emptyDirectory( $outDir );
// Write the autoloader files.
AutoloadFileWriter::copyAutoloaderFiles( $this->io, $outDir, $suffix );
}
/**
* Writes all of the manifest files to disk.
*
* @param string $outDir The directory to write to.
* @param array $processedAutoloads The processed autoloads.
*/
private function writeManifests( $outDir, $processedAutoloads ) {
$this->io->writeError( "<info>Generating jetpack autoloader manifests ($outDir)</info>" );
$manifestFiles = array(
'classmap' => 'jetpack_autoload_classmap.php',
'psr-4' => 'jetpack_autoload_psr4.php',
'files' => 'jetpack_autoload_filemap.php',
);
foreach ( $manifestFiles as $key => $file ) {
// Make sure the file doesn't exist so it isn't there if we don't write it.
$this->filesystem->remove( $outDir . '/' . $file );
if ( empty( $processedAutoloads[ $key ] ) ) {
continue;
}
$content = ManifestGenerator::buildManifest( $key, $file, $processedAutoloads[ $key ] );
if ( empty( $content ) ) {
continue;
}
if ( file_put_contents( $outDir . '/' . $file, $content ) ) {
$this->io->writeError( " <info>Generated: $file</info>" );
} else {
$this->io->writeError( " <error>Error: $file</error>" );
}
}
}
}
AutoloadProcessor.php 0000644 00000012407 15154650006 0010733 0 ustar 00 <?php // phpcs:ignore WordPress.Files.FileName
/**
* Autoload Processor.
*
* @package automattic/jetpack-autoloader
*/
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
// phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
namespace Automattic\Jetpack\Autoloader;
/**
* Class AutoloadProcessor.
*/
class AutoloadProcessor {
/**
* A callable for scanning a directory for all of its classes.
*
* @var callable
*/
private $classmapScanner;
/**
* A callable for transforming a path into one to be used in code.
*
* @var callable
*/
private $pathCodeTransformer;
/**
* The constructor.
*
* @param callable $classmapScanner A callable for scanning a directory for all of its classes.
* @param callable $pathCodeTransformer A callable for transforming a path into one to be used in code.
*/
public function __construct( $classmapScanner, $pathCodeTransformer ) {
$this->classmapScanner = $classmapScanner;
$this->pathCodeTransformer = $pathCodeTransformer;
}
/**
* Processes the classmap autoloads into a relative path format including the version for each file.
*
* @param array $autoloads The autoloads we are processing.
* @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap.
*
* @return array $processed
*/
public function processClassmap( $autoloads, $scanPsrPackages ) {
// We can't scan PSR packages if we don't actually have any.
if ( empty( $autoloads['psr-4'] ) ) {
$scanPsrPackages = false;
}
if ( empty( $autoloads['classmap'] ) && ! $scanPsrPackages ) {
return null;
}
$excludedClasses = null;
if ( ! empty( $autoloads['exclude-from-classmap'] ) ) {
$excludedClasses = '{(' . implode( '|', $autoloads['exclude-from-classmap'] ) . ')}';
}
$processed = array();
if ( $scanPsrPackages ) {
foreach ( $autoloads['psr-4'] as $namespace => $sources ) {
$namespace = empty( $namespace ) ? null : $namespace;
foreach ( $sources as $source ) {
$classmap = call_user_func( $this->classmapScanner, $source['path'], $excludedClasses, $namespace );
foreach ( $classmap as $class => $path ) {
$processed[ $class ] = array(
'version' => $source['version'],
'path' => call_user_func( $this->pathCodeTransformer, $path ),
);
}
}
}
}
/*
* PSR-0 namespaces are converted to classmaps for both optimized and unoptimized autoloaders because any new
* development should use classmap or PSR-4 autoloading.
*/
if ( ! empty( $autoloads['psr-0'] ) ) {
foreach ( $autoloads['psr-0'] as $namespace => $sources ) {
$namespace = empty( $namespace ) ? null : $namespace;
foreach ( $sources as $source ) {
$classmap = call_user_func( $this->classmapScanner, $source['path'], $excludedClasses, $namespace );
foreach ( $classmap as $class => $path ) {
$processed[ $class ] = array(
'version' => $source['version'],
'path' => call_user_func( $this->pathCodeTransformer, $path ),
);
}
}
}
}
if ( ! empty( $autoloads['classmap'] ) ) {
foreach ( $autoloads['classmap'] as $package ) {
$classmap = call_user_func( $this->classmapScanner, $package['path'], $excludedClasses, null );
foreach ( $classmap as $class => $path ) {
$processed[ $class ] = array(
'version' => $package['version'],
'path' => call_user_func( $this->pathCodeTransformer, $path ),
);
}
}
}
ksort( $processed );
return $processed;
}
/**
* Processes the PSR-4 autoloads into a relative path format including the version for each file.
*
* @param array $autoloads The autoloads we are processing.
* @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap.
*
* @return array $processed
*/
public function processPsr4Packages( $autoloads, $scanPsrPackages ) {
if ( $scanPsrPackages || empty( $autoloads['psr-4'] ) ) {
return null;
}
$processed = array();
foreach ( $autoloads['psr-4'] as $namespace => $packages ) {
$namespace = empty( $namespace ) ? null : $namespace;
$paths = array();
foreach ( $packages as $package ) {
$paths[] = call_user_func( $this->pathCodeTransformer, $package['path'] );
}
$processed[ $namespace ] = array(
'version' => $package['version'],
'path' => $paths,
);
}
return $processed;
}
/**
* Processes the file autoloads into a relative format including the version for each file.
*
* @param array $autoloads The autoloads we are processing.
*
* @return array|null $processed
*/
public function processFiles( $autoloads ) {
if ( empty( $autoloads['files'] ) ) {
return null;
}
$processed = array();
foreach ( $autoloads['files'] as $file_id => $package ) {
$processed[ $file_id ] = array(
'version' => $package['version'],
'path' => call_user_func( $this->pathCodeTransformer, $package['path'] ),
);
}
return $processed;
}
}
CustomAutoloaderPlugin.php 0000644 00000013542 15154650006 0011735 0 ustar 00 <?php //phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase
/**
* Custom Autoloader Composer Plugin, hooks into composer events to generate the custom autoloader.
*
* @package automattic/jetpack-autoloader
*/
// phpcs:disable PHPCompatibility.Keywords.NewKeywords.t_useFound
// phpcs:disable PHPCompatibility.LanguageConstructs.NewLanguageConstructs.t_ns_separatorFound
// phpcs:disable PHPCompatibility.Keywords.NewKeywords.t_namespaceFound
// phpcs:disable WordPress.Files.FileName.NotHyphenatedLowercase
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
namespace Automattic\Jetpack\Autoloader;
use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;
/**
* Class CustomAutoloaderPlugin.
*
* @package automattic/jetpack-autoloader
*/
class CustomAutoloaderPlugin implements PluginInterface, EventSubscriberInterface {
/**
* IO object.
*
* @var IOInterface IO object.
*/
private $io;
/**
* Composer object.
*
* @var Composer Composer object.
*/
private $composer;
/**
* Do nothing.
*
* @param Composer $composer Composer object.
* @param IOInterface $io IO object.
*/
public function activate( Composer $composer, IOInterface $io ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$this->composer = $composer;
$this->io = $io;
}
/**
* Do nothing.
* phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*
* @param Composer $composer Composer object.
* @param IOInterface $io IO object.
*/
public function deactivate( Composer $composer, IOInterface $io ) {
/*
* Intentionally left empty. This is a PluginInterface method.
* phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*/
}
/**
* Do nothing.
* phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*
* @param Composer $composer Composer object.
* @param IOInterface $io IO object.
*/
public function uninstall( Composer $composer, IOInterface $io ) {
/*
* Intentionally left empty. This is a PluginInterface method.
* phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*/
}
/**
* Tell composer to listen for events and do something with them.
*
* @return array List of subscribed events.
*/
public static function getSubscribedEvents() {
return array(
ScriptEvents::POST_AUTOLOAD_DUMP => 'postAutoloadDump',
);
}
/**
* Generate the custom autolaoder.
*
* @param Event $event Script event object.
*/
public function postAutoloadDump( Event $event ) {
// When the autoloader is not required by the root package we don't want to execute it.
// This prevents unwanted transitive execution that generates unused autoloaders or
// at worst throws fatal executions.
if ( ! $this->isRequiredByRoot() ) {
return;
}
$config = $this->composer->getConfig();
if ( 'vendor' !== $config->raw()['config']['vendor-dir'] ) {
$this->io->writeError( "\n<error>An error occurred while generating the autoloader files:", true );
$this->io->writeError( 'The project\'s composer.json or composer environment set a non-default vendor directory.', true );
$this->io->writeError( 'The default composer vendor directory must be used.</error>', true );
exit();
}
$installationManager = $this->composer->getInstallationManager();
$repoManager = $this->composer->getRepositoryManager();
$localRepo = $repoManager->getLocalRepository();
$package = $this->composer->getPackage();
$optimize = $event->getFlags()['optimize'];
$suffix = $this->determineSuffix();
$generator = new AutoloadGenerator( $this->io );
$generator->dump( $this->composer, $config, $localRepo, $package, $installationManager, 'composer', $optimize, $suffix );
}
/**
* Determine the suffix for the autoloader class.
*
* Reuses an existing suffix from vendor/autoload_packages.php or vendor/autoload.php if possible.
*
* @return string Suffix.
*/
private function determineSuffix() {
$config = $this->composer->getConfig();
$vendorPath = $config->get( 'vendor-dir' );
// Command line.
$suffix = $config->get( 'autoloader-suffix' );
if ( $suffix ) {
return $suffix;
}
// Reuse our own suffix, if any.
if ( is_readable( $vendorPath . '/autoload_packages.php' ) ) {
$content = file_get_contents( $vendorPath . '/autoload_packages.php' );
if ( preg_match( '/^namespace Automattic\\\\Jetpack\\\\Autoloader\\\\jp([^;\s]+);/m', $content, $match ) ) {
return $match[1];
}
}
// Reuse Composer's suffix, if any.
if ( is_readable( $vendorPath . '/autoload.php' ) ) {
$content = file_get_contents( $vendorPath . '/autoload.php' );
if ( preg_match( '{ComposerAutoloaderInit([^:\s]+)::}', $content, $match ) ) {
return $match[1];
}
}
// Generate a random suffix.
return md5( uniqid( '', true ) );
}
/**
* Checks to see whether or not the root package is the one that required the autoloader.
*
* @return bool
*/
private function isRequiredByRoot() {
$package = $this->composer->getPackage();
$requires = $package->getRequires();
if ( ! is_array( $requires ) ) {
$requires = array();
}
$devRequires = $package->getDevRequires();
if ( ! is_array( $devRequires ) ) {
$devRequires = array();
}
$requires = array_merge( $requires, $devRequires );
if ( empty( $requires ) ) {
$this->io->writeError( "\n<error>The package is not required and this should never happen?</error>", true );
exit();
}
foreach ( $requires as $require ) {
if ( 'automattic/jetpack-autoloader' === $require->getTarget() ) {
return true;
}
}
return false;
}
}
ManifestGenerator.php 0000644 00000007132 15154650006 0010677 0 ustar 00 <?php // phpcs:ignore WordPress.Files.FileName
/**
* Manifest Generator.
*
* @package automattic/jetpack-autoloader
*/
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
// phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export
namespace Automattic\Jetpack\Autoloader;
/**
* Class ManifestGenerator.
*/
class ManifestGenerator {
/**
* Builds a manifest file for the given autoloader type.
*
* @param string $autoloaderType The type of autoloader to build a manifest for.
* @param string $fileName The filename of the manifest.
* @param array $content The manifest content to generate using.
*
* @return string|null $manifestFile
* @throws \InvalidArgumentException When an invalid autoloader type is given.
*/
public static function buildManifest( $autoloaderType, $fileName, $content ) {
if ( empty( $content ) ) {
return null;
}
switch ( $autoloaderType ) {
case 'classmap':
case 'files':
return self::buildStandardManifest( $fileName, $content );
case 'psr-4':
return self::buildPsr4Manifest( $fileName, $content );
}
throw new \InvalidArgumentException( 'An invalid manifest type of ' . $autoloaderType . ' was passed!' );
}
/**
* Builds the contents for the standard manifest file.
*
* @param string $fileName The filename we are building.
* @param array $manifestData The formatted data for the manifest.
*
* @return string|null $manifestFile
*/
private static function buildStandardManifest( $fileName, $manifestData ) {
$fileContent = PHP_EOL;
foreach ( $manifestData as $key => $data ) {
$key = var_export( $key, true );
$versionCode = var_export( $data['version'], true );
$fileContent .= <<<MANIFEST_CODE
$key => array(
'version' => $versionCode,
'path' => {$data['path']}
),
MANIFEST_CODE;
$fileContent .= PHP_EOL;
}
return self::buildFile( $fileName, $fileContent );
}
/**
* Builds the contents for the PSR-4 manifest file.
*
* @param string $fileName The filename we are building.
* @param array $namespaces The formatted PSR-4 data for the manifest.
*
* @return string|null $manifestFile
*/
private static function buildPsr4Manifest( $fileName, $namespaces ) {
$fileContent = PHP_EOL;
foreach ( $namespaces as $namespace => $data ) {
$namespaceCode = var_export( $namespace, true );
$versionCode = var_export( $data['version'], true );
$pathCode = 'array( ' . implode( ', ', $data['path'] ) . ' )';
$fileContent .= <<<MANIFEST_CODE
$namespaceCode => array(
'version' => $versionCode,
'path' => $pathCode
),
MANIFEST_CODE;
$fileContent .= PHP_EOL;
}
return self::buildFile( $fileName, $fileContent );
}
/**
* Generate the PHP that will be used in the file.
*
* @param string $fileName The filename we are building.
* @param string $content The content to be written into the file.
*
* @return string $fileContent
*/
private static function buildFile( $fileName, $content ) {
return <<<INCLUDE_FILE
<?php
// This file `$fileName` was auto generated by automattic/jetpack-autoloader.
\$vendorDir = dirname(__DIR__);
\$baseDir = dirname(\$vendorDir);
return array($content);
INCLUDE_FILE;
}
}
autoload.php 0000644 00000000173 15154650006 0007070 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
require_once __DIR__ . '/jetpack-autoloader/class-autoloader.php';
Autoloader::init();
class-autoloader-handler.php 0000644 00000010465 15154650006 0012142 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
use Automattic\Jetpack\Autoloader\AutoloadGenerator;
/**
* This class selects the package version for the autoloader.
*/
class Autoloader_Handler {
/**
* The PHP_Autoloader instance.
*
* @var PHP_Autoloader
*/
private $php_autoloader;
/**
* The Hook_Manager instance.
*
* @var Hook_Manager
*/
private $hook_manager;
/**
* The Manifest_Reader instance.
*
* @var Manifest_Reader
*/
private $manifest_reader;
/**
* The Version_Selector instance.
*
* @var Version_Selector
*/
private $version_selector;
/**
* The constructor.
*
* @param PHP_Autoloader $php_autoloader The PHP_Autoloader instance.
* @param Hook_Manager $hook_manager The Hook_Manager instance.
* @param Manifest_Reader $manifest_reader The Manifest_Reader instance.
* @param Version_Selector $version_selector The Version_Selector instance.
*/
public function __construct( $php_autoloader, $hook_manager, $manifest_reader, $version_selector ) {
$this->php_autoloader = $php_autoloader;
$this->hook_manager = $hook_manager;
$this->manifest_reader = $manifest_reader;
$this->version_selector = $version_selector;
}
/**
* Checks to see whether or not an autoloader is currently in the process of initializing.
*
* @return bool
*/
public function is_initializing() {
// If no version has been set it means that no autoloader has started initializing yet.
global $jetpack_autoloader_latest_version;
if ( ! isset( $jetpack_autoloader_latest_version ) ) {
return false;
}
// When the version is set but the classmap is not it ALWAYS means that this is the
// latest autoloader and is being included by an older one.
global $jetpack_packages_classmap;
if ( empty( $jetpack_packages_classmap ) ) {
return true;
}
// Version 2.4.0 added a new global and altered the reset semantics. We need to check
// the other global as well since it may also point at initialization.
// Note: We don't need to check for the class first because every autoloader that
// will set the latest version global requires this class in the classmap.
$replacing_version = $jetpack_packages_classmap[ AutoloadGenerator::class ]['version'];
if ( $this->version_selector->is_dev_version( $replacing_version ) || version_compare( $replacing_version, '2.4.0.0', '>=' ) ) {
global $jetpack_autoloader_loader;
if ( ! isset( $jetpack_autoloader_loader ) ) {
return true;
}
}
return false;
}
/**
* Activates an autoloader using the given plugins and activates it.
*
* @param string[] $plugins The plugins to initialize the autoloader for.
*/
public function activate_autoloader( $plugins ) {
global $jetpack_packages_psr4;
$jetpack_packages_psr4 = array();
$this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_psr4.php', $jetpack_packages_psr4 );
global $jetpack_packages_classmap;
$jetpack_packages_classmap = array();
$this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_classmap.php', $jetpack_packages_classmap );
global $jetpack_packages_filemap;
$jetpack_packages_filemap = array();
$this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_filemap.php', $jetpack_packages_filemap );
$loader = new Version_Loader(
$this->version_selector,
$jetpack_packages_classmap,
$jetpack_packages_psr4,
$jetpack_packages_filemap
);
$this->php_autoloader->register_autoloader( $loader );
// Now that the autoloader is active we can load the filemap.
$loader->load_filemap();
}
/**
* Resets the active autoloader and all related global state.
*/
public function reset_autoloader() {
$this->php_autoloader->unregister_autoloader();
$this->hook_manager->reset();
// Clear all of the autoloader globals so that older autoloaders don't do anything strange.
global $jetpack_autoloader_latest_version;
$jetpack_autoloader_latest_version = null;
global $jetpack_packages_classmap;
$jetpack_packages_classmap = array(); // Must be array to avoid exceptions in old autoloaders!
global $jetpack_packages_psr4;
$jetpack_packages_psr4 = array(); // Must be array to avoid exceptions in old autoloaders!
global $jetpack_packages_filemap;
$jetpack_packages_filemap = array(); // Must be array to avoid exceptions in old autoloaders!
}
}
class-autoloader-locator.php 0000644 00000003616 15154650006 0012170 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
use Automattic\Jetpack\Autoloader\AutoloadGenerator;
/**
* This class locates autoloaders.
*/
class Autoloader_Locator {
/**
* The object for comparing autoloader versions.
*
* @var Version_Selector
*/
private $version_selector;
/**
* The constructor.
*
* @param Version_Selector $version_selector The version selector object.
*/
public function __construct( $version_selector ) {
$this->version_selector = $version_selector;
}
/**
* Finds the path to the plugin with the latest autoloader.
*
* @param array $plugin_paths An array of plugin paths.
* @param string $latest_version The latest version reference.
*
* @return string|null
*/
public function find_latest_autoloader( $plugin_paths, &$latest_version ) {
$latest_plugin = null;
foreach ( $plugin_paths as $plugin_path ) {
$version = $this->get_autoloader_version( $plugin_path );
if ( ! $this->version_selector->is_version_update_required( $latest_version, $version ) ) {
continue;
}
$latest_version = $version;
$latest_plugin = $plugin_path;
}
return $latest_plugin;
}
/**
* Gets the path to the autoloader.
*
* @param string $plugin_path The path to the plugin.
*
* @return string
*/
public function get_autoloader_path( $plugin_path ) {
return trailingslashit( $plugin_path ) . 'vendor/autoload_packages.php';
}
/**
* Gets the version for the autoloader.
*
* @param string $plugin_path The path to the plugin.
*
* @return string|null
*/
public function get_autoloader_version( $plugin_path ) {
$classmap = trailingslashit( $plugin_path ) . 'vendor/composer/jetpack_autoload_classmap.php';
if ( ! file_exists( $classmap ) ) {
return null;
}
$classmap = require $classmap;
if ( isset( $classmap[ AutoloadGenerator::class ] ) ) {
return $classmap[ AutoloadGenerator::class ]['version'];
}
return null;
}
}
class-autoloader.php 0000644 00000007573 15154650006 0010535 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class handles management of the actual PHP autoloader.
*/
class Autoloader {
/**
* Checks to see whether or not the autoloader should be initialized and then initializes it if so.
*
* @param Container|null $container The container we want to use for autoloader initialization. If none is given
* then a container will be created automatically.
*/
public static function init( $container = null ) {
// The container holds and manages the lifecycle of our dependencies
// to make them easier to work with and increase flexibility.
if ( ! isset( $container ) ) {
require_once __DIR__ . '/class-container.php';
$container = new Container();
}
// phpcs:disable Generic.Commenting.DocComment.MissingShort
/** @var Autoloader_Handler $autoloader_handler */
$autoloader_handler = $container->get( Autoloader_Handler::class );
// If the autoloader is already initializing it means that it has included us as the latest.
$was_included_by_autoloader = $autoloader_handler->is_initializing();
/** @var Plugin_Locator $plugin_locator */
$plugin_locator = $container->get( Plugin_Locator::class );
/** @var Plugins_Handler $plugins_handler */
$plugins_handler = $container->get( Plugins_Handler::class );
// The current plugin is the one that we are attempting to initialize here.
$current_plugin = $plugin_locator->find_current_plugin();
// The active plugins are those that we were able to discover on the site. This list will not
// include mu-plugins, those activated by code, or those who are hidden by filtering. We also
// want to take care to not consider the current plugin unknown if it was included by an
// autoloader. This avoids the case where a plugin will be marked "active" while deactivated
// due to it having the latest autoloader.
$active_plugins = $plugins_handler->get_active_plugins( true, ! $was_included_by_autoloader );
// The cached plugins are all of those that were active or discovered by the autoloader during a previous request.
// Note that it's possible this list will include plugins that have since been deactivated, but after a request
// the cache should be updated and the deactivated plugins will be removed.
$cached_plugins = $plugins_handler->get_cached_plugins();
// We combine the active list and cached list to preemptively load classes for plugins that are
// presently unknown but will be loaded during the request. While this may result in us considering packages in
// deactivated plugins there shouldn't be any problems as a result and the eventual consistency is sufficient.
$all_plugins = array_merge( $active_plugins, $cached_plugins );
// In particular we also include the current plugin to address the case where it is the latest autoloader
// but also unknown (and not cached). We don't want it in the active list because we don't know that it
// is active but we need it in the all plugins list so that it is considered by the autoloader.
$all_plugins[] = $current_plugin;
// We require uniqueness in the array to avoid processing the same plugin more than once.
$all_plugins = array_values( array_unique( $all_plugins ) );
/** @var Latest_Autoloader_Guard $guard */
$guard = $container->get( Latest_Autoloader_Guard::class );
if ( $guard->should_stop_init( $current_plugin, $all_plugins, $was_included_by_autoloader ) ) {
return;
}
// Initialize the autoloader using the handler now that we're ready.
$autoloader_handler->activate_autoloader( $all_plugins );
/** @var Hook_Manager $hook_manager */
$hook_manager = $container->get( Hook_Manager::class );
// Register a shutdown handler to clean up the autoloader.
$hook_manager->add_action( 'shutdown', new Shutdown_Handler( $plugins_handler, $cached_plugins, $was_included_by_autoloader ) );
// phpcs:enable Generic.Commenting.DocComment.MissingShort
}
}
class-container.php 0000644 00000011157 15154650006 0010351 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class manages the files and dependencies of the autoloader.
*/
class Container {
/**
* Since each autoloader's class files exist within their own namespace we need a map to
* convert between the local class and a shared key. Note that no version checking is
* performed on these dependencies and the first autoloader to register will be the
* one that is utilized.
*/
const SHARED_DEPENDENCY_KEYS = array(
Hook_Manager::class => 'Hook_Manager',
);
/**
* A map of all the dependencies we've registered with the container and created.
*
* @var array
*/
protected $dependencies;
/**
* The constructor.
*/
public function __construct() {
$this->dependencies = array();
$this->register_shared_dependencies();
$this->register_dependencies();
$this->initialize_globals();
}
/**
* Gets a dependency out of the container.
*
* @param string $class The class to fetch.
*
* @return mixed
* @throws \InvalidArgumentException When a class that isn't registered with the container is fetched.
*/
public function get( $class ) {
if ( ! isset( $this->dependencies[ $class ] ) ) {
throw new \InvalidArgumentException( "Class '$class' is not registered with the container." );
}
return $this->dependencies[ $class ];
}
/**
* Registers all of the dependencies that are shared between all instances of the autoloader.
*/
private function register_shared_dependencies() {
global $jetpack_autoloader_container_shared;
if ( ! isset( $jetpack_autoloader_container_shared ) ) {
$jetpack_autoloader_container_shared = array();
}
$key = self::SHARED_DEPENDENCY_KEYS[ Hook_Manager::class ];
if ( ! isset( $jetpack_autoloader_container_shared[ $key ] ) ) {
require_once __DIR__ . '/class-hook-manager.php';
$jetpack_autoloader_container_shared[ $key ] = new Hook_Manager();
}
$this->dependencies[ Hook_Manager::class ] = &$jetpack_autoloader_container_shared[ $key ];
}
/**
* Registers all of the dependencies with the container.
*/
private function register_dependencies() {
require_once __DIR__ . '/class-path-processor.php';
$this->dependencies[ Path_Processor::class ] = new Path_Processor();
require_once __DIR__ . '/class-plugin-locator.php';
$this->dependencies[ Plugin_Locator::class ] = new Plugin_Locator(
$this->get( Path_Processor::class )
);
require_once __DIR__ . '/class-version-selector.php';
$this->dependencies[ Version_Selector::class ] = new Version_Selector();
require_once __DIR__ . '/class-autoloader-locator.php';
$this->dependencies[ Autoloader_Locator::class ] = new Autoloader_Locator(
$this->get( Version_Selector::class )
);
require_once __DIR__ . '/class-php-autoloader.php';
$this->dependencies[ PHP_Autoloader::class ] = new PHP_Autoloader();
require_once __DIR__ . '/class-manifest-reader.php';
$this->dependencies[ Manifest_Reader::class ] = new Manifest_Reader(
$this->get( Version_Selector::class )
);
require_once __DIR__ . '/class-plugins-handler.php';
$this->dependencies[ Plugins_Handler::class ] = new Plugins_Handler(
$this->get( Plugin_Locator::class ),
$this->get( Path_Processor::class )
);
require_once __DIR__ . '/class-autoloader-handler.php';
$this->dependencies[ Autoloader_Handler::class ] = new Autoloader_Handler(
$this->get( PHP_Autoloader::class ),
$this->get( Hook_Manager::class ),
$this->get( Manifest_Reader::class ),
$this->get( Version_Selector::class )
);
require_once __DIR__ . '/class-latest-autoloader-guard.php';
$this->dependencies[ Latest_Autoloader_Guard::class ] = new Latest_Autoloader_Guard(
$this->get( Plugins_Handler::class ),
$this->get( Autoloader_Handler::class ),
$this->get( Autoloader_Locator::class )
);
// Register any classes that we will use elsewhere.
require_once __DIR__ . '/class-version-loader.php';
require_once __DIR__ . '/class-shutdown-handler.php';
}
/**
* Initializes any of the globals needed by the autoloader.
*/
private function initialize_globals() {
/*
* This global was retired in version 2.9. The value is set to 'false' to maintain
* compatibility with older versions of the autoloader.
*/
global $jetpack_autoloader_including_latest;
$jetpack_autoloader_including_latest = false;
// Not all plugins can be found using the locator. In cases where a plugin loads the autoloader
// but was not discoverable, we will record them in this array to track them as "active".
global $jetpack_autoloader_activating_plugins_paths;
if ( ! isset( $jetpack_autoloader_activating_plugins_paths ) ) {
$jetpack_autoloader_activating_plugins_paths = array();
}
}
}
class-hook-manager.php 0000644 00000003643 15154650006 0010740 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* Allows the latest autoloader to register hooks that can be removed when the autoloader is reset.
*/
class Hook_Manager {
/**
* An array containing all of the hooks that we've registered.
*
* @var array
*/
private $registered_hooks;
/**
* The constructor.
*/
public function __construct() {
$this->registered_hooks = array();
}
/**
* Adds an action to WordPress and registers it internally.
*
* @param string $tag The name of the action which is hooked.
* @param callable $callable The function to call.
* @param int $priority Used to specify the priority of the action.
* @param int $accepted_args Used to specify the number of arguments the callable accepts.
*/
public function add_action( $tag, $callable, $priority = 10, $accepted_args = 1 ) {
$this->registered_hooks[ $tag ][] = array(
'priority' => $priority,
'callable' => $callable,
);
add_action( $tag, $callable, $priority, $accepted_args );
}
/**
* Adds a filter to WordPress and registers it internally.
*
* @param string $tag The name of the filter which is hooked.
* @param callable $callable The function to call.
* @param int $priority Used to specify the priority of the filter.
* @param int $accepted_args Used to specify the number of arguments the callable accepts.
*/
public function add_filter( $tag, $callable, $priority = 10, $accepted_args = 1 ) {
$this->registered_hooks[ $tag ][] = array(
'priority' => $priority,
'callable' => $callable,
);
add_filter( $tag, $callable, $priority, $accepted_args );
}
/**
* Removes all of the registered hooks.
*/
public function reset() {
foreach ( $this->registered_hooks as $tag => $hooks ) {
foreach ( $hooks as $hook ) {
remove_filter( $tag, $hook['callable'], $hook['priority'] );
}
}
$this->registered_hooks = array();
}
}
class-latest-autoloader-guard.php 0000644 00000005062 15154650006 0013116 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class ensures that we're only executing the latest autoloader.
*/
class Latest_Autoloader_Guard {
/**
* The Plugins_Handler instance.
*
* @var Plugins_Handler
*/
private $plugins_handler;
/**
* The Autoloader_Handler instance.
*
* @var Autoloader_Handler
*/
private $autoloader_handler;
/**
* The Autoloader_locator instance.
*
* @var Autoloader_Locator
*/
private $autoloader_locator;
/**
* The constructor.
*
* @param Plugins_Handler $plugins_handler The Plugins_Handler instance.
* @param Autoloader_Handler $autoloader_handler The Autoloader_Handler instance.
* @param Autoloader_Locator $autoloader_locator The Autoloader_Locator instance.
*/
public function __construct( $plugins_handler, $autoloader_handler, $autoloader_locator ) {
$this->plugins_handler = $plugins_handler;
$this->autoloader_handler = $autoloader_handler;
$this->autoloader_locator = $autoloader_locator;
}
/**
* Indicates whether or not the autoloader should be initialized. Note that this function
* has the side-effect of actually loading the latest autoloader in the event that this
* is not it.
*
* @param string $current_plugin The current plugin we're checking.
* @param string[] $plugins The active plugins to check for autoloaders in.
* @param bool $was_included_by_autoloader Indicates whether or not this autoloader was included by another.
*
* @return bool True if we should stop initialization, otherwise false.
*/
public function should_stop_init( $current_plugin, $plugins, $was_included_by_autoloader ) {
global $jetpack_autoloader_latest_version;
// We need to reset the autoloader when the plugins change because
// that means the autoloader was generated with a different list.
if ( $this->plugins_handler->have_plugins_changed( $plugins ) ) {
$this->autoloader_handler->reset_autoloader();
}
// When the latest autoloader has already been found we don't need to search for it again.
// We should take care however because this will also trigger if the autoloader has been
// included by an older one.
if ( isset( $jetpack_autoloader_latest_version ) && ! $was_included_by_autoloader ) {
return true;
}
$latest_plugin = $this->autoloader_locator->find_latest_autoloader( $plugins, $jetpack_autoloader_latest_version );
if ( isset( $latest_plugin ) && $latest_plugin !== $current_plugin ) {
require $this->autoloader_locator->get_autoloader_path( $latest_plugin );
return true;
}
return false;
}
}
class-manifest-reader.php 0000644 00000004673 15154650006 0011442 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class reads autoloader manifest files.
*/
class Manifest_Reader {
/**
* The Version_Selector object.
*
* @var Version_Selector
*/
private $version_selector;
/**
* The constructor.
*
* @param Version_Selector $version_selector The Version_Selector object.
*/
public function __construct( $version_selector ) {
$this->version_selector = $version_selector;
}
/**
* Reads all of the manifests in the given plugin paths.
*
* @param array $plugin_paths The paths to the plugins we're loading the manifest in.
* @param string $manifest_path The path that we're loading the manifest from in each plugin.
* @param array $path_map The path map to add the contents of the manifests to.
*
* @return array $path_map The path map we've built using the manifests in each plugin.
*/
public function read_manifests( $plugin_paths, $manifest_path, &$path_map ) {
$file_paths = array_map(
function ( $path ) use ( $manifest_path ) {
return trailingslashit( $path ) . $manifest_path;
},
$plugin_paths
);
foreach ( $file_paths as $path ) {
$this->register_manifest( $path, $path_map );
}
return $path_map;
}
/**
* Registers a plugin's manifest file with the path map.
*
* @param string $manifest_path The absolute path to the manifest that we're loading.
* @param array $path_map The path map to add the contents of the manifest to.
*/
protected function register_manifest( $manifest_path, &$path_map ) {
if ( ! is_readable( $manifest_path ) ) {
return;
}
$manifest = require $manifest_path;
if ( ! is_array( $manifest ) ) {
return;
}
foreach ( $manifest as $key => $data ) {
$this->register_record( $key, $data, $path_map );
}
}
/**
* Registers an entry from the manifest in the path map.
*
* @param string $key The identifier for the entry we're registering.
* @param array $data The data for the entry we're registering.
* @param array $path_map The path map to add the contents of the manifest to.
*/
protected function register_record( $key, $data, &$path_map ) {
if ( isset( $path_map[ $key ]['version'] ) ) {
$selected_version = $path_map[ $key ]['version'];
} else {
$selected_version = null;
}
if ( $this->version_selector->is_version_update_required( $selected_version, $data['version'] ) ) {
$path_map[ $key ] = array(
'version' => $data['version'],
'path' => $data['path'],
);
}
}
}
class-path-processor.php 0000644 00000012557 15154650006 0011345 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class handles dealing with paths for the autoloader.
*/
class Path_Processor {
/**
* Given a path this will replace any of the path constants with a token to represent it.
*
* @param string $path The path we want to process.
*
* @return string The tokenized path.
*/
public function tokenize_path_constants( $path ) {
$path = wp_normalize_path( $path );
$constants = $this->get_normalized_constants();
foreach ( $constants as $constant => $constant_path ) {
$len = strlen( $constant_path );
if ( substr( $path, 0, $len ) !== $constant_path ) {
continue;
}
return substr_replace( $path, '{{' . $constant . '}}', 0, $len );
}
return $path;
}
/**
* Given a path this will replace any of the path constant tokens with the expanded path.
*
* @param string $tokenized_path The path we want to process.
*
* @return string The expanded path.
*/
public function untokenize_path_constants( $tokenized_path ) {
$tokenized_path = wp_normalize_path( $tokenized_path );
$constants = $this->get_normalized_constants();
foreach ( $constants as $constant => $constant_path ) {
$constant = '{{' . $constant . '}}';
$len = strlen( $constant );
if ( substr( $tokenized_path, 0, $len ) !== $constant ) {
continue;
}
return $this->get_real_path( substr_replace( $tokenized_path, $constant_path, 0, $len ) );
}
return $tokenized_path;
}
/**
* Given a file and an array of places it might be, this will find the absolute path and return it.
*
* @param string $file The plugin or theme file to resolve.
* @param array $directories_to_check The directories we should check for the file if it isn't an absolute path.
*
* @return string|false Returns the absolute path to the directory, otherwise false.
*/
public function find_directory_with_autoloader( $file, $directories_to_check ) {
$file = wp_normalize_path( $file );
if ( ! $this->is_absolute_path( $file ) ) {
$file = $this->find_absolute_plugin_path( $file, $directories_to_check );
if ( ! isset( $file ) ) {
return false;
}
}
// We need the real path for consistency with __DIR__ paths.
$file = $this->get_real_path( $file );
// phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
$directory = @is_file( $file ) ? dirname( $file ) : $file;
if ( ! @is_file( $directory . '/vendor/composer/jetpack_autoload_classmap.php' ) ) {
return false;
}
// phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged
return $directory;
}
/**
* Fetches an array of normalized paths keyed by the constant they came from.
*
* @return string[] The normalized paths keyed by the constant.
*/
private function get_normalized_constants() {
$raw_constants = array(
// Order the constants from most-specific to least-specific.
'WP_PLUGIN_DIR',
'WPMU_PLUGIN_DIR',
'WP_CONTENT_DIR',
'ABSPATH',
);
$constants = array();
foreach ( $raw_constants as $raw ) {
if ( ! defined( $raw ) ) {
continue;
}
$path = wp_normalize_path( constant( $raw ) );
if ( isset( $path ) ) {
$constants[ $raw ] = $path;
}
}
return $constants;
}
/**
* Indicates whether or not a path is absolute.
*
* @param string $path The path to check.
*
* @return bool True if the path is absolute, otherwise false.
*/
private function is_absolute_path( $path ) {
if ( 0 === strlen( $path ) || '.' === $path[0] ) {
return false;
}
// Absolute paths on Windows may begin with a drive letter.
if ( preg_match( '/^[a-zA-Z]:[\/\\\\]/', $path ) ) {
return true;
}
// A path starting with / or \ is absolute; anything else is relative.
return ( '/' === $path[0] || '\\' === $path[0] );
}
/**
* Given a file and a list of directories to check, this method will try to figure out
* the absolute path to the file in question.
*
* @param string $normalized_path The normalized path to the plugin or theme file to resolve.
* @param array $directories_to_check The directories we should check for the file if it isn't an absolute path.
*
* @return string|null The absolute path to the plugin file, otherwise null.
*/
private function find_absolute_plugin_path( $normalized_path, $directories_to_check ) {
// We're only able to find the absolute path for plugin/theme PHP files.
if ( ! is_string( $normalized_path ) || '.php' !== substr( $normalized_path, -4 ) ) {
return null;
}
foreach ( $directories_to_check as $directory ) {
$normalized_check = wp_normalize_path( trailingslashit( $directory ) ) . $normalized_path;
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( @is_file( $normalized_check ) ) {
return $normalized_check;
}
}
return null;
}
/**
* Given a path this will figure out the real path that we should be using.
*
* @param string $path The path to resolve.
*
* @return string The resolved path.
*/
private function get_real_path( $path ) {
// We want to resolve symbolic links for consistency with __DIR__ paths.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$real_path = @realpath( $path );
if ( false === $real_path ) {
// Let the autoloader deal with paths that don't exist.
$real_path = $path;
}
// Using realpath will make it platform-specific so we must normalize it after.
if ( $path !== $real_path ) {
$real_path = wp_normalize_path( $real_path );
}
return $real_path;
}
}
class-php-autoloader.php 0000644 00000005145 15154650006 0011313 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class handles management of the actual PHP autoloader.
*/
class PHP_Autoloader {
/**
* Registers the autoloader with PHP so that it can begin autoloading classes.
*
* @param Version_Loader $version_loader The class loader to use in the autoloader.
*/
public function register_autoloader( $version_loader ) {
// Make sure no other autoloaders are registered.
$this->unregister_autoloader();
// Set the global so that it can be used to load classes.
global $jetpack_autoloader_loader;
$jetpack_autoloader_loader = $version_loader;
// Ensure that the autoloader is first to avoid contention with others.
spl_autoload_register( array( self::class, 'load_class' ), true, true );
}
/**
* Unregisters the active autoloader so that it will no longer autoload classes.
*/
public function unregister_autoloader() {
// Remove any v2 autoloader that we've already registered.
$autoload_chain = spl_autoload_functions();
if ( ! $autoload_chain ) {
return;
}
foreach ( $autoload_chain as $autoloader ) {
// We can identify a v2 autoloader using the namespace.
$namespace_check = null;
// Functions are recorded as strings.
if ( is_string( $autoloader ) ) {
$namespace_check = $autoloader;
} elseif ( is_array( $autoloader ) && is_string( $autoloader[0] ) ) {
// Static method calls have the class as the first array element.
$namespace_check = $autoloader[0];
} else {
// Since the autoloader has only ever been a function or a static method we don't currently need to check anything else.
continue;
}
// Check for the namespace without the generated suffix.
if ( 'Automattic\\Jetpack\\Autoloader\\jp' === substr( $namespace_check, 0, 32 ) ) {
spl_autoload_unregister( $autoloader );
}
}
// Clear the global now that the autoloader has been unregistered.
global $jetpack_autoloader_loader;
$jetpack_autoloader_loader = null;
}
/**
* Loads a class file if one could be found.
*
* Note: This function is static so that the autoloader can be easily unregistered. If
* it was a class method we would have to unwrap the object to check the namespace.
*
* @param string $class_name The name of the class to autoload.
*
* @return bool Indicates whether or not a class file was loaded.
*/
public static function load_class( $class_name ) {
global $jetpack_autoloader_loader;
if ( ! isset( $jetpack_autoloader_loader ) ) {
return;
}
$file = $jetpack_autoloader_loader->find_class_file( $class_name );
if ( ! isset( $file ) ) {
return false;
}
require $file;
return true;
}
}
class-plugin-locator.php 0000644 00000010732 15154650006 0011324 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class scans the WordPress installation to find active plugins.
*/
class Plugin_Locator {
/**
* The path processor for finding plugin paths.
*
* @var Path_Processor
*/
private $path_processor;
/**
* The constructor.
*
* @param Path_Processor $path_processor The Path_Processor instance.
*/
public function __construct( $path_processor ) {
$this->path_processor = $path_processor;
}
/**
* Finds the path to the current plugin.
*
* @return string $path The path to the current plugin.
*
* @throws \RuntimeException If the current plugin does not have an autoloader.
*/
public function find_current_plugin() {
// Escape from `vendor/__DIR__` to root plugin directory.
$plugin_directory = dirname( dirname( __DIR__ ) );
// Use the path processor to ensure that this is an autoloader we're referencing.
$path = $this->path_processor->find_directory_with_autoloader( $plugin_directory, array() );
if ( false === $path ) {
throw new \RuntimeException( 'Failed to locate plugin ' . $plugin_directory );
}
return $path;
}
/**
* Checks a given option for plugin paths.
*
* @param string $option_name The option that we want to check for plugin information.
* @param bool $site_option Indicates whether or not we want to check the site option.
*
* @return array $plugin_paths The list of absolute paths we've found.
*/
public function find_using_option( $option_name, $site_option = false ) {
$raw = $site_option ? get_site_option( $option_name ) : get_option( $option_name );
if ( false === $raw ) {
return array();
}
return $this->convert_plugins_to_paths( $raw );
}
/**
* Checks for plugins in the `action` request parameter.
*
* @param string[] $allowed_actions The actions that we're allowed to return plugins for.
*
* @return array $plugin_paths The list of absolute paths we've found.
*/
public function find_using_request_action( $allowed_actions ) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
/**
* Note: we're not actually checking the nonce here because it's too early
* in the execution. The pluggable functions are not yet loaded to give
* plugins a chance to plug their versions. Therefore we're doing the bare
* minimum: checking whether the nonce exists and it's in the right place.
* The request will fail later if the nonce doesn't pass the check.
*/
if ( empty( $_REQUEST['_wpnonce'] ) ) {
return array();
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated just below.
$action = isset( $_REQUEST['action'] ) ? wp_unslash( $_REQUEST['action'] ) : false;
if ( ! in_array( $action, $allowed_actions, true ) ) {
return array();
}
$plugin_slugs = array();
switch ( $action ) {
case 'activate':
case 'deactivate':
if ( empty( $_REQUEST['plugin'] ) ) {
break;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by convert_plugins_to_paths.
$plugin_slugs[] = wp_unslash( $_REQUEST['plugin'] );
break;
case 'activate-selected':
case 'deactivate-selected':
if ( empty( $_REQUEST['checked'] ) ) {
break;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by convert_plugins_to_paths.
$plugin_slugs = wp_unslash( $_REQUEST['checked'] );
break;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return $this->convert_plugins_to_paths( $plugin_slugs );
}
/**
* Given an array of plugin slugs or paths, this will convert them to absolute paths and filter
* out the plugins that are not directory plugins. Note that array keys will also be included
* if they are plugin paths!
*
* @param string[] $plugins Plugin paths or slugs to filter.
*
* @return string[]
*/
private function convert_plugins_to_paths( $plugins ) {
if ( ! is_array( $plugins ) || empty( $plugins ) ) {
return array();
}
// We're going to look for plugins in the standard directories.
$path_constants = array( WP_PLUGIN_DIR, WPMU_PLUGIN_DIR );
$plugin_paths = array();
foreach ( $plugins as $key => $value ) {
$path = $this->path_processor->find_directory_with_autoloader( $key, $path_constants );
if ( $path ) {
$plugin_paths[] = $path;
}
$path = $this->path_processor->find_directory_with_autoloader( $value, $path_constants );
if ( $path ) {
$plugin_paths[] = $path;
}
}
return $plugin_paths;
}
}
class-plugins-handler.php 0000644 00000013071 15154650006 0011460 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class handles locating and caching all of the active plugins.
*/
class Plugins_Handler {
/**
* The transient key for plugin paths.
*/
const TRANSIENT_KEY = 'jetpack_autoloader_plugin_paths';
/**
* The locator for finding plugins in different locations.
*
* @var Plugin_Locator
*/
private $plugin_locator;
/**
* The processor for transforming cached paths.
*
* @var Path_Processor
*/
private $path_processor;
/**
* The constructor.
*
* @param Plugin_Locator $plugin_locator The locator for finding active plugins.
* @param Path_Processor $path_processor The processor for transforming cached paths.
*/
public function __construct( $plugin_locator, $path_processor ) {
$this->plugin_locator = $plugin_locator;
$this->path_processor = $path_processor;
}
/**
* Gets all of the active plugins we can find.
*
* @param bool $include_deactivating When true, plugins deactivating this request will be considered active.
* @param bool $record_unknown When true, the current plugin will be marked as active and recorded when unknown.
*
* @return string[]
*/
public function get_active_plugins( $include_deactivating, $record_unknown ) {
global $jetpack_autoloader_activating_plugins_paths;
// We're going to build a unique list of plugins from a few different sources
// to find all of our "active" plugins. While we need to return an integer
// array, we're going to use an associative array internally to reduce
// the amount of time that we're going to spend checking uniqueness
// and merging different arrays together to form the output.
$active_plugins = array();
// Make sure that plugins which have activated this request are considered as "active" even though
// they probably won't be present in any option.
if ( is_array( $jetpack_autoloader_activating_plugins_paths ) ) {
foreach ( $jetpack_autoloader_activating_plugins_paths as $path ) {
$active_plugins[ $path ] = $path;
}
}
// This option contains all of the plugins that have been activated.
$plugins = $this->plugin_locator->find_using_option( 'active_plugins' );
foreach ( $plugins as $path ) {
$active_plugins[ $path ] = $path;
}
// This option contains all of the multisite plugins that have been activated.
if ( is_multisite() ) {
$plugins = $this->plugin_locator->find_using_option( 'active_sitewide_plugins', true );
foreach ( $plugins as $path ) {
$active_plugins[ $path ] = $path;
}
}
// These actions contain plugins that are being activated/deactivated during this request.
$plugins = $this->plugin_locator->find_using_request_action( array( 'activate', 'activate-selected', 'deactivate', 'deactivate-selected' ) );
foreach ( $plugins as $path ) {
$active_plugins[ $path ] = $path;
}
// When the current plugin isn't considered "active" there's a problem.
// Since we're here, the plugin is active and currently being loaded.
// We can support this case (mu-plugins and non-standard activation)
// by adding the current plugin to the active list and marking it
// as an unknown (activating) plugin. This also has the benefit
// of causing a reset because the active plugins list has
// been changed since it was saved in the global.
$current_plugin = $this->plugin_locator->find_current_plugin();
if ( $record_unknown && ! in_array( $current_plugin, $active_plugins, true ) ) {
$active_plugins[ $current_plugin ] = $current_plugin;
$jetpack_autoloader_activating_plugins_paths[] = $current_plugin;
}
// When deactivating plugins aren't desired we should entirely remove them from the active list.
if ( ! $include_deactivating ) {
// These actions contain plugins that are being deactivated during this request.
$plugins = $this->plugin_locator->find_using_request_action( array( 'deactivate', 'deactivate-selected' ) );
foreach ( $plugins as $path ) {
unset( $active_plugins[ $path ] );
}
}
// Transform the array so that we don't have to worry about the keys interacting with other array types later.
return array_values( $active_plugins );
}
/**
* Gets all of the cached plugins if there are any.
*
* @return string[]
*/
public function get_cached_plugins() {
$cached = get_transient( self::TRANSIENT_KEY );
if ( ! is_array( $cached ) || empty( $cached ) ) {
return array();
}
// We need to expand the tokens to an absolute path for this webserver.
return array_map( array( $this->path_processor, 'untokenize_path_constants' ), $cached );
}
/**
* Saves the plugin list to the cache.
*
* @param array $plugins The plugin list to save to the cache.
*/
public function cache_plugins( $plugins ) {
// We store the paths in a tokenized form so that that webservers with different absolute paths don't break.
$plugins = array_map( array( $this->path_processor, 'tokenize_path_constants' ), $plugins );
set_transient( self::TRANSIENT_KEY, $plugins );
}
/**
* Checks to see whether or not the plugin list given has changed when compared to the
* shared `$jetpack_autoloader_cached_plugin_paths` global. This allows us to deal
* with cases where the active list may change due to filtering..
*
* @param string[] $plugins The plugins list to check against the global cache.
*
* @return bool True if the plugins have changed, otherwise false.
*/
public function have_plugins_changed( $plugins ) {
global $jetpack_autoloader_cached_plugin_paths;
if ( $jetpack_autoloader_cached_plugin_paths !== $plugins ) {
$jetpack_autoloader_cached_plugin_paths = $plugins;
return true;
}
return false;
}
}
class-shutdown-handler.php 0000644 00000005203 15154650006 0011650 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class handles the shutdown of the autoloader.
*/
class Shutdown_Handler {
/**
* The Plugins_Handler instance.
*
* @var Plugins_Handler
*/
private $plugins_handler;
/**
* The plugins cached by this autoloader.
*
* @var string[]
*/
private $cached_plugins;
/**
* Indicates whether or not this autoloader was included by another.
*
* @var bool
*/
private $was_included_by_autoloader;
/**
* Constructor.
*
* @param Plugins_Handler $plugins_handler The Plugins_Handler instance to use.
* @param string[] $cached_plugins The plugins cached by the autoloaer.
* @param bool $was_included_by_autoloader Indicates whether or not the autoloader was included by another.
*/
public function __construct( $plugins_handler, $cached_plugins, $was_included_by_autoloader ) {
$this->plugins_handler = $plugins_handler;
$this->cached_plugins = $cached_plugins;
$this->was_included_by_autoloader = $was_included_by_autoloader;
}
/**
* Handles the shutdown of the autoloader.
*/
public function __invoke() {
// Don't save a broken cache if an error happens during some plugin's initialization.
if ( ! did_action( 'plugins_loaded' ) ) {
// Ensure that the cache is emptied to prevent consecutive failures if the cache is to blame.
if ( ! empty( $this->cached_plugins ) ) {
$this->plugins_handler->cache_plugins( array() );
}
return;
}
// Load the active plugins fresh since the list we pulled earlier might not contain
// plugins that were activated but did not reset the autoloader. This happens
// when a plugin is in the cache but not "active" when the autoloader loads.
// We also want to make sure that plugins which are deactivating are not
// considered "active" so that they will be removed from the cache now.
try {
$active_plugins = $this->plugins_handler->get_active_plugins( false, ! $this->was_included_by_autoloader );
} catch ( \Exception $ex ) {
// When the package is deleted before shutdown it will throw an exception.
// In the event this happens we should erase the cache.
if ( ! empty( $this->cached_plugins ) ) {
$this->plugins_handler->cache_plugins( array() );
}
return;
}
// The paths should be sorted for easy comparisons with those loaded from the cache.
// Note we don't need to sort the cached entries because they're already sorted.
sort( $active_plugins );
// We don't want to waste time saving a cache that hasn't changed.
if ( $this->cached_plugins === $active_plugins ) {
return;
}
$this->plugins_handler->cache_plugins( $active_plugins );
}
}
class-version-loader.php 0000644 00000007664 15154650006 0011330 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* This class loads other classes based on given parameters.
*/
class Version_Loader {
/**
* The Version_Selector object.
*
* @var Version_Selector
*/
private $version_selector;
/**
* A map of available classes and their version and file path.
*
* @var array
*/
private $classmap;
/**
* A map of PSR-4 namespaces and their version and directory path.
*
* @var array
*/
private $psr4_map;
/**
* A map of all the files that we should load.
*
* @var array
*/
private $filemap;
/**
* The constructor.
*
* @param Version_Selector $version_selector The Version_Selector object.
* @param array $classmap The verioned classmap to load using.
* @param array $psr4_map The versioned PSR-4 map to load using.
* @param array $filemap The versioned filemap to load.
*/
public function __construct( $version_selector, $classmap, $psr4_map, $filemap ) {
$this->version_selector = $version_selector;
$this->classmap = $classmap;
$this->psr4_map = $psr4_map;
$this->filemap = $filemap;
}
/**
* Finds the file path for the given class.
*
* @param string $class_name The class to find.
*
* @return string|null $file_path The path to the file if found, null if no class was found.
*/
public function find_class_file( $class_name ) {
$data = $this->select_newest_file(
isset( $this->classmap[ $class_name ] ) ? $this->classmap[ $class_name ] : null,
$this->find_psr4_file( $class_name )
);
if ( ! isset( $data ) ) {
return null;
}
return $data['path'];
}
/**
* Load all of the files in the filemap.
*/
public function load_filemap() {
if ( empty( $this->filemap ) ) {
return;
}
foreach ( $this->filemap as $file_identifier => $file_data ) {
if ( empty( $GLOBALS['__composer_autoload_files'][ $file_identifier ] ) ) {
require_once $file_data['path'];
$GLOBALS['__composer_autoload_files'][ $file_identifier ] = true;
}
}
}
/**
* Compares different class sources and returns the newest.
*
* @param array|null $classmap_data The classmap class data.
* @param array|null $psr4_data The PSR-4 class data.
*
* @return array|null $data
*/
private function select_newest_file( $classmap_data, $psr4_data ) {
if ( ! isset( $classmap_data ) ) {
return $psr4_data;
} elseif ( ! isset( $psr4_data ) ) {
return $classmap_data;
}
if ( $this->version_selector->is_version_update_required( $classmap_data['version'], $psr4_data['version'] ) ) {
return $psr4_data;
}
return $classmap_data;
}
/**
* Finds the file for a given class in a PSR-4 namespace.
*
* @param string $class_name The class to find.
*
* @return array|null $data The version and path path to the file if found, null otherwise.
*/
private function find_psr4_file( $class_name ) {
if ( ! isset( $this->psr4_map ) ) {
return null;
}
// Don't bother with classes that have no namespace.
$class_index = strrpos( $class_name, '\\' );
if ( ! $class_index ) {
return null;
}
$class_for_path = str_replace( '\\', '/', $class_name );
// Search for the namespace by iteratively cutting off the last segment until
// we find a match. This allows us to check the most-specific namespaces
// first as well as minimize the amount of time spent looking.
for (
$class_namespace = substr( $class_name, 0, $class_index );
! empty( $class_namespace );
$class_namespace = substr( $class_namespace, 0, strrpos( $class_namespace, '\\' ) )
) {
$namespace = $class_namespace . '\\';
if ( ! isset( $this->psr4_map[ $namespace ] ) ) {
continue;
}
$data = $this->psr4_map[ $namespace ];
foreach ( $data['path'] as $path ) {
$path .= '/' . substr( $class_for_path, strlen( $namespace ) ) . '.php';
if ( file_exists( $path ) ) {
return array(
'version' => $data['version'],
'path' => $path,
);
}
}
}
return null;
}
}
class-version-selector.php 0000644 00000003163 15154650006 0011670 0 ustar 00 <?php
/* HEADER */ // phpcs:ignore
/**
* Used to select package versions.
*/
class Version_Selector {
/**
* Checks whether the selected package version should be updated. Composer development
* package versions ('9999999-dev' or versions that start with 'dev-') are favored
* when the JETPACK_AUTOLOAD_DEV constant is set to true.
*
* @param String $selected_version The currently selected package version.
* @param String $compare_version The package version that is being evaluated to
* determine if the version needs to be updated.
*
* @return bool Returns true if the selected package version should be updated,
* else false.
*/
public function is_version_update_required( $selected_version, $compare_version ) {
$use_dev_versions = defined( 'JETPACK_AUTOLOAD_DEV' ) && JETPACK_AUTOLOAD_DEV;
if ( $selected_version === null ) {
return true;
}
if ( $use_dev_versions && $this->is_dev_version( $selected_version ) ) {
return false;
}
if ( $this->is_dev_version( $compare_version ) ) {
if ( $use_dev_versions ) {
return true;
} else {
return false;
}
}
if ( version_compare( $selected_version, $compare_version, '<' ) ) {
return true;
}
return false;
}
/**
* Checks whether the given package version is a development version.
*
* @param String $version The package version.
*
* @return bool True if the version is a dev version, else false.
*/
public function is_dev_version( $version ) {
if ( 'dev-' === substr( $version, 0, 4 ) || '9999999-dev' === $version ) {
return true;
}
return false;
}
}
Caching/SimpleStringCache.php 0000644 00000004031 15154677242 0012170 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Caching;
/**
* This cache caches string values with string keys. It is not PSR-6-compliant.
*
* Usage:
*
* ```php
* $cache = new SimpleStringCache();
* $cache->set($key, $value);
* …
* if ($cache->has($key) {
* $cachedValue = $cache->get($value);
* }
* ```
*
* @internal
*/
class SimpleStringCache
{
/**
* @var array<string, string>
*/
private $values = [];
/**
* Checks whether there is an entry stored for the given key.
*
* @param string $key the key to check; must not be empty
*
* @throws \InvalidArgumentException
*/
public function has(string $key): bool
{
$this->assertNotEmptyKey($key);
return isset($this->values[$key]);
}
/**
* Returns the entry stored for the given key, and throws an exception if the value does not exist
* (which helps keep the return type simple).
*
* @param string $key the key to of the item to retrieve; must not be empty
*
* @return string the retrieved value; may be empty
*
* @throws \BadMethodCallException
*/
public function get(string $key): string
{
if (!$this->has($key)) {
throw new \BadMethodCallException('You can only call `get` with a key for an existing value.', 1625996246);
}
return $this->values[$key];
}
/**
* Sets or overwrites an entry.
*
* @param string $key the key to of the item to set; must not be empty
* @param string $value the value to set; can be empty
*
* @throws \BadMethodCallException
*/
public function set(string $key, string $value): void
{
$this->assertNotEmptyKey($key);
$this->values[$key] = $value;
}
/**
* @throws \InvalidArgumentException
*/
private function assertNotEmptyKey(string $key): void
{
if ($key === '') {
throw new \InvalidArgumentException('Please provide a non-empty key.', 1625995840);
}
}
}
Css/CssDocument.php 0000644 00000014747 15154677242 0010266 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Css;
use Sabberworm\CSS\CSSList\AtRuleBlockList as CssAtRuleBlockList;
use Sabberworm\CSS\CSSList\Document as SabberwormCssDocument;
use Sabberworm\CSS\Parser as CssParser;
use Sabberworm\CSS\Property\AtRule as CssAtRule;
use Sabberworm\CSS\Property\Charset as CssCharset;
use Sabberworm\CSS\Property\Import as CssImport;
use Sabberworm\CSS\Renderable as CssRenderable;
use Sabberworm\CSS\RuleSet\DeclarationBlock as CssDeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet as CssRuleSet;
/**
* Parses and stores a CSS document from a string of CSS, and provides methods to obtain the CSS in parts or as data
* structures.
*
* @internal
*/
class CssDocument
{
/**
* @var SabberwormCssDocument
*/
private $sabberwormCssDocument;
/**
* `@import` rules must precede all other types of rules, except `@charset` rules. This property is used while
* rendering at-rules to enforce that.
*
* @var bool
*/
private $isImportRuleAllowed = true;
/**
* @param string $css
*/
public function __construct(string $css)
{
$cssParser = new CssParser($css);
/** @var SabberwormCssDocument $sabberwormCssDocument */
$sabberwormCssDocument = $cssParser->parse();
$this->sabberwormCssDocument = $sabberwormCssDocument;
}
/**
* Collates the media query, selectors and declarations for individual rules from the parsed CSS, in order.
*
* @param array<array-key, string> $allowedMediaTypes
*
* @return array<int, StyleRule>
*/
public function getStyleRulesData(array $allowedMediaTypes): array
{
$ruleMatches = [];
/** @var CssRenderable $rule */
foreach ($this->sabberwormCssDocument->getContents() as $rule) {
if ($rule instanceof CssAtRuleBlockList) {
$containingAtRule = $this->getFilteredAtIdentifierAndRule($rule, $allowedMediaTypes);
if (\is_string($containingAtRule)) {
/** @var CssRenderable $nestedRule */
foreach ($rule->getContents() as $nestedRule) {
if ($nestedRule instanceof CssDeclarationBlock) {
$ruleMatches[] = new StyleRule($nestedRule, $containingAtRule);
}
}
}
} elseif ($rule instanceof CssDeclarationBlock) {
$ruleMatches[] = new StyleRule($rule);
}
}
return $ruleMatches;
}
/**
* Renders at-rules from the parsed CSS that are valid and not conditional group rules (i.e. not rules such as
* `@media` which contain style rules whose data is returned by {@see getStyleRulesData}). Also does not render
* `@charset` rules; these are discarded (only UTF-8 is supported).
*
* @return string
*/
public function renderNonConditionalAtRules(): string
{
$this->isImportRuleAllowed = true;
/** @var array<int, CssRenderable> $cssContents */
$cssContents = $this->sabberwormCssDocument->getContents();
$atRules = \array_filter($cssContents, [$this, 'isValidAtRuleToRender']);
if ($atRules === []) {
return '';
}
$atRulesDocument = new SabberwormCssDocument();
$atRulesDocument->setContents($atRules);
/** @var string $renderedRules */
$renderedRules = $atRulesDocument->render();
return $renderedRules;
}
/**
* @param CssAtRuleBlockList $rule
* @param array<array-key, string> $allowedMediaTypes
*
* @return ?string
* If the nested at-rule is supported, it's opening declaration (e.g. "@media (max-width: 768px)") is
* returned; otherwise the return value is null.
*/
private function getFilteredAtIdentifierAndRule(CssAtRuleBlockList $rule, array $allowedMediaTypes): ?string
{
$result = null;
if ($rule->atRuleName() === 'media') {
/** @var string $mediaQueryList */
$mediaQueryList = $rule->atRuleArgs();
[$mediaType] = \explode('(', $mediaQueryList, 2);
if (\trim($mediaType) !== '') {
$escapedAllowedMediaTypes = \array_map(
static function (string $allowedMediaType): string {
return \preg_quote($allowedMediaType, '/');
},
$allowedMediaTypes
);
$mediaTypesMatcher = \implode('|', $escapedAllowedMediaTypes);
$isAllowed = \preg_match('/^\\s*+(?:only\\s++)?+(?:' . $mediaTypesMatcher . ')/i', $mediaType) > 0;
} else {
$isAllowed = true;
}
if ($isAllowed) {
$result = '@media ' . $mediaQueryList;
}
}
return $result;
}
/**
* Tests if a CSS rule is an at-rule that should be passed though and copied to a `<style>` element unmodified:
* - `@charset` rules are discarded - only UTF-8 is supported - `false` is returned;
* - `@import` rules are passed through only if they satisfy the specification ("user agents must ignore any
* '@import' rule that occurs inside a block or after any non-ignored statement other than an '@charset' or an
* '@import' rule");
* - `@media` rules are processed separately to see if their nested rules apply - `false` is returned;
* - `@font-face` rules are checked for validity - they must contain both a `src` and `font-family` property;
* - other at-rules are assumed to be valid and treated as a black box - `true` is returned.
*
* @param CssRenderable $rule
*
* @return bool
*/
private function isValidAtRuleToRender(CssRenderable $rule): bool
{
if ($rule instanceof CssCharset) {
return false;
}
if ($rule instanceof CssImport) {
return $this->isImportRuleAllowed;
}
$this->isImportRuleAllowed = false;
if (!$rule instanceof CssAtRule) {
return false;
}
switch ($rule->atRuleName()) {
case 'media':
$result = false;
break;
case 'font-face':
$result = $rule instanceof CssRuleSet
&& $rule->getRules('font-family') !== []
&& $rule->getRules('src') !== [];
break;
default:
$result = true;
}
return $result;
}
}
Css/StyleRule.php 0000644 00000004174 15154677242 0007760 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Css;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
/**
* This class represents a CSS style rule, including selectors, a declaration block, and an optional containing at-rule.
*
* @internal
*/
class StyleRule
{
/**
* @var DeclarationBlock
*/
private $declarationBlock;
/**
* @var string
*/
private $containingAtRule;
/**
* @param DeclarationBlock $declarationBlock
* @param string $containingAtRule e.g. `@media screen and (max-width: 480px)`
*/
public function __construct(DeclarationBlock $declarationBlock, string $containingAtRule = '')
{
$this->declarationBlock = $declarationBlock;
$this->containingAtRule = \trim($containingAtRule);
}
/**
* @return array<int, string> the selectors, e.g. `["h1", "p"]`
*/
public function getSelectors(): array
{
/** @var array<int, Selector> $selectors */
$selectors = $this->declarationBlock->getSelectors();
return \array_map(
static function (Selector $selector): string {
return (string)$selector;
},
$selectors
);
}
/**
* @return string the CSS declarations, separated and followed by a semicolon, e.g., `color: red; height: 4px;`
*/
public function getDeclarationAsText(): string
{
return \implode(' ', $this->declarationBlock->getRules());
}
/**
* Checks whether the declaration block has at least one declaration.
*/
public function hasAtLeastOneDeclaration(): bool
{
return $this->declarationBlock->getRules() !== [];
}
/**
* @returns string e.g. `@media screen and (max-width: 480px)`, or an empty string
*/
public function getContainingAtRule(): string
{
return $this->containingAtRule;
}
/**
* Checks whether the containing at-rule is non-empty and has any non-whitespace characters.
*/
public function hasContainingAtRule(): bool
{
return $this->getContainingAtRule() !== '';
}
}
CssInliner.php 0000644 00000123627 15154677242 0007356 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier;
use Pelago\Emogrifier\Css\CssDocument;
use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
use Pelago\Emogrifier\Utilities\CssConcatenator;
use Symfony\Component\CssSelector\CssSelectorConverter;
use Symfony\Component\CssSelector\Exception\ParseException;
/**
* This class provides functions for converting CSS styles into inline style attributes in your HTML code.
*/
class CssInliner extends AbstractHtmlProcessor
{
/**
* @var int
*/
private const CACHE_KEY_SELECTOR = 0;
/**
* @var int
*/
private const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 1;
/**
* @var int
*/
private const CACHE_KEY_COMBINED_STYLES = 2;
/**
* Regular expression component matching a static pseudo class in a selector, without the preceding ":",
* for which the applicable elements can be determined (by converting the selector to an XPath expression).
* (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
* group, as appropriate for the usage context.)
*
* @var string
*/
private const PSEUDO_CLASS_MATCHER
= 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)';
/**
* This regular expression componenet matches an `...of-type` pseudo class name, without the preceding ":". These
* pseudo-classes can currently online be inlined if they have an associated type in the selector expression.
*
* @var string
*/
private const OF_TYPE_PSEUDO_CLASS_MATCHER = '(?:first|last|nth(?:-last)?+|only)-of-type';
/**
* regular expression component to match a selector combinator
*
* @var string
*/
private const COMBINATOR_MATCHER = '(?:\\s++|\\s*+[>+~]\\s*+)(?=[[:alpha:]_\\-.#*:\\[])';
/**
* @var array<string, bool>
*/
private $excludedSelectors = [];
/**
* @var array<string, bool>
*/
private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
/**
* @var array{
* 0: array<string, int>,
* 1: array<string, array<string, string>>,
* 2: array<string, string>
* }
*/
private $caches = [
self::CACHE_KEY_SELECTOR => [],
self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
self::CACHE_KEY_COMBINED_STYLES => [],
];
/**
* @var ?CssSelectorConverter
*/
private $cssSelectorConverter = null;
/**
* the visited nodes with the XPath paths as array keys
*
* @var array<string, \DOMElement>
*/
private $visitedNodes = [];
/**
* the styles to apply to the nodes with the XPath paths as array keys for the outer array
* and the attribute names/values as key/value pairs for the inner array
*
* @var array<string, array<string, string>>
*/
private $styleAttributesForNodes = [];
/**
* Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
* If set to false, the value of the style attributes will be discarded.
*
* @var bool
*/
private $isInlineStyleAttributesParsingEnabled = true;
/**
* Determines whether the `<style>` blocks in the HTML passed to this class should be parsed.
*
* If set to true, the `<style>` blocks will be removed from the HTML and their contents will be applied to the HTML
* via inline styles.
*
* If set to false, the `<style>` blocks will be left as they are in the HTML.
*
* @var bool
*/
private $isStyleBlocksParsingEnabled = true;
/**
* For calculating selector precedence order.
* Keys are a regular expression part to match before a CSS name.
* Values are a multiplier factor per match to weight specificity.
*
* @var array<string, int>
*/
private $selectorPrecedenceMatchers = [
// IDs: worth 10000
'\\#' => 10000,
// classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
'(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
// elements (not attribute values or `:not`), pseudo-elements: worth 1
'(?:(?<![="\':\\w\\-])|::)' => 1,
];
/**
* array of data describing CSS rules which apply to the document but cannot be inlined, in the format returned by
* {@see collateCssRules}
*
* @var array<array-key, array{
* media: string,
* selector: string,
* hasUnmatchablePseudo: bool,
* declarationsBlock: string,
* line: int
* }>|null
*/
private $matchingUninlinableCssRules = null;
/**
* Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
*
* @var bool
*/
private $debug = false;
/**
* Inlines the given CSS into the existing HTML.
*
* @param string $css the CSS to inline, must be UTF-8-encoded
*
* @return self fluent interface
*
* @throws ParseException in debug mode, if an invalid selector is encountered
* @throws \RuntimeException in debug mode, if an internal PCRE error occurs
*/
public function inlineCss(string $css = ''): self
{
$this->clearAllCaches();
$this->purgeVisitedNodes();
$this->normalizeStyleAttributesOfAllNodes();
$combinedCss = $css;
// grab any existing style blocks from the HTML and append them to the existing CSS
// (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
if ($this->isStyleBlocksParsingEnabled) {
$combinedCss .= $this->getCssFromAllStyleNodes();
}
$parsedCss = new CssDocument($combinedCss);
$excludedNodes = $this->getNodesToExclude();
$cssRules = $this->collateCssRules($parsedCss);
$cssSelectorConverter = $this->getCssSelectorConverter();
foreach ($cssRules['inlinable'] as $cssRule) {
try {
$nodesMatchingCssSelectors = $this->getXPath()
->query($cssSelectorConverter->toXPath($cssRule['selector']));
/** @var \DOMElement $node */
foreach ($nodesMatchingCssSelectors as $node) {
if (\in_array($node, $excludedNodes, true)) {
continue;
}
$this->copyInlinableCssToStyleAttribute($node, $cssRule);
}
} catch (ParseException $e) {
if ($this->debug) {
throw $e;
}
}
}
if ($this->isInlineStyleAttributesParsingEnabled) {
$this->fillStyleAttributesWithMergedStyles();
}
$this->removeImportantAnnotationFromAllInlineStyles();
$this->determineMatchingUninlinableCssRules($cssRules['uninlinable']);
$this->copyUninlinableCssToStyleNode($parsedCss);
return $this;
}
/**
* Disables the parsing of inline styles.
*
* @return self fluent interface
*/
public function disableInlineStyleAttributesParsing(): self
{
$this->isInlineStyleAttributesParsingEnabled = false;
return $this;
}
/**
* Disables the parsing of `<style>` blocks.
*
* @return self fluent interface
*/
public function disableStyleBlocksParsing(): self
{
$this->isStyleBlocksParsingEnabled = false;
return $this;
}
/**
* Marks a media query type to keep.
*
* @param string $mediaName the media type name, e.g., "braille"
*
* @return self fluent interface
*/
public function addAllowedMediaType(string $mediaName): self
{
$this->allowedMediaTypes[$mediaName] = true;
return $this;
}
/**
* Drops a media query type from the allowed list.
*
* @param string $mediaName the tag name, e.g., "braille"
*
* @return self fluent interface
*/
public function removeAllowedMediaType(string $mediaName): self
{
if (isset($this->allowedMediaTypes[$mediaName])) {
unset($this->allowedMediaTypes[$mediaName]);
}
return $this;
}
/**
* Adds a selector to exclude nodes from emogrification.
*
* Any nodes that match the selector will not have their style altered.
*
* @param string $selector the selector to exclude, e.g., ".editor"
*
* @return self fluent interface
*/
public function addExcludedSelector(string $selector): self
{
$this->excludedSelectors[$selector] = true;
return $this;
}
/**
* No longer excludes the nodes matching this selector from emogrification.
*
* @param string $selector the selector to no longer exclude, e.g., ".editor"
*
* @return self fluent interface
*/
public function removeExcludedSelector(string $selector): self
{
if (isset($this->excludedSelectors[$selector])) {
unset($this->excludedSelectors[$selector]);
}
return $this;
}
/**
* Sets the debug mode.
*
* @param bool $debug set to true to enable debug mode
*
* @return self fluent interface
*/
public function setDebug(bool $debug): self
{
$this->debug = $debug;
return $this;
}
/**
* Gets the array of selectors present in the CSS provided to `inlineCss()` for which the declarations could not be
* applied as inline styles, but which may affect elements in the HTML. The relevant CSS will have been placed in a
* `<style>` element. The selectors may include those used within `@media` rules or those involving dynamic
* pseudo-classes (such as `:hover`) or pseudo-elements (such as `::after`).
*
* @return array<array-key, string>
*
* @throws \BadMethodCallException if `inlineCss` has not been called first
*/
public function getMatchingUninlinableSelectors(): array
{
return \array_column($this->getMatchingUninlinableCssRules(), 'selector');
}
/**
* @return array<array-key, array{
* media: string,
* selector: string,
* hasUnmatchablePseudo: bool,
* declarationsBlock: string,
* line: int
* }>
*
* @throws \BadMethodCallException if `inlineCss` has not been called first
*/
private function getMatchingUninlinableCssRules(): array
{
if (!\is_array($this->matchingUninlinableCssRules)) {
throw new \BadMethodCallException('inlineCss must be called first', 1568385221);
}
return $this->matchingUninlinableCssRules;
}
/**
* Clears all caches.
*/
private function clearAllCaches(): void
{
$this->caches = [
self::CACHE_KEY_SELECTOR => [],
self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
self::CACHE_KEY_COMBINED_STYLES => [],
];
}
/**
* Purges the visited nodes.
*/
private function purgeVisitedNodes(): void
{
$this->visitedNodes = [];
$this->styleAttributesForNodes = [];
}
/**
* Parses the document and normalizes all existing CSS attributes.
* This changes 'DISPLAY: none' to 'display: none'.
* We wouldn't have to do this if DOMXPath supported XPath 2.0.
* Also stores a reference of nodes with existing inline styles so we don't overwrite them.
*/
private function normalizeStyleAttributesOfAllNodes(): void
{
/** @var \DOMElement $node */
foreach ($this->getAllNodesWithStyleAttribute() as $node) {
if ($this->isInlineStyleAttributesParsingEnabled) {
$this->normalizeStyleAttributes($node);
}
// Remove style attribute in every case, so we can add them back (if inline style attributes
// parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
// else original inline style rules may remain at the beginning of the final inline style definition
// of a node, which may give not the desired results
$node->removeAttribute('style');
}
}
/**
* Returns a list with all DOM nodes that have a style attribute.
*
* @return \DOMNodeList
*
* @throws \RuntimeException
*/
private function getAllNodesWithStyleAttribute(): \DOMNodeList
{
$query = '//*[@style]';
$matches = $this->getXPath()->query($query);
if (!$matches instanceof \DOMNodeList) {
throw new \RuntimeException('XPatch query failed: ' . $query, 1618577797);
}
return $matches;
}
/**
* Normalizes the value of the "style" attribute and saves it.
*
* @param \DOMElement $node
*/
private function normalizeStyleAttributes(\DOMElement $node): void
{
$normalizedOriginalStyle = \preg_replace_callback(
'/-?+[_a-zA-Z][\\w\\-]*+(?=:)/S',
/** @param array<array-key, string> $propertyNameMatches */
static function (array $propertyNameMatches): string {
return \strtolower($propertyNameMatches[0]);
},
$node->getAttribute('style')
);
// In order to not overwrite existing style attributes in the HTML, we have to save the original HTML styles.
$nodePath = $node->getNodePath();
if (\is_string($nodePath) && !isset($this->styleAttributesForNodes[$nodePath])) {
$this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
$this->visitedNodes[$nodePath] = $node;
}
$node->setAttribute('style', $normalizedOriginalStyle);
}
/**
* Parses a CSS declaration block into property name/value pairs.
*
* Example:
*
* The declaration block
*
* "color: #000; font-weight: bold;"
*
* will be parsed into the following array:
*
* "color" => "#000"
* "font-weight" => "bold"
*
* @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
*
* @return array<string, string>
* the CSS declarations with the property names as array keys and the property values as array values
*/
private function parseCssDeclarationsBlock(string $cssDeclarationsBlock): array
{
if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
}
$properties = [];
foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
/** @var array<int, string> $matches */
$matches = [];
if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
continue;
}
$propertyName = \strtolower($matches[1]);
$propertyValue = $matches[2];
$properties[$propertyName] = $propertyValue;
}
$this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
return $properties;
}
/**
* Returns CSS content.
*
* @return string
*/
private function getCssFromAllStyleNodes(): string
{
$styleNodes = $this->getXPath()->query('//style');
if ($styleNodes === false) {
return '';
}
$css = '';
foreach ($styleNodes as $styleNode) {
$css .= "\n\n" . $styleNode->nodeValue;
$parentNode = $styleNode->parentNode;
if ($parentNode instanceof \DOMNode) {
$parentNode->removeChild($styleNode);
}
}
return $css;
}
/**
* Find the nodes that are not to be emogrified.
*
* @return array<int, \DOMElement>
*
* @throws ParseException
* @throws \UnexpectedValueException
*/
private function getNodesToExclude(): array
{
$excludedNodes = [];
foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
try {
$matchingNodes = $this->getXPath()
->query($this->getCssSelectorConverter()->toXPath($selectorToExclude));
foreach ($matchingNodes as $node) {
if (!$node instanceof \DOMElement) {
$path = $node->getNodePath() ?? '$node';
throw new \UnexpectedValueException($path . ' is not a DOMElement.', 1617975914);
}
$excludedNodes[] = $node;
}
} catch (ParseException $e) {
if ($this->debug) {
throw $e;
}
}
}
return $excludedNodes;
}
/**
* @return CssSelectorConverter
*/
private function getCssSelectorConverter(): CssSelectorConverter
{
if (!$this->cssSelectorConverter instanceof CssSelectorConverter) {
$this->cssSelectorConverter = new CssSelectorConverter();
}
return $this->cssSelectorConverter;
}
/**
* Collates the individual rules from a `CssDocument` object.
*
* @param CssDocument $parsedCss
*
* @return array<string, array<array-key, array{
* media: string,
* selector: string,
* hasUnmatchablePseudo: bool,
* declarationsBlock: string,
* line: int
* }>>
* This 2-entry array has the key "inlinable" containing rules which can be inlined as `style` attributes
* and the key "uninlinable" containing rules which cannot. Each value is an array of sub-arrays with the
* following keys:
* - "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
* or an empty string if not from a `@media` rule);
* - "selector" (the CSS selector, e.g., "*" or "header h1");
* - "hasUnmatchablePseudo" (`true` if that selector contains pseudo-elements or dynamic pseudo-classes such
* that the declarations cannot be applied inline);
* - "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
* e.g., `color: red; height: 4px;`);
* - "line" (the line number, e.g. 42).
*/
private function collateCssRules(CssDocument $parsedCss): array
{
$matches = $parsedCss->getStyleRulesData(\array_keys($this->allowedMediaTypes));
$cssRules = [
'inlinable' => [],
'uninlinable' => [],
];
foreach ($matches as $key => $cssRule) {
if (!$cssRule->hasAtLeastOneDeclaration()) {
continue;
}
$mediaQuery = $cssRule->getContainingAtRule();
$declarationsBlock = $cssRule->getDeclarationAsText();
foreach ($cssRule->getSelectors() as $selector) {
// don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
// only allow structural pseudo-classes
$hasPseudoElement = \strpos($selector, '::') !== false;
$hasUnmatchablePseudo = $hasPseudoElement || $this->hasUnsupportedPseudoClass($selector);
$parsedCssRule = [
'media' => $mediaQuery,
'selector' => $selector,
'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
'declarationsBlock' => $declarationsBlock,
// keep track of where it appears in the file, since order is important
'line' => $key,
];
$ruleType = (!$cssRule->hasContainingAtRule() && !$hasUnmatchablePseudo) ? 'inlinable' : 'uninlinable';
$cssRules[$ruleType][] = $parsedCssRule;
}
}
\usort(
$cssRules['inlinable'],
/**
* @param array{selector: string, line: int} $first
* @param array{selector: string, line: int} $second
*/
function (array $first, array $second): int {
return $this->sortBySelectorPrecedence($first, $second);
}
);
return $cssRules;
}
/**
* Tests if a selector contains a pseudo-class which would mean it cannot be converted to an XPath expression for
* inlining CSS declarations.
*
* Any pseudo class that does not match {@see PSEUDO_CLASS_MATCHER} cannot be converted. Additionally, `...of-type`
* pseudo-classes cannot be converted if they are not associated with a type selector.
*
* @param string $selector
*
* @return bool
*/
private function hasUnsupportedPseudoClass(string $selector): bool
{
if (\preg_match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector)) {
return true;
}
if (!\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector)) {
return false;
}
foreach (\preg_split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
if ($this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
return true;
}
}
return false;
}
/**
* Tests if part of a selector contains an `...of-type` pseudo-class such that it cannot be converted to an XPath
* expression.
*
* @param string $selectorPart part of a selector which has been split up at combinators
*
* @return bool `true` if the selector part does not have a type but does have an `...of-type` pseudo-class
*/
private function selectorPartHasUnsupportedOfTypePseudoClass(string $selectorPart): bool
{
if (\preg_match('/^[\\w\\-]/', $selectorPart)) {
return false;
}
return (bool)\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart);
}
/**
* @param array{selector: string, line: int} $first
* @param array{selector: string, line: int} $second
*
* @return int
*/
private function sortBySelectorPrecedence(array $first, array $second): int
{
$precedenceOfFirst = $this->getCssSelectorPrecedence($first['selector']);
$precedenceOfSecond = $this->getCssSelectorPrecedence($second['selector']);
// We want these sorted in ascending order so selectors with lesser precedence get processed first and
// selectors with greater precedence get sorted last.
$precedenceForEquals = $first['line'] < $second['line'] ? -1 : 1;
$precedenceForNotEquals = $precedenceOfFirst < $precedenceOfSecond ? -1 : 1;
return ($precedenceOfFirst === $precedenceOfSecond) ? $precedenceForEquals : $precedenceForNotEquals;
}
/**
* @param string $selector
*
* @return int
*/
private function getCssSelectorPrecedence(string $selector): int
{
$selectorKey = \md5($selector);
if (isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
}
$precedence = 0;
foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
if (\trim($selector) === '') {
break;
}
$number = 0;
$selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
$precedence += ($value * (int)$number);
}
$this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
return $precedence;
}
/**
* Copies $cssRule into the style attribute of $node.
*
* Note: This method does not check whether $cssRule matches $node.
*
* @param \DOMElement $node
* @param array{
* media: string,
* selector: string,
* hasUnmatchablePseudo: bool,
* declarationsBlock: string,
* line: int
* } $cssRule
*/
private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule): void
{
$declarationsBlock = $cssRule['declarationsBlock'];
$newStyleDeclarations = $this->parseCssDeclarationsBlock($declarationsBlock);
if ($newStyleDeclarations === []) {
return;
}
// if it has a style attribute, get it, process it, and append (overwrite) new stuff
if ($node->hasAttribute('style')) {
// break it up into an associative array
$oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
} else {
$oldStyleDeclarations = [];
}
$node->setAttribute(
'style',
$this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
);
}
/**
* This method merges old or existing name/value array with new name/value array
* and then generates a string of the combined style suitable for placing inline.
* This becomes the single point for CSS string generation allowing for consistent
* CSS output no matter where the CSS originally came from.
*
* @param array<string, string> $oldStyles
* @param array<string, string> $newStyles
*
* @return string
*/
private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles): string
{
$cacheKey = \serialize([$oldStyles, $newStyles]);
if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
}
// Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
foreach ($oldStyles as $attributeName => $attributeValue) {
if (!isset($newStyles[$attributeName])) {
continue;
}
$newAttributeValue = $newStyles[$attributeName];
if (
$this->attributeValueIsImportant($attributeValue)
&& !$this->attributeValueIsImportant($newAttributeValue)
) {
unset($newStyles[$attributeName]);
} else {
unset($oldStyles[$attributeName]);
}
}
$combinedStyles = \array_merge($oldStyles, $newStyles);
$style = '';
foreach ($combinedStyles as $attributeName => $attributeValue) {
$style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
}
$trimmedStyle = \rtrim($style);
$this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
return $trimmedStyle;
}
/**
* Checks whether $attributeValue is marked as !important.
*
* @param string $attributeValue
*
* @return bool
*/
private function attributeValueIsImportant(string $attributeValue): bool
{
return (bool)\preg_match('/!\\s*+important$/i', $attributeValue);
}
/**
* Merges styles from styles attributes and style nodes and applies them to the attribute nodes
*/
private function fillStyleAttributesWithMergedStyles(): void
{
foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
$node = $this->visitedNodes[$nodePath];
$currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
$node->setAttribute(
'style',
$this->generateStyleStringFromDeclarationsArrays(
$currentStyleAttributes,
$styleAttributesForNode
)
);
}
}
/**
* Searches for all nodes with a style attribute and removes the "!important" annotations out of
* the inline style declarations, eventually by rearranging declarations.
*
* @throws \RuntimeException
*/
private function removeImportantAnnotationFromAllInlineStyles(): void
{
/** @var \DOMElement $node */
foreach ($this->getAllNodesWithStyleAttribute() as $node) {
$this->removeImportantAnnotationFromNodeInlineStyle($node);
}
}
/**
* Removes the "!important" annotations out of the inline style declarations,
* eventually by rearranging declarations.
* Rearranging needed when !important shorthand properties are followed by some of their
* not !important expanded-version properties.
* For example "font: 12px serif !important; font-size: 13px;" must be reordered
* to "font-size: 13px; font: 12px serif;" in order to remain correct.
*
* @param \DOMElement $node
*
* @throws \RuntimeException
*/
private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node): void
{
$inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
/** @var array<string, string> $regularStyleDeclarations */
$regularStyleDeclarations = [];
/** @var array<string, string> $importantStyleDeclarations */
$importantStyleDeclarations = [];
foreach ($inlineStyleDeclarations as $property => $value) {
if ($this->attributeValueIsImportant($value)) {
$importantStyleDeclarations[$property] = $this->pregReplace('/\\s*+!\\s*+important$/i', '', $value);
} else {
$regularStyleDeclarations[$property] = $value;
}
}
$inlineStyleDeclarationsInNewOrder = \array_merge($regularStyleDeclarations, $importantStyleDeclarations);
$node->setAttribute(
'style',
$this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
);
}
/**
* Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
*
* @param array<string, string> $styleDeclarations
*
* @return string
*/
private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations): string
{
return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
}
/**
* Determines which of `$cssRules` actually apply to `$this->domDocument`, and sets them in
* `$this->matchingUninlinableCssRules`.
*
* @param array<array-key, array{
* media: string,
* selector: string,
* hasUnmatchablePseudo: bool,
* declarationsBlock: string,
* line: int
* }> $cssRules
* the "uninlinable" array of CSS rules returned by `collateCssRules`
*/
private function determineMatchingUninlinableCssRules(array $cssRules): void
{
$this->matchingUninlinableCssRules = \array_filter(
$cssRules,
function (array $cssRule): bool {
return $this->existsMatchForSelectorInCssRule($cssRule);
}
);
}
/**
* Checks whether there is at least one matching element for the CSS selector contained in the `selector` element
* of the provided CSS rule.
*
* Any dynamic pseudo-classes will be assumed to apply. If the selector matches a pseudo-element,
* it will test for a match with its originating element.
*
* @param array{
* media: string,
* selector: string,
* hasUnmatchablePseudo: bool,
* declarationsBlock: string,
* line: int
* } $cssRule
*
* @return bool
*
* @throws ParseException
*/
private function existsMatchForSelectorInCssRule(array $cssRule): bool
{
$selector = $cssRule['selector'];
if ($cssRule['hasUnmatchablePseudo']) {
$selector = $this->removeUnmatchablePseudoComponents($selector);
}
return $this->existsMatchForCssSelector($selector);
}
/**
* Checks whether there is at least one matching element for $cssSelector.
* When not in debug mode, it returns true also for invalid selectors (because they may be valid,
* just not implemented/recognized yet by Emogrifier).
*
* @param string $cssSelector
*
* @return bool
*
* @throws ParseException
*/
private function existsMatchForCssSelector(string $cssSelector): bool
{
try {
$nodesMatchingSelector = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($cssSelector));
} catch (ParseException $e) {
if ($this->debug) {
throw $e;
}
return true;
}
return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
}
/**
* Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
* If such a pseudo-component is within the argument of `:not`, the entire `:not` component is removed or replaced.
*
* @param string $selector
*
* @return string
* selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply, or in the
* case of pseudo-elements will match their originating element
*/
private function removeUnmatchablePseudoComponents(string $selector): string
{
// The regex allows nested brackets via `(?2)`.
// A space is temporarily prepended because the callback can't determine if the match was at the very start.
$selectorWithoutNots = \ltrim(\preg_replace_callback(
'/([\\s>+~]?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i',
/** @param array<array-key, string> $matches */
function (array $matches): string {
return $this->replaceUnmatchableNotComponent($matches);
},
' ' . $selector
));
$selectorWithoutUnmatchablePseudoComponents = $this->removeSelectorComponents(
':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+',
$selectorWithoutNots
);
if (
!\preg_match(
'/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i',
$selectorWithoutUnmatchablePseudoComponents
)
) {
return $selectorWithoutUnmatchablePseudoComponents;
}
return \implode('', \array_map(
function (string $selectorPart): string {
return $this->removeUnsupportedOfTypePseudoClasses($selectorPart);
},
\preg_split(
'/(' . self::COMBINATOR_MATCHER . ')/',
$selectorWithoutUnmatchablePseudoComponents,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
)
));
}
/**
* Helps `removeUnmatchablePseudoComponents()` replace or remove a selector `:not(...)` component if its argument
* contains pseudo-elements or dynamic pseudo-classes.
*
* @param array<array-key, string> $matches array of elements matched by the regular expression
*
* @return string
* the full match if there were no unmatchable pseudo components within; otherwise, any preceding combinator
* followed by "*", or an empty string if there was no preceding combinator
*/
private function replaceUnmatchableNotComponent(array $matches): string
{
[$notComponentWithAnyPrecedingCombinator, $anyPrecedingCombinator, $notArgumentInBrackets] = $matches;
if ($this->hasUnsupportedPseudoClass($notArgumentInBrackets)) {
return $anyPrecedingCombinator !== '' ? $anyPrecedingCombinator . '*' : '';
}
return $notComponentWithAnyPrecedingCombinator;
}
/**
* Removes components from a CSS selector, replacing them with "*" if necessary.
*
* @param string $matcher regular expression part to match the components to remove
* @param string $selector
*
* @return string
* selector which will match the relevant DOM elements if the removed components are assumed to apply (or in
* the case of pseudo-elements will match their originating element)
*/
private function removeSelectorComponents(string $matcher, string $selector): string
{
return \preg_replace(
['/([\\s>+~]|^)' . $matcher . '/i', '/' . $matcher . '/i'],
['$1*', ''],
$selector
);
}
/**
* Removes any `...-of-type` pseudo-classes from part of a CSS selector, if it does not have a type, replacing them
* with "*" if necessary.
*
* @param string $selectorPart part of a selector which has been split up at combinators
*
* @return string
* selector part which will match the relevant DOM elements if the pseudo-classes are assumed to apply
*/
private function removeUnsupportedOfTypePseudoClasses(string $selectorPart): string
{
if (!$this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
return $selectorPart;
}
return $this->removeSelectorComponents(
':(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')(?:\\([^\\)]*+\\))?+',
$selectorPart
);
}
/**
* Applies `$this->matchingUninlinableCssRules` to `$this->domDocument` by placing them as CSS in a `<style>`
* element.
* If there are no uninlinable CSS rules to copy there, a `<style>` element will be created containing only the
* applicable at-rules from `$parsedCss`.
* If there are none of either, an empty `<style>` element will not be created.
*
* @param CssDocument $parsedCss
* This may contain various at-rules whose content `CssInliner` does not currently attempt to inline or
* process in any other way, such as `@import`, `@font-face`, `@keyframes`, etc., and which should precede
* the processed but found-to-be-uninlinable CSS placed in the `<style>` element.
* Note that `CssInliner` processes `@media` rules so that they can be ordered correctly with respect to
* other uninlinable rules; these will not be duplicated from `$parsedCss`.
*/
private function copyUninlinableCssToStyleNode(CssDocument $parsedCss): void
{
$css = $parsedCss->renderNonConditionalAtRules();
// avoid including unneeded class dependency if there are no rules
if ($this->getMatchingUninlinableCssRules() !== []) {
$cssConcatenator = new CssConcatenator();
foreach ($this->getMatchingUninlinableCssRules() as $cssRule) {
$cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
}
$css .= $cssConcatenator->getCss();
}
// avoid adding empty style element
if ($css !== '') {
$this->addStyleElementToDocument($css);
}
}
/**
* Adds a style element with $css to $this->domDocument.
*
* This method is protected to allow overriding.
*
* @see https://github.com/MyIntervals/emogrifier/issues/103
*
* @param string $css
*/
protected function addStyleElementToDocument(string $css): void
{
$domDocument = $this->getDomDocument();
$styleElement = $domDocument->createElement('style', $css);
$styleAttribute = $domDocument->createAttribute('type');
$styleAttribute->value = 'text/css';
$styleElement->appendChild($styleAttribute);
$headElement = $this->getHeadElement();
$headElement->appendChild($styleElement);
}
/**
* Returns the HEAD element.
*
* This method assumes that there always is a HEAD element.
*
* @return \DOMElement
*
* @throws \UnexpectedValueException
*/
private function getHeadElement(): \DOMElement
{
$node = $this->getDomDocument()->getElementsByTagName('head')->item(0);
if (!$node instanceof \DOMElement) {
throw new \UnexpectedValueException('There is no HEAD element. This should never happen.', 1617923227);
}
return $node;
}
/**
* Wraps `preg_replace`. If an error occurs (which is highly unlikely), either it is logged and the original
* `$subject` is returned, or in debug mode an exception is thrown.
*
* This method only supports strings, not arrays of strings.
*
* @param string $pattern
* @param string $replacement
* @param string $subject
*
* @return string
*
* @throws \RuntimeException
*/
private function pregReplace(string $pattern, string $replacement, string $subject): string
{
$result = \preg_replace($pattern, $replacement, $subject);
if (!\is_string($result)) {
$this->logOrThrowPregLastError();
$result = $subject;
}
return $result;
}
/**
* Obtains the name of the error constant for `preg_last_error` (based on code posted at
* {@see https://www.php.net/manual/en/function.preg-last-error.php#124124}) and puts it into an error message
* which is either passed to `trigger_error` (in non-debug mode) or an exception which is thrown (in debug mode).
*
* @throws \RuntimeException
*/
private function logOrThrowPregLastError(): void
{
$pcreConstants = \get_defined_constants(true)['pcre'];
$pcreErrorConstantNames = \array_flip(\array_filter(
$pcreConstants,
static function (string $key): bool {
return \substr($key, -6) === '_ERROR';
},
ARRAY_FILTER_USE_KEY
));
$pregLastError = \preg_last_error();
$message = 'PCRE regex execution error `' . (string)($pcreErrorConstantNames[$pregLastError] ?? $pregLastError)
. '`';
if ($this->debug) {
throw new \RuntimeException($message, 1592870147);
}
\trigger_error($message);
}
}
HtmlProcessor/AbstractHtmlProcessor.php 0000644 00000035420 15154677242 0014372 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\HtmlProcessor;
/**
* Base class for HTML processor that e.g., can remove, add or modify nodes or attributes.
*
* The "vanilla" subclass is the HtmlNormalizer.
*
* @psalm-consistent-constructor
*/
abstract class AbstractHtmlProcessor
{
/**
* @var string
*/
protected const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
/**
* @var string
*/
protected const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
/**
* @var string Regular expression part to match tag names that PHP's DOMDocument implementation is not aware are
* self-closing. These are mostly HTML5 elements, but for completeness <command> (obsolete) and <keygen>
* (deprecated) are also included.
*
* @see https://bugs.php.net/bug.php?id=73175
*/
protected const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
/**
* Regular expression part to match tag names that may appear before the start of the `<body>` element. A start tag
* for any other element would implicitly start the `<body>` element due to tag omission rules.
*
* @var string
*/
protected const TAGNAME_ALLOWED_BEFORE_BODY_MATCHER
= '(?:html|head|base|command|link|meta|noscript|script|style|template|title)';
/**
* regular expression pattern to match an HTML comment, including delimiters and modifiers
*
* @var string
*/
protected const HTML_COMMENT_PATTERN = '/<!--[^-]*+(?:-(?!->)[^-]*+)*+(?:-->|$)/';
/**
* regular expression pattern to match an HTML `<template>` element, including delimiters and modifiers
*
* @var string
*/
protected const HTML_TEMPLATE_ELEMENT_PATTERN
= '%<template[\\s>][^<]*+(?:<(?!/template>)[^<]*+)*+(?:</template>|$)%i';
/**
* @var ?\DOMDocument
*/
protected $domDocument = null;
/**
* @var ?\DOMXPath
*/
private $xPath = null;
/**
* The constructor.
*
* Please use `::fromHtml` or `::fromDomDocument` instead.
*/
private function __construct()
{
}
/**
* Builds a new instance from the given HTML.
*
* @param string $unprocessedHtml raw HTML, must be UTF-encoded, must not be empty
*
* @return static
*
* @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
*/
public static function fromHtml(string $unprocessedHtml): self
{
if ($unprocessedHtml === '') {
throw new \InvalidArgumentException('The provided HTML must not be empty.', 1515763647);
}
$instance = new static();
$instance->setHtml($unprocessedHtml);
return $instance;
}
/**
* Builds a new instance from the given DOM document.
*
* @param \DOMDocument $document a DOM document returned by getDomDocument() of another instance
*
* @return static
*/
public static function fromDomDocument(\DOMDocument $document): self
{
$instance = new static();
$instance->setDomDocument($document);
return $instance;
}
/**
* Sets the HTML to process.
*
* @param string $html the HTML to process, must be UTF-8-encoded
*/
private function setHtml(string $html): void
{
$this->createUnifiedDomDocument($html);
}
/**
* Provides access to the internal DOMDocument representation of the HTML in its current state.
*
* @return \DOMDocument
*
* @throws \UnexpectedValueException
*/
public function getDomDocument(): \DOMDocument
{
if (!$this->domDocument instanceof \DOMDocument) {
$message = self::class . '::setDomDocument() has not yet been called on ' . static::class;
throw new \UnexpectedValueException($message, 1570472239);
}
return $this->domDocument;
}
/**
* @param \DOMDocument $domDocument
*/
private function setDomDocument(\DOMDocument $domDocument): void
{
$this->domDocument = $domDocument;
$this->xPath = new \DOMXPath($this->domDocument);
}
/**
* @return \DOMXPath
*
* @throws \UnexpectedValueException
*/
protected function getXPath(): \DOMXPath
{
if (!$this->xPath instanceof \DOMXPath) {
$message = self::class . '::setDomDocument() has not yet been called on ' . static::class;
throw new \UnexpectedValueException($message, 1617819086);
}
return $this->xPath;
}
/**
* Renders the normalized and processed HTML.
*
* @return string
*/
public function render(): string
{
$htmlWithPossibleErroneousClosingTags = $this->getDomDocument()->saveHTML();
return $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
}
/**
* Renders the content of the BODY element of the normalized and processed HTML.
*
* @return string
*/
public function renderBodyContent(): string
{
$htmlWithPossibleErroneousClosingTags = $this->getDomDocument()->saveHTML($this->getBodyElement());
$bodyNodeHtml = $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
return \preg_replace('%</?+body(?:\\s[^>]*+)?+>%', '', $bodyNodeHtml);
}
/**
* Eliminates any invalid closing tags for void elements from the given HTML.
*
* @param string $html
*
* @return string
*/
private function removeSelfClosingTagsClosingTags(string $html): string
{
return \preg_replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
}
/**
* Returns the BODY element.
*
* This method assumes that there always is a BODY element.
*
* @return \DOMElement
*
* @throws \RuntimeException
*/
private function getBodyElement(): \DOMElement
{
$node = $this->getDomDocument()->getElementsByTagName('body')->item(0);
if (!$node instanceof \DOMElement) {
throw new \RuntimeException('There is no body element.', 1617922607);
}
return $node;
}
/**
* Creates a DOM document from the given HTML and stores it in $this->domDocument.
*
* The DOM document will always have a BODY element and a document type.
*
* @param string $html
*/
private function createUnifiedDomDocument(string $html): void
{
$this->createRawDomDocument($html);
$this->ensureExistenceOfBodyElement();
}
/**
* Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
*
* @param string $html
*/
private function createRawDomDocument(string $html): void
{
$domDocument = new \DOMDocument();
$domDocument->strictErrorChecking = false;
$domDocument->formatOutput = true;
$libXmlState = \libxml_use_internal_errors(true);
$domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
\libxml_clear_errors();
\libxml_use_internal_errors($libXmlState);
$this->setDomDocument($domDocument);
}
/**
* Returns the HTML with added document type, Content-Type meta tag, and self-closing slashes, if needed,
* ensuring that the HTML will be good for creating a DOM document from it.
*
* @param string $html
*
* @return string the unified HTML
*/
private function prepareHtmlForDomConversion(string $html): string
{
$htmlWithSelfClosingSlashes = $this->ensurePhpUnrecognizedSelfClosingTagsAreXml($html);
$htmlWithDocumentType = $this->ensureDocumentType($htmlWithSelfClosingSlashes);
return $this->addContentTypeMetaTag($htmlWithDocumentType);
}
/**
* Makes sure that the passed HTML has a document type, with lowercase "html".
*
* @param string $html
*
* @return string HTML with document type
*/
private function ensureDocumentType(string $html): string
{
$hasDocumentType = \stripos($html, '<!DOCTYPE') !== false;
if ($hasDocumentType) {
return $this->normalizeDocumentType($html);
}
return self::DEFAULT_DOCUMENT_TYPE . $html;
}
/**
* Makes sure the document type in the passed HTML has lowercase "html".
*
* @param string $html
*
* @return string HTML with normalized document type
*/
private function normalizeDocumentType(string $html): string
{
// Limit to replacing the first occurrence: as an optimization; and in case an example exists as unescaped text.
return \preg_replace(
'/<!DOCTYPE\\s++html(?=[\\s>])/i',
'<!DOCTYPE html',
$html,
1
);
}
/**
* Adds a Content-Type meta tag for the charset.
*
* This method also ensures that there is a HEAD element.
*
* @param string $html
*
* @return string the HTML with the meta tag added
*/
private function addContentTypeMetaTag(string $html): string
{
if ($this->hasContentTypeMetaTagInHead($html)) {
return $html;
}
// We are trying to insert the meta tag to the right spot in the DOM.
// If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
$hasHeadTag = \preg_match('/<head[\\s>]/i', $html);
$hasHtmlTag = \stripos($html, '<html') !== false;
if ($hasHeadTag) {
$reworkedHtml = \preg_replace(
'/<head(?=[\\s>])([^>]*+)>/i',
'<head$1>' . self::CONTENT_TYPE_META_TAG,
$html
);
} elseif ($hasHtmlTag) {
$reworkedHtml = \preg_replace(
'/<html(.*?)>/is',
'<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
$html
);
} else {
$reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
}
return $reworkedHtml;
}
/**
* Tests whether the given HTML has a valid `Content-Type` metadata element within the `<head>` element. Due to tag
* omission rules, HTML parsers are expected to end the `<head>` element and start the `<body>` element upon
* encountering a start tag for any element which is permitted only within the `<body>`.
*
* @param string $html
*
* @return bool
*/
private function hasContentTypeMetaTagInHead(string $html): bool
{
\preg_match('%^.*?(?=<meta(?=\\s)[^>]*\\shttp-equiv=(["\']?+)Content-Type\\g{-1}[\\s/>])%is', $html, $matches);
if (isset($matches[0])) {
$htmlBefore = $matches[0];
try {
$hasContentTypeMetaTagInHead = !$this->hasEndOfHeadElement($htmlBefore);
} catch (\RuntimeException $exception) {
// If something unexpected occurs, assume the `Content-Type` that was found is valid.
\trigger_error($exception->getMessage());
$hasContentTypeMetaTagInHead = true;
}
} else {
$hasContentTypeMetaTagInHead = false;
}
return $hasContentTypeMetaTagInHead;
}
/**
* Tests whether the `<head>` element ends within the given HTML. Due to tag omission rules, HTML parsers are
* expected to end the `<head>` element and start the `<body>` element upon encountering a start tag for any element
* which is permitted only within the `<body>`.
*
* @param string $html
*
* @return bool
*
* @throws \RuntimeException
*/
private function hasEndOfHeadElement(string $html): bool
{
$headEndTagMatchCount
= \preg_match('%<(?!' . self::TAGNAME_ALLOWED_BEFORE_BODY_MATCHER . '[\\s/>])\\w|</head>%i', $html);
if (\is_int($headEndTagMatchCount) && $headEndTagMatchCount > 0) {
// An exception to the implicit end of the `<head>` is any content within a `<template>` element, as well in
// comments. As an optimization, this is only checked for if a potential `<head>` end tag is found.
$htmlWithoutCommentsOrTemplates = $this->removeHtmlTemplateElements($this->removeHtmlComments($html));
$hasEndOfHeadElement = $htmlWithoutCommentsOrTemplates === $html
|| $this->hasEndOfHeadElement($htmlWithoutCommentsOrTemplates);
} else {
$hasEndOfHeadElement = false;
}
return $hasEndOfHeadElement;
}
/**
* Removes comments from the given HTML, including any which are unterminated, for which the remainder of the string
* is removed.
*
* @param string $html
*
* @return string
*
* @throws \RuntimeException
*/
private function removeHtmlComments(string $html): string
{
$result = \preg_replace(self::HTML_COMMENT_PATTERN, '', $html);
if (!\is_string($result)) {
throw new \RuntimeException('Internal PCRE error', 1616521475);
}
return $result;
}
/**
* Removes `<template>` elements from the given HTML, including any without an end tag, for which the remainder of
* the string is removed.
*
* @param string $html
*
* @return string
*
* @throws \RuntimeException
*/
private function removeHtmlTemplateElements(string $html): string
{
$result = \preg_replace(self::HTML_TEMPLATE_ELEMENT_PATTERN, '', $html);
if (!\is_string($result)) {
throw new \RuntimeException('Internal PCRE error', 1616519652);
}
return $result;
}
/**
* Makes sure that any self-closing tags not recognized as such by PHP's DOMDocument implementation have a
* self-closing slash.
*
* @param string $html
*
* @return string HTML with problematic tags converted.
*/
private function ensurePhpUnrecognizedSelfClosingTagsAreXml(string $html): string
{
return \preg_replace(
'%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
'$0/',
$html
);
}
/**
* Checks that $this->domDocument has a BODY element and adds it if it is missing.
*
* @throws \UnexpectedValueException
*/
private function ensureExistenceOfBodyElement(): void
{
if ($this->getDomDocument()->getElementsByTagName('body')->item(0) instanceof \DOMElement) {
return;
}
$htmlElement = $this->getDomDocument()->getElementsByTagName('html')->item(0);
if (!$htmlElement instanceof \DOMElement) {
throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930853);
}
$htmlElement->appendChild($this->getDomDocument()->createElement('body'));
}
}
HtmlProcessor/CssToAttributeConverter.php 0000644 00000024101 15154677242 0014703 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\HtmlProcessor;
/**
* This HtmlProcessor can convert style HTML attributes to the corresponding other visual HTML attributes,
* e.g. it converts style="width: 100px" to width="100".
*
* It will only add attributes, but leaves the style attribute untouched.
*
* To trigger the conversion, call the convertCssToVisualAttributes method.
*/
class CssToAttributeConverter extends AbstractHtmlProcessor
{
/**
* This multi-level array contains simple mappings of CSS properties to
* HTML attributes. If a mapping only applies to certain HTML nodes or
* only for certain values, the mapping is an object with a whitelist
* of nodes and values.
*
* @var array<string, array{attribute: string, nodes?: array<int, string>, values?: array<int, string>}>
*/
private $cssToHtmlMap = [
'background-color' => [
'attribute' => 'bgcolor',
],
'text-align' => [
'attribute' => 'align',
'nodes' => ['p', 'div', 'td', 'th'],
'values' => ['left', 'right', 'center', 'justify'],
],
'float' => [
'attribute' => 'align',
'nodes' => ['table', 'img'],
'values' => ['left', 'right'],
],
'border-spacing' => [
'attribute' => 'cellspacing',
'nodes' => ['table'],
],
];
/**
* @var array<string, array<string, string>>
*/
private static $parsedCssCache = [];
/**
* Maps the CSS from the style nodes to visual HTML attributes.
*
* @return self fluent interface
*/
public function convertCssToVisualAttributes(): self
{
/** @var \DOMElement $node */
foreach ($this->getAllNodesWithStyleAttribute() as $node) {
$inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
$this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
}
return $this;
}
/**
* Returns a list with all DOM nodes that have a style attribute.
*
* @return \DOMNodeList
*/
private function getAllNodesWithStyleAttribute(): \DOMNodeList
{
return $this->getXPath()->query('//*[@style]');
}
/**
* Parses a CSS declaration block into property name/value pairs.
*
* Example:
*
* The declaration block
*
* "color: #000; font-weight: bold;"
*
* will be parsed into the following array:
*
* "color" => "#000"
* "font-weight" => "bold"
*
* @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
*
* @return array<string, string>
* the CSS declarations with the property names as array keys and the property values as array values
*/
private function parseCssDeclarationsBlock(string $cssDeclarationsBlock): array
{
if (isset(self::$parsedCssCache[$cssDeclarationsBlock])) {
return self::$parsedCssCache[$cssDeclarationsBlock];
}
$properties = [];
foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
/** @var array<int, string> $matches */
$matches = [];
if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
continue;
}
$propertyName = \strtolower($matches[1]);
$propertyValue = $matches[2];
$properties[$propertyName] = $propertyValue;
}
self::$parsedCssCache[$cssDeclarationsBlock] = $properties;
return $properties;
}
/**
* Applies $styles to $node.
*
* This method maps CSS styles to HTML attributes and adds those to the
* node.
*
* @param array<string, string> $styles the new CSS styles taken from the global styles to be applied to this node
* @param \DOMElement $node node to apply styles to
*/
private function mapCssToHtmlAttributes(array $styles, \DOMElement $node): void
{
foreach ($styles as $property => $value) {
// Strip !important indicator
$value = \trim(\str_replace('!important', '', $value));
$this->mapCssToHtmlAttribute($property, $value, $node);
}
}
/**
* Tries to apply the CSS style to $node as an attribute.
*
* This method maps a CSS rule to HTML attributes and adds those to the node.
*
* @param string $property the name of the CSS property to map
* @param string $value the value of the style rule to map
* @param \DOMElement $node node to apply styles to
*/
private function mapCssToHtmlAttribute(string $property, string $value, \DOMElement $node): void
{
if (!$this->mapSimpleCssProperty($property, $value, $node)) {
$this->mapComplexCssProperty($property, $value, $node);
}
}
/**
* Looks up the CSS property in the mapping table and maps it if it matches the conditions.
*
* @param string $property the name of the CSS property to map
* @param string $value the value of the style rule to map
* @param \DOMElement $node node to apply styles to
*
* @return bool true if the property can be mapped using the simple mapping table
*/
private function mapSimpleCssProperty(string $property, string $value, \DOMElement $node): bool
{
if (!isset($this->cssToHtmlMap[$property])) {
return false;
}
$mapping = $this->cssToHtmlMap[$property];
$nodesMatch = !isset($mapping['nodes']) || \in_array($node->nodeName, $mapping['nodes'], true);
$valuesMatch = !isset($mapping['values']) || \in_array($value, $mapping['values'], true);
$canBeMapped = $nodesMatch && $valuesMatch;
if ($canBeMapped) {
$node->setAttribute($mapping['attribute'], $value);
}
return $canBeMapped;
}
/**
* Maps CSS properties that need special transformation to an HTML attribute.
*
* @param string $property the name of the CSS property to map
* @param string $value the value of the style rule to map
* @param \DOMElement $node node to apply styles to
*/
private function mapComplexCssProperty(string $property, string $value, \DOMElement $node): void
{
switch ($property) {
case 'background':
$this->mapBackgroundProperty($node, $value);
break;
case 'width':
// intentional fall-through
case 'height':
$this->mapWidthOrHeightProperty($node, $value, $property);
break;
case 'margin':
$this->mapMarginProperty($node, $value);
break;
case 'border':
$this->mapBorderProperty($node, $value);
break;
default:
}
}
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
*/
private function mapBackgroundProperty(\DOMElement $node, string $value): void
{
// parse out the color, if any
/** @var array<int, string> $styles */
$styles = \explode(' ', $value, 2);
$first = $styles[0];
if (\is_numeric($first[0]) || \strncmp($first, 'url', 3) === 0) {
return;
}
// as this is not a position or image, assume it's a color
$node->setAttribute('bgcolor', $first);
}
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
* @param string $property the name of the CSS property to map
*/
private function mapWidthOrHeightProperty(\DOMElement $node, string $value, string $property): void
{
// only parse values in px and %, but not values like "auto"
if (!\preg_match('/^(\\d+)(\\.(\\d+))?(px|%)$/', $value)) {
return;
}
$number = \preg_replace('/[^0-9.%]/', '', $value);
$node->setAttribute($property, $number);
}
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
*/
private function mapMarginProperty(\DOMElement $node, string $value): void
{
if (!$this->isTableOrImageNode($node)) {
return;
}
$margins = $this->parseCssShorthandValue($value);
if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
$node->setAttribute('align', 'center');
}
}
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
*/
private function mapBorderProperty(\DOMElement $node, string $value): void
{
if (!$this->isTableOrImageNode($node)) {
return;
}
if ($value === 'none' || $value === '0') {
$node->setAttribute('border', '0');
}
}
/**
* @param \DOMElement $node
*
* @return bool
*/
private function isTableOrImageNode(\DOMElement $node): bool
{
return $node->nodeName === 'table' || $node->nodeName === 'img';
}
/**
* Parses a shorthand CSS value and splits it into individual values. For example: `padding: 0 auto;` - `0 auto` is
* split into top: 0, left: auto, bottom: 0, right: auto.
*
* @param string $value a CSS property value with 1, 2, 3 or 4 sizes
*
* @return array<string, string>
* an array of values for top, right, bottom and left (using these as associative array keys)
*/
private function parseCssShorthandValue(string $value): array
{
/** @var array<int, string> $values */
$values = \preg_split('/\\s+/', $value);
$css = [];
$css['top'] = $values[0];
$css['right'] = (\count($values) > 1) ? $values[1] : $css['top'];
$css['bottom'] = (\count($values) > 2) ? $values[2] : $css['top'];
$css['left'] = (\count($values) > 3) ? $values[3] : $css['right'];
return $css;
}
}
HtmlProcessor/HtmlNormalizer.php 0000644 00000000502 15154677242 0013042 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\HtmlProcessor;
/**
* Normalizes HTML:
* - add a document type (HTML5) if missing
* - disentangle incorrectly nested tags
* - add HEAD and BODY elements (if they are missing)
* - reformat the HTML
*/
class HtmlNormalizer extends AbstractHtmlProcessor
{
}
HtmlProcessor/HtmlPruner.php 0000644 00000012010 15154677242 0012170 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\HtmlProcessor;
use Pelago\Emogrifier\CssInliner;
use Pelago\Emogrifier\Utilities\ArrayIntersector;
/**
* This class can remove things from HTML.
*/
class HtmlPruner extends AbstractHtmlProcessor
{
/**
* We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
* supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
* not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
* to lowercase.
*
* @var string
*/
private const DISPLAY_NONE_MATCHER
= '//*[@style and contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")'
. ' and not(@class and contains(concat(" ", normalize-space(@class), " "), " -emogrifier-keep "))]';
/**
* Removes elements that have a "display: none;" style.
*
* @return self fluent interface
*/
public function removeElementsWithDisplayNone(): self
{
$elementsWithStyleDisplayNone = $this->getXPath()->query(self::DISPLAY_NONE_MATCHER);
if ($elementsWithStyleDisplayNone->length === 0) {
return $this;
}
foreach ($elementsWithStyleDisplayNone as $element) {
$parentNode = $element->parentNode;
if ($parentNode !== null) {
$parentNode->removeChild($element);
}
}
return $this;
}
/**
* Removes classes that are no longer required (e.g. because there are no longer any CSS rules that reference them)
* from `class` attributes.
*
* Note that this does not inspect the CSS, but expects to be provided with a list of classes that are still in use.
*
* This method also has the (presumably beneficial) side-effect of minifying (removing superfluous whitespace from)
* `class` attributes.
*
* @param array<array-key, string> $classesToKeep names of classes that should not be removed
*
* @return self fluent interface
*/
public function removeRedundantClasses(array $classesToKeep = []): self
{
$elementsWithClassAttribute = $this->getXPath()->query('//*[@class]');
if ($classesToKeep !== []) {
$this->removeClassesFromElements($elementsWithClassAttribute, $classesToKeep);
} else {
// Avoid unnecessary processing if there are no classes to keep.
$this->removeClassAttributeFromElements($elementsWithClassAttribute);
}
return $this;
}
/**
* Removes classes from the `class` attribute of each element in `$elements`, except any in `$classesToKeep`,
* removing the `class` attribute itself if the resultant list is empty.
*
* @param \DOMNodeList $elements
* @param array<array-key, string> $classesToKeep
*/
private function removeClassesFromElements(\DOMNodeList $elements, array $classesToKeep): void
{
$classesToKeepIntersector = new ArrayIntersector($classesToKeep);
/** @var \DOMElement $element */
foreach ($elements as $element) {
$elementClasses = \preg_split('/\\s++/', \trim($element->getAttribute('class')));
$elementClassesToKeep = $classesToKeepIntersector->intersectWith($elementClasses);
if ($elementClassesToKeep !== []) {
$element->setAttribute('class', \implode(' ', $elementClassesToKeep));
} else {
$element->removeAttribute('class');
}
}
}
/**
* Removes the `class` attribute from each element in `$elements`.
*
* @param \DOMNodeList $elements
*/
private function removeClassAttributeFromElements(\DOMNodeList $elements): void
{
/** @var \DOMElement $element */
foreach ($elements as $element) {
$element->removeAttribute('class');
}
}
/**
* After CSS has been inlined, there will likely be some classes in `class` attributes that are no longer referenced
* by any remaining (uninlinable) CSS. This method removes such classes.
*
* Note that it does not inspect the remaining CSS, but uses information readily available from the `CssInliner`
* instance about the CSS rules that could not be inlined.
*
* @param CssInliner $cssInliner object instance that performed the CSS inlining
*
* @return self fluent interface
*
* @throws \BadMethodCallException if `inlineCss` has not first been called on `$cssInliner`
*/
public function removeRedundantClassesAfterCssInlined(CssInliner $cssInliner): self
{
$classesToKeepAsKeys = [];
foreach ($cssInliner->getMatchingUninlinableSelectors() as $selector) {
\preg_match_all('/\\.(-?+[_a-zA-Z][\\w\\-]*+)/', $selector, $matches);
$classesToKeepAsKeys += \array_fill_keys($matches[1], true);
}
$this->removeRedundantClasses(\array_keys($classesToKeepAsKeys));
return $this;
}
}
Utilities/ArrayIntersector.php 0000644 00000003560 15154677242 0012551 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Utilities;
/**
* When computing many array intersections using the same array, it is more efficient to use `array_flip()` first and
* then `array_intersect_key()`, than `array_intersect()`. See the discussion at
* {@link https://stackoverflow.com/questions/6329211/php-array-intersect-efficiency Stack Overflow} for more
* information.
*
* Of course, this is only possible if the arrays contain integer or string values, and either don't contain duplicates,
* or that fact that duplicates will be removed does not matter.
*
* This class takes care of the detail.
*
* @internal
*/
class ArrayIntersector
{
/**
* the array with which the object was constructed, with all its keys exchanged with their associated values
*
* @var array<array-key, array-key>
*/
private $invertedArray;
/**
* Constructs the object with the array that will be reused for many intersection computations.
*
* @param array<array-key, array-key> $array
*/
public function __construct(array $array)
{
$this->invertedArray = \array_flip($array);
}
/**
* Computes the intersection of `$array` and the array with which this object was constructed.
*
* @param array<array-key, array-key> $array
*
* @return array<array-key, array-key>
* Returns an array containing all of the values in `$array` whose values exist in the array
* with which this object was constructed. Note that keys are preserved, order is maintained, but
* duplicates are removed.
*/
public function intersectWith(array $array): array
{
$invertedArray = \array_flip($array);
$invertedIntersection = \array_intersect_key($invertedArray, $this->invertedArray);
return \array_flip($invertedIntersection);
}
}
Utilities/CssConcatenator.php 0000644 00000015116 15154677242 0012342 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Utilities;
/**
* Facilitates building a CSS string by appending rule blocks one at a time, checking whether the media query,
* selectors, or declarations block are the same as those from the preceding block and combining blocks in such cases.
*
* Example:
* $concatenator = new CssConcatenator();
* $concatenator->append(['body'], 'color: blue;');
* $concatenator->append(['body'], 'font-size: 16px;');
* $concatenator->append(['p'], 'margin: 1em 0;');
* $concatenator->append(['ul', 'ol'], 'margin: 1em 0;');
* $concatenator->append(['body'], 'font-size: 14px;', '@media screen and (max-width: 400px)');
* $concatenator->append(['ul', 'ol'], 'margin: 0.75em 0;', '@media screen and (max-width: 400px)');
* $css = $concatenator->getCss();
*
* `$css` (if unminified) would contain the following CSS:
* ` body {
* ` color: blue;
* ` font-size: 16px;
* ` }
* ` p, ul, ol {
* ` margin: 1em 0;
* ` }
* ` @media screen and (max-width: 400px) {
* ` body {
* ` font-size: 14px;
* ` }
* ` ul, ol {
* ` margin: 0.75em 0;
* ` }
* ` }
*
* @internal
*/
class CssConcatenator
{
/**
* Array of media rules in order. Each element is an object with the following properties:
* - string `media` - The media query string, e.g. "@media screen and (max-width:639px)", or an empty string for
* rules not within a media query block;
* - object[] `ruleBlocks` - Array of rule blocks in order, where each element is an object with the following
* properties:
* - mixed[] `selectorsAsKeys` - Array whose keys are selectors for the rule block (values are of no
* significance);
* - string `declarationsBlock` - The property declarations, e.g. "margin-top: 0.5em; padding: 0".
*
* @var array<int, object{
* media: string,
* ruleBlocks: array<int, object{
* selectorsAsKeys: array<string, array-key>,
* declarationsBlock: string
* }>
* }>
*/
private $mediaRules = [];
/**
* Appends a declaration block to the CSS.
*
* @param array<array-key, string> $selectors
* array of selectors for the rule, e.g. ["ul", "ol", "p:first-child"]
* @param string $declarationsBlock
* the property declarations, e.g. "margin-top: 0.5em; padding: 0"
* @param string $media
* the media query for the rule, e.g. "@media screen and (max-width:639px)", or an empty string if none
*/
public function append(array $selectors, string $declarationsBlock, string $media = ''): void
{
$selectorsAsKeys = \array_flip($selectors);
$mediaRule = $this->getOrCreateMediaRuleToAppendTo($media);
$ruleBlocks = $mediaRule->ruleBlocks;
$lastRuleBlock = \end($ruleBlocks);
$hasSameDeclarationsAsLastRule = \is_object($lastRuleBlock)
&& $declarationsBlock === $lastRuleBlock->declarationsBlock;
if ($hasSameDeclarationsAsLastRule) {
$lastRuleBlock->selectorsAsKeys += $selectorsAsKeys;
} else {
$lastRuleBlockSelectors = \is_object($lastRuleBlock) ? $lastRuleBlock->selectorsAsKeys : [];
$hasSameSelectorsAsLastRule = \is_object($lastRuleBlock)
&& self::hasEquivalentSelectors($selectorsAsKeys, $lastRuleBlockSelectors);
if ($hasSameSelectorsAsLastRule) {
$lastDeclarationsBlockWithoutSemicolon = \rtrim(\rtrim($lastRuleBlock->declarationsBlock), ';');
$lastRuleBlock->declarationsBlock = $lastDeclarationsBlockWithoutSemicolon . ';' . $declarationsBlock;
} else {
$mediaRule->ruleBlocks[] = (object)\compact('selectorsAsKeys', 'declarationsBlock');
}
}
}
/**
* @return string
*/
public function getCss(): string
{
return \implode('', \array_map([self::class, 'getMediaRuleCss'], $this->mediaRules));
}
/**
* @param string $media The media query for rules to be appended, e.g. "@media screen and (max-width:639px)",
* or an empty string if none.
*
* @return object{
* media: string,
* ruleBlocks: array<int, object{
* selectorsAsKeys: array<string, array-key>,
* declarationsBlock: string
* }>
* }
*/
private function getOrCreateMediaRuleToAppendTo(string $media): object
{
$lastMediaRule = \end($this->mediaRules);
if (\is_object($lastMediaRule) && $media === $lastMediaRule->media) {
return $lastMediaRule;
}
$newMediaRule = (object)[
'media' => $media,
'ruleBlocks' => [],
];
$this->mediaRules[] = $newMediaRule;
return $newMediaRule;
}
/**
* Tests if two sets of selectors are equivalent (i.e. the same selectors, possibly in a different order).
*
* @param array<string, array-key> $selectorsAsKeys1
* array in which the selectors are the keys, and the values are of no significance
* @param array<string, array-key> $selectorsAsKeys2 another such array
*
* @return bool
*/
private static function hasEquivalentSelectors(array $selectorsAsKeys1, array $selectorsAsKeys2): bool
{
return \count($selectorsAsKeys1) === \count($selectorsAsKeys2)
&& \count($selectorsAsKeys1) === \count($selectorsAsKeys1 + $selectorsAsKeys2);
}
/**
* @param object{
* media: string,
* ruleBlocks: array<int, object{
* selectorsAsKeys: array<string, array-key>,
* declarationsBlock: string
* }>
* } $mediaRule
*
* @return string CSS for the media rule.
*/
private static function getMediaRuleCss(object $mediaRule): string
{
$ruleBlocks = $mediaRule->ruleBlocks;
$css = \implode('', \array_map([self::class, 'getRuleBlockCss'], $ruleBlocks));
$media = $mediaRule->media;
if ($media !== '') {
$css = $media . '{' . $css . '}';
}
return $css;
}
/**
* @param object{selectorsAsKeys: array<string, array-key>, declarationsBlock: string} $ruleBlock
*
* @return string CSS for the rule block.
*/
private static function getRuleBlockCss(object $ruleBlock): string
{
$selectorsAsKeys = $ruleBlock->selectorsAsKeys;
$selectors = \array_keys($selectorsAsKeys);
$declarationsBlock = $ruleBlock->declarationsBlock;
return \implode(',', $selectors) . '{' . $declarationsBlock . '}';
}
}
CSSList/AtRuleBlockList.php 0000644 00000003172 15154702573 0011560 0 ustar 00 <?php
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\AtRule;
/**
* A `BlockList` constructed by an unknown at-rule. `@media` rules are rendered into `AtRuleBlockList` objects.
*/
class AtRuleBlockList extends CSSBlockList implements AtRule
{
/**
* @var string
*/
private $sType;
/**
* @var string
*/
private $sArgs;
/**
* @param string $sType
* @param string $sArgs
* @param int $iLineNo
*/
public function __construct($sType, $sArgs = '', $iLineNo = 0)
{
parent::__construct($iLineNo);
$this->sType = $sType;
$this->sArgs = $sArgs;
}
/**
* @return string
*/
public function atRuleName()
{
return $this->sType;
}
/**
* @return string
*/
public function atRuleArgs()
{
return $this->sArgs;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sArgs = $this->sArgs;
if ($sArgs) {
$sArgs = ' ' . $sArgs;
}
$sResult = $oOutputFormat->sBeforeAtRuleBlock;
$sResult .= "@{$this->sType}$sArgs{$oOutputFormat->spaceBeforeOpeningBrace()}{";
$sResult .= parent::render($oOutputFormat);
$sResult .= '}';
$sResult .= $oOutputFormat->sAfterAtRuleBlock;
return $sResult;
}
/**
* @return bool
*/
public function isRootList()
{
return false;
}
}
CSSList/CSSBlockList.php 0000644 00000012164 15154702573 0011015 0 ustar 00 <?php
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Rule\Rule;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Value\CSSFunction;
use Sabberworm\CSS\Value\Value;
use Sabberworm\CSS\Value\ValueList;
/**
* A `CSSBlockList` is a `CSSList` whose `DeclarationBlock`s are guaranteed to contain valid declaration blocks or
* at-rules.
*
* Most `CSSList`s conform to this category but some at-rules (such as `@keyframes`) do not.
*/
abstract class CSSBlockList extends CSSList
{
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
parent::__construct($iLineNo);
}
/**
* @param array<int, DeclarationBlock> $aResult
*
* @return void
*/
protected function allDeclarationBlocks(array &$aResult)
{
foreach ($this->aContents as $mContent) {
if ($mContent instanceof DeclarationBlock) {
$aResult[] = $mContent;
} elseif ($mContent instanceof CSSBlockList) {
$mContent->allDeclarationBlocks($aResult);
}
}
}
/**
* @param array<int, RuleSet> $aResult
*
* @return void
*/
protected function allRuleSets(array &$aResult)
{
foreach ($this->aContents as $mContent) {
if ($mContent instanceof RuleSet) {
$aResult[] = $mContent;
} elseif ($mContent instanceof CSSBlockList) {
$mContent->allRuleSets($aResult);
}
}
}
/**
* @param CSSList|Rule|RuleSet|Value $oElement
* @param array<int, Value> $aResult
* @param string|null $sSearchString
* @param bool $bSearchInFunctionArguments
*
* @return void
*/
protected function allValues($oElement, array &$aResult, $sSearchString = null, $bSearchInFunctionArguments = false)
{
if ($oElement instanceof CSSBlockList) {
foreach ($oElement->getContents() as $oContent) {
$this->allValues($oContent, $aResult, $sSearchString, $bSearchInFunctionArguments);
}
} elseif ($oElement instanceof RuleSet) {
foreach ($oElement->getRules($sSearchString) as $oRule) {
$this->allValues($oRule, $aResult, $sSearchString, $bSearchInFunctionArguments);
}
} elseif ($oElement instanceof Rule) {
$this->allValues($oElement->getValue(), $aResult, $sSearchString, $bSearchInFunctionArguments);
} elseif ($oElement instanceof ValueList) {
if ($bSearchInFunctionArguments || !($oElement instanceof CSSFunction)) {
foreach ($oElement->getListComponents() as $mComponent) {
$this->allValues($mComponent, $aResult, $sSearchString, $bSearchInFunctionArguments);
}
}
} else {
// Non-List `Value` or `CSSString` (CSS identifier)
$aResult[] = $oElement;
}
}
/**
* @param array<int, Selector> $aResult
* @param string|null $sSpecificitySearch
*
* @return void
*/
protected function allSelectors(array &$aResult, $sSpecificitySearch = null)
{
/** @var array<int, DeclarationBlock> $aDeclarationBlocks */
$aDeclarationBlocks = [];
$this->allDeclarationBlocks($aDeclarationBlocks);
foreach ($aDeclarationBlocks as $oBlock) {
foreach ($oBlock->getSelectors() as $oSelector) {
if ($sSpecificitySearch === null) {
$aResult[] = $oSelector;
} else {
$sComparator = '===';
$aSpecificitySearch = explode(' ', $sSpecificitySearch);
$iTargetSpecificity = $aSpecificitySearch[0];
if (count($aSpecificitySearch) > 1) {
$sComparator = $aSpecificitySearch[0];
$iTargetSpecificity = $aSpecificitySearch[1];
}
$iTargetSpecificity = (int)$iTargetSpecificity;
$iSelectorSpecificity = $oSelector->getSpecificity();
$bMatches = false;
switch ($sComparator) {
case '<=':
$bMatches = $iSelectorSpecificity <= $iTargetSpecificity;
break;
case '<':
$bMatches = $iSelectorSpecificity < $iTargetSpecificity;
break;
case '>=':
$bMatches = $iSelectorSpecificity >= $iTargetSpecificity;
break;
case '>':
$bMatches = $iSelectorSpecificity > $iTargetSpecificity;
break;
default:
$bMatches = $iSelectorSpecificity === $iTargetSpecificity;
break;
}
if ($bMatches) {
$aResult[] = $oSelector;
}
}
}
}
}
}
CSSList/CSSList.php 0000644 00000036416 15154702573 0010050 0 ustar 00 <?php
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Property\AtRule;
use Sabberworm\CSS\Property\Charset;
use Sabberworm\CSS\Property\CSSNamespace;
use Sabberworm\CSS\Property\Import;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Renderable;
use Sabberworm\CSS\RuleSet\AtRuleSet;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Settings;
use Sabberworm\CSS\Value\CSSString;
use Sabberworm\CSS\Value\URL;
use Sabberworm\CSS\Value\Value;
/**
* A `CSSList` is the most generic container available. Its contents include `RuleSet` as well as other `CSSList`
* objects.
*
* Also, it may contain `Import` and `Charset` objects stemming from at-rules.
*/
abstract class CSSList implements Renderable, Commentable
{
/**
* @var array<array-key, Comment>
*/
protected $aComments;
/**
* @var array<int, RuleSet|CSSList|Import|Charset>
*/
protected $aContents;
/**
* @var int
*/
protected $iLineNo;
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
$this->aComments = [];
$this->aContents = [];
$this->iLineNo = $iLineNo;
}
/**
* @return void
*
* @throws UnexpectedTokenException
* @throws SourceException
*/
public static function parseList(ParserState $oParserState, CSSList $oList)
{
$bIsRoot = $oList instanceof Document;
if (is_string($oParserState)) {
$oParserState = new ParserState($oParserState, Settings::create());
}
$bLenientParsing = $oParserState->getSettings()->bLenientParsing;
while (!$oParserState->isEnd()) {
$comments = $oParserState->consumeWhiteSpace();
$oListItem = null;
if ($bLenientParsing) {
try {
$oListItem = self::parseListItem($oParserState, $oList);
} catch (UnexpectedTokenException $e) {
$oListItem = false;
}
} else {
$oListItem = self::parseListItem($oParserState, $oList);
}
if ($oListItem === null) {
// List parsing finished
return;
}
if ($oListItem) {
$oListItem->setComments($comments);
$oList->append($oListItem);
}
$oParserState->consumeWhiteSpace();
}
if (!$bIsRoot && !$bLenientParsing) {
throw new SourceException("Unexpected end of document", $oParserState->currentLine());
}
}
/**
* @return AtRuleBlockList|KeyFrame|Charset|CSSNamespace|Import|AtRuleSet|DeclarationBlock|null|false
*
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseListItem(ParserState $oParserState, CSSList $oList)
{
$bIsRoot = $oList instanceof Document;
if ($oParserState->comes('@')) {
$oAtRule = self::parseAtRule($oParserState);
if ($oAtRule instanceof Charset) {
if (!$bIsRoot) {
throw new UnexpectedTokenException(
'@charset may only occur in root document',
'',
'custom',
$oParserState->currentLine()
);
}
if (count($oList->getContents()) > 0) {
throw new UnexpectedTokenException(
'@charset must be the first parseable token in a document',
'',
'custom',
$oParserState->currentLine()
);
}
$oParserState->setCharset($oAtRule->getCharset()->getString());
}
return $oAtRule;
} elseif ($oParserState->comes('}')) {
if (!$oParserState->getSettings()->bLenientParsing) {
throw new UnexpectedTokenException('CSS selector', '}', 'identifier', $oParserState->currentLine());
} else {
if ($bIsRoot) {
if ($oParserState->getSettings()->bLenientParsing) {
return DeclarationBlock::parse($oParserState);
} else {
throw new SourceException("Unopened {", $oParserState->currentLine());
}
} else {
return null;
}
}
} else {
return DeclarationBlock::parse($oParserState, $oList);
}
}
/**
* @param ParserState $oParserState
*
* @return AtRuleBlockList|KeyFrame|Charset|CSSNamespace|Import|AtRuleSet|null
*
* @throws SourceException
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
private static function parseAtRule(ParserState $oParserState)
{
$oParserState->consume('@');
$sIdentifier = $oParserState->parseIdentifier();
$iIdentifierLineNum = $oParserState->currentLine();
$oParserState->consumeWhiteSpace();
if ($sIdentifier === 'import') {
$oLocation = URL::parse($oParserState);
$oParserState->consumeWhiteSpace();
$sMediaQuery = null;
if (!$oParserState->comes(';')) {
$sMediaQuery = trim($oParserState->consumeUntil([';', ParserState::EOF]));
}
$oParserState->consumeUntil([';', ParserState::EOF], true, true);
return new Import($oLocation, $sMediaQuery ?: null, $iIdentifierLineNum);
} elseif ($sIdentifier === 'charset') {
$sCharset = CSSString::parse($oParserState);
$oParserState->consumeWhiteSpace();
$oParserState->consumeUntil([';', ParserState::EOF], true, true);
return new Charset($sCharset, $iIdentifierLineNum);
} elseif (self::identifierIs($sIdentifier, 'keyframes')) {
$oResult = new KeyFrame($iIdentifierLineNum);
$oResult->setVendorKeyFrame($sIdentifier);
$oResult->setAnimationName(trim($oParserState->consumeUntil('{', false, true)));
CSSList::parseList($oParserState, $oResult);
if ($oParserState->comes('}')) {
$oParserState->consume('}');
}
return $oResult;
} elseif ($sIdentifier === 'namespace') {
$sPrefix = null;
$mUrl = Value::parsePrimitiveValue($oParserState);
if (!$oParserState->comes(';')) {
$sPrefix = $mUrl;
$mUrl = Value::parsePrimitiveValue($oParserState);
}
$oParserState->consumeUntil([';', ParserState::EOF], true, true);
if ($sPrefix !== null && !is_string($sPrefix)) {
throw new UnexpectedTokenException('Wrong namespace prefix', $sPrefix, 'custom', $iIdentifierLineNum);
}
if (!($mUrl instanceof CSSString || $mUrl instanceof URL)) {
throw new UnexpectedTokenException(
'Wrong namespace url of invalid type',
$mUrl,
'custom',
$iIdentifierLineNum
);
}
return new CSSNamespace($mUrl, $sPrefix, $iIdentifierLineNum);
} else {
// Unknown other at rule (font-face or such)
$sArgs = trim($oParserState->consumeUntil('{', false, true));
if (substr_count($sArgs, "(") != substr_count($sArgs, ")")) {
if ($oParserState->getSettings()->bLenientParsing) {
return null;
} else {
throw new SourceException("Unmatched brace count in media query", $oParserState->currentLine());
}
}
$bUseRuleSet = true;
foreach (explode('/', AtRule::BLOCK_RULES) as $sBlockRuleName) {
if (self::identifierIs($sIdentifier, $sBlockRuleName)) {
$bUseRuleSet = false;
break;
}
}
if ($bUseRuleSet) {
$oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum);
RuleSet::parseRuleSet($oParserState, $oAtRule);
} else {
$oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum);
CSSList::parseList($oParserState, $oAtRule);
if ($oParserState->comes('}')) {
$oParserState->consume('}');
}
}
return $oAtRule;
}
}
/**
* Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
* We need to check for these versions too.
*
* @param string $sIdentifier
* @param string $sMatch
*
* @return bool
*/
private static function identifierIs($sIdentifier, $sMatch)
{
return (strcasecmp($sIdentifier, $sMatch) === 0)
?: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1;
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* Prepends an item to the list of contents.
*
* @param RuleSet|CSSList|Import|Charset $oItem
*
* @return void
*/
public function prepend($oItem)
{
array_unshift($this->aContents, $oItem);
}
/**
* Appends an item to tje list of contents.
*
* @param RuleSet|CSSList|Import|Charset $oItem
*
* @return void
*/
public function append($oItem)
{
$this->aContents[] = $oItem;
}
/**
* Splices the list of contents.
*
* @param int $iOffset
* @param int $iLength
* @param array<int, RuleSet|CSSList|Import|Charset> $mReplacement
*
* @return void
*/
public function splice($iOffset, $iLength = null, $mReplacement = null)
{
array_splice($this->aContents, $iOffset, $iLength, $mReplacement);
}
/**
* Removes an item from the CSS list.
*
* @param RuleSet|Import|Charset|CSSList $oItemToRemove
* May be a RuleSet (most likely a DeclarationBlock), a Import,
* a Charset or another CSSList (most likely a MediaQuery)
*
* @return bool whether the item was removed
*/
public function remove($oItemToRemove)
{
$iKey = array_search($oItemToRemove, $this->aContents, true);
if ($iKey !== false) {
unset($this->aContents[$iKey]);
return true;
}
return false;
}
/**
* Replaces an item from the CSS list.
*
* @param RuleSet|Import|Charset|CSSList $oOldItem
* May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
* or another `CSSList` (most likely a `MediaQuery`)
*
* @return bool
*/
public function replace($oOldItem, $mNewItem)
{
$iKey = array_search($oOldItem, $this->aContents, true);
if ($iKey !== false) {
if (is_array($mNewItem)) {
array_splice($this->aContents, $iKey, 1, $mNewItem);
} else {
array_splice($this->aContents, $iKey, 1, [$mNewItem]);
}
return true;
}
return false;
}
/**
* @param array<int, RuleSet|Import|Charset|CSSList> $aContents
*/
public function setContents(array $aContents)
{
$this->aContents = [];
foreach ($aContents as $content) {
$this->append($content);
}
}
/**
* Removes a declaration block from the CSS list if it matches all given selectors.
*
* @param DeclarationBlock|array<array-key, Selector>|string $mSelector the selectors to match
* @param bool $bRemoveAll whether to stop at the first declaration block found or remove all blocks
*
* @return void
*/
public function removeDeclarationBlockBySelector($mSelector, $bRemoveAll = false)
{
if ($mSelector instanceof DeclarationBlock) {
$mSelector = $mSelector->getSelectors();
}
if (!is_array($mSelector)) {
$mSelector = explode(',', $mSelector);
}
foreach ($mSelector as $iKey => &$mSel) {
if (!($mSel instanceof Selector)) {
if (!Selector::isValid($mSel)) {
throw new UnexpectedTokenException(
"Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
$mSel,
"custom"
);
}
$mSel = new Selector($mSel);
}
}
foreach ($this->aContents as $iKey => $mItem) {
if (!($mItem instanceof DeclarationBlock)) {
continue;
}
if ($mItem->getSelectors() == $mSelector) {
unset($this->aContents[$iKey]);
if (!$bRemoveAll) {
return;
}
}
}
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sResult = '';
$bIsFirst = true;
$oNextLevel = $oOutputFormat;
if (!$this->isRootList()) {
$oNextLevel = $oOutputFormat->nextLevel();
}
foreach ($this->aContents as $oContent) {
$sRendered = $oOutputFormat->safely(function () use ($oNextLevel, $oContent) {
return $oContent->render($oNextLevel);
});
if ($sRendered === null) {
continue;
}
if ($bIsFirst) {
$bIsFirst = false;
$sResult .= $oNextLevel->spaceBeforeBlocks();
} else {
$sResult .= $oNextLevel->spaceBetweenBlocks();
}
$sResult .= $sRendered;
}
if (!$bIsFirst) {
// Had some output
$sResult .= $oOutputFormat->spaceAfterBlocks();
}
return $sResult;
}
/**
* Return true if the list can not be further outdented. Only important when rendering.
*
* @return bool
*/
abstract public function isRootList();
/**
* @return array<int, RuleSet|Import|Charset|CSSList>
*/
public function getContents()
{
return $this->aContents;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments)
{
$this->aComments = array_merge($this->aComments, $aComments);
}
/**
* @return array<array-key, Comment>
*/
public function getComments()
{
return $this->aComments;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments)
{
$this->aComments = $aComments;
}
}
CSSList/Document.php 0000644 00000011211 15154702573 0010324 0 ustar 00 <?php
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Value\Value;
/**
* The root `CSSList` of a parsed file. Contains all top-level CSS contents, mostly declaration blocks,
* but also any at-rules encountered.
*/
class Document extends CSSBlockList
{
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
parent::__construct($iLineNo);
}
/**
* @return Document
*
* @throws SourceException
*/
public static function parse(ParserState $oParserState)
{
$oDocument = new Document($oParserState->currentLine());
CSSList::parseList($oParserState, $oDocument);
return $oDocument;
}
/**
* Gets all `DeclarationBlock` objects recursively.
*
* @return array<int, DeclarationBlock>
*/
public function getAllDeclarationBlocks()
{
/** @var array<int, DeclarationBlock> $aResult */
$aResult = [];
$this->allDeclarationBlocks($aResult);
return $aResult;
}
/**
* Gets all `DeclarationBlock` objects recursively.
*
* @return array<int, DeclarationBlock>
*
* @deprecated will be removed in version 9.0; use `getAllDeclarationBlocks()` instead
*/
public function getAllSelectors()
{
return $this->getAllDeclarationBlocks();
}
/**
* Returns all `RuleSet` objects found recursively in the tree.
*
* @return array<int, RuleSet>
*/
public function getAllRuleSets()
{
/** @var array<int, RuleSet> $aResult */
$aResult = [];
$this->allRuleSets($aResult);
return $aResult;
}
/**
* Returns all `Value` objects found recursively in the tree.
*
* @param CSSList|RuleSet|string $mElement
* the `CSSList` or `RuleSet` to start the search from (defaults to the whole document).
* If a string is given, it is used as rule name filter.
* @param bool $bSearchInFunctionArguments whether to also return Value objects used as Function arguments.
*
* @return array<int, Value>
*
* @see RuleSet->getRules()
*/
public function getAllValues($mElement = null, $bSearchInFunctionArguments = false)
{
$sSearchString = null;
if ($mElement === null) {
$mElement = $this;
} elseif (is_string($mElement)) {
$sSearchString = $mElement;
$mElement = $this;
}
/** @var array<int, Value> $aResult */
$aResult = [];
$this->allValues($mElement, $aResult, $sSearchString, $bSearchInFunctionArguments);
return $aResult;
}
/**
* Returns all `Selector` objects found recursively in the tree.
*
* Note that this does not yield the full `DeclarationBlock` that the selector belongs to
* (and, currently, there is no way to get to that).
*
* @param string|null $sSpecificitySearch
* An optional filter by specificity.
* May contain a comparison operator and a number or just a number (defaults to "==").
*
* @return array<int, Selector>
* @example `getSelectorsBySpecificity('>= 100')`
*
*/
public function getSelectorsBySpecificity($sSpecificitySearch = null)
{
/** @var array<int, Selector> $aResult */
$aResult = [];
$this->allSelectors($aResult, $sSpecificitySearch);
return $aResult;
}
/**
* Expands all shorthand properties to their long value.
*
* @return void
*/
public function expandShorthands()
{
foreach ($this->getAllDeclarationBlocks() as $oDeclaration) {
$oDeclaration->expandShorthands();
}
}
/**
* Create shorthands properties whenever possible.
*
* @return void
*/
public function createShorthands()
{
foreach ($this->getAllDeclarationBlocks() as $oDeclaration) {
$oDeclaration->createShorthands();
}
}
/**
* Overrides `render()` to make format argument optional.
*
* @param OutputFormat|null $oOutputFormat
*
* @return string
*/
public function render(OutputFormat $oOutputFormat = null)
{
if ($oOutputFormat === null) {
$oOutputFormat = new OutputFormat();
}
return parent::render($oOutputFormat);
}
/**
* @return bool
*/
public function isRootList()
{
return true;
}
}
CSSList/KeyFrame.php 0000644 00000003616 15154702573 0010263 0 ustar 00 <?php
namespace Sabberworm\CSS\CSSList;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\AtRule;
class KeyFrame extends CSSList implements AtRule
{
/**
* @var string|null
*/
private $vendorKeyFrame;
/**
* @var string|null
*/
private $animationName;
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
parent::__construct($iLineNo);
$this->vendorKeyFrame = null;
$this->animationName = null;
}
/**
* @param string $vendorKeyFrame
*/
public function setVendorKeyFrame($vendorKeyFrame)
{
$this->vendorKeyFrame = $vendorKeyFrame;
}
/**
* @return string|null
*/
public function getVendorKeyFrame()
{
return $this->vendorKeyFrame;
}
/**
* @param string $animationName
*/
public function setAnimationName($animationName)
{
$this->animationName = $animationName;
}
/**
* @return string|null
*/
public function getAnimationName()
{
return $this->animationName;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sResult = "@{$this->vendorKeyFrame} {$this->animationName}{$oOutputFormat->spaceBeforeOpeningBrace()}{";
$sResult .= parent::render($oOutputFormat);
$sResult .= '}';
return $sResult;
}
/**
* @return bool
*/
public function isRootList()
{
return false;
}
/**
* @return string|null
*/
public function atRuleName()
{
return $this->vendorKeyFrame;
}
/**
* @return string|null
*/
public function atRuleArgs()
{
return $this->animationName;
}
}
Comment/Comment.php 0000644 00000002215 15154702573 0010272 0 ustar 00 <?php
namespace Sabberworm\CSS\Comment;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Renderable;
class Comment implements Renderable
{
/**
* @var int
*/
protected $iLineNo;
/**
* @var string
*/
protected $sComment;
/**
* @param string $sComment
* @param int $iLineNo
*/
public function __construct($sComment = '', $iLineNo = 0)
{
$this->sComment = $sComment;
$this->iLineNo = $iLineNo;
}
/**
* @return string
*/
public function getComment()
{
return $this->sComment;
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* @param string $sComment
*
* @return void
*/
public function setComment($sComment)
{
$this->sComment = $sComment;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return '/*' . $this->sComment . '*/';
}
}
Comment/Commentable.php 0000644 00000000704 15154702573 0011117 0 ustar 00 <?php
namespace Sabberworm\CSS\Comment;
interface Commentable
{
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments);
/**
* @return array<array-key, Comment>
*/
public function getComments();
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments);
}
OutputFormat.php 0000644 00000017233 15154702573 0007745 0 ustar 00 <?php
namespace Sabberworm\CSS;
/**
* Class OutputFormat
*
* @method OutputFormat setSemicolonAfterLastRule(bool $bSemicolonAfterLastRule) Set whether semicolons are added after
* last rule.
*/
class OutputFormat
{
/**
* Value format: `"` means double-quote, `'` means single-quote
*
* @var string
*/
public $sStringQuotingType = '"';
/**
* Output RGB colors in hash notation if possible
*
* @var string
*/
public $bRGBHashNotation = true;
/**
* Declaration format
*
* Semicolon after the last rule of a declaration block can be omitted. To do that, set this false.
*
* @var bool
*/
public $bSemicolonAfterLastRule = true;
/**
* Spacing
* Note that these strings are not sanity-checked: the value should only consist of whitespace
* Any newline character will be indented according to the current level.
* The triples (After, Before, Between) can be set using a wildcard (e.g. `$oFormat->set('Space*Rules', "\n");`)
*/
public $sSpaceAfterRuleName = ' ';
/**
* @var string
*/
public $sSpaceBeforeRules = '';
/**
* @var string
*/
public $sSpaceAfterRules = '';
/**
* @var string
*/
public $sSpaceBetweenRules = '';
/**
* @var string
*/
public $sSpaceBeforeBlocks = '';
/**
* @var string
*/
public $sSpaceAfterBlocks = '';
/**
* @var string
*/
public $sSpaceBetweenBlocks = "\n";
/**
* Content injected in and around at-rule blocks.
*
* @var string
*/
public $sBeforeAtRuleBlock = '';
/**
* @var string
*/
public $sAfterAtRuleBlock = '';
/**
* This is what’s printed before and after the comma if a declaration block contains multiple selectors.
*
* @var string
*/
public $sSpaceBeforeSelectorSeparator = '';
/**
* @var string
*/
public $sSpaceAfterSelectorSeparator = ' ';
/**
* This is what’s printed after the comma of value lists
*
* @var string
*/
public $sSpaceBeforeListArgumentSeparator = '';
/**
* @var string
*/
public $sSpaceAfterListArgumentSeparator = '';
/**
* @var string
*/
public $sSpaceBeforeOpeningBrace = ' ';
/**
* Content injected in and around declaration blocks.
*
* @var string
*/
public $sBeforeDeclarationBlock = '';
/**
* @var string
*/
public $sAfterDeclarationBlockSelectors = '';
/**
* @var string
*/
public $sAfterDeclarationBlock = '';
/**
* Indentation character(s) per level. Only applicable if newlines are used in any of the spacing settings.
*
* @var string
*/
public $sIndentation = "\t";
/**
* Output exceptions.
*
* @var bool
*/
public $bIgnoreExceptions = false;
/**
* @var OutputFormatter|null
*/
private $oFormatter = null;
/**
* @var OutputFormat|null
*/
private $oNextLevelFormat = null;
/**
* @var int
*/
private $iIndentationLevel = 0;
public function __construct()
{
}
/**
* @param string $sName
*
* @return string|null
*/
public function get($sName)
{
$aVarPrefixes = ['a', 's', 'm', 'b', 'f', 'o', 'c', 'i'];
foreach ($aVarPrefixes as $sPrefix) {
$sFieldName = $sPrefix . ucfirst($sName);
if (isset($this->$sFieldName)) {
return $this->$sFieldName;
}
}
return null;
}
/**
* @param array<array-key, string>|string $aNames
* @param mixed $mValue
*
* @return self|false
*/
public function set($aNames, $mValue)
{
$aVarPrefixes = ['a', 's', 'm', 'b', 'f', 'o', 'c', 'i'];
if (is_string($aNames) && strpos($aNames, '*') !== false) {
$aNames =
[
str_replace('*', 'Before', $aNames),
str_replace('*', 'Between', $aNames),
str_replace('*', 'After', $aNames),
];
} elseif (!is_array($aNames)) {
$aNames = [$aNames];
}
foreach ($aVarPrefixes as $sPrefix) {
$bDidReplace = false;
foreach ($aNames as $sName) {
$sFieldName = $sPrefix . ucfirst($sName);
if (isset($this->$sFieldName)) {
$this->$sFieldName = $mValue;
$bDidReplace = true;
}
}
if ($bDidReplace) {
return $this;
}
}
// Break the chain so the user knows this option is invalid
return false;
}
/**
* @param string $sMethodName
* @param array<array-key, mixed> $aArguments
*
* @return mixed
*
* @throws \Exception
*/
public function __call($sMethodName, array $aArguments)
{
if (strpos($sMethodName, 'set') === 0) {
return $this->set(substr($sMethodName, 3), $aArguments[0]);
} elseif (strpos($sMethodName, 'get') === 0) {
return $this->get(substr($sMethodName, 3));
} elseif (method_exists(OutputFormatter::class, $sMethodName)) {
return call_user_func_array([$this->getFormatter(), $sMethodName], $aArguments);
} else {
throw new \Exception('Unknown OutputFormat method called: ' . $sMethodName);
}
}
/**
* @param int $iNumber
*
* @return self
*/
public function indentWithTabs($iNumber = 1)
{
return $this->setIndentation(str_repeat("\t", $iNumber));
}
/**
* @param int $iNumber
*
* @return self
*/
public function indentWithSpaces($iNumber = 2)
{
return $this->setIndentation(str_repeat(" ", $iNumber));
}
/**
* @return OutputFormat
*/
public function nextLevel()
{
if ($this->oNextLevelFormat === null) {
$this->oNextLevelFormat = clone $this;
$this->oNextLevelFormat->iIndentationLevel++;
$this->oNextLevelFormat->oFormatter = null;
}
return $this->oNextLevelFormat;
}
/**
* @return void
*/
public function beLenient()
{
$this->bIgnoreExceptions = true;
}
/**
* @return OutputFormatter
*/
public function getFormatter()
{
if ($this->oFormatter === null) {
$this->oFormatter = new OutputFormatter($this);
}
return $this->oFormatter;
}
/**
* @return int
*/
public function level()
{
return $this->iIndentationLevel;
}
/**
* Creates an instance of this class without any particular formatting settings.
*
* @return self
*/
public static function create()
{
return new OutputFormat();
}
/**
* Creates an instance of this class with a preset for compact formatting.
*
* @return self
*/
public static function createCompact()
{
$format = self::create();
$format->set('Space*Rules', "")->set('Space*Blocks', "")->setSpaceAfterRuleName('')
->setSpaceBeforeOpeningBrace('')->setSpaceAfterSelectorSeparator('');
return $format;
}
/**
* Creates an instance of this class with a preset for pretty formatting.
*
* @return self
*/
public static function createPretty()
{
$format = self::create();
$format->set('Space*Rules', "\n")->set('Space*Blocks', "\n")
->setSpaceBetweenBlocks("\n\n")->set('SpaceAfterListArgumentSeparator', ['default' => '', ',' => ' ']);
return $format;
}
}
OutputFormatter.php 0000644 00000012336 15154702573 0010457 0 ustar 00 <?php
namespace Sabberworm\CSS;
use Sabberworm\CSS\Parsing\OutputException;
class OutputFormatter
{
/**
* @var OutputFormat
*/
private $oFormat;
public function __construct(OutputFormat $oFormat)
{
$this->oFormat = $oFormat;
}
/**
* @param string $sName
* @param string|null $sType
*
* @return string
*/
public function space($sName, $sType = null)
{
$sSpaceString = $this->oFormat->get("Space$sName");
// If $sSpaceString is an array, we have multiple values configured
// depending on the type of object the space applies to
if (is_array($sSpaceString)) {
if ($sType !== null && isset($sSpaceString[$sType])) {
$sSpaceString = $sSpaceString[$sType];
} else {
$sSpaceString = reset($sSpaceString);
}
}
return $this->prepareSpace($sSpaceString);
}
/**
* @return string
*/
public function spaceAfterRuleName()
{
return $this->space('AfterRuleName');
}
/**
* @return string
*/
public function spaceBeforeRules()
{
return $this->space('BeforeRules');
}
/**
* @return string
*/
public function spaceAfterRules()
{
return $this->space('AfterRules');
}
/**
* @return string
*/
public function spaceBetweenRules()
{
return $this->space('BetweenRules');
}
/**
* @return string
*/
public function spaceBeforeBlocks()
{
return $this->space('BeforeBlocks');
}
/**
* @return string
*/
public function spaceAfterBlocks()
{
return $this->space('AfterBlocks');
}
/**
* @return string
*/
public function spaceBetweenBlocks()
{
return $this->space('BetweenBlocks');
}
/**
* @return string
*/
public function spaceBeforeSelectorSeparator()
{
return $this->space('BeforeSelectorSeparator');
}
/**
* @return string
*/
public function spaceAfterSelectorSeparator()
{
return $this->space('AfterSelectorSeparator');
}
/**
* @param string $sSeparator
*
* @return string
*/
public function spaceBeforeListArgumentSeparator($sSeparator)
{
return $this->space('BeforeListArgumentSeparator', $sSeparator);
}
/**
* @param string $sSeparator
*
* @return string
*/
public function spaceAfterListArgumentSeparator($sSeparator)
{
return $this->space('AfterListArgumentSeparator', $sSeparator);
}
/**
* @return string
*/
public function spaceBeforeOpeningBrace()
{
return $this->space('BeforeOpeningBrace');
}
/**
* Runs the given code, either swallowing or passing exceptions, depending on the `bIgnoreExceptions` setting.
*
* @param string $cCode the name of the function to call
*
* @return string|null
*/
public function safely($cCode)
{
if ($this->oFormat->get('IgnoreExceptions')) {
// If output exceptions are ignored, run the code with exception guards
try {
return $cCode();
} catch (OutputException $e) {
return null;
} // Do nothing
} else {
// Run the code as-is
return $cCode();
}
}
/**
* Clone of the `implode` function, but calls `render` with the current output format instead of `__toString()`.
*
* @param string $sSeparator
* @param array<array-key, Renderable|string> $aValues
* @param bool $bIncreaseLevel
*
* @return string
*/
public function implode($sSeparator, array $aValues, $bIncreaseLevel = false)
{
$sResult = '';
$oFormat = $this->oFormat;
if ($bIncreaseLevel) {
$oFormat = $oFormat->nextLevel();
}
$bIsFirst = true;
foreach ($aValues as $mValue) {
if ($bIsFirst) {
$bIsFirst = false;
} else {
$sResult .= $sSeparator;
}
if ($mValue instanceof Renderable) {
$sResult .= $mValue->render($oFormat);
} else {
$sResult .= $mValue;
}
}
return $sResult;
}
/**
* @param string $sString
*
* @return string
*/
public function removeLastSemicolon($sString)
{
if ($this->oFormat->get('SemicolonAfterLastRule')) {
return $sString;
}
$sString = explode(';', $sString);
if (count($sString) < 2) {
return $sString[0];
}
$sLast = array_pop($sString);
$sNextToLast = array_pop($sString);
array_push($sString, $sNextToLast . $sLast);
return implode(';', $sString);
}
/**
* @param string $sSpaceString
*
* @return string
*/
private function prepareSpace($sSpaceString)
{
return str_replace("\n", "\n" . $this->indent(), $sSpaceString);
}
/**
* @return string
*/
private function indent()
{
return str_repeat($this->oFormat->sIndentation, $this->oFormat->level());
}
}
Parser.php 0000644 00000002457 15154702573 0006532 0 ustar 00 <?php
namespace Sabberworm\CSS;
use Sabberworm\CSS\CSSList\Document;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
/**
* This class parses CSS from text into a data structure.
*/
class Parser
{
/**
* @var ParserState
*/
private $oParserState;
/**
* @param string $sText
* @param Settings|null $oParserSettings
* @param int $iLineNo the line number (starting from 1, not from 0)
*/
public function __construct($sText, Settings $oParserSettings = null, $iLineNo = 1)
{
if ($oParserSettings === null) {
$oParserSettings = Settings::create();
}
$this->oParserState = new ParserState($sText, $oParserSettings, $iLineNo);
}
/**
* @param string $sCharset
*
* @return void
*/
public function setCharset($sCharset)
{
$this->oParserState->setCharset($sCharset);
}
/**
* @return void
*/
public function getCharset()
{
// Note: The `return` statement is missing here. This is a bug that needs to be fixed.
$this->oParserState->getCharset();
}
/**
* @return Document
*
* @throws SourceException
*/
public function parse()
{
return Document::parse($this->oParserState);
}
}
Parsing/OutputException.php 0000644 00000000546 15154702573 0012055 0 ustar 00 <?php
namespace Sabberworm\CSS\Parsing;
/**
* Thrown if the CSS parser attempts to print something invalid.
*/
class OutputException extends SourceException
{
/**
* @param string $sMessage
* @param int $iLineNo
*/
public function __construct($sMessage, $iLineNo = 0)
{
parent::__construct($sMessage, $iLineNo);
}
}
Parsing/ParserState.php 0000644 00000032212 15154702573 0011126 0 ustar 00 <?php
namespace Sabberworm\CSS\Parsing;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Settings;
class ParserState
{
/**
* @var null
*/
const EOF = null;
/**
* @var Settings
*/
private $oParserSettings;
/**
* @var string
*/
private $sText;
/**
* @var array<int, string>
*/
private $aText;
/**
* @var int
*/
private $iCurrentPosition;
/**
* @var string
*/
private $sCharset;
/**
* @var int
*/
private $iLength;
/**
* @var int
*/
private $iLineNo;
/**
* @param string $sText
* @param int $iLineNo
*/
public function __construct($sText, Settings $oParserSettings, $iLineNo = 1)
{
$this->oParserSettings = $oParserSettings;
$this->sText = $sText;
$this->iCurrentPosition = 0;
$this->iLineNo = $iLineNo;
$this->setCharset($this->oParserSettings->sDefaultCharset);
}
/**
* @param string $sCharset
*
* @return void
*/
public function setCharset($sCharset)
{
$this->sCharset = $sCharset;
$this->aText = $this->strsplit($this->sText);
if (is_array($this->aText)) {
$this->iLength = count($this->aText);
}
}
/**
* @return string
*/
public function getCharset()
{
return $this->sCharset;
}
/**
* @return int
*/
public function currentLine()
{
return $this->iLineNo;
}
/**
* @return int
*/
public function currentColumn()
{
return $this->iCurrentPosition;
}
/**
* @return Settings
*/
public function getSettings()
{
return $this->oParserSettings;
}
/**
* @param bool $bIgnoreCase
*
* @return string
*
* @throws UnexpectedTokenException
*/
public function parseIdentifier($bIgnoreCase = true)
{
$sResult = $this->parseCharacter(true);
if ($sResult === null) {
throw new UnexpectedTokenException($sResult, $this->peek(5), 'identifier', $this->iLineNo);
}
$sCharacter = null;
while (($sCharacter = $this->parseCharacter(true)) !== null) {
if (preg_match('/[a-zA-Z0-9\x{00A0}-\x{FFFF}_-]/Sux', $sCharacter)) {
$sResult .= $sCharacter;
} else {
$sResult .= '\\' . $sCharacter;
}
}
if ($bIgnoreCase) {
$sResult = $this->strtolower($sResult);
}
return $sResult;
}
/**
* @param bool $bIsForIdentifier
*
* @return string|null
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function parseCharacter($bIsForIdentifier)
{
if ($this->peek() === '\\') {
if (
$bIsForIdentifier && $this->oParserSettings->bLenientParsing
&& ($this->comes('\0') || $this->comes('\9'))
) {
// Non-strings can contain \0 or \9 which is an IE hack supported in lenient parsing.
return null;
}
$this->consume('\\');
if ($this->comes('\n') || $this->comes('\r')) {
return '';
}
if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
return $this->consume(1);
}
$sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
if ($this->strlen($sUnicode) < 6) {
// Consume whitespace after incomplete unicode escape
if (preg_match('/\\s/isSu', $this->peek())) {
if ($this->comes('\r\n')) {
$this->consume(2);
} else {
$this->consume(1);
}
}
}
$iUnicode = intval($sUnicode, 16);
$sUtf32 = "";
for ($i = 0; $i < 4; ++$i) {
$sUtf32 .= chr($iUnicode & 0xff);
$iUnicode = $iUnicode >> 8;
}
return iconv('utf-32le', $this->sCharset, $sUtf32);
}
if ($bIsForIdentifier) {
$peek = ord($this->peek());
// Ranges: a-z A-Z 0-9 - _
if (
($peek >= 97 && $peek <= 122)
|| ($peek >= 65 && $peek <= 90)
|| ($peek >= 48 && $peek <= 57)
|| ($peek === 45)
|| ($peek === 95)
|| ($peek > 0xa1)
) {
return $this->consume(1);
}
} else {
return $this->consume(1);
}
return null;
}
/**
* @return array<int, Comment>|void
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consumeWhiteSpace()
{
$comments = [];
do {
while (preg_match('/\\s/isSu', $this->peek()) === 1) {
$this->consume(1);
}
if ($this->oParserSettings->bLenientParsing) {
try {
$oComment = $this->consumeComment();
} catch (UnexpectedEOFException $e) {
$this->iCurrentPosition = $this->iLength;
return;
}
} else {
$oComment = $this->consumeComment();
}
if ($oComment !== false) {
$comments[] = $oComment;
}
} while ($oComment !== false);
return $comments;
}
/**
* @param string $sString
* @param bool $bCaseInsensitive
*
* @return bool
*/
public function comes($sString, $bCaseInsensitive = false)
{
$sPeek = $this->peek(strlen($sString));
return ($sPeek == '')
? false
: $this->streql($sPeek, $sString, $bCaseInsensitive);
}
/**
* @param int $iLength
* @param int $iOffset
*
* @return string
*/
public function peek($iLength = 1, $iOffset = 0)
{
$iOffset += $this->iCurrentPosition;
if ($iOffset >= $this->iLength) {
return '';
}
return $this->substr($iOffset, $iLength);
}
/**
* @param int $mValue
*
* @return string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consume($mValue = 1)
{
if (is_string($mValue)) {
$iLineCount = substr_count($mValue, "\n");
$iLength = $this->strlen($mValue);
if (!$this->streql($this->substr($this->iCurrentPosition, $iLength), $mValue)) {
throw new UnexpectedTokenException($mValue, $this->peek(max($iLength, 5)), $this->iLineNo);
}
$this->iLineNo += $iLineCount;
$this->iCurrentPosition += $this->strlen($mValue);
return $mValue;
} else {
if ($this->iCurrentPosition + $mValue > $this->iLength) {
throw new UnexpectedEOFException($mValue, $this->peek(5), 'count', $this->iLineNo);
}
$sResult = $this->substr($this->iCurrentPosition, $mValue);
$iLineCount = substr_count($sResult, "\n");
$this->iLineNo += $iLineCount;
$this->iCurrentPosition += $mValue;
return $sResult;
}
}
/**
* @param string $mExpression
* @param int|null $iMaxLength
*
* @return string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consumeExpression($mExpression, $iMaxLength = null)
{
$aMatches = null;
$sInput = $iMaxLength !== null ? $this->peek($iMaxLength) : $this->inputLeft();
if (preg_match($mExpression, $sInput, $aMatches, PREG_OFFSET_CAPTURE) === 1) {
return $this->consume($aMatches[0][0]);
}
throw new UnexpectedTokenException($mExpression, $this->peek(5), 'expression', $this->iLineNo);
}
/**
* @return Comment|false
*/
public function consumeComment()
{
$mComment = false;
if ($this->comes('/*')) {
$iLineNo = $this->iLineNo;
$this->consume(1);
$mComment = '';
while (($char = $this->consume(1)) !== '') {
$mComment .= $char;
if ($this->comes('*/')) {
$this->consume(2);
break;
}
}
}
if ($mComment !== false) {
// We skip the * which was included in the comment.
return new Comment(substr($mComment, 1), $iLineNo);
}
return $mComment;
}
/**
* @return bool
*/
public function isEnd()
{
return $this->iCurrentPosition >= $this->iLength;
}
/**
* @param array<array-key, string>|string $aEnd
* @param string $bIncludeEnd
* @param string $consumeEnd
* @param array<int, Comment> $comments
*
* @return string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public function consumeUntil($aEnd, $bIncludeEnd = false, $consumeEnd = false, array &$comments = [])
{
$aEnd = is_array($aEnd) ? $aEnd : [$aEnd];
$out = '';
$start = $this->iCurrentPosition;
while (!$this->isEnd()) {
$char = $this->consume(1);
if (in_array($char, $aEnd)) {
if ($bIncludeEnd) {
$out .= $char;
} elseif (!$consumeEnd) {
$this->iCurrentPosition -= $this->strlen($char);
}
return $out;
}
$out .= $char;
if ($comment = $this->consumeComment()) {
$comments[] = $comment;
}
}
if (in_array(self::EOF, $aEnd)) {
return $out;
}
$this->iCurrentPosition = $start;
throw new UnexpectedEOFException(
'One of ("' . implode('","', $aEnd) . '")',
$this->peek(5),
'search',
$this->iLineNo
);
}
/**
* @return string
*/
private function inputLeft()
{
return $this->substr($this->iCurrentPosition, -1);
}
/**
* @param string $sString1
* @param string $sString2
* @param bool $bCaseInsensitive
*
* @return bool
*/
public function streql($sString1, $sString2, $bCaseInsensitive = true)
{
if ($bCaseInsensitive) {
return $this->strtolower($sString1) === $this->strtolower($sString2);
} else {
return $sString1 === $sString2;
}
}
/**
* @param int $iAmount
*
* @return void
*/
public function backtrack($iAmount)
{
$this->iCurrentPosition -= $iAmount;
}
/**
* @param string $sString
*
* @return int
*/
public function strlen($sString)
{
if ($this->oParserSettings->bMultibyteSupport) {
return mb_strlen($sString, $this->sCharset);
} else {
return strlen($sString);
}
}
/**
* @param int $iStart
* @param int $iLength
*
* @return string
*/
private function substr($iStart, $iLength)
{
if ($iLength < 0) {
$iLength = $this->iLength - $iStart + $iLength;
}
if ($iStart + $iLength > $this->iLength) {
$iLength = $this->iLength - $iStart;
}
$sResult = '';
while ($iLength > 0) {
$sResult .= $this->aText[$iStart];
$iStart++;
$iLength--;
}
return $sResult;
}
/**
* @param string $sString
*
* @return string
*/
private function strtolower($sString)
{
if ($this->oParserSettings->bMultibyteSupport) {
return mb_strtolower($sString, $this->sCharset);
} else {
return strtolower($sString);
}
}
/**
* @param string $sString
*
* @return array<int, string>
*/
private function strsplit($sString)
{
if ($this->oParserSettings->bMultibyteSupport) {
if ($this->streql($this->sCharset, 'utf-8')) {
return preg_split('//u', $sString, -1, PREG_SPLIT_NO_EMPTY);
} else {
$iLength = mb_strlen($sString, $this->sCharset);
$aResult = [];
for ($i = 0; $i < $iLength; ++$i) {
$aResult[] = mb_substr($sString, $i, 1, $this->sCharset);
}
return $aResult;
}
} else {
if ($sString === '') {
return [];
} else {
return str_split($sString);
}
}
}
/**
* @param string $sString
* @param string $sNeedle
* @param int $iOffset
*
* @return int|false
*/
private function strpos($sString, $sNeedle, $iOffset)
{
if ($this->oParserSettings->bMultibyteSupport) {
return mb_strpos($sString, $sNeedle, $iOffset, $this->sCharset);
} else {
return strpos($sString, $sNeedle, $iOffset);
}
}
}
Parsing/SourceException.php 0000644 00000001062 15154702573 0012007 0 ustar 00 <?php
namespace Sabberworm\CSS\Parsing;
class SourceException extends \Exception
{
/**
* @var int
*/
private $iLineNo;
/**
* @param string $sMessage
* @param int $iLineNo
*/
public function __construct($sMessage, $iLineNo = 0)
{
$this->iLineNo = $iLineNo;
if (!empty($iLineNo)) {
$sMessage .= " [line no: $iLineNo]";
}
parent::__construct($sMessage);
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
}
Parsing/UnexpectedEOFException.php 0000644 00000000421 15154702573 0013203 0 ustar 00 <?php
namespace Sabberworm\CSS\Parsing;
/**
* Thrown if the CSS parser encounters end of file it did not expect.
*
* Extends `UnexpectedTokenException` in order to preserve backwards compatibility.
*/
class UnexpectedEOFException extends UnexpectedTokenException
{
}
Parsing/UnexpectedTokenException.php 0000644 00000002704 15154702573 0013660 0 ustar 00 <?php
namespace Sabberworm\CSS\Parsing;
/**
* Thrown if the CSS parser encounters a token it did not expect.
*/
class UnexpectedTokenException extends SourceException
{
/**
* @var string
*/
private $sExpected;
/**
* @var string
*/
private $sFound;
/**
* Possible values: literal, identifier, count, expression, search
*
* @var string
*/
private $sMatchType;
/**
* @param string $sExpected
* @param string $sFound
* @param string $sMatchType
* @param int $iLineNo
*/
public function __construct($sExpected, $sFound, $sMatchType = 'literal', $iLineNo = 0)
{
$this->sExpected = $sExpected;
$this->sFound = $sFound;
$this->sMatchType = $sMatchType;
$sMessage = "Token “{$sExpected}” ({$sMatchType}) not found. Got “{$sFound}”.";
if ($this->sMatchType === 'search') {
$sMessage = "Search for “{$sExpected}” returned no results. Context: “{$sFound}”.";
} elseif ($this->sMatchType === 'count') {
$sMessage = "Next token was expected to have {$sExpected} chars. Context: “{$sFound}”.";
} elseif ($this->sMatchType === 'identifier') {
$sMessage = "Identifier expected. Got “{$sFound}”";
} elseif ($this->sMatchType === 'custom') {
$sMessage = trim("$sExpected $sFound");
}
parent::__construct($sMessage, $iLineNo);
}
}
Property/AtRule.php 0000644 00000001441 15154702573 0010306 0 ustar 00 <?php
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\Renderable;
interface AtRule extends Renderable, Commentable
{
/**
* Since there are more set rules than block rules,
* we’re whitelisting the block rules and have anything else be treated as a set rule.
*
* @var string
*/
const BLOCK_RULES = 'media/document/supports/region-style/font-feature-values';
/**
* … and more font-specific ones (to be used inside font-feature-values)
*
* @var string
*/
const SET_RULES = 'font-face/counter-style/page/swash/styleset/annotation';
/**
* @return string|null
*/
public function atRuleName();
/**
* @return string|null
*/
public function atRuleArgs();
}
Property/CSSNamespace.php 0000644 00000005247 15154702573 0011367 0 ustar 00 <?php
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\OutputFormat;
/**
* `CSSNamespace` represents an `@namespace` rule.
*/
class CSSNamespace implements AtRule
{
/**
* @var string
*/
private $mUrl;
/**
* @var string
*/
private $sPrefix;
/**
* @var int
*/
private $iLineNo;
/**
* @var array<array-key, Comment>
*/
protected $aComments;
/**
* @param string $mUrl
* @param string|null $sPrefix
* @param int $iLineNo
*/
public function __construct($mUrl, $sPrefix = null, $iLineNo = 0)
{
$this->mUrl = $mUrl;
$this->sPrefix = $sPrefix;
$this->iLineNo = $iLineNo;
$this->aComments = [];
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return '@namespace ' . ($this->sPrefix === null ? '' : $this->sPrefix . ' ')
. $this->mUrl->render($oOutputFormat) . ';';
}
/**
* @return string
*/
public function getUrl()
{
return $this->mUrl;
}
/**
* @return string|null
*/
public function getPrefix()
{
return $this->sPrefix;
}
/**
* @param string $mUrl
*
* @return void
*/
public function setUrl($mUrl)
{
$this->mUrl = $mUrl;
}
/**
* @param string $sPrefix
*
* @return void
*/
public function setPrefix($sPrefix)
{
$this->sPrefix = $sPrefix;
}
/**
* @return string
*/
public function atRuleName()
{
return 'namespace';
}
/**
* @return array<int, string>
*/
public function atRuleArgs()
{
$aResult = [$this->mUrl];
if ($this->sPrefix) {
array_unshift($aResult, $this->sPrefix);
}
return $aResult;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments)
{
$this->aComments = array_merge($this->aComments, $aComments);
}
/**
* @return array<array-key, Comment>
*/
public function getComments()
{
return $this->aComments;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments)
{
$this->aComments = $aComments;
}
}
Property/Charset.php 0000644 00000004440 15154702573 0010505 0 ustar 00 <?php
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\OutputFormat;
/**
* Class representing an `@charset` rule.
*
* The following restrictions apply:
* - May not be found in any CSSList other than the Document.
* - May only appear at the very top of a Document’s contents.
* - Must not appear more than once.
*/
class Charset implements AtRule
{
/**
* @var string
*/
private $sCharset;
/**
* @var int
*/
protected $iLineNo;
/**
* @var array<array-key, Comment>
*/
protected $aComments;
/**
* @param string $sCharset
* @param int $iLineNo
*/
public function __construct($sCharset, $iLineNo = 0)
{
$this->sCharset = $sCharset;
$this->iLineNo = $iLineNo;
$this->aComments = [];
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* @param string $sCharset
*
* @return void
*/
public function setCharset($sCharset)
{
$this->sCharset = $sCharset;
}
/**
* @return string
*/
public function getCharset()
{
return $this->sCharset;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return "@charset {$this->sCharset->render($oOutputFormat)};";
}
/**
* @return string
*/
public function atRuleName()
{
return 'charset';
}
/**
* @return string
*/
public function atRuleArgs()
{
return $this->sCharset;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments)
{
$this->aComments = array_merge($this->aComments, $aComments);
}
/**
* @return array<array-key, Comment>
*/
public function getComments()
{
return $this->aComments;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments)
{
$this->aComments = $aComments;
}
}
Property/Import.php 0000644 00000004760 15154702573 0010373 0 ustar 00 <?php
namespace Sabberworm\CSS\Property;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Value\URL;
/**
* Class representing an `@import` rule.
*/
class Import implements AtRule
{
/**
* @var URL
*/
private $oLocation;
/**
* @var string
*/
private $sMediaQuery;
/**
* @var int
*/
protected $iLineNo;
/**
* @var array<array-key, Comment>
*/
protected $aComments;
/**
* @param URL $oLocation
* @param string $sMediaQuery
* @param int $iLineNo
*/
public function __construct(URL $oLocation, $sMediaQuery, $iLineNo = 0)
{
$this->oLocation = $oLocation;
$this->sMediaQuery = $sMediaQuery;
$this->iLineNo = $iLineNo;
$this->aComments = [];
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* @param URL $oLocation
*
* @return void
*/
public function setLocation($oLocation)
{
$this->oLocation = $oLocation;
}
/**
* @return URL
*/
public function getLocation()
{
return $this->oLocation;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return "@import " . $this->oLocation->render($oOutputFormat)
. ($this->sMediaQuery === null ? '' : ' ' . $this->sMediaQuery) . ';';
}
/**
* @return string
*/
public function atRuleName()
{
return 'import';
}
/**
* @return array<int, URL|string>
*/
public function atRuleArgs()
{
$aResult = [$this->oLocation];
if ($this->sMediaQuery) {
array_push($aResult, $this->sMediaQuery);
}
return $aResult;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments)
{
$this->aComments = array_merge($this->aComments, $aComments);
}
/**
* @return array<array-key, Comment>
*/
public function getComments()
{
return $this->aComments;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments)
{
$this->aComments = $aComments;
}
}
Property/KeyframeSelector.php 0000644 00000001271 15154702573 0012357 0 ustar 00 <?php
namespace Sabberworm\CSS\Property;
class KeyframeSelector extends Selector
{
/**
* regexp for specificity calculations
*
* @var string
*/
const SELECTOR_VALIDATION_RX = '/
^(
(?:
[a-zA-Z0-9\x{00A0}-\x{FFFF}_^$|*="\'~\[\]()\-\s\.:#+>]* # any sequence of valid unescaped characters
(?:\\\\.)? # a single escaped character
(?:([\'"]).*?(?<!\\\\)\2)? # a quoted text like [id="example"]
)*
)|
(\d+%) # keyframe animation progress percentage (e.g. 50%)
$
/ux';
}
Property/Selector.php 0000644 00000006553 15154702573 0010703 0 ustar 00 <?php
namespace Sabberworm\CSS\Property;
/**
* Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this
* class.
*/
class Selector
{
/**
* regexp for specificity calculations
*
* @var string
*/
const NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX = '/
(\.[\w]+) # classes
|
\[(\w+) # attributes
|
(\:( # pseudo classes
link|visited|active
|hover|focus
|lang
|target
|enabled|disabled|checked|indeterminate
|root
|nth-child|nth-last-child|nth-of-type|nth-last-of-type
|first-child|last-child|first-of-type|last-of-type
|only-child|only-of-type
|empty|contains
))
/ix';
/**
* regexp for specificity calculations
*
* @var string
*/
const ELEMENTS_AND_PSEUDO_ELEMENTS_RX = '/
((^|[\s\+\>\~]+)[\w]+ # elements
|
\:{1,2}( # pseudo-elements
after|before|first-letter|first-line|selection
))
/ix';
/**
* regexp for specificity calculations
*
* @var string
*/
const SELECTOR_VALIDATION_RX = '/
^(
(?:
[a-zA-Z0-9\x{00A0}-\x{FFFF}_^$|*="\'~\[\]()\-\s\.:#+>]* # any sequence of valid unescaped characters
(?:\\\\.)? # a single escaped character
(?:([\'"]).*?(?<!\\\\)\2)? # a quoted text like [id="example"]
)*
)$
/ux';
/**
* @var string
*/
private $sSelector;
/**
* @var int|null
*/
private $iSpecificity;
/**
* @param string $sSelector
*
* @return bool
*/
public static function isValid($sSelector)
{
return preg_match(static::SELECTOR_VALIDATION_RX, $sSelector);
}
/**
* @param string $sSelector
* @param bool $bCalculateSpecificity
*/
public function __construct($sSelector, $bCalculateSpecificity = false)
{
$this->setSelector($sSelector);
if ($bCalculateSpecificity) {
$this->getSpecificity();
}
}
/**
* @return string
*/
public function getSelector()
{
return $this->sSelector;
}
/**
* @param string $sSelector
*
* @return void
*/
public function setSelector($sSelector)
{
$this->sSelector = trim($sSelector);
$this->iSpecificity = null;
}
/**
* @return string
*/
public function __toString()
{
return $this->getSelector();
}
/**
* @return int
*/
public function getSpecificity()
{
if ($this->iSpecificity === null) {
$a = 0;
/// @todo should exclude \# as well as "#"
$aMatches = null;
$b = substr_count($this->sSelector, '#');
$c = preg_match_all(self::NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX, $this->sSelector, $aMatches);
$d = preg_match_all(self::ELEMENTS_AND_PSEUDO_ELEMENTS_RX, $this->sSelector, $aMatches);
$this->iSpecificity = ($a * 1000) + ($b * 100) + ($c * 10) + $d;
}
return $this->iSpecificity;
}
}
Renderable.php 0000644 00000000450 15154702573 0007330 0 ustar 00 <?php
namespace Sabberworm\CSS;
interface Renderable
{
/**
* @return string
*/
public function __toString();
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat);
/**
* @return int
*/
public function getLineNo();
}
Rule/Rule.php 0000644 00000023660 15154702573 0007113 0 ustar 00 <?php
namespace Sabberworm\CSS\Rule;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Renderable;
use Sabberworm\CSS\Value\RuleValueList;
use Sabberworm\CSS\Value\Value;
/**
* RuleSets contains Rule objects which always have a key and a value.
* In CSS, Rules are expressed as follows: “key: value[0][0] value[0][1], value[1][0] value[1][1];”
*/
class Rule implements Renderable, Commentable
{
/**
* @var string
*/
private $sRule;
/**
* @var RuleValueList|null
*/
private $mValue;
/**
* @var bool
*/
private $bIsImportant;
/**
* @var array<int, int>
*/
private $aIeHack;
/**
* @var int
*/
protected $iLineNo;
/**
* @var int
*/
protected $iColNo;
/**
* @var array<array-key, Comment>
*/
protected $aComments;
/**
* @param string $sRule
* @param int $iLineNo
* @param int $iColNo
*/
public function __construct($sRule, $iLineNo = 0, $iColNo = 0)
{
$this->sRule = $sRule;
$this->mValue = null;
$this->bIsImportant = false;
$this->aIeHack = [];
$this->iLineNo = $iLineNo;
$this->iColNo = $iColNo;
$this->aComments = [];
}
/**
* @return Rule
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public static function parse(ParserState $oParserState)
{
$aComments = $oParserState->consumeWhiteSpace();
$oRule = new Rule(
$oParserState->parseIdentifier(!$oParserState->comes("--")),
$oParserState->currentLine(),
$oParserState->currentColumn()
);
$oRule->setComments($aComments);
$oRule->addComments($oParserState->consumeWhiteSpace());
$oParserState->consume(':');
$oValue = Value::parseValue($oParserState, self::listDelimiterForRule($oRule->getRule()));
$oRule->setValue($oValue);
if ($oParserState->getSettings()->bLenientParsing) {
while ($oParserState->comes('\\')) {
$oParserState->consume('\\');
$oRule->addIeHack($oParserState->consume());
$oParserState->consumeWhiteSpace();
}
}
$oParserState->consumeWhiteSpace();
if ($oParserState->comes('!')) {
$oParserState->consume('!');
$oParserState->consumeWhiteSpace();
$oParserState->consume('important');
$oRule->setIsImportant(true);
}
$oParserState->consumeWhiteSpace();
while ($oParserState->comes(';')) {
$oParserState->consume(';');
}
$oParserState->consumeWhiteSpace();
return $oRule;
}
/**
* @param string $sRule
*
* @return array<int, string>
*/
private static function listDelimiterForRule($sRule)
{
if (preg_match('/^font($|-)/', $sRule)) {
return [',', '/', ' '];
}
return [',', ' ', '/'];
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* @return int
*/
public function getColNo()
{
return $this->iColNo;
}
/**
* @param int $iLine
* @param int $iColumn
*
* @return void
*/
public function setPosition($iLine, $iColumn)
{
$this->iColNo = $iColumn;
$this->iLineNo = $iLine;
}
/**
* @param string $sRule
*
* @return void
*/
public function setRule($sRule)
{
$this->sRule = $sRule;
}
/**
* @return string
*/
public function getRule()
{
return $this->sRule;
}
/**
* @return RuleValueList|null
*/
public function getValue()
{
return $this->mValue;
}
/**
* @param RuleValueList|null $mValue
*
* @return void
*/
public function setValue($mValue)
{
$this->mValue = $mValue;
}
/**
* @param array<array-key, array<array-key, RuleValueList>> $aSpaceSeparatedValues
*
* @return RuleValueList
*
* @deprecated will be removed in version 9.0
* Old-Style 2-dimensional array given. Retained for (some) backwards-compatibility.
* Use `setValue()` instead and wrap the value inside a RuleValueList if necessary.
*/
public function setValues(array $aSpaceSeparatedValues)
{
$oSpaceSeparatedList = null;
if (count($aSpaceSeparatedValues) > 1) {
$oSpaceSeparatedList = new RuleValueList(' ', $this->iLineNo);
}
foreach ($aSpaceSeparatedValues as $aCommaSeparatedValues) {
$oCommaSeparatedList = null;
if (count($aCommaSeparatedValues) > 1) {
$oCommaSeparatedList = new RuleValueList(',', $this->iLineNo);
}
foreach ($aCommaSeparatedValues as $mValue) {
if (!$oSpaceSeparatedList && !$oCommaSeparatedList) {
$this->mValue = $mValue;
return $mValue;
}
if ($oCommaSeparatedList) {
$oCommaSeparatedList->addListComponent($mValue);
} else {
$oSpaceSeparatedList->addListComponent($mValue);
}
}
if (!$oSpaceSeparatedList) {
$this->mValue = $oCommaSeparatedList;
return $oCommaSeparatedList;
} else {
$oSpaceSeparatedList->addListComponent($oCommaSeparatedList);
}
}
$this->mValue = $oSpaceSeparatedList;
return $oSpaceSeparatedList;
}
/**
* @return array<int, array<int, RuleValueList>>
*
* @deprecated will be removed in version 9.0
* Old-Style 2-dimensional array returned. Retained for (some) backwards-compatibility.
* Use `getValue()` instead and check for the existence of a (nested set of) ValueList object(s).
*/
public function getValues()
{
if (!$this->mValue instanceof RuleValueList) {
return [[$this->mValue]];
}
if ($this->mValue->getListSeparator() === ',') {
return [$this->mValue->getListComponents()];
}
$aResult = [];
foreach ($this->mValue->getListComponents() as $mValue) {
if (!$mValue instanceof RuleValueList || $mValue->getListSeparator() !== ',') {
$aResult[] = [$mValue];
continue;
}
if ($this->mValue->getListSeparator() === ' ' || count($aResult) === 0) {
$aResult[] = [];
}
foreach ($mValue->getListComponents() as $mValue) {
$aResult[count($aResult) - 1][] = $mValue;
}
}
return $aResult;
}
/**
* Adds a value to the existing value. Value will be appended if a `RuleValueList` exists of the given type.
* Otherwise, the existing value will be wrapped by one.
*
* @param RuleValueList|array<int, RuleValueList> $mValue
* @param string $sType
*
* @return void
*/
public function addValue($mValue, $sType = ' ')
{
if (!is_array($mValue)) {
$mValue = [$mValue];
}
if (!$this->mValue instanceof RuleValueList || $this->mValue->getListSeparator() !== $sType) {
$mCurrentValue = $this->mValue;
$this->mValue = new RuleValueList($sType, $this->iLineNo);
if ($mCurrentValue) {
$this->mValue->addListComponent($mCurrentValue);
}
}
foreach ($mValue as $mValueItem) {
$this->mValue->addListComponent($mValueItem);
}
}
/**
* @param int $iModifier
*
* @return void
*/
public function addIeHack($iModifier)
{
$this->aIeHack[] = $iModifier;
}
/**
* @param array<int, int> $aModifiers
*
* @return void
*/
public function setIeHack(array $aModifiers)
{
$this->aIeHack = $aModifiers;
}
/**
* @return array<int, int>
*/
public function getIeHack()
{
return $this->aIeHack;
}
/**
* @param bool $bIsImportant
*
* @return void
*/
public function setIsImportant($bIsImportant)
{
$this->bIsImportant = $bIsImportant;
}
/**
* @return bool
*/
public function getIsImportant()
{
return $this->bIsImportant;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sResult = "{$this->sRule}:{$oOutputFormat->spaceAfterRuleName()}";
if ($this->mValue instanceof Value) { //Can also be a ValueList
$sResult .= $this->mValue->render($oOutputFormat);
} else {
$sResult .= $this->mValue;
}
if (!empty($this->aIeHack)) {
$sResult .= ' \\' . implode('\\', $this->aIeHack);
}
if ($this->bIsImportant) {
$sResult .= ' !important';
}
$sResult .= ';';
return $sResult;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments)
{
$this->aComments = array_merge($this->aComments, $aComments);
}
/**
* @return array<array-key, Comment>
*/
public function getComments()
{
return $this->aComments;
}
/**
* @param array<array-key, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments)
{
$this->aComments = $aComments;
}
}
RuleSet/AtRuleSet.php 0000644 00000002620 15154702573 0010521 0 ustar 00 <?php
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Property\AtRule;
/**
* A RuleSet constructed by an unknown at-rule. `@font-face` rules are rendered into AtRuleSet objects.
*/
class AtRuleSet extends RuleSet implements AtRule
{
/**
* @var string
*/
private $sType;
/**
* @var string
*/
private $sArgs;
/**
* @param string $sType
* @param string $sArgs
* @param int $iLineNo
*/
public function __construct($sType, $sArgs = '', $iLineNo = 0)
{
parent::__construct($iLineNo);
$this->sType = $sType;
$this->sArgs = $sArgs;
}
/**
* @return string
*/
public function atRuleName()
{
return $this->sType;
}
/**
* @return string
*/
public function atRuleArgs()
{
return $this->sArgs;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sArgs = $this->sArgs;
if ($sArgs) {
$sArgs = ' ' . $sArgs;
}
$sResult = "@{$this->sType}$sArgs{$oOutputFormat->spaceBeforeOpeningBrace()}{";
$sResult .= parent::render($oOutputFormat);
$sResult .= '}';
return $sResult;
}
}
RuleSet/DeclarationBlock.php 0000644 00000071606 15154702573 0012063 0 ustar 00 <?php
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\CSSList\CSSList;
use Sabberworm\CSS\CSSList\KeyFrame;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\OutputException;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Property\KeyframeSelector;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\Rule\Rule;
use Sabberworm\CSS\Value\Color;
use Sabberworm\CSS\Value\RuleValueList;
use Sabberworm\CSS\Value\Size;
use Sabberworm\CSS\Value\URL;
use Sabberworm\CSS\Value\Value;
/**
* Declaration blocks are the parts of a CSS file which denote the rules belonging to a selector.
*
* Declaration blocks usually appear directly inside a `Document` or another `CSSList` (mostly a `MediaQuery`).
*/
class DeclarationBlock extends RuleSet
{
/**
* @var array<int, Selector|string>
*/
private $aSelectors;
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
parent::__construct($iLineNo);
$this->aSelectors = [];
}
/**
* @param CSSList|null $oList
*
* @return DeclarationBlock|false
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
public static function parse(ParserState $oParserState, $oList = null)
{
$aComments = [];
$oResult = new DeclarationBlock($oParserState->currentLine());
try {
$aSelectorParts = [];
$sStringWrapperChar = false;
do {
$aSelectorParts[] = $oParserState->consume(1)
. $oParserState->consumeUntil(['{', '}', '\'', '"'], false, false, $aComments);
if (in_array($oParserState->peek(), ['\'', '"']) && substr(end($aSelectorParts), -1) != "\\") {
if ($sStringWrapperChar === false) {
$sStringWrapperChar = $oParserState->peek();
} elseif ($sStringWrapperChar == $oParserState->peek()) {
$sStringWrapperChar = false;
}
}
} while (!in_array($oParserState->peek(), ['{', '}']) || $sStringWrapperChar !== false);
$oResult->setSelectors(implode('', $aSelectorParts), $oList);
if ($oParserState->comes('{')) {
$oParserState->consume(1);
}
} catch (UnexpectedTokenException $e) {
if ($oParserState->getSettings()->bLenientParsing) {
if (!$oParserState->comes('}')) {
$oParserState->consumeUntil('}', false, true);
}
return false;
} else {
throw $e;
}
}
$oResult->setComments($aComments);
RuleSet::parseRuleSet($oParserState, $oResult);
return $oResult;
}
/**
* @param array<int, Selector|string>|string $mSelector
* @param CSSList|null $oList
*
* @throws UnexpectedTokenException
*/
public function setSelectors($mSelector, $oList = null)
{
if (is_array($mSelector)) {
$this->aSelectors = $mSelector;
} else {
$this->aSelectors = explode(',', $mSelector);
}
foreach ($this->aSelectors as $iKey => $mSelector) {
if (!($mSelector instanceof Selector)) {
if ($oList === null || !($oList instanceof KeyFrame)) {
if (!Selector::isValid($mSelector)) {
throw new UnexpectedTokenException(
"Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
$mSelector,
"custom"
);
}
$this->aSelectors[$iKey] = new Selector($mSelector);
} else {
if (!KeyframeSelector::isValid($mSelector)) {
throw new UnexpectedTokenException(
"Selector did not match '" . KeyframeSelector::SELECTOR_VALIDATION_RX . "'.",
$mSelector,
"custom"
);
}
$this->aSelectors[$iKey] = new KeyframeSelector($mSelector);
}
}
}
}
/**
* Remove one of the selectors of the block.
*
* @param Selector|string $mSelector
*
* @return bool
*/
public function removeSelector($mSelector)
{
if ($mSelector instanceof Selector) {
$mSelector = $mSelector->getSelector();
}
foreach ($this->aSelectors as $iKey => $oSelector) {
if ($oSelector->getSelector() === $mSelector) {
unset($this->aSelectors[$iKey]);
return true;
}
}
return false;
}
/**
* @return array<int, Selector|string>
*
* @deprecated will be removed in version 9.0; use `getSelectors()` instead
*/
public function getSelector()
{
return $this->getSelectors();
}
/**
* @param Selector|string $mSelector
* @param CSSList|null $oList
*
* @return void
*
* @deprecated will be removed in version 9.0; use `setSelectors()` instead
*/
public function setSelector($mSelector, $oList = null)
{
$this->setSelectors($mSelector, $oList);
}
/**
* @return array<int, Selector|string>
*/
public function getSelectors()
{
return $this->aSelectors;
}
/**
* Splits shorthand declarations (e.g. `margin` or `font`) into their constituent parts.
*
* @return void
*/
public function expandShorthands()
{
// border must be expanded before dimensions
$this->expandBorderShorthand();
$this->expandDimensionsShorthand();
$this->expandFontShorthand();
$this->expandBackgroundShorthand();
$this->expandListStyleShorthand();
}
/**
* Creates shorthand declarations (e.g. `margin` or `font`) whenever possible.
*
* @return void
*/
public function createShorthands()
{
$this->createBackgroundShorthand();
$this->createDimensionsShorthand();
// border must be shortened after dimensions
$this->createBorderShorthand();
$this->createFontShorthand();
$this->createListStyleShorthand();
}
/**
* Splits shorthand border declarations (e.g. `border: 1px red;`).
*
* Additional splitting happens in expandDimensionsShorthand.
*
* Multiple borders are not yet supported as of 3.
*
* @return void
*/
public function expandBorderShorthand()
{
$aBorderRules = [
'border',
'border-left',
'border-right',
'border-top',
'border-bottom',
];
$aBorderSizes = [
'thin',
'medium',
'thick',
];
$aRules = $this->getRulesAssoc();
foreach ($aBorderRules as $sBorderRule) {
if (!isset($aRules[$sBorderRule])) {
continue;
}
$oRule = $aRules[$sBorderRule];
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
foreach ($aValues as $mValue) {
if ($mValue instanceof Value) {
$mNewValue = clone $mValue;
} else {
$mNewValue = $mValue;
}
if ($mValue instanceof Size) {
$sNewRuleName = $sBorderRule . "-width";
} elseif ($mValue instanceof Color) {
$sNewRuleName = $sBorderRule . "-color";
} else {
if (in_array($mValue, $aBorderSizes)) {
$sNewRuleName = $sBorderRule . "-width";
} else {
$sNewRuleName = $sBorderRule . "-style";
}
}
$oNewRule = new Rule($sNewRuleName, $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->setIsImportant($oRule->getIsImportant());
$oNewRule->addValue([$mNewValue]);
$this->addRule($oNewRule);
}
$this->removeRule($sBorderRule);
}
}
/**
* Splits shorthand dimensional declarations (e.g. `margin: 0px auto;`)
* into their constituent parts.
*
* Handles `margin`, `padding`, `border-color`, `border-style` and `border-width`.
*
* @return void
*/
public function expandDimensionsShorthand()
{
$aExpansions = [
'margin' => 'margin-%s',
'padding' => 'padding-%s',
'border-color' => 'border-%s-color',
'border-style' => 'border-%s-style',
'border-width' => 'border-%s-width',
];
$aRules = $this->getRulesAssoc();
foreach ($aExpansions as $sProperty => $sExpanded) {
if (!isset($aRules[$sProperty])) {
continue;
}
$oRule = $aRules[$sProperty];
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
$top = $right = $bottom = $left = null;
switch (count($aValues)) {
case 1:
$top = $right = $bottom = $left = $aValues[0];
break;
case 2:
$top = $bottom = $aValues[0];
$left = $right = $aValues[1];
break;
case 3:
$top = $aValues[0];
$left = $right = $aValues[1];
$bottom = $aValues[2];
break;
case 4:
$top = $aValues[0];
$right = $aValues[1];
$bottom = $aValues[2];
$left = $aValues[3];
break;
}
foreach (['top', 'right', 'bottom', 'left'] as $sPosition) {
$oNewRule = new Rule(sprintf($sExpanded, $sPosition), $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->setIsImportant($oRule->getIsImportant());
$oNewRule->addValue(${$sPosition});
$this->addRule($oNewRule);
}
$this->removeRule($sProperty);
}
}
/**
* Converts shorthand font declarations
* (e.g. `font: 300 italic 11px/14px verdana, helvetica, sans-serif;`)
* into their constituent parts.
*
* @return void
*/
public function expandFontShorthand()
{
$aRules = $this->getRulesAssoc();
if (!isset($aRules['font'])) {
return;
}
$oRule = $aRules['font'];
// reset properties to 'normal' per http://www.w3.org/TR/21/fonts.html#font-shorthand
$aFontProperties = [
'font-style' => 'normal',
'font-variant' => 'normal',
'font-weight' => 'normal',
'font-size' => 'normal',
'line-height' => 'normal',
];
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
foreach ($aValues as $mValue) {
if (!$mValue instanceof Value) {
$mValue = mb_strtolower($mValue);
}
if (in_array($mValue, ['normal', 'inherit'])) {
foreach (['font-style', 'font-weight', 'font-variant'] as $sProperty) {
if (!isset($aFontProperties[$sProperty])) {
$aFontProperties[$sProperty] = $mValue;
}
}
} elseif (in_array($mValue, ['italic', 'oblique'])) {
$aFontProperties['font-style'] = $mValue;
} elseif ($mValue == 'small-caps') {
$aFontProperties['font-variant'] = $mValue;
} elseif (
in_array($mValue, ['bold', 'bolder', 'lighter'])
|| ($mValue instanceof Size
&& in_array($mValue->getSize(), range(100, 900, 100)))
) {
$aFontProperties['font-weight'] = $mValue;
} elseif ($mValue instanceof RuleValueList && $mValue->getListSeparator() == '/') {
list($oSize, $oHeight) = $mValue->getListComponents();
$aFontProperties['font-size'] = $oSize;
$aFontProperties['line-height'] = $oHeight;
} elseif ($mValue instanceof Size && $mValue->getUnit() !== null) {
$aFontProperties['font-size'] = $mValue;
} else {
$aFontProperties['font-family'] = $mValue;
}
}
foreach ($aFontProperties as $sProperty => $mValue) {
$oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->addValue($mValue);
$oNewRule->setIsImportant($oRule->getIsImportant());
$this->addRule($oNewRule);
}
$this->removeRule('font');
}
/**
* Converts shorthand background declarations
* (e.g. `background: url("chess.png") gray 50% repeat fixed;`)
* into their constituent parts.
*
* @see http://www.w3.org/TR/21/colors.html#propdef-background
*
* @return void
*/
public function expandBackgroundShorthand()
{
$aRules = $this->getRulesAssoc();
if (!isset($aRules['background'])) {
return;
}
$oRule = $aRules['background'];
$aBgProperties = [
'background-color' => ['transparent'],
'background-image' => ['none'],
'background-repeat' => ['repeat'],
'background-attachment' => ['scroll'],
'background-position' => [
new Size(0, '%', null, false, $this->iLineNo),
new Size(0, '%', null, false, $this->iLineNo),
],
];
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
if (count($aValues) == 1 && $aValues[0] == 'inherit') {
foreach ($aBgProperties as $sProperty => $mValue) {
$oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->addValue('inherit');
$oNewRule->setIsImportant($oRule->getIsImportant());
$this->addRule($oNewRule);
}
$this->removeRule('background');
return;
}
$iNumBgPos = 0;
foreach ($aValues as $mValue) {
if (!$mValue instanceof Value) {
$mValue = mb_strtolower($mValue);
}
if ($mValue instanceof URL) {
$aBgProperties['background-image'] = $mValue;
} elseif ($mValue instanceof Color) {
$aBgProperties['background-color'] = $mValue;
} elseif (in_array($mValue, ['scroll', 'fixed'])) {
$aBgProperties['background-attachment'] = $mValue;
} elseif (in_array($mValue, ['repeat', 'no-repeat', 'repeat-x', 'repeat-y'])) {
$aBgProperties['background-repeat'] = $mValue;
} elseif (
in_array($mValue, ['left', 'center', 'right', 'top', 'bottom'])
|| $mValue instanceof Size
) {
if ($iNumBgPos == 0) {
$aBgProperties['background-position'][0] = $mValue;
$aBgProperties['background-position'][1] = 'center';
} else {
$aBgProperties['background-position'][$iNumBgPos] = $mValue;
}
$iNumBgPos++;
}
}
foreach ($aBgProperties as $sProperty => $mValue) {
$oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->setIsImportant($oRule->getIsImportant());
$oNewRule->addValue($mValue);
$this->addRule($oNewRule);
}
$this->removeRule('background');
}
/**
* @return void
*/
public function expandListStyleShorthand()
{
$aListProperties = [
'list-style-type' => 'disc',
'list-style-position' => 'outside',
'list-style-image' => 'none',
];
$aListStyleTypes = [
'none',
'disc',
'circle',
'square',
'decimal-leading-zero',
'decimal',
'lower-roman',
'upper-roman',
'lower-greek',
'lower-alpha',
'lower-latin',
'upper-alpha',
'upper-latin',
'hebrew',
'armenian',
'georgian',
'cjk-ideographic',
'hiragana',
'hira-gana-iroha',
'katakana-iroha',
'katakana',
];
$aListStylePositions = [
'inside',
'outside',
];
$aRules = $this->getRulesAssoc();
if (!isset($aRules['list-style'])) {
return;
}
$oRule = $aRules['list-style'];
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
if (count($aValues) == 1 && $aValues[0] == 'inherit') {
foreach ($aListProperties as $sProperty => $mValue) {
$oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->addValue('inherit');
$oNewRule->setIsImportant($oRule->getIsImportant());
$this->addRule($oNewRule);
}
$this->removeRule('list-style');
return;
}
foreach ($aValues as $mValue) {
if (!$mValue instanceof Value) {
$mValue = mb_strtolower($mValue);
}
if ($mValue instanceof Url) {
$aListProperties['list-style-image'] = $mValue;
} elseif (in_array($mValue, $aListStyleTypes)) {
$aListProperties['list-style-types'] = $mValue;
} elseif (in_array($mValue, $aListStylePositions)) {
$aListProperties['list-style-position'] = $mValue;
}
}
foreach ($aListProperties as $sProperty => $mValue) {
$oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
$oNewRule->setIsImportant($oRule->getIsImportant());
$oNewRule->addValue($mValue);
$this->addRule($oNewRule);
}
$this->removeRule('list-style');
}
/**
* @param array<array-key, string> $aProperties
* @param string $sShorthand
*
* @return void
*/
public function createShorthandProperties(array $aProperties, $sShorthand)
{
$aRules = $this->getRulesAssoc();
$aNewValues = [];
foreach ($aProperties as $sProperty) {
if (!isset($aRules[$sProperty])) {
continue;
}
$oRule = $aRules[$sProperty];
if (!$oRule->getIsImportant()) {
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
foreach ($aValues as $mValue) {
$aNewValues[] = $mValue;
}
$this->removeRule($sProperty);
}
}
if (count($aNewValues)) {
$oNewRule = new Rule($sShorthand, $oRule->getLineNo(), $oRule->getColNo());
foreach ($aNewValues as $mValue) {
$oNewRule->addValue($mValue);
}
$this->addRule($oNewRule);
}
}
/**
* @return void
*/
public function createBackgroundShorthand()
{
$aProperties = [
'background-color',
'background-image',
'background-repeat',
'background-position',
'background-attachment',
];
$this->createShorthandProperties($aProperties, 'background');
}
/**
* @return void
*/
public function createListStyleShorthand()
{
$aProperties = [
'list-style-type',
'list-style-position',
'list-style-image',
];
$this->createShorthandProperties($aProperties, 'list-style');
}
/**
* Combines `border-color`, `border-style` and `border-width` into `border`.
*
* Should be run after `create_dimensions_shorthand`!
*
* @return void
*/
public function createBorderShorthand()
{
$aProperties = [
'border-width',
'border-style',
'border-color',
];
$this->createShorthandProperties($aProperties, 'border');
}
/**
* Looks for long format CSS dimensional properties
* (margin, padding, border-color, border-style and border-width)
* and converts them into shorthand CSS properties.
*
* @return void
*/
public function createDimensionsShorthand()
{
$aPositions = ['top', 'right', 'bottom', 'left'];
$aExpansions = [
'margin' => 'margin-%s',
'padding' => 'padding-%s',
'border-color' => 'border-%s-color',
'border-style' => 'border-%s-style',
'border-width' => 'border-%s-width',
];
$aRules = $this->getRulesAssoc();
foreach ($aExpansions as $sProperty => $sExpanded) {
$aFoldable = [];
foreach ($aRules as $sRuleName => $oRule) {
foreach ($aPositions as $sPosition) {
if ($sRuleName == sprintf($sExpanded, $sPosition)) {
$aFoldable[$sRuleName] = $oRule;
}
}
}
// All four dimensions must be present
if (count($aFoldable) == 4) {
$aValues = [];
foreach ($aPositions as $sPosition) {
$oRule = $aRules[sprintf($sExpanded, $sPosition)];
$mRuleValue = $oRule->getValue();
$aRuleValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aRuleValues[] = $mRuleValue;
} else {
$aRuleValues = $mRuleValue->getListComponents();
}
$aValues[$sPosition] = $aRuleValues;
}
$oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
if ((string)$aValues['left'][0] == (string)$aValues['right'][0]) {
if ((string)$aValues['top'][0] == (string)$aValues['bottom'][0]) {
if ((string)$aValues['top'][0] == (string)$aValues['left'][0]) {
// All 4 sides are equal
$oNewRule->addValue($aValues['top']);
} else {
// Top and bottom are equal, left and right are equal
$oNewRule->addValue($aValues['top']);
$oNewRule->addValue($aValues['left']);
}
} else {
// Only left and right are equal
$oNewRule->addValue($aValues['top']);
$oNewRule->addValue($aValues['left']);
$oNewRule->addValue($aValues['bottom']);
}
} else {
// No sides are equal
$oNewRule->addValue($aValues['top']);
$oNewRule->addValue($aValues['left']);
$oNewRule->addValue($aValues['bottom']);
$oNewRule->addValue($aValues['right']);
}
$this->addRule($oNewRule);
foreach ($aPositions as $sPosition) {
$this->removeRule(sprintf($sExpanded, $sPosition));
}
}
}
}
/**
* Looks for long format CSS font properties (e.g. `font-weight`) and
* tries to convert them into a shorthand CSS `font` property.
*
* At least `font-size` AND `font-family` must be present in order to create a shorthand declaration.
*
* @return void
*/
public function createFontShorthand()
{
$aFontProperties = [
'font-style',
'font-variant',
'font-weight',
'font-size',
'line-height',
'font-family',
];
$aRules = $this->getRulesAssoc();
if (!isset($aRules['font-size']) || !isset($aRules['font-family'])) {
return;
}
$oOldRule = isset($aRules['font-size']) ? $aRules['font-size'] : $aRules['font-family'];
$oNewRule = new Rule('font', $oOldRule->getLineNo(), $oOldRule->getColNo());
unset($oOldRule);
foreach (['font-style', 'font-variant', 'font-weight'] as $sProperty) {
if (isset($aRules[$sProperty])) {
$oRule = $aRules[$sProperty];
$mRuleValue = $oRule->getValue();
$aValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aValues[] = $mRuleValue;
} else {
$aValues = $mRuleValue->getListComponents();
}
if ($aValues[0] !== 'normal') {
$oNewRule->addValue($aValues[0]);
}
}
}
// Get the font-size value
$oRule = $aRules['font-size'];
$mRuleValue = $oRule->getValue();
$aFSValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aFSValues[] = $mRuleValue;
} else {
$aFSValues = $mRuleValue->getListComponents();
}
// But wait to know if we have line-height to add it
if (isset($aRules['line-height'])) {
$oRule = $aRules['line-height'];
$mRuleValue = $oRule->getValue();
$aLHValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aLHValues[] = $mRuleValue;
} else {
$aLHValues = $mRuleValue->getListComponents();
}
if ($aLHValues[0] !== 'normal') {
$val = new RuleValueList('/', $this->iLineNo);
$val->addListComponent($aFSValues[0]);
$val->addListComponent($aLHValues[0]);
$oNewRule->addValue($val);
}
} else {
$oNewRule->addValue($aFSValues[0]);
}
$oRule = $aRules['font-family'];
$mRuleValue = $oRule->getValue();
$aFFValues = [];
if (!$mRuleValue instanceof RuleValueList) {
$aFFValues[] = $mRuleValue;
} else {
$aFFValues = $mRuleValue->getListComponents();
}
$oFFValue = new RuleValueList(',', $this->iLineNo);
$oFFValue->setListComponents($aFFValues);
$oNewRule->addValue($oFFValue);
$this->addRule($oNewRule);
foreach ($aFontProperties as $sProperty) {
$this->removeRule($sProperty);
}
}
/**
* @return string
*
* @throws OutputException
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*
* @throws OutputException
*/
public function render(OutputFormat $oOutputFormat)
{
if (count($this->aSelectors) === 0) {
// If all the selectors have been removed, this declaration block becomes invalid
throw new OutputException("Attempt to print declaration block with missing selector", $this->iLineNo);
}
$sResult = $oOutputFormat->sBeforeDeclarationBlock;
$sResult .= $oOutputFormat->implode(
$oOutputFormat->spaceBeforeSelectorSeparator() . ',' . $oOutputFormat->spaceAfterSelectorSeparator(),
$this->aSelectors
);
$sResult .= $oOutputFormat->sAfterDeclarationBlockSelectors;
$sResult .= $oOutputFormat->spaceBeforeOpeningBrace() . '{';
$sResult .= parent::render($oOutputFormat);
$sResult .= '}';
$sResult .= $oOutputFormat->sAfterDeclarationBlock;
return $sResult;
}
}
RuleSet/RuleSet.php 0000644 00000025324 15154702573 0010242 0 ustar 00 <?php
namespace Sabberworm\CSS\RuleSet;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\Comment\Commentable;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Renderable;
use Sabberworm\CSS\Rule\Rule;
/**
* RuleSet is a generic superclass denoting rules. The typical example for rule sets are declaration block.
* However, unknown At-Rules (like `@font-face`) are also rule sets.
*/
abstract class RuleSet implements Renderable, Commentable
{
/**
* @var array<string, Rule>
*/
private $aRules;
/**
* @var int
*/
protected $iLineNo;
/**
* @var array<array-key, Comment>
*/
protected $aComments;
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
$this->aRules = [];
$this->iLineNo = $iLineNo;
$this->aComments = [];
}
/**
* @return void
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
public static function parseRuleSet(ParserState $oParserState, RuleSet $oRuleSet)
{
while ($oParserState->comes(';')) {
$oParserState->consume(';');
}
while (!$oParserState->comes('}')) {
$oRule = null;
if ($oParserState->getSettings()->bLenientParsing) {
try {
$oRule = Rule::parse($oParserState);
} catch (UnexpectedTokenException $e) {
try {
$sConsume = $oParserState->consumeUntil(["\n", ";", '}'], true);
// We need to “unfind” the matches to the end of the ruleSet as this will be matched later
if ($oParserState->streql(substr($sConsume, -1), '}')) {
$oParserState->backtrack(1);
} else {
while ($oParserState->comes(';')) {
$oParserState->consume(';');
}
}
} catch (UnexpectedTokenException $e) {
// We’ve reached the end of the document. Just close the RuleSet.
return;
}
}
} else {
$oRule = Rule::parse($oParserState);
}
if ($oRule) {
$oRuleSet->addRule($oRule);
}
}
$oParserState->consume('}');
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
/**
* @param Rule|null $oSibling
*
* @return void
*/
public function addRule(Rule $oRule, Rule $oSibling = null)
{
$sRule = $oRule->getRule();
if (!isset($this->aRules[$sRule])) {
$this->aRules[$sRule] = [];
}
$iPosition = count($this->aRules[$sRule]);
if ($oSibling !== null) {
$iSiblingPos = array_search($oSibling, $this->aRules[$sRule], true);
if ($iSiblingPos !== false) {
$iPosition = $iSiblingPos;
$oRule->setPosition($oSibling->getLineNo(), $oSibling->getColNo() - 1);
}
}
if ($oRule->getLineNo() === 0 && $oRule->getColNo() === 0) {
//this node is added manually, give it the next best line
$rules = $this->getRules();
$pos = count($rules);
if ($pos > 0) {
$last = $rules[$pos - 1];
$oRule->setPosition($last->getLineNo() + 1, 0);
}
}
array_splice($this->aRules[$sRule], $iPosition, 0, [$oRule]);
}
/**
* Returns all rules matching the given rule name
*
* @example $oRuleSet->getRules('font') // returns array(0 => $oRule, …) or array().
*
* @example $oRuleSet->getRules('font-')
* //returns an array of all rules either beginning with font- or matching font.
*
* @param Rule|string|null $mRule
* Pattern to search for. If null, returns all rules.
* If the pattern ends with a dash, all rules starting with the pattern are returned
* as well as one matching the pattern with the dash excluded.
* Passing a Rule behaves like calling `getRules($mRule->getRule())`.
*
* @return array<int, Rule>
*/
public function getRules($mRule = null)
{
if ($mRule instanceof Rule) {
$mRule = $mRule->getRule();
}
/** @var array<int, Rule> $aResult */
$aResult = [];
foreach ($this->aRules as $sName => $aRules) {
// Either no search rule is given or the search rule matches the found rule exactly
// or the search rule ends in “-” and the found rule starts with the search rule.
if (
!$mRule || $sName === $mRule
|| (
strrpos($mRule, '-') === strlen($mRule) - strlen('-')
&& (strpos($sName, $mRule) === 0 || $sName === substr($mRule, 0, -1))
)
) {
$aResult = array_merge($aResult, $aRules);
}
}
usort($aResult, function (Rule $first, Rule $second) {
if ($first->getLineNo() === $second->getLineNo()) {
return $first->getColNo() - $second->getColNo();
}
return $first->getLineNo() - $second->getLineNo();
});
return $aResult;
}
/**
* Overrides all the rules of this set.
*
* @param array<array-key, Rule> $aRules The rules to override with.
*
* @return void
*/
public function setRules(array $aRules)
{
$this->aRules = [];
foreach ($aRules as $rule) {
$this->addRule($rule);
}
}
/**
* Returns all rules matching the given pattern and returns them in an associative array with the rule’s name
* as keys. This method exists mainly for backwards-compatibility and is really only partially useful.
*
* Note: This method loses some information: Calling this (with an argument of `background-`) on a declaration block
* like `{ background-color: green; background-color; rgba(0, 127, 0, 0.7); }` will only yield an associative array
* containing the rgba-valued rule while `getRules()` would yield an indexed array containing both.
*
* @param Rule|string|null $mRule $mRule
* Pattern to search for. If null, returns all rules. If the pattern ends with a dash,
* all rules starting with the pattern are returned as well as one matching the pattern with the dash
* excluded. Passing a Rule behaves like calling `getRules($mRule->getRule())`.
*
* @return array<string, Rule>
*/
public function getRulesAssoc($mRule = null)
{
/** @var array<string, Rule> $aResult */
$aResult = [];
foreach ($this->getRules($mRule) as $oRule) {
$aResult[$oRule->getRule()] = $oRule;
}
return $aResult;
}
/**
* Removes a rule from this RuleSet. This accepts all the possible values that `getRules()` accepts.
*
* If given a Rule, it will only remove this particular rule (by identity).
* If given a name, it will remove all rules by that name.
*
* Note: this is different from pre-v.2.0 behaviour of PHP-CSS-Parser, where passing a Rule instance would
* remove all rules with the same name. To get the old behaviour, use `removeRule($oRule->getRule())`.
*
* @param Rule|string|null $mRule
* pattern to remove. If $mRule is null, all rules are removed. If the pattern ends in a dash,
* all rules starting with the pattern are removed as well as one matching the pattern with the dash
* excluded. Passing a Rule behaves matches by identity.
*
* @return void
*/
public function removeRule($mRule)
{
if ($mRule instanceof Rule) {
$sRule = $mRule->getRule();
if (!isset($this->aRules[$sRule])) {
return;
}
foreach ($this->aRules[$sRule] as $iKey => $oRule) {
if ($oRule === $mRule) {
unset($this->aRules[$sRule][$iKey]);
}
}
} else {
foreach ($this->aRules as $sName => $aRules) {
// Either no search rule is given or the search rule matches the found rule exactly
// or the search rule ends in “-” and the found rule starts with the search rule or equals it
// (without the trailing dash).
if (
!$mRule || $sName === $mRule
|| (strrpos($mRule, '-') === strlen($mRule) - strlen('-')
&& (strpos($sName, $mRule) === 0 || $sName === substr($mRule, 0, -1)))
) {
unset($this->aRules[$sName]);
}
}
}
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sResult = '';
$bIsFirst = true;
foreach ($this->aRules as $aRules) {
foreach ($aRules as $oRule) {
$sRendered = $oOutputFormat->safely(function () use ($oRule, $oOutputFormat) {
return $oRule->render($oOutputFormat->nextLevel());
});
if ($sRendered === null) {
continue;
}
if ($bIsFirst) {
$bIsFirst = false;
$sResult .= $oOutputFormat->nextLevel()->spaceBeforeRules();
} else {
$sResult .= $oOutputFormat->nextLevel()->spaceBetweenRules();
}
$sResult .= $sRendered;
}
}
if (!$bIsFirst) {
// Had some output
$sResult .= $oOutputFormat->spaceAfterRules();
}
return $oOutputFormat->removeLastSemicolon($sResult);
}
/**
* @param array<string, Comment> $aComments
*
* @return void
*/
public function addComments(array $aComments)
{
$this->aComments = array_merge($this->aComments, $aComments);
}
/**
* @return array<string, Comment>
*/
public function getComments()
{
return $this->aComments;
}
/**
* @param array<string, Comment> $aComments
*
* @return void
*/
public function setComments(array $aComments)
{
$this->aComments = $aComments;
}
}
Settings.php 0000644 00000003647 15154702573 0007100 0 ustar 00 <?php
namespace Sabberworm\CSS;
/**
* Parser settings class.
*
* Configure parser behaviour here.
*/
class Settings
{
/**
* Multi-byte string support.
* If true (mbstring extension must be enabled), will use (slower) `mb_strlen`, `mb_convert_case`, `mb_substr`
* and `mb_strpos` functions. Otherwise, the normal (ASCII-Only) functions will be used.
*
* @var bool
*/
public $bMultibyteSupport;
/**
* The default charset for the CSS if no `@charset` rule is found. Defaults to utf-8.
*
* @var string
*/
public $sDefaultCharset = 'utf-8';
/**
* Lenient parsing. When used (which is true by default), the parser will not choke
* on unexpected tokens but simply ignore them.
*
* @var bool
*/
public $bLenientParsing = true;
private function __construct()
{
$this->bMultibyteSupport = extension_loaded('mbstring');
}
/**
* @return self new instance
*/
public static function create()
{
return new Settings();
}
/**
* @param bool $bMultibyteSupport
*
* @return self fluent interface
*/
public function withMultibyteSupport($bMultibyteSupport = true)
{
$this->bMultibyteSupport = $bMultibyteSupport;
return $this;
}
/**
* @param string $sDefaultCharset
*
* @return self fluent interface
*/
public function withDefaultCharset($sDefaultCharset)
{
$this->sDefaultCharset = $sDefaultCharset;
return $this;
}
/**
* @param bool $bLenientParsing
*
* @return self fluent interface
*/
public function withLenientParsing($bLenientParsing = true)
{
$this->bLenientParsing = $bLenientParsing;
return $this;
}
/**
* @return self fluent interface
*/
public function beStrict()
{
return $this->withLenientParsing(false);
}
}
Value/CSSFunction.php 0000644 00000003066 15154702573 0010505 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
class CSSFunction extends ValueList
{
/**
* @var string
*/
protected $sName;
/**
* @param string $sName
* @param RuleValueList|array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string> $aArguments
* @param string $sSeparator
* @param int $iLineNo
*/
public function __construct($sName, $aArguments, $sSeparator = ',', $iLineNo = 0)
{
if ($aArguments instanceof RuleValueList) {
$sSeparator = $aArguments->getListSeparator();
$aArguments = $aArguments->getListComponents();
}
$this->sName = $sName;
$this->iLineNo = $iLineNo;
parent::__construct($aArguments, $sSeparator, $iLineNo);
}
/**
* @return string
*/
public function getName()
{
return $this->sName;
}
/**
* @param string $sName
*
* @return void
*/
public function setName($sName)
{
$this->sName = $sName;
}
/**
* @return array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string>
*/
public function getArguments()
{
return $this->aComponents;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$aArguments = parent::render($oOutputFormat);
return "{$this->sName}({$aArguments})";
}
}
Value/CSSString.php 0000644 00000005247 15154702573 0010171 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class CSSString extends PrimitiveValue
{
/**
* @var string
*/
private $sString;
/**
* @param string $sString
* @param int $iLineNo
*/
public function __construct($sString, $iLineNo = 0)
{
$this->sString = $sString;
parent::__construct($iLineNo);
}
/**
* @return CSSString
*
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public static function parse(ParserState $oParserState)
{
$sBegin = $oParserState->peek();
$sQuote = null;
if ($sBegin === "'") {
$sQuote = "'";
} elseif ($sBegin === '"') {
$sQuote = '"';
}
if ($sQuote !== null) {
$oParserState->consume($sQuote);
}
$sResult = "";
$sContent = null;
if ($sQuote === null) {
// Unquoted strings end in whitespace or with braces, brackets, parentheses
while (!preg_match('/[\\s{}()<>\\[\\]]/isu', $oParserState->peek())) {
$sResult .= $oParserState->parseCharacter(false);
}
} else {
while (!$oParserState->comes($sQuote)) {
$sContent = $oParserState->parseCharacter(false);
if ($sContent === null) {
throw new SourceException(
"Non-well-formed quoted string {$oParserState->peek(3)}",
$oParserState->currentLine()
);
}
$sResult .= $sContent;
}
$oParserState->consume($sQuote);
}
return new CSSString($sResult, $oParserState->currentLine());
}
/**
* @param string $sString
*
* @return void
*/
public function setString($sString)
{
$this->sString = $sString;
}
/**
* @return string
*/
public function getString()
{
return $this->sString;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$sString = addslashes($this->sString);
$sString = str_replace("\n", '\A', $sString);
return $oOutputFormat->getStringQuotingType() . $sString . $oOutputFormat->getStringQuotingType();
}
}
Value/CalcFunction.php 0000644 00000006520 15154702573 0010715 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class CalcFunction extends CSSFunction
{
/**
* @var int
*/
const T_OPERAND = 1;
/**
* @var int
*/
const T_OPERATOR = 2;
/**
* @return CalcFunction
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
public static function parse(ParserState $oParserState)
{
$aOperators = ['+', '-', '*', '/'];
$sFunction = trim($oParserState->consumeUntil('(', false, true));
$oCalcList = new CalcRuleValueList($oParserState->currentLine());
$oList = new RuleValueList(',', $oParserState->currentLine());
$iNestingLevel = 0;
$iLastComponentType = null;
while (!$oParserState->comes(')') || $iNestingLevel > 0) {
$oParserState->consumeWhiteSpace();
if ($oParserState->comes('(')) {
$iNestingLevel++;
$oCalcList->addListComponent($oParserState->consume(1));
$oParserState->consumeWhiteSpace();
continue;
} elseif ($oParserState->comes(')')) {
$iNestingLevel--;
$oCalcList->addListComponent($oParserState->consume(1));
$oParserState->consumeWhiteSpace();
continue;
}
if ($iLastComponentType != CalcFunction::T_OPERAND) {
$oVal = Value::parsePrimitiveValue($oParserState);
$oCalcList->addListComponent($oVal);
$iLastComponentType = CalcFunction::T_OPERAND;
} else {
if (in_array($oParserState->peek(), $aOperators)) {
if (($oParserState->comes('-') || $oParserState->comes('+'))) {
if (
$oParserState->peek(1, -1) != ' '
|| !($oParserState->comes('- ')
|| $oParserState->comes('+ '))
) {
throw new UnexpectedTokenException(
" {$oParserState->peek()} ",
$oParserState->peek(1, -1) . $oParserState->peek(2),
'literal',
$oParserState->currentLine()
);
}
}
$oCalcList->addListComponent($oParserState->consume(1));
$iLastComponentType = CalcFunction::T_OPERATOR;
} else {
throw new UnexpectedTokenException(
sprintf(
'Next token was expected to be an operand of type %s. Instead "%s" was found.',
implode(', ', $aOperators),
$oVal
),
'',
'custom',
$oParserState->currentLine()
);
}
}
$oParserState->consumeWhiteSpace();
}
$oList->addListComponent($oCalcList);
$oParserState->consume(')');
return new CalcFunction($sFunction, $oList, ',', $oParserState->currentLine());
}
}
Value/CalcRuleValueList.php 0000644 00000000671 15154702573 0011671 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
class CalcRuleValueList extends RuleValueList
{
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
parent::__construct(',', $iLineNo);
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return $oOutputFormat->implode(' ', $this->aComponents);
}
}
Value/Color.php 0000644 00000013230 15154702573 0007417 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class Color extends CSSFunction
{
/**
* @param array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string> $aColor
* @param int $iLineNo
*/
public function __construct(array $aColor, $iLineNo = 0)
{
parent::__construct(implode('', array_keys($aColor)), $aColor, ',', $iLineNo);
}
/**
* @return Color|CSSFunction
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public static function parse(ParserState $oParserState)
{
$aColor = [];
if ($oParserState->comes('#')) {
$oParserState->consume('#');
$sValue = $oParserState->parseIdentifier(false);
if ($oParserState->strlen($sValue) === 3) {
$sValue = $sValue[0] . $sValue[0] . $sValue[1] . $sValue[1] . $sValue[2] . $sValue[2];
} elseif ($oParserState->strlen($sValue) === 4) {
$sValue = $sValue[0] . $sValue[0] . $sValue[1] . $sValue[1] . $sValue[2] . $sValue[2] . $sValue[3]
. $sValue[3];
}
if ($oParserState->strlen($sValue) === 8) {
$aColor = [
'r' => new Size(intval($sValue[0] . $sValue[1], 16), null, true, $oParserState->currentLine()),
'g' => new Size(intval($sValue[2] . $sValue[3], 16), null, true, $oParserState->currentLine()),
'b' => new Size(intval($sValue[4] . $sValue[5], 16), null, true, $oParserState->currentLine()),
'a' => new Size(
round(self::mapRange(intval($sValue[6] . $sValue[7], 16), 0, 255, 0, 1), 2),
null,
true,
$oParserState->currentLine()
),
];
} else {
$aColor = [
'r' => new Size(intval($sValue[0] . $sValue[1], 16), null, true, $oParserState->currentLine()),
'g' => new Size(intval($sValue[2] . $sValue[3], 16), null, true, $oParserState->currentLine()),
'b' => new Size(intval($sValue[4] . $sValue[5], 16), null, true, $oParserState->currentLine()),
];
}
} else {
$sColorMode = $oParserState->parseIdentifier(true);
$oParserState->consumeWhiteSpace();
$oParserState->consume('(');
$bContainsVar = false;
$iLength = $oParserState->strlen($sColorMode);
for ($i = 0; $i < $iLength; ++$i) {
$oParserState->consumeWhiteSpace();
if ($oParserState->comes('var')) {
$aColor[$sColorMode[$i]] = CSSFunction::parseIdentifierOrFunction($oParserState);
$bContainsVar = true;
} else {
$aColor[$sColorMode[$i]] = Size::parse($oParserState, true);
}
if ($bContainsVar && $oParserState->comes(')')) {
// With a var argument the function can have fewer arguments
break;
}
$oParserState->consumeWhiteSpace();
if ($i < ($iLength - 1)) {
$oParserState->consume(',');
}
}
$oParserState->consume(')');
if ($bContainsVar) {
return new CSSFunction($sColorMode, array_values($aColor), ',', $oParserState->currentLine());
}
}
return new Color($aColor, $oParserState->currentLine());
}
/**
* @param float $fVal
* @param float $fFromMin
* @param float $fFromMax
* @param float $fToMin
* @param float $fToMax
*
* @return float
*/
private static function mapRange($fVal, $fFromMin, $fFromMax, $fToMin, $fToMax)
{
$fFromRange = $fFromMax - $fFromMin;
$fToRange = $fToMax - $fToMin;
$fMultiplier = $fToRange / $fFromRange;
$fNewVal = $fVal - $fFromMin;
$fNewVal *= $fMultiplier;
return $fNewVal + $fToMin;
}
/**
* @return array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string>
*/
public function getColor()
{
return $this->aComponents;
}
/**
* @param array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string> $aColor
*
* @return void
*/
public function setColor(array $aColor)
{
$this->setName(implode('', array_keys($aColor)));
$this->aComponents = $aColor;
}
/**
* @return string
*/
public function getColorDescription()
{
return $this->getName();
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
// Shorthand RGB color values
if ($oOutputFormat->getRGBHashNotation() && implode('', array_keys($this->aComponents)) === 'rgb') {
$sResult = sprintf(
'%02x%02x%02x',
$this->aComponents['r']->getSize(),
$this->aComponents['g']->getSize(),
$this->aComponents['b']->getSize()
);
return '#' . (($sResult[0] == $sResult[1]) && ($sResult[2] == $sResult[3]) && ($sResult[4] == $sResult[5])
? "$sResult[0]$sResult[2]$sResult[4]" : $sResult);
}
return parent::render($oOutputFormat);
}
}
Value/LineName.php 0000644 00000003411 15154702573 0010031 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class LineName extends ValueList
{
/**
* @param array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string> $aComponents
* @param int $iLineNo
*/
public function __construct(array $aComponents = [], $iLineNo = 0)
{
parent::__construct($aComponents, ' ', $iLineNo);
}
/**
* @return LineName
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
public static function parse(ParserState $oParserState)
{
$oParserState->consume('[');
$oParserState->consumeWhiteSpace();
$aNames = [];
do {
if ($oParserState->getSettings()->bLenientParsing) {
try {
$aNames[] = $oParserState->parseIdentifier();
} catch (UnexpectedTokenException $e) {
if (!$oParserState->comes(']')) {
throw $e;
}
}
} else {
$aNames[] = $oParserState->parseIdentifier();
}
$oParserState->consumeWhiteSpace();
} while (!$oParserState->comes(']'));
$oParserState->consume(']');
return new LineName($aNames, $oParserState->currentLine());
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return '[' . parent::render(OutputFormat::createCompact()) . ']';
}
}
Value/PrimitiveValue.php 0000644 00000000344 15154702573 0011310 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
abstract class PrimitiveValue extends Value
{
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
parent::__construct($iLineNo);
}
}
Value/RuleValueList.php 0000644 00000000443 15154702573 0011103 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
class RuleValueList extends ValueList
{
/**
* @param string $sSeparator
* @param int $iLineNo
*/
public function __construct($sSeparator = ',', $iLineNo = 0)
{
parent::__construct([], $sSeparator, $iLineNo);
}
}
Value/Size.php 0000644 00000012400 15154702574 0007252 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class Size extends PrimitiveValue
{
/**
* vh/vw/vm(ax)/vmin/rem are absolute insofar as they don’t scale to the immediate parent (only the viewport)
*
* @var array<int, string>
*/
const ABSOLUTE_SIZE_UNITS = ['px', 'cm', 'mm', 'mozmm', 'in', 'pt', 'pc', 'vh', 'vw', 'vmin', 'vmax', 'rem'];
/**
* @var array<int, string>
*/
const RELATIVE_SIZE_UNITS = ['%', 'em', 'ex', 'ch', 'fr'];
/**
* @var array<int, string>
*/
const NON_SIZE_UNITS = ['deg', 'grad', 'rad', 's', 'ms', 'turns', 'Hz', 'kHz'];
/**
* @var array<int, array<string, string>>|null
*/
private static $SIZE_UNITS = null;
/**
* @var float
*/
private $fSize;
/**
* @var string|null
*/
private $sUnit;
/**
* @var bool
*/
private $bIsColorComponent;
/**
* @param float|int|string $fSize
* @param string|null $sUnit
* @param bool $bIsColorComponent
* @param int $iLineNo
*/
public function __construct($fSize, $sUnit = null, $bIsColorComponent = false, $iLineNo = 0)
{
parent::__construct($iLineNo);
$this->fSize = (float)$fSize;
$this->sUnit = $sUnit;
$this->bIsColorComponent = $bIsColorComponent;
}
/**
* @param bool $bIsColorComponent
*
* @return Size
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public static function parse(ParserState $oParserState, $bIsColorComponent = false)
{
$sSize = '';
if ($oParserState->comes('-')) {
$sSize .= $oParserState->consume('-');
}
while (is_numeric($oParserState->peek()) || $oParserState->comes('.')) {
if ($oParserState->comes('.')) {
$sSize .= $oParserState->consume('.');
} else {
$sSize .= $oParserState->consume(1);
}
}
$sUnit = null;
$aSizeUnits = self::getSizeUnits();
foreach ($aSizeUnits as $iLength => &$aValues) {
$sKey = strtolower($oParserState->peek($iLength));
if (array_key_exists($sKey, $aValues)) {
if (($sUnit = $aValues[$sKey]) !== null) {
$oParserState->consume($iLength);
break;
}
}
}
return new Size((float)$sSize, $sUnit, $bIsColorComponent, $oParserState->currentLine());
}
/**
* @return array<int, array<string, string>>
*/
private static function getSizeUnits()
{
if (!is_array(self::$SIZE_UNITS)) {
self::$SIZE_UNITS = [];
foreach (array_merge(self::ABSOLUTE_SIZE_UNITS, self::RELATIVE_SIZE_UNITS, self::NON_SIZE_UNITS) as $val) {
$iSize = strlen($val);
if (!isset(self::$SIZE_UNITS[$iSize])) {
self::$SIZE_UNITS[$iSize] = [];
}
self::$SIZE_UNITS[$iSize][strtolower($val)] = $val;
}
krsort(self::$SIZE_UNITS, SORT_NUMERIC);
}
return self::$SIZE_UNITS;
}
/**
* @param string $sUnit
*
* @return void
*/
public function setUnit($sUnit)
{
$this->sUnit = $sUnit;
}
/**
* @return string|null
*/
public function getUnit()
{
return $this->sUnit;
}
/**
* @param float|int|string $fSize
*/
public function setSize($fSize)
{
$this->fSize = (float)$fSize;
}
/**
* @return float
*/
public function getSize()
{
return $this->fSize;
}
/**
* @return bool
*/
public function isColorComponent()
{
return $this->bIsColorComponent;
}
/**
* Returns whether the number stored in this Size really represents a size (as in a length of something on screen).
*
* @return false if the unit an angle, a duration, a frequency or the number is a component in a Color object.
*/
public function isSize()
{
if (in_array($this->sUnit, self::NON_SIZE_UNITS, true)) {
return false;
}
return !$this->isColorComponent();
}
/**
* @return bool
*/
public function isRelative()
{
if (in_array($this->sUnit, self::RELATIVE_SIZE_UNITS, true)) {
return true;
}
if ($this->sUnit === null && $this->fSize != 0) {
return true;
}
return false;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
$l = localeconv();
$sPoint = preg_quote($l['decimal_point'], '/');
$sSize = preg_match("/[\d\.]+e[+-]?\d+/i", (string)$this->fSize)
? preg_replace("/$sPoint?0+$/", "", sprintf("%f", $this->fSize)) : $this->fSize;
return preg_replace(["/$sPoint/", "/^(-?)0\./"], ['.', '$1.'], $sSize)
. ($this->sUnit === null ? '' : $this->sUnit);
}
}
Value/URL.php 0000644 00000003415 15154702574 0007010 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class URL extends PrimitiveValue
{
/**
* @var CSSString
*/
private $oURL;
/**
* @param int $iLineNo
*/
public function __construct(CSSString $oURL, $iLineNo = 0)
{
parent::__construct($iLineNo);
$this->oURL = $oURL;
}
/**
* @return URL
*
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public static function parse(ParserState $oParserState)
{
$bUseUrl = $oParserState->comes('url', true);
if ($bUseUrl) {
$oParserState->consume('url');
$oParserState->consumeWhiteSpace();
$oParserState->consume('(');
}
$oParserState->consumeWhiteSpace();
$oResult = new URL(CSSString::parse($oParserState), $oParserState->currentLine());
if ($bUseUrl) {
$oParserState->consumeWhiteSpace();
$oParserState->consume(')');
}
return $oResult;
}
/**
* @return void
*/
public function setURL(CSSString $oURL)
{
$this->oURL = $oURL;
}
/**
* @return CSSString
*/
public function getURL()
{
return $this->oURL;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return "url({$this->oURL->render($oOutputFormat)})";
}
}
Value/Value.php 0000644 00000016041 15154702574 0007421 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Renderable;
abstract class Value implements Renderable
{
/**
* @var int
*/
protected $iLineNo;
/**
* @param int $iLineNo
*/
public function __construct($iLineNo = 0)
{
$this->iLineNo = $iLineNo;
}
/**
* @param array<array-key, string> $aListDelimiters
*
* @return RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*/
public static function parseValue(ParserState $oParserState, array $aListDelimiters = [])
{
/** @var array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string> $aStack */
$aStack = [];
$oParserState->consumeWhiteSpace();
//Build a list of delimiters and parsed values
while (
!($oParserState->comes('}') || $oParserState->comes(';') || $oParserState->comes('!')
|| $oParserState->comes(')')
|| $oParserState->comes('\\'))
) {
if (count($aStack) > 0) {
$bFoundDelimiter = false;
foreach ($aListDelimiters as $sDelimiter) {
if ($oParserState->comes($sDelimiter)) {
array_push($aStack, $oParserState->consume($sDelimiter));
$oParserState->consumeWhiteSpace();
$bFoundDelimiter = true;
break;
}
}
if (!$bFoundDelimiter) {
//Whitespace was the list delimiter
array_push($aStack, ' ');
}
}
array_push($aStack, self::parsePrimitiveValue($oParserState));
$oParserState->consumeWhiteSpace();
}
// Convert the list to list objects
foreach ($aListDelimiters as $sDelimiter) {
if (count($aStack) === 1) {
return $aStack[0];
}
$iStartPosition = null;
while (($iStartPosition = array_search($sDelimiter, $aStack, true)) !== false) {
$iLength = 2; //Number of elements to be joined
for ($i = $iStartPosition + 2; $i < count($aStack); $i += 2, ++$iLength) {
if ($sDelimiter !== $aStack[$i]) {
break;
}
}
$oList = new RuleValueList($sDelimiter, $oParserState->currentLine());
for ($i = $iStartPosition - 1; $i - $iStartPosition + 1 < $iLength * 2; $i += 2) {
$oList->addListComponent($aStack[$i]);
}
array_splice($aStack, $iStartPosition - 1, $iLength * 2 - 1, [$oList]);
}
}
if (!isset($aStack[0])) {
throw new UnexpectedTokenException(
" {$oParserState->peek()} ",
$oParserState->peek(1, -1) . $oParserState->peek(2),
'literal',
$oParserState->currentLine()
);
}
return $aStack[0];
}
/**
* @param bool $bIgnoreCase
*
* @return CSSFunction|string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
public static function parseIdentifierOrFunction(ParserState $oParserState, $bIgnoreCase = false)
{
$sResult = $oParserState->parseIdentifier($bIgnoreCase);
if ($oParserState->comes('(')) {
$oParserState->consume('(');
$aArguments = Value::parseValue($oParserState, ['=', ' ', ',']);
$sResult = new CSSFunction($sResult, $aArguments, ',', $oParserState->currentLine());
$oParserState->consume(')');
}
return $sResult;
}
/**
* @return CSSFunction|CSSString|LineName|Size|URL|string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
* @throws SourceException
*/
public static function parsePrimitiveValue(ParserState $oParserState)
{
$oValue = null;
$oParserState->consumeWhiteSpace();
if (
is_numeric($oParserState->peek())
|| ($oParserState->comes('-.')
&& is_numeric($oParserState->peek(1, 2)))
|| (($oParserState->comes('-') || $oParserState->comes('.')) && is_numeric($oParserState->peek(1, 1)))
) {
$oValue = Size::parse($oParserState);
} elseif ($oParserState->comes('#') || $oParserState->comes('rgb', true) || $oParserState->comes('hsl', true)) {
$oValue = Color::parse($oParserState);
} elseif ($oParserState->comes('url', true)) {
$oValue = URL::parse($oParserState);
} elseif (
$oParserState->comes('calc', true) || $oParserState->comes('-webkit-calc', true)
|| $oParserState->comes('-moz-calc', true)
) {
$oValue = CalcFunction::parse($oParserState);
} elseif ($oParserState->comes("'") || $oParserState->comes('"')) {
$oValue = CSSString::parse($oParserState);
} elseif ($oParserState->comes("progid:") && $oParserState->getSettings()->bLenientParsing) {
$oValue = self::parseMicrosoftFilter($oParserState);
} elseif ($oParserState->comes("[")) {
$oValue = LineName::parse($oParserState);
} elseif ($oParserState->comes("U+")) {
$oValue = self::parseUnicodeRangeValue($oParserState);
} else {
$oValue = self::parseIdentifierOrFunction($oParserState);
}
$oParserState->consumeWhiteSpace();
return $oValue;
}
/**
* @return CSSFunction
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseMicrosoftFilter(ParserState $oParserState)
{
$sFunction = $oParserState->consumeUntil('(', false, true);
$aArguments = Value::parseValue($oParserState, [',', '=']);
return new CSSFunction($sFunction, $aArguments, ',', $oParserState->currentLine());
}
/**
* @return string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseUnicodeRangeValue(ParserState $oParserState)
{
$iCodepointMaxLength = 6; // Code points outside BMP can use up to six digits
$sRange = "";
$oParserState->consume("U+");
do {
if ($oParserState->comes('-')) {
$iCodepointMaxLength = 13; // Max length is 2 six digit code points + the dash(-) between them
}
$sRange .= $oParserState->consume(1);
} while (strlen($sRange) < $iCodepointMaxLength && preg_match("/[A-Fa-f0-9\?-]/", $oParserState->peek()));
return "U+{$sRange}";
}
/**
* @return int
*/
public function getLineNo()
{
return $this->iLineNo;
}
}
Value/ValueList.php 0000644 00000004536 15154702574 0010263 0 ustar 00 <?php
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
abstract class ValueList extends Value
{
/**
* @var array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string>
*/
protected $aComponents;
/**
* @var string
*/
protected $sSeparator;
/**
* phpcs:ignore Generic.Files.LineLength
* @param array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string>|RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string $aComponents
* @param string $sSeparator
* @param int $iLineNo
*/
public function __construct($aComponents = [], $sSeparator = ',', $iLineNo = 0)
{
parent::__construct($iLineNo);
if (!is_array($aComponents)) {
$aComponents = [$aComponents];
}
$this->aComponents = $aComponents;
$this->sSeparator = $sSeparator;
}
/**
* @param RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string $mComponent
*
* @return void
*/
public function addListComponent($mComponent)
{
$this->aComponents[] = $mComponent;
}
/**
* @return array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string>
*/
public function getListComponents()
{
return $this->aComponents;
}
/**
* @param array<int, RuleValueList|CSSFunction|CSSString|LineName|Size|URL|string> $aComponents
*
* @return void
*/
public function setListComponents(array $aComponents)
{
$this->aComponents = $aComponents;
}
/**
* @return string
*/
public function getListSeparator()
{
return $this->sSeparator;
}
/**
* @param string $sSeparator
*
* @return void
*/
public function setListSeparator($sSeparator)
{
$this->sSeparator = $sSeparator;
}
/**
* @return string
*/
public function __toString()
{
return $this->render(new OutputFormat());
}
/**
* @return string
*/
public function render(OutputFormat $oOutputFormat)
{
return $oOutputFormat->implode(
$oOutputFormat->spaceBeforeListArgumentSeparator($this->sSeparator) . $this->sSeparator
. $oOutputFormat->spaceAfterListArgumentSeparator($this->sSeparator),
$this->aComponents
);
}
}
WPImporterLogger.php 0000644 00000006522 15154723473 0010505 0 ustar 00 <?php
namespace ProteusThemes\WPContentImporter2;
/**
* Describes a logger instance
*
* Based on PSR-3: http://www.php-fig.org/psr/psr-3/
*
* The message MUST be a string or object implementing __toString().
*
* The message MAY contain placeholders in the form: {foo} where foo
* will be replaced by the context data in key "foo".
*
* The context array can contain arbitrary data, the only assumption that
* can be made by implementors is that if an Exception instance is given
* to produce a stack trace, it MUST be in a key named "exception".
*
* See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md
* for the full interface specification.
*/
class WPImporterLogger {
/**
* System is unusable.
*
* @param string $message
* @param array $context
* @return null
*/
public function emergency( $message, array $context = array() ) {
return $this->log( 'emergency', $message, $context );
}
/**
* Action must be taken immediately.
*
* Example: Entire website down, database unavailable, etc. This should
* trigger the SMS alerts and wake you up.
*
* @param string $message
* @param array $context
* @return null
*/
public function alert( $message, array $context = array() ) {
return $this->log( 'alert', $message, $context );
}
/**
* Critical conditions.
*
* Example: Application component unavailable, unexpected exception.
*
* @param string $message
* @param array $context
* @return null
*/
public function critical( $message, array $context = array() ) {
return $this->log( 'critical', $message, $context );
}
/**
* Runtime errors that do not require immediate action but should typically
* be logged and monitored.
*
* @param string $message
* @param array $context
* @return null
*/
public function error( $message, array $context = array()) {
return $this->log( 'error', $message, $context );
}
/**
* Exceptional occurrences that are not errors.
*
* Example: Use of deprecated APIs, poor use of an API, undesirable things
* that are not necessarily wrong.
*
* @param string $message
* @param array $context
* @return null
*/
public function warning( $message, array $context = array() ) {
return $this->log( 'warning', $message, $context );
}
/**
* Normal but significant events.
*
* @param string $message
* @param array $context
* @return null
*/
public function notice( $message, array $context = array() ) {
return $this->log( 'notice', $message, $context );
}
/**
* Interesting events.
*
* Example: User logs in, SQL logs.
*
* @param string $message
* @param array $context
* @return null
*/
public function info( $message, array $context = array() ) {
return $this->log( 'info', $message, $context );
}
/**
* Detailed debug information.
*
* @param string $message
* @param array $context
* @return null
*/
public function debug( $message, array $context = array() ) {
return $this->log( 'debug', $message, $context );
}
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log( $level, $message, array $context = array() ) {
$this->messages[] = array(
'timestamp' => time(),
'level' => $level,
'message' => $message,
'context' => $context,
);
}
}
WPImporterLoggerCLI.php 0000644 00000001546 15154723473 0011036 0 ustar 00 <?php
namespace ProteusThemes\WPContentImporter2;
class WPImporterLoggerCLI extends WPImporterLogger {
public $min_level = 'notice';
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log( $level, $message, array $context = array() ) {
if ( $this->level_to_numeric( $level ) < $this->level_to_numeric( $this->min_level ) ) {
return;
}
printf(
'[%s] %s' . PHP_EOL,
strtoupper( $level ),
$message
);
}
public static function level_to_numeric( $level ) {
$levels = array(
'emergency' => 8,
'alert' => 7,
'critical' => 6,
'error' => 5,
'warning' => 4,
'notice' => 3,
'info' => 2,
'debug' => 1,
);
if ( ! isset( $levels[ $level ] ) ) {
return 0;
}
return $levels[ $level ];
}
}
WXRImportInfo.php 0000644 00000000451 15154723473 0007757 0 ustar 00 <?php
namespace ProteusThemes\WPContentImporter2;
class WXRImportInfo {
public $home;
public $siteurl;
public $title;
public $users = array();
public $post_count = 0;
public $media_count = 0;
public $comment_count = 0;
public $term_count = 0;
public $generator = '';
public $version;
} WXRImporter.php 0000644 00000220076 15154723473 0007501 0 ustar 00 <?php
namespace ProteusThemes\WPContentImporter2;
class WXRImporter extends \WP_Importer {
/**
* Maximum supported WXR version
*/
const MAX_WXR_VERSION = 1.2;
/**
* Regular expression for checking if a post references an attachment
*
* Note: This is a quick, weak check just to exclude text-only posts. More
* vigorous checking is done later to verify.
*/
const REGEX_HAS_ATTACHMENT_REFS = '!
(
# Match anything with an image or attachment class
class=[\'"].*?\b(wp-image-\d+|attachment-[\w\-]+)\b
|
# Match anything that looks like an upload URL
src=[\'"][^\'"]*(
[0-9]{4}/[0-9]{2}/[^\'"]+\.(jpg|jpeg|png|gif)
|
content/uploads[^\'"]+
)[\'"]
)!ix';
/**
* Version of WXR we're importing.
*
* Defaults to 1.0 for compatibility. Typically overridden by a
* `<wp:wxr_version>` tag at the start of the file.
*
* @var string
*/
protected $version = '1.0';
// information to import from WXR file
protected $categories = array();
protected $tags = array();
protected $base_url = '';
// TODO: REMOVE THESE
protected $processed_terms = array();
protected $processed_posts = array();
protected $processed_menu_items = array();
protected $menu_item_orphans = array();
protected $missing_menu_items = array();
// NEW STYLE
protected $mapping = array();
protected $requires_remapping = array();
protected $exists = array();
protected $user_slug_override = array();
protected $url_remap = array();
protected $featured_images = array();
/**
* Logger instance.
*
* @var WP_Importer_Logger
*/
protected $logger;
/**
* Constructor
*
* @param array $options {
* @var bool $prefill_existing_posts Should we prefill `post_exists` calls? (True prefills and uses more memory, false checks once per imported post and takes longer. Default is true.)
* @var bool $prefill_existing_comments Should we prefill `comment_exists` calls? (True prefills and uses more memory, false checks once per imported comment and takes longer. Default is true.)
* @var bool $prefill_existing_terms Should we prefill `term_exists` calls? (True prefills and uses more memory, false checks once per imported term and takes longer. Default is true.)
* @var bool $update_attachment_guids Should attachment GUIDs be updated to the new URL? (True updates the GUID, which keeps compatibility with v1, false doesn't update, and allows deduplication and reimporting. Default is false.)
* @var bool $fetch_attachments Fetch attachments from the remote server. (True fetches and creates attachment posts, false skips attachments. Default is false.)
* @var bool $aggressive_url_search Should we search/replace for URLs aggressively? (True searches all posts' content for old URLs and replaces, false checks for `<img class="wp-image-*">` only. Default is false.)
* @var int $default_author User ID to use if author is missing or invalid. (Default is null, which leaves posts unassigned.)
* }
*/
public function __construct( $options = array() ) {
// Initialize some important variables
$empty_types = array(
'post' => array(),
'comment' => array(),
'term' => array(),
'user' => array(),
);
$this->mapping = $empty_types;
$this->mapping['user_slug'] = array();
$this->mapping['term_id'] = array();
$this->requires_remapping = $empty_types;
$this->exists = $empty_types;
$this->options = wp_parse_args( $options, array(
'prefill_existing_posts' => true,
'prefill_existing_comments' => true,
'prefill_existing_terms' => true,
'update_attachment_guids' => false,
'fetch_attachments' => false,
'aggressive_url_search' => false,
'default_author' => null,
) );
}
public function set_logger( $logger ) {
$this->logger = $logger;
}
/**
* Get a stream reader for the file.
*
* @param string $file Path to the XML file.
* @return XMLReader|WP_Error Reader instance on success, error otherwise.
*/
protected function get_reader( $file ) {
// Avoid loading external entities for security
$old_value = null;
if ( function_exists( 'libxml_disable_entity_loader' ) ) {
// $old_value = libxml_disable_entity_loader( true );
}
$reader = new \XMLReader();
$status = $reader->open( $file );
if ( ! is_null( $old_value ) ) {
// libxml_disable_entity_loader( $old_value );
}
if ( ! $status ) {
return new \WP_Error( 'wxr_importer.cannot_parse', __( 'Could not open the file for parsing', 'wordpress-importer' ) );
}
return $reader;
}
/**
* The main controller for the actual import stage.
*
* @param string $file Path to the WXR file for importing
*/
public function get_preliminary_information( $file ) {
// Let's run the actual importer now, woot
$reader = $this->get_reader( $file );
if ( is_wp_error( $reader ) ) {
return $reader;
}
// Set the version to compatibility mode first
$this->version = '1.0';
// Start parsing!
$data = new WXRImportInfo();
while ( $reader->read() ) {
// Only deal with element opens
if ( $reader->nodeType !== \XMLReader::ELEMENT ) {
continue;
}
switch ( $reader->name ) {
case 'wp:wxr_version':
// Upgrade to the correct version
$this->version = $reader->readString();
if ( version_compare( $this->version, self::MAX_WXR_VERSION, '>' ) ) {
$this->logger->warning( sprintf(
__( 'This WXR file (version %s) is newer than the importer (version %s) and may not be supported. Please consider updating.', 'wordpress-importer' ),
$this->version,
self::MAX_WXR_VERSION
) );
}
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'generator':
$data->generator = $reader->readString();
$reader->next();
break;
case 'title':
$data->title = $reader->readString();
$reader->next();
break;
case 'wp:base_site_url':
$data->siteurl = $reader->readString();
$reader->next();
break;
case 'wp:base_blog_url':
$data->home = $reader->readString();
$reader->next();
break;
case 'wp:author':
$node = $reader->expand();
$parsed = $this->parse_author_node( $node );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$data->users[] = $parsed;
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'item':
$node = $reader->expand();
$parsed = $this->parse_post_node( $node );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
if ( $parsed['data']['post_type'] === 'attachment' ) {
$data->media_count++;
} else {
$data->post_count++;
}
$data->comment_count += count( $parsed['comments'] );
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:category':
case 'wp:tag':
case 'wp:term':
$data->term_count++;
// Handled everything in this node, move on to the next
$reader->next();
break;
}
}
$data->version = $this->version;
return $data;
}
/**
* The main controller for the actual import stage.
*
* @param string $file Path to the WXR file for importing
*/
public function parse_authors( $file ) {
// Let's run the actual importer now, woot
$reader = $this->get_reader( $file );
if ( is_wp_error( $reader ) ) {
return $reader;
}
// Set the version to compatibility mode first
$this->version = '1.0';
// Start parsing!
$authors = array();
while ( $reader->read() ) {
// Only deal with element opens
if ( $reader->nodeType !== \XMLReader::ELEMENT ) {
continue;
}
switch ( $reader->name ) {
case 'wp:wxr_version':
// Upgrade to the correct version
$this->version = $reader->readString();
if ( version_compare( $this->version, self::MAX_WXR_VERSION, '>' ) ) {
$this->logger->warning( sprintf(
__( 'This WXR file (version %s) is newer than the importer (version %s) and may not be supported. Please consider updating.', 'wordpress-importer' ),
$this->version,
self::MAX_WXR_VERSION
) );
}
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:author':
$node = $reader->expand();
$parsed = $this->parse_author_node( $node );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$authors[] = $parsed;
// Handled everything in this node, move on to the next
$reader->next();
break;
}
}
return $authors;
}
/**
* The main controller for the actual import stage.
*
* @param string $file Path to the WXR file for importing
*/
public function import( $file ) {
add_filter( 'import_post_meta_key', array( $this, 'is_valid_meta_key' ) );
add_filter( 'http_request_timeout', array( &$this, 'bump_request_timeout' ) );
$result = $this->import_start( $file );
if ( is_wp_error( $result ) ) {
return $result;
}
// Let's run the actual importer now, woot
$reader = $this->get_reader( $file );
if ( is_wp_error( $reader ) ) {
return $reader;
}
// Set the version to compatibility mode first
$this->version = '1.0';
// Reset other variables
$this->base_url = '';
// Start parsing!
while ( $reader->read() ) {
// Only deal with element opens
if ( $reader->nodeType !== \XMLReader::ELEMENT ) {
continue;
}
switch ( $reader->name ) {
case 'wp:wxr_version':
// Upgrade to the correct version
$this->version = $reader->readString();
if ( version_compare( $this->version, self::MAX_WXR_VERSION, '>' ) ) {
$this->logger->warning( sprintf(
__( 'This WXR file (version %s) is newer than the importer (version %s) and may not be supported. Please consider updating.', 'wordpress-importer' ),
$this->version,
self::MAX_WXR_VERSION
) );
}
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:base_site_url':
$this->base_url = $reader->readString();
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'item':
$node = $reader->expand();
$parsed = $this->parse_post_node( $node );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$this->process_post( $parsed['data'], $parsed['meta'], $parsed['comments'], $parsed['terms'] );
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:author':
$node = $reader->expand();
$parsed = $this->parse_author_node( $node );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$status = $this->process_author( $parsed['data'], $parsed['meta'] );
if ( is_wp_error( $status ) ) {
$this->log_error( $status );
}
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:category':
$node = $reader->expand();
$parsed = $this->parse_term_node( $node, 'category' );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$status = $this->process_term( $parsed['data'], $parsed['meta'] );
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:tag':
$node = $reader->expand();
$parsed = $this->parse_term_node( $node, 'tag' );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$status = $this->process_term( $parsed['data'], $parsed['meta'] );
// Handled everything in this node, move on to the next
$reader->next();
break;
case 'wp:term':
$node = $reader->expand();
$parsed = $this->parse_term_node( $node );
if ( is_wp_error( $parsed ) ) {
$this->log_error( $parsed );
// Skip the rest of this post
$reader->next();
break;
}
$status = $this->process_term( $parsed['data'], $parsed['meta'] );
// Handled everything in this node, move on to the next
$reader->next();
break;
default:
// Skip this node, probably handled by something already
break;
}
}
// Now that we've done the main processing, do any required
// post-processing and remapping.
$this->post_process();
if ( $this->options['aggressive_url_search'] ) {
$this->replace_attachment_urls_in_content();
}
$this->remap_featured_images();
$this->import_end();
}
/**
* Log an error instance to the logger.
*
* @param WP_Error $error Error instance to log.
*/
protected function log_error( WP_Error $error ) {
$this->logger->warning( $error->get_error_message() );
// Log the data as debug info too
$data = $error->get_error_data();
if ( ! empty( $data ) ) {
$this->logger->debug( var_export( $data, true ) );
}
}
/**
* Parses the WXR file and prepares us for the task of processing parsed data
*
* @param string $file Path to the WXR file for importing
*/
protected function import_start( $file ) {
if ( ! is_file( $file ) ) {
return new \WP_Error( 'wxr_importer.file_missing', __( 'The file does not exist, please try again.', 'wordpress-importer' ) );
}
// Suspend bunches of stuff in WP core
wp_defer_term_counting( true );
wp_defer_comment_counting( true );
wp_suspend_cache_invalidation( true );
// Prefill exists calls if told to
if ( $this->options['prefill_existing_posts'] ) {
$this->prefill_existing_posts();
}
if ( $this->options['prefill_existing_comments'] ) {
$this->prefill_existing_comments();
}
if ( $this->options['prefill_existing_terms'] ) {
$this->prefill_existing_terms();
}
/**
* Begin the import.
*
* Fires before the import process has begun. If you need to suspend
* caching or heavy processing on hooks, do so here.
*/
do_action( 'import_start' );
}
/**
* Performs post-import cleanup of files and the cache
*/
protected function import_end() {
// Re-enable stuff in core
wp_suspend_cache_invalidation( false );
wp_cache_flush();
foreach ( get_taxonomies() as $tax ) {
delete_option( "{$tax}_children" );
_get_term_hierarchy( $tax );
}
wp_defer_term_counting( false );
wp_defer_comment_counting( false );
/**
* Complete the import.
*
* Fires after the import process has finished. If you need to update
* your cache or re-enable processing, do so here.
*/
do_action( 'import_end' );
}
/**
* Set the user mapping.
*
* @param array $mapping List of map arrays (containing `old_slug`, `old_id`, `new_id`)
*/
public function set_user_mapping( $mapping ) {
foreach ( $mapping as $map ) {
if ( empty( $map['old_slug'] ) || empty( $map['old_id'] ) || empty( $map['new_id'] ) ) {
$this->logger->warning( __( 'Invalid author mapping', 'wordpress-importer' ) );
$this->logger->debug( var_export( $map, true ) );
continue;
}
$old_slug = $map['old_slug'];
$old_id = $map['old_id'];
$new_id = $map['new_id'];
$this->mapping['user'][ $old_id ] = $new_id;
$this->mapping['user_slug'][ $old_slug ] = $new_id;
}
}
/**
* Set the user slug overrides.
*
* Allows overriding the slug in the import with a custom/renamed version.
*
* @param string[] $overrides Map of old slug to new slug.
*/
public function set_user_slug_overrides( $overrides ) {
foreach ( $overrides as $original => $renamed ) {
$this->user_slug_override[ $original ] = $renamed;
}
}
/**
* Parse a post node into post data.
*
* @param DOMElement $node Parent node of post data (typically `item`).
* @return array|WP_Error Post data array on success, error otherwise.
*/
protected function parse_post_node( $node ) {
$data = array();
$meta = array();
$comments = array();
$terms = array();
foreach ( $node->childNodes as $child ) {
// We only care about child elements
if ( $child->nodeType !== XML_ELEMENT_NODE ) {
continue;
}
switch ( $child->tagName ) {
case 'wp:post_type':
$data['post_type'] = $child->textContent;
break;
case 'title':
$data['post_title'] = $child->textContent;
break;
case 'guid':
$data['guid'] = $child->textContent;
break;
case 'dc:creator':
$data['post_author'] = $child->textContent;
break;
case 'content:encoded':
$data['post_content'] = $child->textContent;
break;
case 'excerpt:encoded':
$data['post_excerpt'] = $child->textContent;
break;
case 'wp:post_id':
$data['post_id'] = $child->textContent;
break;
case 'wp:post_date':
$data['post_date'] = $child->textContent;
break;
case 'wp:post_date_gmt':
$data['post_date_gmt'] = $child->textContent;
break;
case 'wp:comment_status':
$data['comment_status'] = $child->textContent;
break;
case 'wp:ping_status':
$data['ping_status'] = $child->textContent;
break;
case 'wp:post_name':
$data['post_name'] = $child->textContent;
break;
case 'wp:status':
$data['post_status'] = $child->textContent;
if ( $data['post_status'] === 'auto-draft' ) {
// Bail now
return new \WP_Error(
'wxr_importer.post.cannot_import_draft',
__( 'Cannot import auto-draft posts' ),
$data
);
}
break;
case 'wp:post_parent':
$data['post_parent'] = $child->textContent;
break;
case 'wp:menu_order':
$data['menu_order'] = $child->textContent;
break;
case 'wp:post_password':
$data['post_password'] = $child->textContent;
break;
case 'wp:is_sticky':
$data['is_sticky'] = $child->textContent;
break;
case 'wp:attachment_url':
$data['attachment_url'] = $child->textContent;
break;
case 'wp:postmeta':
$meta_item = $this->parse_meta_node( $child );
if ( ! empty( $meta_item ) ) {
$meta[] = $meta_item;
}
break;
case 'wp:comment':
$comment_item = $this->parse_comment_node( $child );
if ( ! empty( $comment_item ) ) {
$comments[] = $comment_item;
}
break;
case 'category':
$term_item = $this->parse_category_node( $child );
if ( ! empty( $term_item ) ) {
$terms[] = $term_item;
}
break;
}
}
return compact( 'data', 'meta', 'comments', 'terms' );
}
/**
* Create new posts based on import information
*
* Posts marked as having a parent which doesn't exist will become top level items.
* Doesn't create a new post if: the post type doesn't exist, the given post ID
* is already noted as imported or a post with the same title and date already exists.
* Note that new/updated terms, comments and meta are imported for the last of the above.
*/
protected function process_post( $data, $meta, $comments, $terms ) {
/**
* Pre-process post data.
*
* @param array $data Post data. (Return empty to skip.)
* @param array $meta Meta data.
* @param array $comments Comments on the post.
* @param array $terms Terms on the post.
*/
$data = apply_filters( 'wxr_importer.pre_process.post', $data, $meta, $comments, $terms );
if ( empty( $data ) ) {
return false;
}
$original_id = isset( $data['post_id'] ) ? (int) $data['post_id'] : 0;
$parent_id = isset( $data['post_parent'] ) ? (int) $data['post_parent'] : 0;
$author_id = isset( $data['post_author'] ) ? (int) $data['post_author'] : 0;
// Have we already processed this?
if ( isset( $this->mapping['post'][ $original_id ] ) ) {
return;
}
$post_type_object = get_post_type_object( $data['post_type'] );
// Is this type even valid?
if ( ! $post_type_object ) {
$this->logger->warning( sprintf(
__( 'Failed to import "%s": Invalid post type %s', 'wordpress-importer' ),
$data['post_title'],
$data['post_type']
) );
return false;
}
$post_exists = $this->post_exists( $data );
if ( $post_exists ) {
$this->logger->info( sprintf(
__( '%s "%s" already exists.', 'wordpress-importer' ),
$post_type_object->labels->singular_name,
$data['post_title']
) );
// Even though this post already exists, new comments might need importing
$this->process_comments( $comments, $original_id, $data, $post_exists );
return false;
}
// Map the parent post, or mark it as one we need to fix
$requires_remapping = false;
if ( $parent_id ) {
if ( isset( $this->mapping['post'][ $parent_id ] ) ) {
$data['post_parent'] = $this->mapping['post'][ $parent_id ];
} else {
$meta[] = array( 'key' => '_wxr_import_parent', 'value' => $parent_id );
$requires_remapping = true;
$data['post_parent'] = 0;
}
}
// Map the author, or mark it as one we need to fix
$author = sanitize_user( $data['post_author'], true );
if ( empty( $author ) ) {
// Missing or invalid author, use default if available.
$data['post_author'] = $this->options['default_author'];
} elseif ( isset( $this->mapping['user_slug'][ $author ] ) ) {
$data['post_author'] = $this->mapping['user_slug'][ $author ];
} else {
$meta[] = array( 'key' => '_wxr_import_user_slug', 'value' => $author );
$requires_remapping = true;
$data['post_author'] = (int) get_current_user_id();
}
// Does the post look like it contains attachment images?
if ( preg_match( self::REGEX_HAS_ATTACHMENT_REFS, $data['post_content'] ) ) {
$meta[] = array( 'key' => '_wxr_import_has_attachment_refs', 'value' => true );
$requires_remapping = true;
}
// Whitelist to just the keys we allow
$postdata = array(
'import_id' => $data['post_id'],
);
$allowed = array(
'post_author' => true,
'post_date' => true,
'post_date_gmt' => true,
'post_content' => true,
'post_excerpt' => true,
'post_title' => true,
'post_status' => true,
'post_name' => true,
'comment_status' => true,
'ping_status' => true,
'guid' => true,
'post_parent' => true,
'menu_order' => true,
'post_type' => true,
'post_password' => true,
);
foreach ( $data as $key => $value ) {
if ( ! isset( $allowed[ $key ] ) ) {
continue;
}
$postdata[ $key ] = $data[ $key ];
}
$postdata = apply_filters( 'wp_import_post_data_processed', $postdata, $data );
if ( 'attachment' === $postdata['post_type'] ) {
if ( ! $this->options['fetch_attachments'] ) {
$this->logger->notice( sprintf(
__( 'Skipping attachment "%s", fetching attachments disabled' ),
$data['post_title']
) );
return false;
}
$remote_url = ! empty( $data['attachment_url'] ) ? $data['attachment_url'] : $data['guid'];
$post_id = $this->process_attachment( $postdata, $meta, $remote_url );
} else {
$post_id = wp_insert_post( $postdata, true );
do_action( 'wp_import_insert_post', $post_id, $original_id, $postdata, $data );
}
if ( is_wp_error( $post_id ) ) {
$this->logger->error( sprintf(
__( 'Failed to import "%s" (%s)', 'wordpress-importer' ),
$data['post_title'],
$post_type_object->labels->singular_name
) );
$this->logger->debug( $post_id->get_error_message() );
/**
* Post processing failed.
*
* @param WP_Error $post_id Error object.
* @param array $data Raw data imported for the post.
* @param array $meta Raw meta data, already processed by {@see process_post_meta}.
* @param array $comments Raw comment data, already processed by {@see process_comments}.
* @param array $terms Raw term data, already processed.
*/
do_action( 'wxr_importer.process_failed.post', $post_id, $data, $meta, $comments, $terms );
return false;
}
// Ensure stickiness is handled correctly too
if ( $data['is_sticky'] === '1' ) {
stick_post( $post_id );
}
// map pre-import ID to local ID
$this->mapping['post'][ $original_id ] = (int) $post_id;
if ( $requires_remapping ) {
$this->requires_remapping['post'][ $post_id ] = true;
}
$this->mark_post_exists( $data, $post_id );
$this->logger->info( sprintf(
__( 'Imported "%s" (%s)', 'wordpress-importer' ),
$data['post_title'],
$post_type_object->labels->singular_name
) );
$this->logger->debug( sprintf(
__( 'Post %d remapped to %d', 'wordpress-importer' ),
$original_id,
$post_id
) );
// Handle the terms too
$terms = apply_filters( 'wp_import_post_terms', $terms, $post_id, $data );
if ( ! empty( $terms ) ) {
$term_ids = array();
foreach ( $terms as $term ) {
$taxonomy = $term['taxonomy'];
$key = sha1( $taxonomy . ':' . $term['slug'] );
if ( isset( $this->mapping['term'][ $key ] ) ) {
$term_ids[ $taxonomy ][] = (int) $this->mapping['term'][ $key ];
} else {
/**
* Fix for the post format "categories".
* The issue in this importer is, that these post formats are misused as categories in WP export
* (as the export data <category> item in the post export item), but they are not actually
* exported as wp:category items in the XML file, so they need to be inserted on the fly (here).
*
* Maybe something better can be done in the future?
*
* Original issue reported here: https://wordpress.org/support/topic/post-format-videoquotegallery-became-format-standard/#post-8447683
*
*/
if ( 'post_format' === $taxonomy ) {
$term_exists = term_exists( $term['slug'], $taxonomy );
$term_id = is_array( $term_exists ) ? $term_exists['term_id'] : $term_exists;
if ( empty( $term_id ) ) {
$t = wp_insert_term( $term['name'], $taxonomy, array( 'slug' => $term['slug'] ) );
if ( ! is_wp_error( $t ) ) {
$term_id = $t['term_id'];
$this->mapping['term'][ $key ] = $term_id;
} else {
$this->logger->warning( sprintf(
esc_html__( 'Failed to import term: %s - %s', 'wordpress-importer' ),
esc_html( $taxonomy ),
esc_html( $term['name'] )
) );
continue;
}
}
if ( ! empty( $term_id ) ) {
$term_ids[ $taxonomy ][] = intval( $term_id );
}
} // End of fix.
else {
$meta[] = array( 'key' => '_wxr_import_term', 'value' => $term );
$requires_remapping = true;
}
}
}
foreach ( $term_ids as $tax => $ids ) {
$tt_ids = wp_set_post_terms( $post_id, $ids, $tax );
do_action( 'wp_import_set_post_terms', $tt_ids, $ids, $tax, $post_id, $data );
}
}
$this->process_comments( $comments, $post_id, $data );
$this->process_post_meta( $meta, $post_id, $data );
if ( 'nav_menu_item' === $data['post_type'] ) {
$this->process_menu_item_meta( $post_id, $data, $meta );
}
/**
* Post processing completed.
*
* @param int $post_id New post ID.
* @param array $data Raw data imported for the post.
* @param array $meta Raw meta data, already processed by {@see process_post_meta}.
* @param array $comments Raw comment data, already processed by {@see process_comments}.
* @param array $terms Raw term data, already processed.
*/
do_action( 'wxr_importer.processed.post', $post_id, $data, $meta, $comments, $terms );
}
/**
* Attempt to create a new menu item from import data
*
* Fails for draft, orphaned menu items and those without an associated nav_menu
* or an invalid nav_menu term. If the post type or term object which the menu item
* represents doesn't exist then the menu item will not be imported (waits until the
* end of the import to retry again before discarding).
*
* @param array $item Menu item details from WXR file
*/
protected function process_menu_item_meta( $post_id, $data, $meta ) {
$item_type = get_post_meta( $post_id, '_menu_item_type', true );
$original_object_id = get_post_meta( $post_id, '_menu_item_object_id', true );
$object_id = null;
$this->logger->debug( sprintf( 'Processing menu item %s', $item_type ) );
$requires_remapping = false;
switch ( $item_type ) {
case 'taxonomy':
if ( isset( $this->mapping['term_id'][ $original_object_id ] ) ) {
$object_id = $this->mapping['term_id'][ $original_object_id ];
} else {
add_post_meta( $post_id, '_wxr_import_menu_item', wp_slash( $original_object_id ) );
$requires_remapping = true;
}
break;
case 'post_type':
if ( isset( $this->mapping['post'][ $original_object_id ] ) ) {
$object_id = $this->mapping['post'][ $original_object_id ];
} else {
add_post_meta( $post_id, '_wxr_import_menu_item', wp_slash( $original_object_id ) );
$requires_remapping = true;
}
break;
case 'custom':
// Custom refers to itself, wonderfully easy.
$object_id = $post_id;
break;
default:
// associated object is missing or not imported yet, we'll retry later
$this->missing_menu_items[] = $data;
$this->logger->debug( 'Unknown menu item type' );
break;
}
if ( $requires_remapping ) {
$this->requires_remapping['post'][ $post_id ] = true;
}
if ( empty( $object_id ) ) {
// Nothing needed here.
return;
}
$this->logger->debug( sprintf( 'Menu item %d mapped to %d', $original_object_id, $object_id ) );
update_post_meta( $post_id, '_menu_item_object_id', wp_slash( $object_id ) );
}
/**
* If fetching attachments is enabled then attempt to create a new attachment
*
* @param array $post Attachment post details from WXR
* @param string $url URL to fetch attachment from
* @return int|WP_Error Post ID on success, WP_Error otherwise
*/
protected function process_attachment( $post, $meta, $remote_url ) {
// try to use _wp_attached file for upload folder placement to ensure the same location as the export site
// e.g. location is 2003/05/image.jpg but the attachment post_date is 2010/09, see media_handle_upload()
$post['upload_date'] = $post['post_date'];
foreach ( $meta as $meta_item ) {
if ( $meta_item['key'] !== '_wp_attached_file' ) {
continue;
}
if ( preg_match( '%^[0-9]{4}/[0-9]{2}%', $meta_item['value'], $matches ) ) {
$post['upload_date'] = $matches[0];
}
break;
}
// if the URL is absolute, but does not contain address, then upload it assuming base_site_url
if ( preg_match( '|^/[\w\W]+$|', $remote_url ) ) {
$remote_url = rtrim( $this->base_url, '/' ) . $remote_url;
}
$upload = $this->fetch_remote_file( $remote_url, $post );
if ( is_wp_error( $upload ) ) {
return $upload;
}
$info = wp_check_filetype( $upload['file'] );
if ( ! $info ) {
return new \WP_Error( 'attachment_processing_error', __( 'Invalid file type', 'wordpress-importer' ) );
}
$post['post_mime_type'] = $info['type'];
// WP really likes using the GUID for display. Allow updating it.
// See https://core.trac.wordpress.org/ticket/33386
if ( $this->options['update_attachment_guids'] ) {
$post['guid'] = $upload['url'];
}
// as per wp-admin/includes/upload.php
$post_id = wp_insert_attachment( $post, $upload['file'] );
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
$attachment_metadata = wp_generate_attachment_metadata( $post_id, $upload['file'] );
wp_update_attachment_metadata( $post_id, $attachment_metadata );
// Map this image URL later if we need to
$this->url_remap[ $remote_url ] = $upload['url'];
// If we have a HTTPS URL, ensure the HTTP URL gets replaced too
if ( substr( $remote_url, 0, 8 ) === 'https://' ) {
$insecure_url = 'http' . substr( $remote_url, 5 );
$this->url_remap[ $insecure_url ] = $upload['url'];
}
if ( $this->options['aggressive_url_search'] ) {
// remap resized image URLs, works by stripping the extension and remapping the URL stub.
/*if ( preg_match( '!^image/!', $info['type'] ) ) {
$parts = pathinfo( $remote_url );
$name = basename( $parts['basename'], ".{$parts['extension']}" ); // PATHINFO_FILENAME in PHP 5.2
$parts_new = pathinfo( $upload['url'] );
$name_new = basename( $parts_new['basename'], ".{$parts_new['extension']}" );
$this->